Files
Portfolio/tests/unit/soundtrack-engine.test.ts

341 lines
13 KiB
TypeScript

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 main load failures terminal on every activation path', async () => {
const intro = createEngineHarness(initialState, { rejectIntro: true });
await expect(intro.graph.resume()).rejects.toThrow('/intro.opus unavailable');
const startMain = createEngineHarness(initialState, { rejectMain: true });
await expect(startMain.graph.resume()).rejects.toThrow('/main.opus unavailable');
expect(startMain.sourcesFor('/intro.opus')).toHaveLength(0);
const directMain = createEngineHarness({
chapter: resolveChapterIntent('profile'),
drive: { energy: 0, source: 'idle' },
}, { rejectMain: true });
await expect(directMain.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);
});
});