/** * A minimal Web Audio stand-in: enough of the API for AudioEngine to build its * graph, with the connections recorded so tests can assert on the topology * rather than on implementation details. */ export interface Connection { from: MockNode; to: MockNode; } let nodeCounter = 0; export class MockParam { value: number; readonly events: Array<{ type: string; value: number; time: number }> = []; constructor(value = 0) { this.value = value; } setValueAtTime(value: number, time: number): this { this.value = value; this.events.push({ type: 'setValueAtTime', value, time }); return this; } setTargetAtTime(value: number, time: number): this { this.value = value; this.events.push({ type: 'setTargetAtTime', value, time }); return this; } linearRampToValueAtTime(value: number, time: number): this { this.value = value; this.events.push({ type: 'linearRamp', value, time }); return this; } cancelScheduledValues(): this { return this; } } export class MockNode { readonly id: string; readonly outputs: MockNode[] = []; constructor(readonly kind: string, readonly ctx: MockAudioContext) { this.id = `${kind}#${nodeCounter++}`; ctx.nodes.push(this); } connect(target: MockNode): MockNode { this.outputs.push(target); this.ctx.connections.push({ from: this, to: target }); return target; } disconnect(): void { this.outputs.length = 0; } } class MockAudioParamNode extends MockNode { readonly gain = new MockParam(1); } export class MockAudioContext { readonly connections: Connection[] = []; readonly nodes: MockNode[] = []; readonly sampleRate = 48000; currentTime = 0; state: AudioContextState = 'suspended'; readonly destination = new MockNode('destination', this); readonly listener = { positionX: new MockParam(), positionY: new MockParam(), positionZ: new MockParam(), forwardX: new MockParam(0), forwardY: new MockParam(0), forwardZ: new MockParam(-1), upX: new MockParam(0), upY: new MockParam(1), upZ: new MockParam(0), }; readonly started: MockNode[] = []; async resume(): Promise { this.state = 'running'; } async close(): Promise { this.state = 'closed'; } createGain() { return new MockAudioParamNode('gain', this); } createDelay(maxDelay = 1) { const node = new MockNode('delay', this) as MockNode & { delayTime: MockParam; maxDelayTime: number; }; node.delayTime = new MockParam(0); node.maxDelayTime = maxDelay; return node; } createBiquadFilter() { const node = new MockNode('biquad', this) as MockNode & { type: string; frequency: MockParam; Q: MockParam; }; node.type = 'lowpass'; node.frequency = new MockParam(350); node.Q = new MockParam(1); return node; } createPanner() { const node = new MockNode('panner', this) as MockNode & Record; node.panningModel = 'equalpower'; node.distanceModel = 'inverse'; node.rolloffFactor = 1; node.coneInnerAngle = 360; node.coneOuterAngle = 360; node.coneOuterGain = 0; node.positionX = new MockParam(); node.positionY = new MockParam(); node.positionZ = new MockParam(); node.orientationX = new MockParam(1); node.orientationY = new MockParam(0); node.orientationZ = new MockParam(0); return node; } createConvolver() { const node = new MockNode('convolver', this) as MockNode & { buffer: AudioBuffer | null; normalize: boolean; }; node.buffer = null; node.normalize = true; return node; } createDynamicsCompressor() { const node = new MockNode('compressor', this) as MockNode & Record; for (const key of ['threshold', 'knee', 'ratio', 'attack', 'release']) { node[key] = new MockParam(0); } return node; } createAnalyser() { const node = new MockNode('analyser', this) as MockNode & Record; node.fftSize = 2048; node.frequencyBinCount = 1024; node.smoothingTimeConstant = 0.8; node.minDecibels = -100; node.maxDecibels = -30; node.getByteFrequencyData = (array: Uint8Array) => array.fill(0); node.getByteTimeDomainData = (array: Uint8Array) => array.fill(128); return node; } createChannelSplitter() { return new MockNode('splitter', this); } createBufferSource() { const ctx = this; const node = new MockNode('bufferSource', this) as MockNode & Record; node.buffer = null; node.loop = false; node.playbackRate = new MockParam(1); node.start = () => { ctx.started.push(node); }; node.stop = () => {}; return node; } /** * Stands in for the browser decoder. A payload whose first byte is 0 is * treated as undecodable, which is how tests exercise the failure path. */ async decodeAudioData(bytes: ArrayBuffer): Promise { const view = new Uint8Array(bytes); if (view.length === 0 || view[0] === 0) throw new Error('EncodingError'); // Two channels of a recognisable ramp, so downmixing can be checked. const length = 4096; const buffer = this.createBuffer(2, length, this.sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); for (let i = 0; i < length; i++) { left[i] = Math.sin((i / length) * Math.PI * 2) * 0.8; right[i] = -left[i] * 0.5; } return buffer; } createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer { const data = Array.from({ length: channels }, () => new Float32Array(length)); return { numberOfChannels: channels, length, sampleRate, duration: length / sampleRate, getChannelData: (channel: number) => data[channel], copyFromChannel: () => {}, copyToChannel: () => {}, } as unknown as AudioBuffer; } /** Every edge in the graph, as "from->to" strings. */ edges(): string[] { return this.connections.map((c) => `${c.from.kind}->${c.to.kind}`); } /** The single node of `kind` that feeds a node of `intoKind`. */ feederOf(kind: string, intoKind: string): MockNode & { gain: MockParam } { const edge = this.connections.find((c) => c.from.kind === kind && c.to.kind === intoKind); if (!edge) throw new Error(`No ${kind} -> ${intoKind} edge in the graph`); return edge.from as MockNode & { gain: MockParam }; } nodeOf(kind: string): T { const node = this.nodes.find((n) => n.kind === kind); if (!node) throw new Error(`No ${kind} node in the graph`); return node as T; } } /** Installs the mock as the global AudioContext and returns a reset function. */ export function installMockAudio(): { restore: () => void; instances: MockAudioContext[] } { const instances: MockAudioContext[] = []; const previous = (globalThis as Record).AudioContext; (globalThis as Record).AudioContext = function MockCtor() { const ctx = new MockAudioContext(); instances.push(ctx); return ctx; } as unknown as typeof AudioContext; return { instances, restore() { (globalThis as Record).AudioContext = previous; }, }; }