export type AudioParamEvent = | { readonly kind: 'set'; readonly value: number; readonly at: number } | { readonly kind: 'linear'; readonly value: number; readonly at: number } | { readonly kind: 'exponential'; readonly value: number; readonly at: number } | { readonly kind: 'target'; readonly value: number; readonly at: number; readonly constant: number } | { readonly kind: 'cancel'; readonly at: number } | { readonly kind: 'hold'; readonly at: number }; export interface FakeAudioParamRecord { readonly param: AudioParam; readonly events: AudioParamEvent[]; } export interface FakeSourceRecord { readonly kind: 'buffer' | 'oscillator'; readonly node: AudioScheduledSourceNode; readonly starts: Array<{ at: number; offset: number; duration: number | null }>; readonly stops: number[]; disconnectCount: number; emitEnded(): void; } export interface FakeAudioHarness { readonly context: AudioContext; readonly params: FakeAudioParamRecord[]; readonly sources: FakeSourceRecord[]; 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; } const createParam = ( records: FakeAudioParamRecord[], initial = 0, ): AudioParam => { const events: AudioParamEvent[] = []; const parameter = { value: initial, defaultValue: initial, minValue: Number.NEGATIVE_INFINITY, maxValue: Number.POSITIVE_INFINITY, automationRate: 'a-rate' as const, setValueAtTime(this: { value: number }, value: number, at: number) { this.value = value; events.push({ kind: 'set', value, at }); return this as unknown as AudioParam; }, linearRampToValueAtTime(this: { value: number }, value: number, at: number) { this.value = value; events.push({ kind: 'linear', value, at }); return this as unknown as AudioParam; }, exponentialRampToValueAtTime(this: { value: number }, value: number, at: number) { this.value = value; events.push({ kind: 'exponential', value, at }); return this as unknown as AudioParam; }, setTargetAtTime(this: { value: number }, value: number, at: number, constant: number) { this.value = value; events.push({ kind: 'target', value, at, constant }); return this as unknown as AudioParam; }, cancelScheduledValues(at: number) { events.push({ kind: 'cancel', at }); return this as unknown as AudioParam; }, cancelAndHoldAtTime(at: number) { events.push({ kind: 'hold', at }); return this as unknown as AudioParam; }, setValueCurveAtTime() { throw new Error('Unexpected value-curve scheduling in focused audio tests.'); }, } as unknown as AudioParam; records.push({ param: parameter, events }); return parameter; }; interface DisconnectableNode extends AudioNode { disconnectCount: number; } function createNode(): DisconnectableNode { const node = { disconnectCount: 0, connect(destination: AudioNode | AudioParam) { return destination; }, disconnect(this: { disconnectCount: number }) { this.disconnectCount += 1; }, } as unknown as DisconnectableNode; return node; } export function createFakeAudioHarness(): FakeAudioHarness { const params: FakeAudioParamRecord[] = []; const sources: FakeSourceRecord[] = []; const gainNodes: GainNode[] = []; const buffers: AudioBuffer[] = []; const stateListeners = new Set<() => void>(); 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)); const data = Array.from({ length: channels }, () => new Float32Array(length)); const buffer = { sampleRate, length, duration: length / sampleRate, numberOfChannels: channels, getChannelData(channel: number) { const samples = data[channel]; if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`); return samples; }, } as unknown as AudioBuffer; buffers.push(buffer); return buffer; }; const destination = createNode(); const createScheduledSource = (kind: FakeSourceRecord['kind']): AudioScheduledSourceNode => { const node = createNode() as AudioScheduledSourceNode & DisconnectableNode; const starts: FakeSourceRecord['starts'] = []; const stops: number[] = []; const endedListeners = new Set<() => void>(); const record: FakeSourceRecord = { kind, node, starts, stops, get disconnectCount() { return node.disconnectCount; }, set disconnectCount(value: number) { node.disconnectCount = value; }, emitEnded() { for (const listener of endedListeners) listener(); endedListeners.clear(); }, }; Object.assign(node, { start(at = 0, offset = 0, duration?: number) { starts.push({ at, offset, duration: duration ?? null }); }, stop(at = 0) { stops.push(at); }, addEventListener(type: string, listener: () => void, options?: AddEventListenerOptions) { if (type === 'ended') endedListeners.add(listener); if (options?.once !== true) return; }, removeEventListener(type: string, listener: () => void) { if (type === 'ended') endedListeners.delete(listener); }, }); sources.push(record); return node; }; const context = { get currentTime() { return currentTime; }, get state() { return state; }, sampleRate, destination, createGain() { const node = Object.assign(createNode(), { gain: createParam(params, 1) }) as GainNode; gainNodes.push(node); return node; }, createBiquadFilter() { return Object.assign(createNode(), { type: 'lowpass' as BiquadFilterType, frequency: createParam(params), detune: createParam(params), Q: createParam(params), gain: createParam(params), }) as unknown as BiquadFilterNode; }, createStereoPanner() { return Object.assign(createNode(), { pan: createParam(params) }) as StereoPannerNode; }, createBuffer(numberOfChannels: number, length: number, bufferSampleRate: number) { const data = Array.from( { length: numberOfChannels }, () => new Float32Array(length), ); const buffer = { sampleRate: bufferSampleRate, length, duration: length / bufferSampleRate, numberOfChannels, getChannelData(channel: number) { const samples = data[channel]; if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`); return samples; }, } as unknown as AudioBuffer; buffers.push(buffer); return buffer; }, createBufferSource() { return Object.assign(createScheduledSource('buffer'), { buffer: null as AudioBuffer | null, loop: false, loopStart: 0, loopEnd: 0, playbackRate: createParam(params, 1), detune: createParam(params), }) as AudioBufferSourceNode; }, createOscillator() { return Object.assign(createScheduledSource('oscillator'), { type: 'sine' as OscillatorType, frequency: createParam(params, 440), detune: createParam(params), }) as OscillatorNode; }, async decodeAudioData() { 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) { if (type === 'statechange') stateListeners.add(listener); }, removeEventListener(type: string, listener: () => void) { if (type === 'statechange') stateListeners.delete(listener); }, } as unknown as AudioContext; return { context, params, sources, gainNodes, buffers, destination, get resumeCount() { return resumeCount; }, get suspendCount() { return suspendCount; }, get closeCount() { return closeCount; }, setCurrentTime(seconds) { currentTime = seconds; }, decodedBuffer, emitContextState(nextState) { state = nextState; for (const listener of stateListeners) listener(); }, }; }