diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts index 0286add..d5f5383 100644 --- a/src/audio/soundtrack-engine.ts +++ b/src/audio/soundtrack-engine.ts @@ -1,4 +1,8 @@ import { + AFTERGLOW_LOOP_END_SECONDS, + AFTERGLOW_LOOP_START_SECONDS, + AFTERGLOW_TRANSITION_SECONDS, + DRIVE_LIFT_LOOP_SECONDS, INTRO_FIRST_PASS_END_SECONDS, INTRO_IN_END_SECONDS, INTRO_LOOP_END_SECONDS, @@ -9,166 +13,343 @@ import { SOUNDTRACK_SAMPLE_RATE, mainPhraseOffsetSeconds, nextSafeBarTime, + phaseAlignedLoopOffset, + resolveMainSchedule, + type MainSchedule, } from '@/audio/soundtrack-transport'; +import { + createAdaptiveMixer, + type AdaptiveMixerFactory, +} from '@/audio/adaptive-mixer'; +import { + createDriveTexture, + type DriveTexture, + type DriveTextureFactory, +} from '@/audio/drive-texture'; +import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; import type { AudioContextLifecycleState, AudioGraph, AudioInitialState, - ChapterIntent, - DriveEnergySample, - MusicMood, + AudioSourceVariant, SoundtrackSources, } from '@/audio/types'; +declare const __PORTFOLIO_TEST_BUILD__: boolean; +const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; + const SOURCE_LEAD_SECONDS = 0.08; const START_FADE_SECONDS = 0.8; const EXIT_CROSSFADE_SECONDS = 0.25; const EXIT_MINIMUM_LEAD_SECONDS = 0.35; -const EXIT_COALESCE_MS = 120; -const PROFILE_RAMP_SECONDS = 0.85; -interface SourceHandle { +interface OwnedSource { readonly node: AudioBufferSourceNode; + stopAt(when: number): void; + cancel(): void; +} + +interface SourceHandle extends OwnedSource { readonly gain: GainNode; } -interface MixProfile { - readonly gain: number; - readonly highShelfDb: number; +interface PendingIntroExit { + readonly requestedGeneration: number; + readonly transitionTime: number; + readonly outro: SourceHandle; + readonly main: SourceHandle; + readonly commitMarker: OwnedSource; + readonly mainStartMarker: OwnedSource; + readonly schedule: MainSchedule; } -const MIX_PROFILES: Readonly> = Object.freeze({ - open: { gain: 0.985, highShelfDb: -0.35 }, - shadow: { gain: 0.97, highShelfDb: -0.9 }, - lift: { gain: 1, highShelfDb: 0.35 }, - resolve: { gain: 0.985, highShelfDb: -0.15 }, -}); +export interface SoundtrackEngineDependencies { + createContext(): AudioContext; + loadBuffer( + context: BaseAudioContext, + variants: readonly AudioSourceVariant[], + signal: AbortSignal, + ): Promise; + createMixer: AdaptiveMixerFactory; + createDriveTexture: DriveTextureFactory; +} -export function createBrowserSoundtrackEngine( +export function createSoundtrackEngine( sources: SoundtrackSources, initialState: AudioInitialState, + dependencies: SoundtrackEngineDependencies, ): AudioGraph { - const BrowserAudioContext = window.AudioContext; - if (BrowserAudioContext === undefined) throw new Error('Web Audio is unavailable.'); - - const context = new BrowserAudioContext({ - latencyHint: 'playback', - sampleRate: SOUNDTRACK_SAMPLE_RATE, - }); - const profileGain = context.createGain(); - const tone = context.createBiquadFilter(); + const context = dependencies.createContext(); const outputGain = context.createGain(); - const sourceNodes = new Set(); + outputGain.gain.value = 0; + const mixer = dependencies.createMixer(context, outputGain); + let driveTexture: DriveTexture | null = null; + try { + driveTexture = dependencies.createDriveTexture(context, mixer.driveTextureInput); + } catch { + driveTexture = null; + } + + const ownedSources = new Set(); const stateListeners = new Set<(state: AudioContextLifecycleState) => void>(); + const loadController = new AbortController(); + const activatedFromStart = initialState.chapter.atStart; let latestChapter = initialState.chapter; + let latestDrive = initialState.drive; let connected = false; let closed = false; - const activatedFromStart = initialState.chapter.atStart; let transportStarted = false; let introStarted = false; let mainCommitted = false; let introAnchorTime = 0; let introProgramGain: GainNode | null = null; - let introSourceNodes: AudioBufferSourceNode[] = []; + let introSourceNodes: SourceHandle[] = []; let loadedIntroBuffer: AudioBuffer | null = null; + let loadedMainBuffer: AudioBuffer | null = null; let mainBufferPromise: Promise | null = null; - let loadController: AbortController | null = null; + let driveLiftBufferPromise: Promise | null = null; + let afterglowBufferPromise: Promise | null = null; let loadPromise: Promise | null = null; - let exitTimer: number | undefined; + let exitLoadRequest: Promise | null = null; + let pendingIntroExit: PendingIntroExit | null = null; + let driveScheduleAttached = false; + let afterglowScheduleAttached = false; + let driveLiftSource: SourceHandle | null = null; + let afterglowSource: SourceHandle | null = null; + let mainSource: SourceHandle | null = null; + let mainSchedule: MainSchedule | null = null; - tone.type = 'highshelf'; - tone.frequency.value = 5_200; - tone.Q.value = 0.55; - outputGain.gain.value = 0; - profileGain.connect(tone); - tone.connect(outputGain); - - const trackSource = (node: AudioBufferSourceNode): void => { - sourceNodes.add(node); - node.addEventListener('ended', () => { - sourceNodes.delete(node); + const ownSource = ( + node: AudioBufferSourceNode, + releaseDependents: () => void, + ): OwnedSource => { + let released = false; + let stopCalled = false; + let owned: OwnedSource; + const release = (): void => { + if (released) return; + released = true; + ownedSources.delete(owned); node.disconnect(); - }, { once: true }); + releaseDependents(); + }; + const stopOnce = (when?: number): void => { + if (stopCalled) return; + stopCalled = true; + try { + if (when === undefined) node.stop(); + else node.stop(when); + } catch { + // All teardown paths converge on the same idempotent release below. + } + }; + owned = { + node, + stopAt(when) { + stopOnce(when); + }, + cancel() { + stopOnce(); + release(); + }, + }; + ownedSources.add(owned); + node.addEventListener('ended', release, { once: true }); + return owned; }; - const makeSource = (buffer: AudioBuffer): SourceHandle => { + const makeSource = ( + buffer: AudioBuffer, + destination: AudioNode, + onRelease: () => void = () => {}, + ): SourceHandle => { const node = context.createBufferSource(); const gain = context.createGain(); node.buffer = buffer; node.connect(gain); - gain.connect(profileGain); - node.addEventListener('ended', () => gain.disconnect(), { once: true }); - trackSource(node); - return { node, gain }; + gain.connect(destination); + const owned = ownSource(node, () => { + gain.disconnect(); + onRelease(); + }); + return { ...owned, gain }; }; - const ramp = (parameter: AudioParam, value: number, duration: number): void => { - const now = context.currentTime; - holdParameter(parameter, now); - parameter.linearRampToValueAtTime(value, now + duration); + const makeMarker = (when: number, onEnded: () => void): OwnedSource => { + const node = context.createBufferSource(); + node.buffer = context.createBuffer(1, 1, context.sampleRate); + const marker = ownSource(node, () => {}); + node.addEventListener('ended', onEnded, { once: true }); + node.start(when); + return marker; }; - const applyMix = (chapter: ChapterIntent): void => { - const profile = MIX_PROFILES[chapter.mood]; - ramp(profileGain.gain, profile.gain, PROFILE_RAMP_SECONDS); - ramp(tone.gain, profile.highShelfDb, PROFILE_RAMP_SECONDS); + const loadOptional = ( + variants: readonly AudioSourceVariant[], + ): Promise => dependencies + .loadBuffer(context, variants, loadController.signal) + .catch(() => null); + + const ensureMainLoad = (): Promise => { + if (mainBufferPromise !== null) return mainBufferPromise; + const promise = dependencies.loadBuffer(context, sources.main, loadController.signal); + mainBufferPromise = promise; + void promise.then((buffer) => { + if (!closed && !loadController.signal.aborted) loadedMainBuffer = buffer; + }).catch(() => undefined); + return promise; }; - const startMain = ( - buffer: AudioBuffer, - when: number, - offsetSeconds: number, - fadeSeconds: number, - ): SourceHandle => { - const main = makeSource(buffer); - const offset = Math.min(Math.max(0, offsetSeconds), Math.max(0, buffer.duration - 0.05)); + const ensureDriveLiftLoad = (): Promise => { + driveLiftBufferPromise ??= loadOptional(sources.driveLift); + return driveLiftBufferPromise; + }; + + const attachMainEnded = (main: SourceHandle): void => { + main.node.addEventListener('ended', () => { + if (mainSource !== main) return; + mainSource = null; + loadedMainBuffer = null; + mainBufferPromise = null; + }, { once: true }); + }; + + const startDriveLift = (buffer: AudioBuffer, schedule: MainSchedule): void => { + if (closed || driveLiftSource !== null) return; + const when = Math.max( + schedule.sourceStartTime, + context.currentTime + SOURCE_LEAD_SECONDS, + ); + const source = makeSource(buffer, mixer.driveLiftInput); + source.node.loop = true; + source.node.loopStart = 0; + source.node.loopEnd = DRIVE_LIFT_LOOP_SECONDS; + source.node.start( + when, + phaseAlignedLoopOffset( + when, + schedule.musicalAnchorTime, + DRIVE_LIFT_LOOP_SECONDS, + ), + ); + driveLiftSource = source; + mixer.setDriveLiftAvailableAt(when); + }; + + const startAfterglow = (buffer: AudioBuffer, schedule: MainSchedule): void => { + if ( + closed + || afterglowSource !== null + || context.currentTime >= schedule.sourceEndTime + ) return; + const when = Math.max( + schedule.afterglowStartTime, + context.currentTime + SOURCE_LEAD_SECONDS, + ); + if (when >= schedule.sourceEndTime) return; + const offset = Math.max(0, when - schedule.afterglowStartTime); + if (offset >= AFTERGLOW_LOOP_END_SECONDS) return; + const source = makeSource(buffer, mixer.bedInput); + source.node.loop = true; + source.node.loopStart = AFTERGLOW_LOOP_START_SECONDS; + source.node.loopEnd = AFTERGLOW_LOOP_END_SECONDS; + const progress = Math.min(1, offset / AFTERGLOW_TRANSITION_SECONDS); + source.gain.gain.setValueAtTime(progress, when); + source.gain.gain.linearRampToValueAtTime(1, schedule.sourceEndTime); + source.node.start(when, offset); + afterglowSource = source; + }; + + const beginDriveLayer = (schedule: MainSchedule): void => { + if (closed || driveScheduleAttached) return; + driveScheduleAttached = true; + const promise = ensureDriveLiftLoad(); + void promise.then((buffer) => { + driveLiftBufferPromise = null; + if (buffer !== null) startDriveLift(buffer, schedule); + }); + }; + + const beginAfterglowLayer = (schedule: MainSchedule): void => { + if (closed || afterglowScheduleAttached) return; + afterglowScheduleAttached = true; + const promise = loadOptional(sources.afterglow); + afterglowBufferPromise = promise; + void promise.then((buffer) => { + afterglowBufferPromise = null; + if (buffer !== null) startAfterglow(buffer, schedule); + }); + }; + + const startDirectMain = (buffer: AudioBuffer): void => { + const when = context.currentTime + SOURCE_LEAD_SECONDS; + const schedule = resolveMainSchedule({ + startTime: when, + offsetSeconds: mainPhraseOffsetSeconds(latestChapter.entryPhrase), + bufferDuration: buffer.duration, + }); + const main = makeSource(buffer, mixer.bedInput); main.gain.gain.setValueAtTime(0, when); - main.gain.gain.linearRampToValueAtTime(1, when + fadeSeconds); - main.node.start(when, offset); + main.gain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS); + main.node.start(when, schedule.sourceOffsetSeconds); mainCommitted = true; - return main; + mainSource = main; + mainSchedule = schedule; + attachMainEnded(main); + mixer.setTransportAnchor(schedule.musicalAnchorTime); + beginDriveLayer(schedule); + beginAfterglowLayer(schedule); }; const startIntro = (buffer: AudioBuffer, when: number): void => { - const lead = context.createBufferSource(); - const loop = context.createBufferSource(); const programGain = context.createGain(); - - lead.buffer = buffer; - loop.buffer = buffer; - loop.loop = true; - loop.loopStart = INTRO_LOOP_START_SECONDS; - loop.loopEnd = INTRO_LOOP_END_SECONDS; - lead.connect(programGain); - loop.connect(programGain); - programGain.connect(profileGain); + programGain.connect(mixer.bedInput); programGain.gain.setValueAtTime(0, when); programGain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS); - - trackSource(lead); - trackSource(loop); - lead.start(when, 0, INTRO_FIRST_PASS_END_SECONDS); - loop.start(when + INTRO_FIRST_PASS_END_SECONDS, INTRO_LOOP_START_SECONDS); - + let remainingSources = 2; + let programReleased = false; + const releaseProgram = (): void => { + remainingSources -= 1; + if (remainingSources !== 0 || programReleased) return; + programReleased = true; + programGain.disconnect(); + if (introProgramGain === programGain) introProgramGain = null; + }; + const lead = makeSource(buffer, programGain, releaseProgram); + const loop = makeSource(buffer, programGain, releaseProgram); + loop.node.loop = true; + loop.node.loopStart = INTRO_LOOP_START_SECONDS; + loop.node.loopEnd = INTRO_LOOP_END_SECONDS; + lead.node.start(when, 0, INTRO_FIRST_PASS_END_SECONDS); + loop.node.start(when + INTRO_FIRST_PASS_END_SECONDS, INTRO_LOOP_START_SECONDS); introProgramGain = programGain; introSourceNodes = [lead, loop]; introAnchorTime = when; introStarted = true; }; - const scheduleIntroExit = (introBuffer: AudioBuffer, mainBuffer: AudioBuffer): void => { - if (!introStarted || mainCommitted || introProgramGain === null || closed) return; - mainCommitted = true; - const schedulingTime = Math.max( - context.currentTime, - introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS, - ); + const scheduleIntroExit = ( + introBuffer: AudioBuffer, + mainBuffer: AudioBuffer, + requestedGeneration: number, + ): void => { + if ( + closed + || !introStarted + || mainCommitted + || pendingIntroExit !== null + || latestChapter.atStart + || introProgramGain === null + ) return; const transitionTime = nextSafeBarTime( - schedulingTime, + Math.max( + context.currentTime, + introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS, + ), introAnchorTime, EXIT_MINIMUM_LEAD_SECONDS, ); - const outro = makeSource(introBuffer); + const outro = makeSource(introBuffer, mixer.bedInput); outro.gain.gain.setValueAtTime(0, transitionTime); outro.gain.gain.linearRampToValueAtTime(1, transitionTime + EXIT_CROSSFADE_SECONDS); outro.node.start( @@ -179,34 +360,103 @@ export function createBrowserSoundtrackEngine( introProgramGain.gain.cancelScheduledValues(transitionTime); introProgramGain.gain.setValueAtTime(1, transitionTime); - introProgramGain.gain.linearRampToValueAtTime(0, transitionTime + EXIT_CROSSFADE_SECONDS); - for (const source of introSourceNodes) { - stopSourceAt(source, transitionTime + EXIT_CROSSFADE_SECONDS); - } + introProgramGain.gain.linearRampToValueAtTime( + 0, + transitionTime + EXIT_CROSSFADE_SECONDS, + ); const mainTime = transitionTime + INTRO_OUT_END_SECONDS - INTRO_OUT_START_SECONDS; - const main = startMain(mainBuffer, mainTime, 0, EXIT_CROSSFADE_SECONDS); + const schedule = resolveMainSchedule({ + startTime: mainTime, + offsetSeconds: 0, + bufferDuration: mainBuffer.duration, + }); + const main = makeSource(mainBuffer, mixer.bedInput); + main.gain.gain.setValueAtTime(0, mainTime); + main.gain.gain.linearRampToValueAtTime(1, mainTime + EXIT_CROSSFADE_SECONDS); + main.node.start(mainTime, 0); outro.gain.gain.cancelScheduledValues(mainTime); outro.gain.gain.setValueAtTime(1, mainTime); outro.gain.gain.linearRampToValueAtTime(0, mainTime + EXIT_CROSSFADE_SECONDS); - const finishedProgramGain = introProgramGain; - outro.node.addEventListener('ended', () => { - finishedProgramGain.disconnect(); - if (introProgramGain === finishedProgramGain) introProgramGain = null; - introSourceNodes = []; + + let pending!: PendingIntroExit; + const commitMarker = makeMarker(transitionTime, () => { + if (closed || pendingIntroExit !== pending) return; + mainCommitted = true; + pendingIntroExit = null; + mainSource = main; + mainSchedule = schedule; + attachMainEnded(main); + for (const source of introSourceNodes) { + source.stopAt(transitionTime + EXIT_CROSSFADE_SECONDS); + } loadedIntroBuffer = null; introStarted = false; - }, { once: true }); + beginDriveLayer(schedule); + }); + const mainStartMarker = makeMarker(mainTime, () => { + if (closed || mainSchedule !== schedule) return; + mixer.setTransportAnchor(schedule.musicalAnchorTime); + beginAfterglowLayer(schedule); + }); + pending = { + requestedGeneration, + transitionTime, + outro, + main, + commitMarker, + mainStartMarker, + schedule, + }; + pendingIntroExit = pending; + }; + + const cancelPendingIntroExit = (): void => { + const pending = pendingIntroExit; + if (pending === null || context.currentTime >= pending.transitionTime) return; + pendingIntroExit = null; + pending.outro.cancel(); + pending.main.cancel(); + pending.commitMarker.cancel(); + pending.mainStartMarker.cancel(); + if (introProgramGain !== null) { + holdParameter(introProgramGain.gain, context.currentTime); + introProgramGain.gain.linearRampToValueAtTime(1, context.currentTime + 0.05); + } + }; + + const requestIntroExit = (requestedGeneration: number): void => { + if ( + closed + || !introStarted + || mainCommitted + || pendingIntroExit !== null + || exitLoadRequest !== null + ) return; + const introBuffer = loadedIntroBuffer; + const mainPromise = mainBufferPromise; + if (introBuffer === null || mainPromise === null) return; + if (loadedMainBuffer !== null) { + scheduleIntroExit(introBuffer, loadedMainBuffer, requestedGeneration); + return; + } + exitLoadRequest = mainPromise.then((mainBuffer) => { + if (!closed && !latestChapter.atStart && !mainCommitted) { + scheduleIntroExit(introBuffer, mainBuffer, requestedGeneration); + } + }).catch(() => undefined).finally(() => { + exitLoadRequest = null; + }); }; const loadAndStart = async (): Promise => { - loadController = new AbortController(); + ensureDriveLiftLoad(); const signal = loadController.signal; - mainBufferPromise = loadAudioBuffer(context, sources.main, signal); - void mainBufferPromise.catch(() => undefined); + const mainPromise = ensureMainLoad(); + void mainPromise.catch(() => undefined); if (activatedFromStart) { - loadedIntroBuffer = await loadAudioBuffer(context, sources.intro, signal); + loadedIntroBuffer = await dependencies.loadBuffer(context, sources.intro, signal); if (closed || signal.aborted) throw abortError(); if (latestChapter.atStart) { transportStarted = true; @@ -215,7 +465,7 @@ export function createBrowserSoundtrackEngine( } } - const mainBuffer = await mainBufferPromise; + const mainBuffer = await mainPromise; if (closed || signal.aborted) throw abortError(); if (activatedFromStart && latestChapter.atStart && loadedIntroBuffer !== null) { transportStarted = true; @@ -223,34 +473,13 @@ export function createBrowserSoundtrackEngine( return; } transportStarted = true; - startMain( - mainBuffer, - context.currentTime + SOURCE_LEAD_SECONDS, - mainPhraseOffsetSeconds(latestChapter.entryPhrase), - START_FADE_SECONDS, - ); + startDirectMain(mainBuffer); }; - const requestIntroExit = (): void => { - if (!introStarted || mainCommitted || exitTimer !== undefined || closed) return; - exitTimer = window.setTimeout(() => { - exitTimer = undefined; - if (closed || latestChapter.atStart || mainCommitted) return; - const introBuffer = loadedIntroBuffer; - const pendingMain = mainBufferPromise; - if (introBuffer === null || pendingMain === null) return; - void pendingMain.then((mainBuffer) => { - if (!closed && !latestChapter.atStart && !mainCommitted) { - scheduleIntroExit(introBuffer, mainBuffer); - } - }).catch(() => undefined); - }, EXIT_COALESCE_MS); - }; - - const cancelUnscheduledIntroExit = (): void => { - if (exitTimer === undefined) return; - window.clearTimeout(exitTimer); - exitTimer = undefined; + const rampOutput = (value: number): void => { + const now = context.currentTime; + holdParameter(outputGain.gain, now); + outputGain.gain.linearRampToValueAtTime(value, now + 0.08); }; const onStateChange = (): void => { @@ -258,12 +487,15 @@ export function createBrowserSoundtrackEngine( for (const listener of stateListeners) listener(state); }; context.addEventListener('statechange', onStateChange); - applyMix(initialState.chapter); + mixer.setChapterIntent(latestChapter); + mixer.setDriveEnergy(latestDrive); + driveTexture?.setEnergy(latestDrive); return { async resume() { if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError'); await context.resume(); + ensureDriveLiftLoad(); if (transportStarted) return; loadPromise ??= loadAndStart(); await loadPromise; @@ -274,15 +506,22 @@ export function createBrowserSoundtrackEngine( async close() { if (closed) return; closed = true; - if (exitTimer !== undefined) window.clearTimeout(exitTimer); - loadController?.abort(); - for (const source of sourceNodes) stopSource(source); - sourceNodes.clear(); + loadController.abort(); + cancelPendingIntroExit(); + mixer.setDriveLiftAvailableAt(null); + driveTexture?.dispose(); + driveTexture = null; + mixer.dispose(); + for (const source of [...ownedSources]) source.cancel(); + ownedSources.clear(); context.removeEventListener('statechange', onStateChange); stateListeners.clear(); - profileGain.disconnect(); - tone.disconnect(); outputGain.disconnect(); + connected = false; + loadedIntroBuffer = null; + loadedMainBuffer = null; + afterglowBufferPromise = null; + driveLiftBufferPromise = null; if (context.state !== 'closed') await context.close(); }, connectOutput() { @@ -296,24 +535,26 @@ export function createBrowserSoundtrackEngine( connected = false; }, setOutputGain(value) { - ramp(outputGain.gain, clamp(value), 0.08); + if (!closed) rampOutput(clamp(value)); }, setChapterIntent(intent) { if (closed) return; latestChapter = intent; - applyMix(intent); - if (!intent.atStart) requestIntroExit(); - else cancelUnscheduledIntroExit(); + mixer.setChapterIntent(intent); + if (!mainCommitted && introStarted) { + if (intent.atStart) cancelPendingIntroExit(); + else requestIntroExit(intent.generation); + } }, - setDriveEnergy(_sample: DriveEnergySample) { + setDriveEnergy(sample) { if (closed) return; + latestDrive = sample; + driveTexture?.setEnergy(sample); + mixer.setDriveEnergy(sample); }, cancelPending() { - loadController?.abort(); - if (exitTimer !== undefined) { - window.clearTimeout(exitTimer); - exitTimer = undefined; - } + loadController.abort(); + cancelPendingIntroExit(); }, onContextStateChange(listener) { stateListeners.add(listener); @@ -322,9 +563,56 @@ export function createBrowserSoundtrackEngine( }; } +export function createBrowserSoundtrackEngine( + sources: SoundtrackSources, + initialState: AudioInitialState, +): AudioGraph { + if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) { + recordTestFaultEvent('audio-start-rejected'); + throw new DOMException('Injected audio start rejection', 'NotAllowedError'); + } + const BrowserAudioContext = window.AudioContext + ?? (window as typeof window & { + webkitAudioContext?: typeof AudioContext; + }).webkitAudioContext; + if (BrowserAudioContext === undefined) throw new Error('Web Audio is unavailable.'); + const graph = createSoundtrackEngine(sources, initialState, { + createContext: () => new BrowserAudioContext({ + latencyHint: 'playback', + sampleRate: SOUNDTRACK_SAMPLE_RATE, + }), + loadBuffer: loadAudioBuffer, + createMixer: createAdaptiveMixer, + createDriveTexture, + }); + let resumeCount = 0; + return { + ...graph, + async resume() { + resumeCount += 1; + if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) { + recordTestFaultEvent('audio-resume-rejected'); + throw new DOMException('Injected audio resume rejection', 'NotAllowedError'); + } + if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) { + recordTestFaultEvent('audio-graph-invalid'); + throw new DOMException('Injected invalid audio graph', 'InvalidStateError'); + } + await graph.resume(); + }, + async suspend() { + if (isTestFault('F13-audio-suspend-rejection')) { + recordTestFaultEvent('audio-suspend-rejected'); + throw new DOMException('Injected audio suspend rejection', 'InvalidStateError'); + } + await graph.suspend(); + }, + }; +} + async function loadAudioBuffer( context: BaseAudioContext, - variants: readonly SoundtrackSources['intro'][number][], + variants: readonly AudioSourceVariant[], signal: AbortSignal, ): Promise { const ordered = orderSupportedVariants(variants); @@ -332,7 +620,10 @@ async function loadAudioBuffer( for (const variant of ordered) { if (signal.aborted) throw abortError(); try { - const response = await fetch(variant.url, { signal, credentials: 'same-origin' }); + const response = await fetch(variant.url, { + signal, + credentials: 'same-origin', + }); if (!response.ok) throw new Error(`Soundtrack request failed (${response.status}).`); const encoded = await response.arrayBuffer(); if (signal.aborted) throw abortError(); @@ -342,15 +633,18 @@ async function loadAudioBuffer( lastError = error; } } - throw lastError instanceof Error ? lastError : new Error('No supported soundtrack source loaded.'); + throw lastError instanceof Error + ? lastError + : new Error('No supported soundtrack source loaded.'); } -function orderSupportedVariants( +function orderSupportedVariants( variants: readonly T[], ): readonly T[] { const probe = document.createElement('audio'); return [...variants].sort((left, right) => ( - Number(probe.canPlayType(right.mediaType) !== '') - Number(probe.canPlayType(left.mediaType) !== '') + Number(probe.canPlayType(right.mediaType) !== '') + - Number(probe.canPlayType(left.mediaType) !== '') )); } @@ -363,23 +657,6 @@ function holdParameter(parameter: AudioParam, atTime: number): void { parameter.setValueAtTime(parameter.value, atTime); } -function stopSource(source: AudioBufferSourceNode): void { - try { - source.stop(); - } catch { - // An already-ended or not-yet-started source is safe to ignore during teardown. - } - source.disconnect(); -} - -function stopSourceAt(source: AudioBufferSourceNode, when: number): void { - try { - source.stop(when); - } catch { - // A source that already ended needs no scheduled cleanup. - } -} - function abortError(): DOMException { return new DOMException('Soundtrack loading was cancelled.', 'AbortError'); } diff --git a/tests/helpers/fake-audio-context.ts b/tests/helpers/fake-audio-context.ts index 39051d0..b13f45f 100644 --- a/tests/helpers/fake-audio-context.ts +++ b/tests/helpers/fake-audio-context.ts @@ -27,6 +27,9 @@ export interface FakeAudioHarness { readonly gainNodes: readonly GainNode[]; readonly buffers: AudioBuffer[]; readonly destination: AudioNode; + readonly resumeCount: number; + readonly suspendCount: number; + readonly closeCount: number; setCurrentTime(seconds: number): void; decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer; emitContextState(state: AudioContextState | 'interrupted'): void; @@ -105,6 +108,9 @@ export function createFakeAudioHarness(): FakeAudioHarness { const sampleRate = 48_000; let currentTime = 0; let state: AudioContextState | 'interrupted' = 'suspended'; + let resumeCount = 0; + let suspendCount = 0; + let closeCount = 0; const decodedBuffer = (durationSeconds: number, channels = 2): AudioBuffer => { const length = Math.max(1, Math.round(durationSeconds * sampleRate)); @@ -232,12 +238,15 @@ export function createFakeAudioHarness(): FakeAudioHarness { return decodedBuffer(1); }, async resume() { + resumeCount += 1; state = 'running'; }, async suspend() { + suspendCount += 1; state = 'suspended'; }, async close() { + closeCount += 1; state = 'closed'; }, addEventListener(type: string, listener: () => void) { @@ -255,6 +264,15 @@ export function createFakeAudioHarness(): FakeAudioHarness { gainNodes, buffers, destination, + get resumeCount() { + return resumeCount; + }, + get suspendCount() { + return suspendCount; + }, + get closeCount() { + return closeCount; + }, setCurrentTime(seconds) { currentTime = seconds; }, diff --git a/tests/unit/soundtrack-engine.test.ts b/tests/unit/soundtrack-engine.test.ts new file mode 100644 index 0000000..b76a400 --- /dev/null +++ b/tests/unit/soundtrack-engine.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSoundtrackEngine } from '@/audio/soundtrack-engine'; +import { resolveChapterIntent } from '@/audio/journey-music-state'; +import { + AFTERGLOW_LOOP_END_SECONDS, + AFTERGLOW_LOOP_START_SECONDS, + DRIVE_LIFT_LOOP_SECONDS, + INTRO_RUNTIME_END_SECONDS, + MAIN_DURATION_SECONDS, + mainPhraseOffsetSeconds, + phaseAlignedLoopOffset, +} from '@/audio/soundtrack-transport'; +import type { AdaptiveMixer } from '@/audio/adaptive-mixer'; +import type { DriveTexture } from '@/audio/drive-texture'; +import type { + AudioInitialState, + SoundtrackSources, +} from '@/audio/types'; +import { + createFakeAudioHarness, + type FakeAudioHarness, + type FakeSourceRecord, +} from '../helpers/fake-audio-context'; + +const sources: SoundtrackSources = { + intro: [{ url: '/intro.opus', mediaType: 'audio/ogg' }], + main: [{ url: '/main.opus', mediaType: 'audio/ogg' }], + afterglow: [{ url: '/afterglow.opus', mediaType: 'audio/ogg' }], + driveLift: [{ url: '/drive-lift.opus', mediaType: 'audio/ogg' }], +}; + +const initialState: AudioInitialState = { + chapter: resolveChapterIntent('start'), + drive: { energy: 0, source: 'idle' }, +}; + +interface EngineHarnessOptions { + readonly rejectIntro?: boolean; + readonly rejectMain?: boolean; + readonly rejectAfterglow?: boolean; + readonly rejectDriveLift?: boolean; + readonly rejectDriveTexture?: boolean; + readonly deferDriveLift?: boolean; +} + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; +} + +function createEngineHarness( + state: AudioInitialState = initialState, + options: EngineHarnessOptions = {}, +) { + const harness = createFakeAudioHarness(); + const buffers = new Map([ + ['/intro.opus', harness.decodedBuffer(INTRO_RUNTIME_END_SECONDS)], + ['/main.opus', harness.decodedBuffer(MAIN_DURATION_SECONDS)], + ['/afterglow.opus', harness.decodedBuffer(AFTERGLOW_LOOP_END_SECONDS)], + ['/drive-lift.opus', harness.decodedBuffer(DRIVE_LIFT_LOOP_SECONDS)], + ]); + const pendingDriveLift = deferred(); + const loadCalls: string[] = []; + const loadPromises: Promise[] = []; + const bedInput = harness.context.createGain(); + const driveLiftInput = harness.context.createGain(); + const driveTextureInput = harness.context.createGain(); + const mixer = { + bedInput, + driveLiftInput, + driveTextureInput, + setChapterIntent: vi.fn(), + setDriveEnergy: vi.fn(), + setTransportAnchor: vi.fn(), + setDriveLiftAvailableAt: vi.fn(), + dispose: vi.fn(), + } satisfies AdaptiveMixer; + const texture = { + setEnergy: vi.fn(), + dispose: vi.fn(), + } satisfies DriveTexture; + const createMixer = vi.fn(() => mixer); + const createDriveTexture = vi.fn(() => { + if (options.rejectDriveTexture) throw new Error('drive texture unavailable'); + return texture; + }); + const loadBuffer = vi.fn(( + _context: BaseAudioContext, + variants: SoundtrackSources['intro'], + ): Promise => { + const url = variants[0]?.url; + if (url === undefined) return Promise.reject(new Error('missing source')); + loadCalls.push(url); + const reject = ( + (url === '/intro.opus' && options.rejectIntro) + || (url === '/main.opus' && options.rejectMain) + || (url === '/afterglow.opus' && options.rejectAfterglow) + || (url === '/drive-lift.opus' && options.rejectDriveLift) + ); + const buffer = buffers.get(url); + let promise: Promise; + if (reject || buffer === undefined) promise = Promise.reject(new Error(`${url} unavailable`)); + else if (url === '/drive-lift.opus' && options.deferDriveLift) promise = pendingDriveLift.promise; + else promise = Promise.resolve(buffer); + loadPromises.push(promise); + return promise; + }); + const graph = createSoundtrackEngine(sources, state, { + createContext: () => harness.context, + loadBuffer, + createMixer, + createDriveTexture, + }); + + const sourcesFor = (url: string): FakeSourceRecord[] => { + const buffer = buffers.get(url); + return harness.sources.filter((record) => ( + (record.node as AudioBufferSourceNode).buffer === buffer + )); + }; + const sourceFor = (url: string): FakeSourceRecord => { + const record = sourcesFor(url).at(-1); + if (record === undefined) throw new Error(`Missing source for ${url}.`); + return record; + }; + const markers = (): FakeSourceRecord[] => harness.sources.filter((record) => { + const buffer = (record.node as AudioBufferSourceNode).buffer; + return record.kind === 'buffer' && buffer !== null && ![...buffers.values()].includes(buffer); + }); + const flushOptionalLoads = async (): Promise => { + await Promise.allSettled([...loadPromises]); + await Promise.resolve(); + await Promise.resolve(); + }; + + return { + graph, + harness, + mixer, + texture, + loadCalls, + sourcesFor, + sourceFor, + markers, + flushOptionalLoads, + resolveDriveLift() { + const buffer = buffers.get('/drive-lift.opus'); + if (buffer === undefined) throw new Error('Missing drive lift buffer.'); + pendingDriveLift.resolve(buffer); + }, + }; +} + +describe('soundtrack engine', () => { + it('cancels a Start departure before its audible bar boundary', async () => { + const test = createEngineHarness(); + await test.graph.resume(); + test.graph.setChapterIntent(resolveChapterIntent('profile', 1)); + const pendingMain = test.sourcesFor('/main.opus').at(-1)!; + const pendingOutro = test.sourcesFor('/intro.opus').at(-1)!; + expect(pendingMain.starts).toHaveLength(1); + expect(pendingOutro.starts).toHaveLength(1); + + test.graph.setChapterIntent(resolveChapterIntent('start', 2)); + expect(pendingMain.stops).toHaveLength(1); + expect(pendingOutro.stops).toHaveLength(1); + expect(test.mixer.setTransportAnchor).not.toHaveBeenCalledWith(expect.any(Number)); + }); + + it('reuses one pending Start departure across rapid non-Start moods', async () => { + const test = createEngineHarness(); + await test.graph.resume(); + test.graph.setChapterIntent(resolveChapterIntent('profile', 1)); + const mainCount = test.sourcesFor('/main.opus').length; + const introCount = test.sourcesFor('/intro.opus').length; + test.graph.setChapterIntent(resolveChapterIntent('experience', 2)); + test.graph.setChapterIntent(resolveChapterIntent('toolkit', 3)); + expect(test.sourcesFor('/main.opus')).toHaveLength(mainCount); + expect(test.sourcesFor('/intro.opus')).toHaveLength(introCount); + expect(test.mixer.setChapterIntent).toHaveBeenLastCalledWith( + resolveChapterIntent('toolkit', 3), + ); + }); + + it('keeps the main anchor unchanged across every mood command', async () => { + const test = createEngineHarness({ + chapter: resolveChapterIntent('experience'), + drive: { energy: 0, source: 'idle' }, + }); + await test.graph.resume(); + const main = test.sourceFor('/main.opus'); + const firstAnchor = test.mixer.setTransportAnchor.mock.calls.find( + ([anchor]) => typeof anchor === 'number', + )?.[0]; + test.graph.setChapterIntent(resolveChapterIntent('contact', 1)); + test.graph.setChapterIntent(resolveChapterIntent('start', 2)); + expect(test.mixer.setTransportAnchor.mock.calls.filter( + ([anchor]) => typeof anchor === 'number', + ).map(([anchor]) => anchor)).toEqual([firstAnchor]); + expect(test.sourcesFor('/main.opus')).toEqual([main]); + expect(test.harness.params.find( + ({ param }) => param === (main.node as AudioBufferSourceNode).playbackRate, + )?.events).toEqual([]); + }); + + it('loads afterglow only once the intro path main source has begun', async () => { + const test = createEngineHarness(); + await test.graph.resume(); + expect(test.loadCalls).toEqual(expect.arrayContaining(['/intro.opus', '/main.opus', '/drive-lift.opus'])); + expect(test.loadCalls).not.toContain('/afterglow.opus'); + + test.graph.setChapterIntent(resolveChapterIntent('profile', 1)); + const [commitMarker, mainStartMarker] = test.markers(); + expect(commitMarker).toBeDefined(); + expect(mainStartMarker).toBeDefined(); + test.harness.setCurrentTime(commitMarker!.starts[0]!.at); + commitMarker!.emitEnded(); + expect(test.loadCalls).not.toContain('/afterglow.opus'); + const committedMain = test.sourceFor('/main.opus'); + const committedOutro = test.sourcesFor('/intro.opus').at(-1)!; + test.graph.setChapterIntent(resolveChapterIntent('start', 2)); + expect(committedMain.stops).toHaveLength(0); + expect(committedOutro.stops).toHaveLength(0); + + test.harness.setCurrentTime(mainStartMarker!.starts[0]!.at); + mainStartMarker!.emitEnded(); + expect(test.loadCalls.filter((url) => url === '/afterglow.opus')).toHaveLength(1); + }); + + it('starts one afterglow transition and loops only its eight-bar idle region', async () => { + const test = createEngineHarness({ + chapter: resolveChapterIntent('toolkit'), + drive: { energy: 0, source: 'idle' }, + }); + await test.graph.resume(); + await test.flushOptionalLoads(); + const afterglow = test.sourceFor('/afterglow.opus'); + expect(afterglow.starts).toHaveLength(1); + expect((afterglow.node as AudioBufferSourceNode).loop).toBe(true); + expect((afterglow.node as AudioBufferSourceNode).loopStart) + .toBe(AFTERGLOW_LOOP_START_SECONDS); + expect((afterglow.node as AudioBufferSourceNode).loopEnd) + .toBe(AFTERGLOW_LOOP_END_SECONDS); + }); + + it('contains optional failures and still resolves dominant playback', async () => { + const intro = createEngineHarness(initialState, { + rejectDriveLift: true, + rejectDriveTexture: true, + }); + await expect(intro.graph.resume()).resolves.toBeUndefined(); + await intro.flushOptionalLoads(); + expect(intro.sourceFor('/intro.opus').starts).toHaveLength(1); + + const direct = createEngineHarness({ + chapter: resolveChapterIntent('profile'), + drive: { energy: 0, source: 'idle' }, + }, { rejectAfterglow: true }); + await expect(direct.graph.resume()).resolves.toBeUndefined(); + await direct.flushOptionalLoads(); + expect(direct.sourceFor('/main.opus').starts).toHaveLength(1); + expect(direct.sourcesFor('/afterglow.opus')).toHaveLength(0); + }); + + it('starts direct activation at the mapped phrase-seven source offset', async () => { + const test = createEngineHarness({ + chapter: resolveChapterIntent('contact'), + drive: { energy: 0, source: 'idle' }, + }); + await test.graph.resume(); + expect(test.sourceFor('/main.opus').starts[0]?.offset) + .toBe(mainPhraseOffsetSeconds(7)); + }); + + it('phase-aligns a drive source that becomes ready after main starts', async () => { + const test = createEngineHarness({ + chapter: resolveChapterIntent('experience'), + drive: { energy: 0.8, source: 'physical' }, + }, { deferDriveLift: true }); + await test.graph.resume(); + const mainStart = test.sourceFor('/main.opus').starts[0]!; + const anchor = mainStart.at - mainStart.offset; + test.harness.setCurrentTime(13); + test.resolveDriveLift(); + await test.flushOptionalLoads(); + const drive = test.sourceFor('/drive-lift.opus'); + expect(drive.starts).toHaveLength(1); + expect(drive.starts[0]!.offset).toBeCloseTo( + phaseAlignedLoopOffset(drive.starts[0]!.at, anchor, DRIVE_LIFT_LOOP_SECONDS), + 8, + ); + expect(test.mixer.setDriveLiftAvailableAt).toHaveBeenCalledWith(drive.starts[0]!.at); + }); + + it('routes drive commands immediately to texture and mixer', () => { + const test = createEngineHarness(); + const sample = { energy: 0.72, source: 'physical' } as const; + test.graph.setDriveEnergy(sample); + expect(test.texture.setEnergy).toHaveBeenLastCalledWith(sample); + expect(test.mixer.setDriveEnergy).toHaveBeenLastCalledWith(sample); + }); + + it('keeps dominant intro and direct-main load failures terminal', async () => { + const intro = createEngineHarness(initialState, { rejectIntro: true }); + await expect(intro.graph.resume()).rejects.toThrow('/intro.opus unavailable'); + const main = createEngineHarness({ + chapter: resolveChapterIntent('profile'), + drive: { energy: 0, source: 'idle' }, + }, { rejectMain: true }); + await expect(main.graph.resume()).rejects.toThrow('/main.opus unavailable'); + }); + + it('stops and disconnects each owned source exactly once when closed twice', async () => { + const test = createEngineHarness(); + await test.graph.resume(); + test.graph.setChapterIntent(resolveChapterIntent('profile', 1)); + expect(test.markers()).toHaveLength(2); + await test.graph.close(); + await test.graph.close(); + + expect(test.harness.sources.length).toBeGreaterThan(0); + for (const source of test.harness.sources) { + expect(source.stops).toHaveLength(1); + expect(source.disconnectCount).toBe(1); + } + expect(test.texture.dispose).toHaveBeenCalledOnce(); + expect(test.mixer.dispose).toHaveBeenCalledOnce(); + expect(test.harness.closeCount).toBe(1); + }); +});