341 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|