feat: add afterglow and synchronized drive lift
This commit is contained in:
@@ -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<Record<MusicMood, MixProfile>> = 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<AudioBuffer>;
|
||||
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<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 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<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 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';
|
||||
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<AudioBuffer | null> => dependencies
|
||||
.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 = (
|
||||
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<AudioBuffer | null> => {
|
||||
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<void> => {
|
||||
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<AudioBuffer> {
|
||||
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<T extends SoundtrackSources['intro'][number]>(
|
||||
function orderSupportedVariants<T extends AudioSourceVariant>(
|
||||
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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user