import { describe, expect, it, vi } from 'vitest'; import { createAudioController } from '@/audio/create-audio-controller'; import { createMusicSequencer, MUSIC_BPM, MUSIC_LOOKAHEAD_SECONDS, MUSIC_PROGRESSION, MUSIC_SCHEDULER_INTERVAL_MS, } from '@/audio/music-sequencer'; import { createSynthEngine } from '@/audio/synth-engine'; import type { AudioGraph, AudioGraphFactory } from '@/audio/types'; describe('audio controller lifecycle', () => { it('constructs no graph before activation and only one graph for rapid repeated activation', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); expect(harness.constructed).toBe(0); const first = controller.startFromGesture(); const second = controller.startFromGesture(); expect(harness.constructed).toBe(1); await Promise.all([first, second]); expect(harness.constructed).toBe(1); expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 }); expect(harness.graph.connectOutput).toHaveBeenCalledOnce(); }); it('applies clamped volume and mute changes made while starting without changing lifecycle intent', async () => { const resume = deferred(); const harness = createHarness({ resume }); const controller = createAudioController({ createGraph: harness.createGraph, initialVolume: 3 }); const starting = controller.startFromGesture(); controller.setVolume(-1); controller.setMuted(true); expect(controller.snapshot()).toEqual({ kind: 'starting' }); resume.resolve(); await starting; expect(controller.snapshot()).toEqual({ kind: 'running', muted: true, volume: 0 }); expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0); }); it('retains one initialization when hidden then visible before starting completes', async () => { const resume = deferred(); const harness = createHarness({ resume }); const controller = createAudioController({ createGraph: harness.createGraph }); const starting = controller.startFromGesture(); controller.setVisible(false); controller.setVisible(true); resume.resolve(); await starting; expect(controller.snapshot().kind).toBe('running'); expect(harness.constructed).toBe(1); expect(harness.graph.suspend).not.toHaveBeenCalled(); expect(harness.graph.connectOutput).toHaveBeenCalledOnce(); }); it('finishes an interrupted initialization only into safely suspended user-required state', async () => { const resume = deferred(); const harness = createHarness({ resume }); const controller = createAudioController({ createGraph: harness.createGraph }); const starting = controller.startFromGesture(); controller.noteInterruption(); resume.resolve(); await starting; expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true }); expect(harness.graph.connectOutput).not.toHaveBeenCalled(); expect(harness.graph.suspend).toHaveBeenCalledOnce(); }); it('automatically suspends and resumes an intact graph across visibility and persisted BFCache', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); await controller.pageHide(true); expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: false }); expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce(); await controller.pageShow(true); expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 }); expect(harness.constructed).toBe(1); expect(harness.graph.resume).toHaveBeenCalledTimes(2); expect(harness.graph.connectOutput).toHaveBeenCalledTimes(2); }); it('honors a visible event that arrives before a running suspension completes', async () => { const suspension = deferred(); const harness = createHarness({ suspend: suspension }); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); const hiding = controller.setVisible(false); const showing = controller.setVisible(true); suspension.resolve(); await Promise.all([hiding, showing]); await controller.whenSettled(); expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 }); expect(harness.graph.suspend).toHaveBeenCalledOnce(); expect(harness.graph.resume).toHaveBeenCalledTimes(2); expect(harness.constructed).toBe(1); }); it('does not convert an interruption into automatic resume on later visibility events', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); await controller.noteInterruption(); await controller.setVisible(false); await controller.setVisible(true); expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true }); expect(harness.graph.resume).toHaveBeenCalledOnce(); await controller.resumeFromGesture(); expect(controller.snapshot().kind).toBe('running'); expect(harness.constructed).toBe(1); expect(harness.graph.resume).toHaveBeenCalledTimes(2); }); it('enters a recoverable visible failure when the first explicit start rejects', async () => { const harness = createHarness({ resumeRejects: true }); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' }); expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0); expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce(); expect(harness.graph.close).toHaveBeenCalledOnce(); }); it('keeps an existing graph suspended and user-recoverable when automatic resume rejects', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); await controller.setVisible(false); harness.rejectNextResume(); await controller.setVisible(true); expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true }); expect(harness.graph.close).not.toHaveBeenCalled(); expect(harness.constructed).toBe(1); }); it('forces silence, closes once, and fails when suspension cannot be guaranteed', async () => { const harness = createHarness({ suspendRejects: true }); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); await controller.setVisible(false); await controller.setVisible(true); expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' }); expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0); expect(harness.graph.disconnectOutput).toHaveBeenCalled(); expect(harness.graph.close).toHaveBeenCalledOnce(); }); it('invalidates a pending start and closes exactly once on final pagehide or repeated disposal', async () => { const resume = deferred(); const harness = createHarness({ resume }); const controller = createAudioController({ createGraph: harness.createGraph }); const starting = controller.startFromGesture(); const closing = controller.pageHide(false); controller.dispose(); resume.resolve(); await Promise.all([starting, closing]); await controller.whenSettled(); expect(controller.snapshot()).toEqual({ kind: 'disposed' }); expect(harness.graph.connectOutput).not.toHaveBeenCalled(); expect(harness.graph.close).toHaveBeenCalledOnce(); }); it('ignores lifecycle events before activation and after failure without creating another graph', async () => { const harness = createHarness({ resumeRejects: true }); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.setVisible(false); await controller.pageShow(true); expect(harness.constructed).toBe(0); await controller.startFromGesture(); await controller.setVisible(true); await controller.pageShow(true); expect(harness.constructed).toBe(1); expect(controller.snapshot().kind).toBe('failed'); }); it('clamps and slowly smooths drive intensity before forwarding it to the graph', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); await controller.startFromGesture(); controller.setIntensity(2); controller.setIntensity(Number.NaN); expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(3, 0.12); expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(4, 0.1056); }); }); describe('procedural audio graph', () => { it('routes independent music, engine, and road buses through compression before the master output', async () => { vi.useFakeTimers(); const context = new FakeAudioContext(); try { const graph = createSynthEngine(() => context as unknown as AudioContext); context.currentTime = 1.4; vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); const [master, musicGain, engineGain, , , roadGain] = context.gains; const [musicFilter, engineFilter, roadFilter] = context.filters; const [compressor] = context.compressors; expect(musicFilter?.connections.has(musicGain)).toBe(true); expect(engineFilter?.connections.has(engineGain)).toBe(true); expect(roadFilter?.connections.has(roadGain)).toBe(true); expect(musicGain?.connections.has(compressor)).toBe(true); expect(engineGain?.connections.has(compressor)).toBe(true); expect(roadGain?.connections.has(compressor)).toBe(true); expect(compressor?.connections.has(master)).toBe(true); expect(context.destinationConnections).toBe(0); expect(context.startedNodes).toBeGreaterThan(4); expect(context.bufferSources.length).toBeGreaterThanOrEqual(2); expect(context.filters.length).toBeGreaterThanOrEqual(4); graph.connectOutput(); graph.setOutputGain(0.6); graph.setIntensity(1); expect(context.destinationConnections).toBe(1); expect(context.parameters.some((parameter) => parameter.targetValues.includes(930))).toBe(true); expect(context.parameters.some((parameter) => parameter.targetValues.includes(3_300))).toBe(true); graph.disconnectOutput(); await graph.close(); await graph.close(); expect(context.destinationConnections).toBe(0); expect(context.closeCalls).toBe(1); expect(context.stoppedNodes).toBe(context.startedNodes); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); } }); }); describe('procedural music sequencer', () => { it('schedules the fixed four-bar progression through a 100ms/250ms look-ahead clock', () => { vi.useFakeTimers(); const context = new FakeAudioContext(); const destination = context.createGain(); try { expect(MUSIC_BPM).toBe(86); expect(MUSIC_SCHEDULER_INTERVAL_MS).toBe(100); expect(MUSIC_LOOKAHEAD_SECONDS).toBe(0.25); expect(MUSIC_PROGRESSION.map((chord) => chord.name)).toEqual([ 'F minor', 'D-flat major', 'A-flat major', 'E-flat major', ]); const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); expect(context.startedNodes).toBe(0); sequencer.start(0); const initialStarted = context.startedNodes; expect(initialStarted).toBeGreaterThan(0); expect(vi.getTimerCount()).toBe(1); context.currentTime = 0.1; vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); expect(context.startedNodes).toBeGreaterThan(initialStarted); sequencer.scheduleThrough(11.2); const frequencies = new Set(context.oscillators.map((oscillator) => Number(oscillator.frequency.value.toFixed(2)))); for (const root of [174.61, 138.59, 207.65, 155.56]) expect(frequencies.has(root)).toBe(true); sequencer.stop(); expect(vi.getTimerCount()).toBe(0); expect(context.stoppedNodes).toBe(context.startedNodes); } finally { vi.useRealTimers(); } }); it('raises future pulse energy with intensity without changing the musical clock', () => { vi.useFakeTimers(); const context = new FakeAudioContext(); const destination = context.createGain(); try { const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); sequencer.start(0); sequencer.setIntensity(1, 0); sequencer.scheduleThrough(0.6); expect(context.parameters.some((parameter) => parameter.scheduledValues.includes(0.022))).toBe(true); expect(MUSIC_BPM).toBe(86); sequencer.stop(); } finally { vi.useRealTimers(); } }); }); interface HarnessOptions { resume?: Deferred; suspend?: Deferred; resumeRejects?: boolean; suspendRejects?: boolean; } function createHarness(options: HarnessOptions = {}) { let rejectNextResume = options.resumeRejects === true; const stateListeners = new Set<(state: string) => void>(); const graph: AudioGraph = { resume: vi.fn(async () => { if (options.resume) return options.resume.promise; if (rejectNextResume) { rejectNextResume = false; throw new DOMException('blocked', 'NotAllowedError'); } }), suspend: vi.fn(async () => { if (options.suspend) return options.suspend.promise; if (options.suspendRejects) throw new Error('suspend failed'); }), close: vi.fn(async () => undefined), connectOutput: vi.fn(), disconnectOutput: vi.fn(), setOutputGain: vi.fn(), setIntensity: vi.fn(), onContextStateChange: vi.fn((listener) => { stateListeners.add(listener); return () => stateListeners.delete(listener); }), }; let constructed = 0; const createGraph: AudioGraphFactory = () => { constructed += 1; return graph; }; return { graph, createGraph, get constructed() { return constructed; }, rejectNextResume() { rejectNextResume = true; }, emitState(state: string) { for (const listener of stateListeners) listener(state); }, }; } interface Deferred { promise: Promise; resolve(value: T | PromiseLike): void; reject(reason?: unknown): void; } function deferred(): Deferred { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((onResolve, onReject) => { resolve = onResolve; reject = onReject; }); return { promise, resolve, reject }; } class FakeAudioParam { value = 0; readonly targetValues: number[] = []; readonly scheduledValues: number[] = []; setValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; } setTargetAtTime(value: number): void { this.targetValues.push(value); this.value = value; } linearRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; } exponentialRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; } cancelScheduledValues(): void {} } class FakeNode extends EventTarget { readonly connections = new Set(); constructor(protected readonly owner: FakeAudioContext) { super(); } connect(target: unknown): unknown { this.connections.add(target); if (target === this.owner.destination) this.owner.destinationConnections += 1; return target; } disconnect(target?: unknown): void { if (target === undefined) { if (this.connections.delete(this.owner.destination)) this.owner.destinationConnections -= 1; this.connections.clear(); return; } if (this.connections.delete(target) && target === this.owner.destination) this.owner.destinationConnections -= 1; } } class FakeScheduledNode extends FakeNode { private started = false; private stopped = false; start(): void { if (this.started) return; this.started = true; this.owner.startedNodes += 1; } stop(): void { if (this.stopped) return; this.stopped = true; this.owner.stoppedNodes += 1; } } class FakeAudioContext extends EventTarget { readonly destination = {}; readonly sampleRate = 8_000; currentTime = 0; readonly parameters: FakeAudioParam[] = []; readonly oscillators: Array = []; readonly bufferSources: Array = []; readonly filters: Array = []; readonly gains: Array = []; readonly compressors: FakeNode[] = []; destinationConnections = 0; startedNodes = 0; stoppedNodes = 0; closeCalls = 0; state: AudioContextState = 'suspended'; private parameter(value = 0): FakeAudioParam { const parameter = new FakeAudioParam(); parameter.value = value; this.parameters.push(parameter); return parameter; } createOscillator() { const node = Object.assign(new FakeScheduledNode(this), { type: 'sine', frequency: this.parameter(440) }); this.oscillators.push(node); return node; } createBufferSource() { const node = Object.assign(new FakeScheduledNode(this), { buffer: null as unknown, loop: false }); this.bufferSources.push(node); return node; } createBiquadFilter() { const node = Object.assign(new FakeNode(this), { type: 'lowpass', frequency: this.parameter(350), Q: this.parameter(1), }); this.filters.push(node); return node; } createGain() { const node = Object.assign(new FakeNode(this), { gain: this.parameter(1) }); this.gains.push(node); return node; } createDynamicsCompressor() { const node = new FakeNode(this); this.compressors.push(node); return node; } createBuffer(channels: number, length: number) { const data = Array.from({ length: channels }, () => new Float32Array(length)); return { getChannelData: (channel: number) => data[channel] }; } async resume(): Promise { this.state = 'running'; } async suspend(): Promise { this.state = 'suspended'; } async close(): Promise { this.closeCalls += 1; this.state = 'closed'; } }