import { describe, expect, it, vi } from 'vitest'; import { createAudioController } from '@/audio/create-audio-controller'; 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('builds an original oscillator, filtered-noise, gain, and dynamics bed while output stays disconnected', async () => { const context = new FakeAudioContext(); const graph = createSynthEngine(() => context as unknown as AudioContext); expect(context.oscillators).toHaveLength(3); expect(context.bufferSources).toHaveLength(1); expect(context.filters).toHaveLength(2); expect(context.gains.length).toBeGreaterThanOrEqual(4); expect(context.compressors).toHaveLength(1); expect(context.startedNodes).toBe(4); expect(context.destinationConnections).toBe(0); 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); graph.disconnectOutput(); await graph.close(); await graph.close(); expect(context.destinationConnections).toBe(0); expect(context.closeCalls).toBe(1); expect(context.stoppedNodes).toBe(4); }); }); 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[] = []; setValueAtTime(value: number): void { this.value = value; } setTargetAtTime(value: number): void { this.targetValues.push(value); this.value = value; } linearRampToValueAtTime(value: number): void { this.value = value; } exponentialRampToValueAtTime(value: number): void { 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 { start(): void { this.owner.startedNodes += 1; } stop(): void { this.owner.stoppedNodes += 1; } } class FakeAudioContext extends EventTarget { readonly destination = {}; readonly sampleRate = 8_000; readonly 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'; } }