feat: add afterglow and synchronized drive lift
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
AFTERGLOW_LOOP_END_SECONDS,
|
||||||
|
AFTERGLOW_LOOP_START_SECONDS,
|
||||||
|
AFTERGLOW_TRANSITION_SECONDS,
|
||||||
|
DRIVE_LIFT_LOOP_SECONDS,
|
||||||
INTRO_FIRST_PASS_END_SECONDS,
|
INTRO_FIRST_PASS_END_SECONDS,
|
||||||
INTRO_IN_END_SECONDS,
|
INTRO_IN_END_SECONDS,
|
||||||
INTRO_LOOP_END_SECONDS,
|
INTRO_LOOP_END_SECONDS,
|
||||||
@@ -9,166 +13,343 @@ import {
|
|||||||
SOUNDTRACK_SAMPLE_RATE,
|
SOUNDTRACK_SAMPLE_RATE,
|
||||||
mainPhraseOffsetSeconds,
|
mainPhraseOffsetSeconds,
|
||||||
nextSafeBarTime,
|
nextSafeBarTime,
|
||||||
|
phaseAlignedLoopOffset,
|
||||||
|
resolveMainSchedule,
|
||||||
|
type MainSchedule,
|
||||||
} from '@/audio/soundtrack-transport';
|
} 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 {
|
import type {
|
||||||
AudioContextLifecycleState,
|
AudioContextLifecycleState,
|
||||||
AudioGraph,
|
AudioGraph,
|
||||||
AudioInitialState,
|
AudioInitialState,
|
||||||
ChapterIntent,
|
AudioSourceVariant,
|
||||||
DriveEnergySample,
|
|
||||||
MusicMood,
|
|
||||||
SoundtrackSources,
|
SoundtrackSources,
|
||||||
} from '@/audio/types';
|
} 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 SOURCE_LEAD_SECONDS = 0.08;
|
||||||
const START_FADE_SECONDS = 0.8;
|
const START_FADE_SECONDS = 0.8;
|
||||||
const EXIT_CROSSFADE_SECONDS = 0.25;
|
const EXIT_CROSSFADE_SECONDS = 0.25;
|
||||||
const EXIT_MINIMUM_LEAD_SECONDS = 0.35;
|
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;
|
readonly node: AudioBufferSourceNode;
|
||||||
|
stopAt(when: number): void;
|
||||||
|
cancel(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceHandle extends OwnedSource {
|
||||||
readonly gain: GainNode;
|
readonly gain: GainNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MixProfile {
|
interface PendingIntroExit {
|
||||||
readonly gain: number;
|
readonly requestedGeneration: number;
|
||||||
readonly highShelfDb: number;
|
readonly transitionTime: number;
|
||||||
|
readonly outro: SourceHandle;
|
||||||
|
readonly main: SourceHandle;
|
||||||
|
readonly commitMarker: OwnedSource;
|
||||||
|
readonly mainStartMarker: OwnedSource;
|
||||||
|
readonly schedule: MainSchedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIX_PROFILES: Readonly<Record<MusicMood, MixProfile>> = Object.freeze({
|
export interface SoundtrackEngineDependencies {
|
||||||
open: { gain: 0.985, highShelfDb: -0.35 },
|
createContext(): AudioContext;
|
||||||
shadow: { gain: 0.97, highShelfDb: -0.9 },
|
loadBuffer(
|
||||||
lift: { gain: 1, highShelfDb: 0.35 },
|
context: BaseAudioContext,
|
||||||
resolve: { gain: 0.985, highShelfDb: -0.15 },
|
variants: readonly AudioSourceVariant[],
|
||||||
});
|
signal: AbortSignal,
|
||||||
|
): Promise<AudioBuffer>;
|
||||||
|
createMixer: AdaptiveMixerFactory;
|
||||||
|
createDriveTexture: DriveTextureFactory;
|
||||||
|
}
|
||||||
|
|
||||||
export function createBrowserSoundtrackEngine(
|
export function createSoundtrackEngine(
|
||||||
sources: SoundtrackSources,
|
sources: SoundtrackSources,
|
||||||
initialState: AudioInitialState,
|
initialState: AudioInitialState,
|
||||||
|
dependencies: SoundtrackEngineDependencies,
|
||||||
): AudioGraph {
|
): AudioGraph {
|
||||||
const BrowserAudioContext = window.AudioContext;
|
const context = dependencies.createContext();
|
||||||
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 outputGain = context.createGain();
|
const outputGain = context.createGain();
|
||||||
const sourceNodes = new Set<AudioBufferSourceNode>();
|
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<OwnedSource>();
|
||||||
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
||||||
|
const loadController = new AbortController();
|
||||||
|
const activatedFromStart = initialState.chapter.atStart;
|
||||||
let latestChapter = initialState.chapter;
|
let latestChapter = initialState.chapter;
|
||||||
|
let latestDrive = initialState.drive;
|
||||||
let connected = false;
|
let connected = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const activatedFromStart = initialState.chapter.atStart;
|
|
||||||
let transportStarted = false;
|
let transportStarted = false;
|
||||||
let introStarted = false;
|
let introStarted = false;
|
||||||
let mainCommitted = false;
|
let mainCommitted = false;
|
||||||
let introAnchorTime = 0;
|
let introAnchorTime = 0;
|
||||||
let introProgramGain: GainNode | null = null;
|
let introProgramGain: GainNode | null = null;
|
||||||
let introSourceNodes: AudioBufferSourceNode[] = [];
|
let introSourceNodes: SourceHandle[] = [];
|
||||||
let loadedIntroBuffer: AudioBuffer | null = null;
|
let loadedIntroBuffer: AudioBuffer | null = null;
|
||||||
|
let loadedMainBuffer: AudioBuffer | null = null;
|
||||||
let mainBufferPromise: Promise<AudioBuffer> | null = null;
|
let mainBufferPromise: Promise<AudioBuffer> | null = null;
|
||||||
let loadController: AbortController | null = null;
|
let driveLiftBufferPromise: Promise<AudioBuffer | null> | null = null;
|
||||||
|
let afterglowBufferPromise: Promise<AudioBuffer | null> | null = null;
|
||||||
let loadPromise: Promise<void> | null = null;
|
let loadPromise: Promise<void> | null = null;
|
||||||
let exitTimer: number | undefined;
|
let exitLoadRequest: Promise<void> | 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';
|
const ownSource = (
|
||||||
tone.frequency.value = 5_200;
|
node: AudioBufferSourceNode,
|
||||||
tone.Q.value = 0.55;
|
releaseDependents: () => void,
|
||||||
outputGain.gain.value = 0;
|
): OwnedSource => {
|
||||||
profileGain.connect(tone);
|
let released = false;
|
||||||
tone.connect(outputGain);
|
let stopCalled = false;
|
||||||
|
let owned: OwnedSource;
|
||||||
const trackSource = (node: AudioBufferSourceNode): void => {
|
const release = (): void => {
|
||||||
sourceNodes.add(node);
|
if (released) return;
|
||||||
node.addEventListener('ended', () => {
|
released = true;
|
||||||
sourceNodes.delete(node);
|
ownedSources.delete(owned);
|
||||||
node.disconnect();
|
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 node = context.createBufferSource();
|
||||||
const gain = context.createGain();
|
const gain = context.createGain();
|
||||||
node.buffer = buffer;
|
node.buffer = buffer;
|
||||||
node.connect(gain);
|
node.connect(gain);
|
||||||
gain.connect(profileGain);
|
gain.connect(destination);
|
||||||
node.addEventListener('ended', () => gain.disconnect(), { once: true });
|
const owned = ownSource(node, () => {
|
||||||
trackSource(node);
|
gain.disconnect();
|
||||||
return { node, gain };
|
onRelease();
|
||||||
|
});
|
||||||
|
return { ...owned, gain };
|
||||||
};
|
};
|
||||||
|
|
||||||
const ramp = (parameter: AudioParam, value: number, duration: number): void => {
|
const makeMarker = (when: number, onEnded: () => void): OwnedSource => {
|
||||||
const now = context.currentTime;
|
const node = context.createBufferSource();
|
||||||
holdParameter(parameter, now);
|
node.buffer = context.createBuffer(1, 1, context.sampleRate);
|
||||||
parameter.linearRampToValueAtTime(value, now + duration);
|
const marker = ownSource(node, () => {});
|
||||||
|
node.addEventListener('ended', onEnded, { once: true });
|
||||||
|
node.start(when);
|
||||||
|
return marker;
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyMix = (chapter: ChapterIntent): void => {
|
const loadOptional = (
|
||||||
const profile = MIX_PROFILES[chapter.mood];
|
variants: readonly AudioSourceVariant[],
|
||||||
ramp(profileGain.gain, profile.gain, PROFILE_RAMP_SECONDS);
|
): Promise<AudioBuffer | null> => dependencies
|
||||||
ramp(tone.gain, profile.highShelfDb, PROFILE_RAMP_SECONDS);
|
.loadBuffer(context, variants, loadController.signal)
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
const ensureMainLoad = (): Promise<AudioBuffer> => {
|
||||||
|
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 = (
|
const ensureDriveLiftLoad = (): Promise<AudioBuffer | null> => {
|
||||||
buffer: AudioBuffer,
|
driveLiftBufferPromise ??= loadOptional(sources.driveLift);
|
||||||
when: number,
|
return driveLiftBufferPromise;
|
||||||
offsetSeconds: number,
|
};
|
||||||
fadeSeconds: number,
|
|
||||||
): SourceHandle => {
|
const attachMainEnded = (main: SourceHandle): void => {
|
||||||
const main = makeSource(buffer);
|
main.node.addEventListener('ended', () => {
|
||||||
const offset = Math.min(Math.max(0, offsetSeconds), Math.max(0, buffer.duration - 0.05));
|
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.setValueAtTime(0, when);
|
||||||
main.gain.gain.linearRampToValueAtTime(1, when + fadeSeconds);
|
main.gain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
|
||||||
main.node.start(when, offset);
|
main.node.start(when, schedule.sourceOffsetSeconds);
|
||||||
mainCommitted = true;
|
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 startIntro = (buffer: AudioBuffer, when: number): void => {
|
||||||
const lead = context.createBufferSource();
|
|
||||||
const loop = context.createBufferSource();
|
|
||||||
const programGain = context.createGain();
|
const programGain = context.createGain();
|
||||||
|
programGain.connect(mixer.bedInput);
|
||||||
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.gain.setValueAtTime(0, when);
|
programGain.gain.setValueAtTime(0, when);
|
||||||
programGain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
|
programGain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
|
||||||
|
let remainingSources = 2;
|
||||||
trackSource(lead);
|
let programReleased = false;
|
||||||
trackSource(loop);
|
const releaseProgram = (): void => {
|
||||||
lead.start(when, 0, INTRO_FIRST_PASS_END_SECONDS);
|
remainingSources -= 1;
|
||||||
loop.start(when + INTRO_FIRST_PASS_END_SECONDS, INTRO_LOOP_START_SECONDS);
|
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;
|
introProgramGain = programGain;
|
||||||
introSourceNodes = [lead, loop];
|
introSourceNodes = [lead, loop];
|
||||||
introAnchorTime = when;
|
introAnchorTime = when;
|
||||||
introStarted = true;
|
introStarted = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleIntroExit = (introBuffer: AudioBuffer, mainBuffer: AudioBuffer): void => {
|
const scheduleIntroExit = (
|
||||||
if (!introStarted || mainCommitted || introProgramGain === null || closed) return;
|
introBuffer: AudioBuffer,
|
||||||
mainCommitted = true;
|
mainBuffer: AudioBuffer,
|
||||||
const schedulingTime = Math.max(
|
requestedGeneration: number,
|
||||||
context.currentTime,
|
): void => {
|
||||||
introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS,
|
if (
|
||||||
);
|
closed
|
||||||
|
|| !introStarted
|
||||||
|
|| mainCommitted
|
||||||
|
|| pendingIntroExit !== null
|
||||||
|
|| latestChapter.atStart
|
||||||
|
|| introProgramGain === null
|
||||||
|
) return;
|
||||||
const transitionTime = nextSafeBarTime(
|
const transitionTime = nextSafeBarTime(
|
||||||
schedulingTime,
|
Math.max(
|
||||||
|
context.currentTime,
|
||||||
|
introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS,
|
||||||
|
),
|
||||||
introAnchorTime,
|
introAnchorTime,
|
||||||
EXIT_MINIMUM_LEAD_SECONDS,
|
EXIT_MINIMUM_LEAD_SECONDS,
|
||||||
);
|
);
|
||||||
const outro = makeSource(introBuffer);
|
const outro = makeSource(introBuffer, mixer.bedInput);
|
||||||
outro.gain.gain.setValueAtTime(0, transitionTime);
|
outro.gain.gain.setValueAtTime(0, transitionTime);
|
||||||
outro.gain.gain.linearRampToValueAtTime(1, transitionTime + EXIT_CROSSFADE_SECONDS);
|
outro.gain.gain.linearRampToValueAtTime(1, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||||
outro.node.start(
|
outro.node.start(
|
||||||
@@ -179,34 +360,103 @@ export function createBrowserSoundtrackEngine(
|
|||||||
|
|
||||||
introProgramGain.gain.cancelScheduledValues(transitionTime);
|
introProgramGain.gain.cancelScheduledValues(transitionTime);
|
||||||
introProgramGain.gain.setValueAtTime(1, transitionTime);
|
introProgramGain.gain.setValueAtTime(1, transitionTime);
|
||||||
introProgramGain.gain.linearRampToValueAtTime(0, transitionTime + EXIT_CROSSFADE_SECONDS);
|
introProgramGain.gain.linearRampToValueAtTime(
|
||||||
for (const source of introSourceNodes) {
|
0,
|
||||||
stopSourceAt(source, transitionTime + EXIT_CROSSFADE_SECONDS);
|
transitionTime + EXIT_CROSSFADE_SECONDS,
|
||||||
}
|
);
|
||||||
|
|
||||||
const mainTime = transitionTime + INTRO_OUT_END_SECONDS - INTRO_OUT_START_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.cancelScheduledValues(mainTime);
|
||||||
outro.gain.gain.setValueAtTime(1, mainTime);
|
outro.gain.gain.setValueAtTime(1, mainTime);
|
||||||
outro.gain.gain.linearRampToValueAtTime(0, mainTime + EXIT_CROSSFADE_SECONDS);
|
outro.gain.gain.linearRampToValueAtTime(0, mainTime + EXIT_CROSSFADE_SECONDS);
|
||||||
const finishedProgramGain = introProgramGain;
|
|
||||||
outro.node.addEventListener('ended', () => {
|
let pending!: PendingIntroExit;
|
||||||
finishedProgramGain.disconnect();
|
const commitMarker = makeMarker(transitionTime, () => {
|
||||||
if (introProgramGain === finishedProgramGain) introProgramGain = null;
|
if (closed || pendingIntroExit !== pending) return;
|
||||||
introSourceNodes = [];
|
mainCommitted = true;
|
||||||
|
pendingIntroExit = null;
|
||||||
|
mainSource = main;
|
||||||
|
mainSchedule = schedule;
|
||||||
|
attachMainEnded(main);
|
||||||
|
for (const source of introSourceNodes) {
|
||||||
|
source.stopAt(transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||||
|
}
|
||||||
loadedIntroBuffer = null;
|
loadedIntroBuffer = null;
|
||||||
introStarted = false;
|
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<void> => {
|
const loadAndStart = async (): Promise<void> => {
|
||||||
loadController = new AbortController();
|
ensureDriveLiftLoad();
|
||||||
const signal = loadController.signal;
|
const signal = loadController.signal;
|
||||||
mainBufferPromise = loadAudioBuffer(context, sources.main, signal);
|
const mainPromise = ensureMainLoad();
|
||||||
void mainBufferPromise.catch(() => undefined);
|
void mainPromise.catch(() => undefined);
|
||||||
|
|
||||||
if (activatedFromStart) {
|
if (activatedFromStart) {
|
||||||
loadedIntroBuffer = await loadAudioBuffer(context, sources.intro, signal);
|
loadedIntroBuffer = await dependencies.loadBuffer(context, sources.intro, signal);
|
||||||
if (closed || signal.aborted) throw abortError();
|
if (closed || signal.aborted) throw abortError();
|
||||||
if (latestChapter.atStart) {
|
if (latestChapter.atStart) {
|
||||||
transportStarted = true;
|
transportStarted = true;
|
||||||
@@ -215,7 +465,7 @@ export function createBrowserSoundtrackEngine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainBuffer = await mainBufferPromise;
|
const mainBuffer = await mainPromise;
|
||||||
if (closed || signal.aborted) throw abortError();
|
if (closed || signal.aborted) throw abortError();
|
||||||
if (activatedFromStart && latestChapter.atStart && loadedIntroBuffer !== null) {
|
if (activatedFromStart && latestChapter.atStart && loadedIntroBuffer !== null) {
|
||||||
transportStarted = true;
|
transportStarted = true;
|
||||||
@@ -223,34 +473,13 @@ export function createBrowserSoundtrackEngine(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
transportStarted = true;
|
transportStarted = true;
|
||||||
startMain(
|
startDirectMain(mainBuffer);
|
||||||
mainBuffer,
|
|
||||||
context.currentTime + SOURCE_LEAD_SECONDS,
|
|
||||||
mainPhraseOffsetSeconds(latestChapter.entryPhrase),
|
|
||||||
START_FADE_SECONDS,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestIntroExit = (): void => {
|
const rampOutput = (value: number): void => {
|
||||||
if (!introStarted || mainCommitted || exitTimer !== undefined || closed) return;
|
const now = context.currentTime;
|
||||||
exitTimer = window.setTimeout(() => {
|
holdParameter(outputGain.gain, now);
|
||||||
exitTimer = undefined;
|
outputGain.gain.linearRampToValueAtTime(value, now + 0.08);
|
||||||
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 onStateChange = (): void => {
|
const onStateChange = (): void => {
|
||||||
@@ -258,12 +487,15 @@ export function createBrowserSoundtrackEngine(
|
|||||||
for (const listener of stateListeners) listener(state);
|
for (const listener of stateListeners) listener(state);
|
||||||
};
|
};
|
||||||
context.addEventListener('statechange', onStateChange);
|
context.addEventListener('statechange', onStateChange);
|
||||||
applyMix(initialState.chapter);
|
mixer.setChapterIntent(latestChapter);
|
||||||
|
mixer.setDriveEnergy(latestDrive);
|
||||||
|
driveTexture?.setEnergy(latestDrive);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async resume() {
|
async resume() {
|
||||||
if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError');
|
if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError');
|
||||||
await context.resume();
|
await context.resume();
|
||||||
|
ensureDriveLiftLoad();
|
||||||
if (transportStarted) return;
|
if (transportStarted) return;
|
||||||
loadPromise ??= loadAndStart();
|
loadPromise ??= loadAndStart();
|
||||||
await loadPromise;
|
await loadPromise;
|
||||||
@@ -274,15 +506,22 @@ export function createBrowserSoundtrackEngine(
|
|||||||
async close() {
|
async close() {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
if (exitTimer !== undefined) window.clearTimeout(exitTimer);
|
loadController.abort();
|
||||||
loadController?.abort();
|
cancelPendingIntroExit();
|
||||||
for (const source of sourceNodes) stopSource(source);
|
mixer.setDriveLiftAvailableAt(null);
|
||||||
sourceNodes.clear();
|
driveTexture?.dispose();
|
||||||
|
driveTexture = null;
|
||||||
|
mixer.dispose();
|
||||||
|
for (const source of [...ownedSources]) source.cancel();
|
||||||
|
ownedSources.clear();
|
||||||
context.removeEventListener('statechange', onStateChange);
|
context.removeEventListener('statechange', onStateChange);
|
||||||
stateListeners.clear();
|
stateListeners.clear();
|
||||||
profileGain.disconnect();
|
|
||||||
tone.disconnect();
|
|
||||||
outputGain.disconnect();
|
outputGain.disconnect();
|
||||||
|
connected = false;
|
||||||
|
loadedIntroBuffer = null;
|
||||||
|
loadedMainBuffer = null;
|
||||||
|
afterglowBufferPromise = null;
|
||||||
|
driveLiftBufferPromise = null;
|
||||||
if (context.state !== 'closed') await context.close();
|
if (context.state !== 'closed') await context.close();
|
||||||
},
|
},
|
||||||
connectOutput() {
|
connectOutput() {
|
||||||
@@ -296,24 +535,26 @@ export function createBrowserSoundtrackEngine(
|
|||||||
connected = false;
|
connected = false;
|
||||||
},
|
},
|
||||||
setOutputGain(value) {
|
setOutputGain(value) {
|
||||||
ramp(outputGain.gain, clamp(value), 0.08);
|
if (!closed) rampOutput(clamp(value));
|
||||||
},
|
},
|
||||||
setChapterIntent(intent) {
|
setChapterIntent(intent) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
latestChapter = intent;
|
latestChapter = intent;
|
||||||
applyMix(intent);
|
mixer.setChapterIntent(intent);
|
||||||
if (!intent.atStart) requestIntroExit();
|
if (!mainCommitted && introStarted) {
|
||||||
else cancelUnscheduledIntroExit();
|
if (intent.atStart) cancelPendingIntroExit();
|
||||||
|
else requestIntroExit(intent.generation);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setDriveEnergy(_sample: DriveEnergySample) {
|
setDriveEnergy(sample) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
latestDrive = sample;
|
||||||
|
driveTexture?.setEnergy(sample);
|
||||||
|
mixer.setDriveEnergy(sample);
|
||||||
},
|
},
|
||||||
cancelPending() {
|
cancelPending() {
|
||||||
loadController?.abort();
|
loadController.abort();
|
||||||
if (exitTimer !== undefined) {
|
cancelPendingIntroExit();
|
||||||
window.clearTimeout(exitTimer);
|
|
||||||
exitTimer = undefined;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onContextStateChange(listener) {
|
onContextStateChange(listener) {
|
||||||
stateListeners.add(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(
|
async function loadAudioBuffer(
|
||||||
context: BaseAudioContext,
|
context: BaseAudioContext,
|
||||||
variants: readonly SoundtrackSources['intro'][number][],
|
variants: readonly AudioSourceVariant[],
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<AudioBuffer> {
|
): Promise<AudioBuffer> {
|
||||||
const ordered = orderSupportedVariants(variants);
|
const ordered = orderSupportedVariants(variants);
|
||||||
@@ -332,7 +620,10 @@ async function loadAudioBuffer(
|
|||||||
for (const variant of ordered) {
|
for (const variant of ordered) {
|
||||||
if (signal.aborted) throw abortError();
|
if (signal.aborted) throw abortError();
|
||||||
try {
|
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}).`);
|
if (!response.ok) throw new Error(`Soundtrack request failed (${response.status}).`);
|
||||||
const encoded = await response.arrayBuffer();
|
const encoded = await response.arrayBuffer();
|
||||||
if (signal.aborted) throw abortError();
|
if (signal.aborted) throw abortError();
|
||||||
@@ -342,15 +633,18 @@ async function loadAudioBuffer(
|
|||||||
lastError = error;
|
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<T extends SoundtrackSources['intro'][number]>(
|
function orderSupportedVariants<T extends AudioSourceVariant>(
|
||||||
variants: readonly T[],
|
variants: readonly T[],
|
||||||
): readonly T[] {
|
): readonly T[] {
|
||||||
const probe = document.createElement('audio');
|
const probe = document.createElement('audio');
|
||||||
return [...variants].sort((left, right) => (
|
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);
|
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 {
|
function abortError(): DOMException {
|
||||||
return new DOMException('Soundtrack loading was cancelled.', 'AbortError');
|
return new DOMException('Soundtrack loading was cancelled.', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export interface FakeAudioHarness {
|
|||||||
readonly gainNodes: readonly GainNode[];
|
readonly gainNodes: readonly GainNode[];
|
||||||
readonly buffers: AudioBuffer[];
|
readonly buffers: AudioBuffer[];
|
||||||
readonly destination: AudioNode;
|
readonly destination: AudioNode;
|
||||||
|
readonly resumeCount: number;
|
||||||
|
readonly suspendCount: number;
|
||||||
|
readonly closeCount: number;
|
||||||
setCurrentTime(seconds: number): void;
|
setCurrentTime(seconds: number): void;
|
||||||
decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer;
|
decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer;
|
||||||
emitContextState(state: AudioContextState | 'interrupted'): void;
|
emitContextState(state: AudioContextState | 'interrupted'): void;
|
||||||
@@ -105,6 +108,9 @@ export function createFakeAudioHarness(): FakeAudioHarness {
|
|||||||
const sampleRate = 48_000;
|
const sampleRate = 48_000;
|
||||||
let currentTime = 0;
|
let currentTime = 0;
|
||||||
let state: AudioContextState | 'interrupted' = 'suspended';
|
let state: AudioContextState | 'interrupted' = 'suspended';
|
||||||
|
let resumeCount = 0;
|
||||||
|
let suspendCount = 0;
|
||||||
|
let closeCount = 0;
|
||||||
|
|
||||||
const decodedBuffer = (durationSeconds: number, channels = 2): AudioBuffer => {
|
const decodedBuffer = (durationSeconds: number, channels = 2): AudioBuffer => {
|
||||||
const length = Math.max(1, Math.round(durationSeconds * sampleRate));
|
const length = Math.max(1, Math.round(durationSeconds * sampleRate));
|
||||||
@@ -232,12 +238,15 @@ export function createFakeAudioHarness(): FakeAudioHarness {
|
|||||||
return decodedBuffer(1);
|
return decodedBuffer(1);
|
||||||
},
|
},
|
||||||
async resume() {
|
async resume() {
|
||||||
|
resumeCount += 1;
|
||||||
state = 'running';
|
state = 'running';
|
||||||
},
|
},
|
||||||
async suspend() {
|
async suspend() {
|
||||||
|
suspendCount += 1;
|
||||||
state = 'suspended';
|
state = 'suspended';
|
||||||
},
|
},
|
||||||
async close() {
|
async close() {
|
||||||
|
closeCount += 1;
|
||||||
state = 'closed';
|
state = 'closed';
|
||||||
},
|
},
|
||||||
addEventListener(type: string, listener: () => void) {
|
addEventListener(type: string, listener: () => void) {
|
||||||
@@ -255,6 +264,15 @@ export function createFakeAudioHarness(): FakeAudioHarness {
|
|||||||
gainNodes,
|
gainNodes,
|
||||||
buffers,
|
buffers,
|
||||||
destination,
|
destination,
|
||||||
|
get resumeCount() {
|
||||||
|
return resumeCount;
|
||||||
|
},
|
||||||
|
get suspendCount() {
|
||||||
|
return suspendCount;
|
||||||
|
},
|
||||||
|
get closeCount() {
|
||||||
|
return closeCount;
|
||||||
|
},
|
||||||
setCurrentTime(seconds) {
|
setCurrentTime(seconds) {
|
||||||
currentTime = seconds;
|
currentTime = seconds;
|
||||||
},
|
},
|
||||||
|
|||||||
336
tests/unit/soundtrack-engine.test.ts
Normal file
336
tests/unit/soundtrack-engine.test.ts
Normal file
@@ -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<T> {
|
||||||
|
readonly promise: Promise<T>;
|
||||||
|
resolve(value: T): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((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<AudioBuffer>();
|
||||||
|
const loadCalls: string[] = [];
|
||||||
|
const loadPromises: Promise<AudioBuffer>[] = [];
|
||||||
|
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<AudioBuffer> => {
|
||||||
|
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<AudioBuffer>;
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user