diff --git a/src/audio/adaptive-mixer.ts b/src/audio/adaptive-mixer.ts new file mode 100644 index 0000000..5c5c243 --- /dev/null +++ b/src/audio/adaptive-mixer.ts @@ -0,0 +1,231 @@ +import { MUSIC_BEAT_SECONDS, nextBeatTime } from '@/audio/soundtrack-transport'; +import type { ChapterIntent, DriveEnergySample, MusicMood } from '@/audio/types'; + +const MOOD_RAMP_SECONDS = 1.4; +const LIFT_ENTRY_ENERGY = 0.62; +const LIFT_EXIT_ENERGY = 0.32; +const LIFT_ENTRY_DWELL_SECONDS = 0.45; +const LIFT_EXIT_DWELL_SECONDS = 0.8; + +interface MixProfile { + readonly bedGain: number; + readonly highShelfDb: number; + readonly textureSend: number; + readonly liftCap: number; +} + +const MIX_PROFILES = Object.freeze({ + open: { bedGain: 0.82, highShelfDb: 0, textureSend: 0.82, liftCap: 0.13 }, + shadow: { bedGain: 0.81, highShelfDb: -0.75, textureSend: 0.9, liftCap: 0.1 }, + lift: { bedGain: 0.82, highShelfDb: 0.65, textureSend: 0.9, liftCap: 0.17 }, + resolve: { bedGain: 0.81, highShelfDb: -0.15, textureSend: 0.74, liftCap: 0.11 }, +} as const satisfies Readonly>); + +type LiftPhase = 'off' | 'entering' | 'on' | 'exiting'; + +export interface AdaptiveMixer { + readonly bedInput: GainNode; + readonly driveLiftInput: GainNode; + readonly driveTextureInput: GainNode; + setChapterIntent(intent: ChapterIntent): void; + setDriveEnergy(sample: DriveEnergySample): void; + setTransportAnchor(anchorTime: number | null): void; + setDriveLiftAvailableAt(startTime: number | null): void; + dispose(): void; +} + +export type AdaptiveMixerFactory = ( + context: AudioContext, + destination: AudioNode, +) => AdaptiveMixer; + +function holdParameter(parameter: AudioParam, atTime: number): void { + if (typeof parameter.cancelAndHoldAtTime === 'function') { + parameter.cancelAndHoldAtTime(atTime); + return; + } + parameter.cancelScheduledValues(atTime); + parameter.setValueAtTime(parameter.value, atTime); +} + +export const createAdaptiveMixer: AdaptiveMixerFactory = (context, destination) => { + const bedInput = context.createGain(); + const bedTrim = context.createGain(); + const driveLiftInput = context.createGain(); + const driveLiftGate = context.createGain(); + const driveLiftCap = context.createGain(); + const driveTextureInput = context.createGain(); + const textureSend = context.createGain(); + const sum = context.createGain(); + const highShelf = context.createBiquadFilter(); + + const initialProfile = MIX_PROFILES.open; + bedTrim.gain.value = initialProfile.bedGain; + highShelf.type = 'highshelf'; + highShelf.frequency.value = 5_200; + highShelf.Q.value = 0.55; + highShelf.gain.value = initialProfile.highShelfDb; + driveLiftGate.gain.value = 0; + driveLiftCap.gain.value = initialProfile.liftCap; + textureSend.gain.value = initialProfile.textureSend; + + bedInput.connect(bedTrim); + bedTrim.connect(highShelf); + highShelf.connect(sum); + driveLiftInput.connect(driveLiftGate); + driveLiftGate.connect(driveLiftCap); + driveLiftCap.connect(sum); + driveTextureInput.connect(textureSend); + textureSend.connect(sum); + sum.connect(destination); + + const ownedNodes: readonly AudioNode[] = [ + bedInput, + bedTrim, + highShelf, + driveLiftInput, + driveLiftGate, + driveLiftCap, + driveTextureInput, + textureSend, + sum, + ]; + let transportAnchor: number | null = null; + let driveLiftAvailableAt: number | null = null; + let latestDrive: DriveEnergySample = { energy: 0, source: 'idle' }; + let liftPhase: LiftPhase = 'off'; + let scheduledStart = 0; + let scheduledEnd = 0; + let disposed = false; + + const ramp = (parameter: AudioParam, value: number, duration: number): void => { + const now = context.currentTime; + holdParameter(parameter, now); + parameter.linearRampToValueAtTime(value, now + duration); + }; + + const reconcileLift = (now: number): void => { + if (liftPhase === 'entering' && now >= scheduledEnd) liftPhase = 'on'; + if (liftPhase === 'exiting' && now >= scheduledEnd) liftPhase = 'off'; + }; + + const scheduleEntry = (now: number): void => { + if ( + transportAnchor === null || + driveLiftAvailableAt === null || + liftPhase === 'entering' || + liftPhase === 'on' + ) return; + scheduledStart = nextBeatTime( + Math.max(now, driveLiftAvailableAt) + LIFT_ENTRY_DWELL_SECONDS, + transportAnchor, + ); + scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS; + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.setValueAtTime(0, scheduledStart); + driveLiftGate.gain.linearRampToValueAtTime(1, scheduledEnd); + liftPhase = 'entering'; + }; + + const cancelEntry = (now: number): void => { + if (liftPhase !== 'entering' || now >= scheduledStart) return; + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05); + liftPhase = 'off'; + }; + + const scheduleExit = (now: number): void => { + if ( + transportAnchor === null || + driveLiftAvailableAt === null || + liftPhase === 'off' || + liftPhase === 'exiting' + ) return; + scheduledStart = nextBeatTime( + now + LIFT_EXIT_DWELL_SECONDS, + transportAnchor, + ); + scheduledEnd = scheduledStart + MUSIC_BEAT_SECONDS; + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.setValueAtTime(1, scheduledStart); + driveLiftGate.gain.linearRampToValueAtTime(0, scheduledEnd); + liftPhase = 'exiting'; + }; + + const cancelExit = (now: number): void => { + if (liftPhase !== 'exiting' || now >= scheduledStart) return; + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.linearRampToValueAtTime(1, now + MUSIC_BEAT_SECONDS); + liftPhase = 'on'; + }; + + const applyLatestDrive = (): void => { + if (transportAnchor === null || driveLiftAvailableAt === null) return; + const now = context.currentTime; + reconcileLift(now); + const physical = latestDrive.source === 'physical'; + if (physical && latestDrive.energy > LIFT_ENTRY_ENERGY) { + if (liftPhase === 'exiting') { + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.linearRampToValueAtTime(1, now + MUSIC_BEAT_SECONDS); + liftPhase = 'on'; + } else { + scheduleEntry(now); + } + return; + } + if (physical && latestDrive.energy >= LIFT_EXIT_ENERGY) { + if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now); + if (liftPhase === 'exiting') cancelExit(now); + return; + } + if (liftPhase === 'entering' && now < scheduledStart) cancelEntry(now); + if (!physical || latestDrive.energy < LIFT_EXIT_ENERGY) scheduleExit(now); + }; + + const resetLiftGate = (): void => { + const now = context.currentTime; + holdParameter(driveLiftGate.gain, now); + driveLiftGate.gain.linearRampToValueAtTime(0, now + 0.05); + liftPhase = 'off'; + scheduledStart = 0; + scheduledEnd = 0; + }; + + return { + bedInput, + driveLiftInput, + driveTextureInput, + setChapterIntent(intent) { + if (disposed) return; + const profile = MIX_PROFILES[intent.mood]; + ramp(bedTrim.gain, profile.bedGain, MOOD_RAMP_SECONDS); + ramp(highShelf.gain, profile.highShelfDb, MOOD_RAMP_SECONDS); + ramp(textureSend.gain, profile.textureSend, MOOD_RAMP_SECONDS); + ramp(driveLiftCap.gain, profile.liftCap, MOOD_RAMP_SECONDS); + }, + setDriveEnergy(sample) { + if (disposed) return; + latestDrive = sample; + applyLatestDrive(); + }, + setTransportAnchor(anchorTime) { + if (disposed) return; + transportAnchor = anchorTime; + if (anchorTime === null) resetLiftGate(); + else applyLatestDrive(); + }, + setDriveLiftAvailableAt(startTime) { + if (disposed) return; + driveLiftAvailableAt = startTime; + if (startTime === null) resetLiftGate(); + else applyLatestDrive(); + }, + dispose() { + if (disposed) return; + resetLiftGate(); + disposed = true; + for (const node of ownedNodes) node.disconnect(); + }, + }; +}; diff --git a/src/audio/soundtrack-transport.ts b/src/audio/soundtrack-transport.ts index b3f654e..b93baa9 100644 --- a/src/audio/soundtrack-transport.ts +++ b/src/audio/soundtrack-transport.ts @@ -2,9 +2,30 @@ import type { ChapterIntent } from '@/audio/types'; export const SOUNDTRACK_SAMPLE_RATE = 48_000; export const MUSIC_BPM = 86; +export const MUSIC_BEAT_SECONDS = 60 / MUSIC_BPM; +export const MUSIC_BEAT_SAMPLES = Math.round( + SOUNDTRACK_SAMPLE_RATE * MUSIC_BEAT_SECONDS, +); export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM; +export const MUSIC_BAR_SAMPLES = 133_953; export const MAIN_PHRASE_BARS = 8; +export const MAIN_DURATION_SAMPLES = 8_573_023; +export const MAIN_DURATION_SECONDS = MAIN_DURATION_SAMPLES / SOUNDTRACK_SAMPLE_RATE; +export const AFTERGLOW_TRANSITION_SAMPLES = MUSIC_BAR_SAMPLES; +export const AFTERGLOW_TRANSITION_SECONDS = AFTERGLOW_TRANSITION_SAMPLES / + SOUNDTRACK_SAMPLE_RATE; +export const AFTERGLOW_LOOP_START_SAMPLE = AFTERGLOW_TRANSITION_SAMPLES; +export const AFTERGLOW_LOOP_SAMPLES = 1_071_628; +export const AFTERGLOW_LOOP_END_SAMPLE = 1_205_581; +export const AFTERGLOW_LOOP_START_SECONDS = AFTERGLOW_LOOP_START_SAMPLE / + SOUNDTRACK_SAMPLE_RATE; +export const AFTERGLOW_LOOP_END_SECONDS = AFTERGLOW_LOOP_END_SAMPLE / + SOUNDTRACK_SAMPLE_RATE; +export const DRIVE_LIFT_LOOP_SAMPLES = 535_814; +export const DRIVE_LIFT_LOOP_SECONDS = DRIVE_LIFT_LOOP_SAMPLES / + SOUNDTRACK_SAMPLE_RATE; + export const INTRO_IN_END_SAMPLE = 267_907; export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535; export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE; @@ -51,3 +72,51 @@ export function nextSafeBarTime( const nextBar = Math.ceil(elapsedBars - oneSampleInBars); return anchorTime + nextBar * MUSIC_BAR_SECONDS; } + +export function nextBeatTime(earliest: number, anchorTime: number): number { + const elapsedBeats = (Math.max(anchorTime, earliest) - anchorTime) / + MUSIC_BEAT_SECONDS; + const oneSampleInBeats = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BEAT_SECONDS; + return anchorTime + Math.ceil(elapsedBeats - oneSampleInBeats) * + MUSIC_BEAT_SECONDS; +} + +export function phaseAlignedLoopOffset( + when: number, + anchorTime: number, + loopDuration: number, +): number { + if (!Number.isFinite(loopDuration) || loopDuration <= 0) return 0; + const elapsed = when - anchorTime; + return ((elapsed % loopDuration) + loopDuration) % loopDuration; +} + +export interface MainScheduleInput { + readonly startTime: number; + readonly offsetSeconds: number; + readonly bufferDuration: number; +} + +export interface MainSchedule { + readonly sourceStartTime: number; + readonly sourceOffsetSeconds: number; + readonly musicalAnchorTime: number; + readonly sourceEndTime: number; + readonly afterglowStartTime: number; +} + +export function resolveMainSchedule(input: MainScheduleInput): MainSchedule { + const sourceOffsetSeconds = Math.min( + Math.max(0, input.offsetSeconds), + Math.max(0, input.bufferDuration - 0.05), + ); + const sourceEndTime = input.startTime + input.bufferDuration - + sourceOffsetSeconds; + return { + sourceStartTime: input.startTime, + sourceOffsetSeconds, + musicalAnchorTime: input.startTime - sourceOffsetSeconds, + sourceEndTime, + afterglowStartTime: sourceEndTime - AFTERGLOW_TRANSITION_SECONDS, + }; +} diff --git a/tests/helpers/fake-audio-context.ts b/tests/helpers/fake-audio-context.ts index f10992c..39051d0 100644 --- a/tests/helpers/fake-audio-context.ts +++ b/tests/helpers/fake-audio-context.ts @@ -24,7 +24,7 @@ export interface FakeAudioHarness { readonly context: AudioContext; readonly params: FakeAudioParamRecord[]; readonly sources: FakeSourceRecord[]; - readonly gainNodes: GainNode[]; + readonly gainNodes: readonly GainNode[]; readonly buffers: AudioBuffer[]; readonly destination: AudioNode; setCurrentTime(seconds: number): void; diff --git a/tests/unit/adaptive-mixer.test.ts b/tests/unit/adaptive-mixer.test.ts new file mode 100644 index 0000000..af76820 --- /dev/null +++ b/tests/unit/adaptive-mixer.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { createAdaptiveMixer } from '@/audio/adaptive-mixer'; +import { MUSIC_BEAT_SECONDS } from '@/audio/soundtrack-transport'; +import { + createFakeAudioHarness, + type FakeAudioHarness, + type FakeAudioParamRecord, +} from '../helpers/fake-audio-context'; + +function recordFor( + harness: FakeAudioHarness, + parameter: AudioParam, +): FakeAudioParamRecord { + const record = harness.params.find((candidate) => candidate.param === parameter); + if (record === undefined) throw new Error('Missing fake AudioParam record.'); + return record; +} + +describe('adaptive mixer', () => { + it('ramps only the four approved mood controls over 1.4 seconds', () => { + const harness = createFakeAudioHarness(); + const mixer = createAdaptiveMixer(harness.context, harness.destination); + harness.setCurrentTime(2); + mixer.setChapterIntent({ + sectionId: 'toolkit', + mood: 'resolve', + atStart: false, + entryPhrase: 6, + generation: 1, + }); + const events = harness.params.flatMap((record) => record.events); + expect(events.filter((event) => event.kind === 'linear' && event.at === 3.4)) + .toHaveLength(4); + }); + + it('arms lift after sustained physical input but never for navigation', () => { + const harness = createFakeAudioHarness(); + const mixer = createAdaptiveMixer(harness.context, harness.destination); + const liftGate = harness.gainNodes[3]!.gain; + mixer.setTransportAnchor(0); + mixer.setDriveLiftAvailableAt(0); + + harness.setCurrentTime(1); + mixer.setDriveEnergy({ energy: 1, source: 'navigation' }); + expect(recordFor(harness, liftGate).events) + .not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 })); + + mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); + expect(recordFor(harness, liftGate).events).toContainEqual({ + kind: 'linear', + value: 1, + at: 4 * MUSIC_BEAT_SECONDS, + }); + }); + + it('cancels an unsustained entry and schedules hysteretic exit', () => { + const harness = createFakeAudioHarness(); + const mixer = createAdaptiveMixer(harness.context, harness.destination); + const liftGate = harness.gainNodes[3]!.gain; + mixer.setTransportAnchor(0); + mixer.setDriveLiftAvailableAt(0); + harness.setCurrentTime(1); + mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); + harness.setCurrentTime(1.2); + mixer.setDriveEnergy({ energy: 0.5, source: 'physical' }); + expect(recordFor(harness, liftGate).events) + .toContainEqual({ kind: 'hold', at: 1.2 }); + + harness.setCurrentTime(2); + mixer.setDriveEnergy({ energy: 0.7, source: 'physical' }); + harness.setCurrentTime(5); + mixer.setDriveEnergy({ energy: 0.2, source: 'physical' }); + expect(recordFor(harness, liftGate).events) + .toContainEqual(expect.objectContaining({ kind: 'linear', value: 0 })); + }); + + it('replays cached strong drive only after a late loop source is ready', () => { + const harness = createFakeAudioHarness(); + const mixer = createAdaptiveMixer(harness.context, harness.destination); + const liftGate = harness.gainNodes[3]!.gain; + mixer.setDriveEnergy({ energy: 0.8, source: 'physical' }); + mixer.setTransportAnchor(0); + expect(recordFor(harness, liftGate).events) + .not.toContainEqual(expect.objectContaining({ kind: 'linear', value: 1 })); + + harness.setCurrentTime(5); + mixer.setDriveLiftAvailableAt(5.08); + const entryEnd = recordFor(harness, liftGate).events.findLast( + (event) => event.kind === 'linear' && event.value === 1, + ); + expect(entryEnd).toBeDefined(); + expect(entryEnd!.at).toBeGreaterThan(5.08 + 0.45); + expect(entryEnd!.at / MUSIC_BEAT_SECONDS).toBeCloseTo( + Math.round(entryEnd!.at / MUSIC_BEAT_SECONDS), + 8, + ); + }); + + it('disposes each owned mixer node once without disconnecting the destination', () => { + const harness = createFakeAudioHarness(); + const mixer = createAdaptiveMixer(harness.context, harness.destination); + const destination = harness.destination as AudioNode & { disconnectCount: number }; + mixer.dispose(); + mixer.dispose(); + + expect(harness.gainNodes).toHaveLength(8); + for (const node of harness.gainNodes) { + expect((node as GainNode & { disconnectCount: number }).disconnectCount).toBe(1); + } + expect(destination.disconnectCount).toBe(0); + }); +}); diff --git a/tests/unit/soundtrack-transport.test.ts b/tests/unit/soundtrack-transport.test.ts index d41f548..87e1ffd 100644 --- a/tests/unit/soundtrack-transport.test.ts +++ b/tests/unit/soundtrack-transport.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from 'vitest'; import { + AFTERGLOW_LOOP_END_SAMPLE, + AFTERGLOW_LOOP_START_SAMPLE, + AFTERGLOW_TRANSITION_SECONDS, + DRIVE_LIFT_LOOP_SAMPLES, + DRIVE_LIFT_LOOP_SECONDS, + MAIN_DURATION_SAMPLES, + MAIN_DURATION_SECONDS, MAIN_PHRASE_BARS, + MUSIC_BAR_SAMPLES, MUSIC_BAR_SECONDS, + MUSIC_BEAT_SAMPLES, + MUSIC_BEAT_SECONDS, INTRO_IN_END_SECONDS, SOUNDTRACK_SAMPLE_RATE, mainPhraseOffsetSeconds, + nextBeatTime, nextSafeBarTime, + phaseAlignedLoopOffset, + resolveMainSchedule, resolveInitialPlayback, } from '@/audio/soundtrack-transport'; @@ -50,4 +63,44 @@ describe('soundtrack transport decisions', () => { ); expect(earliestIntroExit).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 2, 8); }); + + it('pins the short adaptive regions to whole samples', () => { + expect(MUSIC_BEAT_SAMPLES).toBe(33_488); + expect(MUSIC_BAR_SAMPLES).toBe(133_953); + expect(DRIVE_LIFT_LOOP_SAMPLES).toBe(535_814); + expect(AFTERGLOW_LOOP_START_SAMPLE).toBe(133_953); + expect(AFTERGLOW_LOOP_END_SAMPLE).toBe(1_205_581); + expect(MAIN_DURATION_SAMPLES).toBe(8_573_023); + }); + + it('derives one immutable main anchor and the final-bar afterglow handoff', () => { + expect(resolveMainSchedule({ + startTime: 10, + offsetSeconds: mainPhraseOffsetSeconds(6), + bufferDuration: MAIN_DURATION_SECONDS, + })).toEqual({ + sourceStartTime: 10, + sourceOffsetSeconds: mainPhraseOffsetSeconds(6), + musicalAnchorTime: 10 - mainPhraseOffsetSeconds(6), + sourceEndTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6), + afterglowStartTime: 10 + MAIN_DURATION_SECONDS - mainPhraseOffsetSeconds(6) - AFTERGLOW_TRANSITION_SECONDS, + }); + }); + + it('aligns a late loop source and sustained entry to the master beat', () => { + expect(phaseAlignedLoopOffset(13, 10, DRIVE_LIFT_LOOP_SECONDS)) + .toBeCloseTo(3, 8); + expect(nextBeatTime(11.45, 10)).toBeCloseTo( + 10 + 3 * MUSIC_BEAT_SECONDS, + 8, + ); + }); + + it('keeps one-sample-rounded beat and bar boundaries on the current boundary', () => { + const oneSample = 1 / SOUNDTRACK_SAMPLE_RATE; + expect(nextBeatTime(MUSIC_BEAT_SECONDS + oneSample, 0)) + .toBeCloseTo(MUSIC_BEAT_SECONDS, 8); + expect(nextSafeBarTime(MUSIC_BAR_SECONDS + oneSample, 0, 0)) + .toBeCloseTo(MUSIC_BAR_SECONDS, 8); + }); });