/** * Deterministic hand-rolled `BaseAudioContext`-shaped shim for tests. * * This is NOT a mock — it is a complete, in-memory implementation of * the surface `WebAudioGraph`, `AudioWorkletManager`, and * `BrowserTimelineAdapter` consume. Tests inject an instance and * assert against its recorded graph state. */ interface ConnectRecord { readonly target: FakeAudioNode; readonly outputIndex?: number; readonly inputIndex?: number; } function tagCtor(target: object, name: string): void { Object.defineProperty(target, "constructor", { value: { name }, configurable: true, }); } export class FakeAudioParam { public value: number; public readonly history: Array<{ value: number; time: number }> = []; public constructor(initial = 1) { this.value = initial; } public setValueAtTime(value: number, time: number): this { this.value = value; this.history.push({ value, time }); return this; } public linearRampToValueAtTime(value: number, time: number): this { this.value = value; this.history.push({ value, time }); return this; } public exponentialRampToValueAtTime(value: number, time: number): this { this.value = value; this.history.push({ value, time }); return this; } public cancelScheduledValues(_time: number): this { return this; } } export class FakeAudioNode { public readonly connections: ConnectRecord[] = []; public disconnected = false; public connect( target: FakeAudioNode, outputIndex?: number, inputIndex?: number ): FakeAudioNode { const rec: ConnectRecord = { target, ...(outputIndex !== undefined ? { outputIndex } : {}), ...(inputIndex !== undefined ? { inputIndex } : {}), }; this.connections.push(rec); return target; } public disconnect(target?: FakeAudioNode): void { if (target === undefined) { this.connections.length = 0; this.disconnected = true; return; } for (let i = this.connections.length - 1; i >= 0; i -= 1) { if (this.connections[i].target === target) { this.connections.splice(i, 1); } } } } class GainNode extends FakeAudioNode { public readonly gain = new FakeAudioParam(1); } class StereoPannerNode extends FakeAudioNode { public readonly pan = new FakeAudioParam(0); } class BiquadFilterNode extends FakeAudioNode { public readonly frequency = new FakeAudioParam(350); public readonly Q = new FakeAudioParam(1); public type: BiquadFilterType = "lowpass"; } type BiquadFilterType = | "lowpass" | "highpass" | "bandpass" | "lowshelf" | "highshelf" | "peaking" | "notch" | "allpass"; class DelayNode extends FakeAudioNode { public readonly delayTime = new FakeAudioParam(0); } class DynamicsCompressorNode extends FakeAudioNode { public readonly threshold = new FakeAudioParam(-24); public readonly ratio = new FakeAudioParam(12); } class ConvolverNode extends FakeAudioNode { public buffer: unknown = null; } class AnalyserNode extends FakeAudioNode { public fftSize = 2048; } class OscillatorNode extends FakeAudioNode { public readonly frequency = new FakeAudioParam(440); public start(_time?: number): void {} public stop(_time?: number): void {} } class AudioBufferSourceNode extends FakeAudioNode { public buffer: unknown = null; public loop = false; public readonly playbackRate = new FakeAudioParam(1); public started = false; public stopped = false; public start(_time?: number, _offset?: number, _duration?: number): void { this.started = true; } public stop(_time?: number): void { this.stopped = true; } } class ChannelSplitterNode extends FakeAudioNode {} class ChannelMergerNode extends FakeAudioNode {} class ScriptProcessorNode extends FakeAudioNode { public onaudioprocess: ((e: unknown) => void) | null = null; } class AudioDestinationNode extends FakeAudioNode {} class WorkletPort { public readonly posted: unknown[] = []; private readonly listeners: Array<(e: MessageEvent) => void> = []; public postMessage(message: unknown, _transfer?: Transferable[]): void { this.posted.push(message); } public addEventListener( _type: "message", listener: (e: MessageEvent) => void ): void { this.listeners.push(listener); } public removeEventListener( _type: "message", listener: (e: MessageEvent) => void ): void { const i = this.listeners.indexOf(listener); if (i >= 0) this.listeners.splice(i, 1); } public emit(message: unknown): void { const ev = { data: message } as MessageEvent; for (const l of this.listeners) l(ev); } } class FakeAudioWorkletNode extends FakeAudioNode { public readonly port = new WorkletPort(); public readonly processorName: string; public readonly options: unknown; public constructor( _context: unknown, processorName: string, options?: unknown ) { super(); this.processorName = processorName; this.options = options; } } class FakeAudioBuffer { public readonly numberOfChannels: number; public readonly length: number; public readonly sampleRate: number; private readonly channels: Float32Array[]; public constructor(channels: number, length: number, sampleRate: number) { this.numberOfChannels = channels; this.length = length; this.sampleRate = sampleRate; this.channels = Array.from( { length: channels }, () => new Float32Array(length) ); } public getChannelData(ch: number): Float32Array { return this.channels[ch]!; } } class FakeAudioWorklet { public readonly loaded = new Set(); public failNext = false; public async addModule( url: string, _options?: { credentials?: string } ): Promise { if (this.failNext) { this.failNext = false; throw new Error("addModule failure"); } this.loaded.add(url); } } export interface FakeContextOptions { readonly sampleRate?: number; readonly length?: number; readonly withWorklet?: boolean; readonly offline?: boolean; } export class FakeAudioContext { public readonly sampleRate: number; public readonly length: number; public readonly destination: AudioDestinationNode; public readonly audioWorklet: FakeAudioWorklet | undefined; public readonly offline: boolean; public state: "suspended" | "running" | "closed" = "suspended"; public currentTime = 0; private renderResult: FakeAudioBuffer | null = null; public rejectStartRendering: Error | null = null; public constructor(options: FakeContextOptions = {}) { this.sampleRate = options.sampleRate ?? 48000; this.length = options.length ?? 0; this.offline = options.offline ?? false; this.destination = new AudioDestinationNode(); tagCtor(this.destination, "AudioDestinationNode"); this.audioWorklet = options.withWorklet === false ? undefined : new FakeAudioWorklet(); } public async resume(): Promise { this.state = "running"; } public async close(): Promise { this.state = "closed"; } public async startRendering(): Promise { if (this.rejectStartRendering) throw this.rejectStartRendering; this.renderResult ??= new FakeAudioBuffer( 2, this.length || Math.ceil(this.sampleRate), this.sampleRate ); return this.renderResult; } public createGain(): GainNode { return tag(new GainNode(), "GainNode"); } public createStereoPanner(): StereoPannerNode { return tag(new StereoPannerNode(), "StereoPannerNode"); } public createBiquadFilter(): BiquadFilterNode { return tag(new BiquadFilterNode(), "BiquadFilterNode"); } public createDelay(_max?: number): DelayNode { return tag(new DelayNode(), "DelayNode"); } public createDynamicsCompressor(): DynamicsCompressorNode { return tag(new DynamicsCompressorNode(), "DynamicsCompressorNode"); } public createConvolver(): ConvolverNode { return tag(new ConvolverNode(), "ConvolverNode"); } public createAnalyser(): AnalyserNode { return tag(new AnalyserNode(), "AnalyserNode"); } public createOscillator(): OscillatorNode { return tag(new OscillatorNode(), "OscillatorNode"); } public createBufferSource(): AudioBufferSourceNode { return tag(new AudioBufferSourceNode(), "AudioBufferSourceNode"); } public createChannelSplitter(_n?: number): ChannelSplitterNode { return tag(new ChannelSplitterNode(), "ChannelSplitterNode"); } public createChannelMerger(_n?: number): ChannelMergerNode { return tag(new ChannelMergerNode(), "ChannelMergerNode"); } public createScriptProcessor( _bufferSize?: number, _inputs?: number, _outputs?: number ): ScriptProcessorNode { return tag(new ScriptProcessorNode(), "ScriptProcessorNode"); } public createBuffer( channels: number, length: number, sr: number ): FakeAudioBuffer { return new FakeAudioBuffer(channels, length, sr); } } function tag(node: T, name: string): T { Object.defineProperty(node, "constructor", { value: { name }, configurable: true, }); return node; } export { AnalyserNode as FakeAnalyserNode, AudioBufferSourceNode as FakeAudioBufferSourceNode, BiquadFilterNode as FakeBiquadFilterNode, DynamicsCompressorNode as FakeDynamicsCompressorNode, FakeAudioBuffer, FakeAudioWorklet, FakeAudioWorkletNode, GainNode as FakeGainNode, ScriptProcessorNode as FakeScriptProcessorNode, StereoPannerNode as FakeStereoPannerNode, };