feat: add afterglow and synchronized drive lift
This commit is contained in:
@@ -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;
|
||||
},
|
||||
|
||||
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