Files
2026-05-27 23:47:53 +01:00

332 lines
10 KiB
TypeScript

/**
* 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<string>();
public failNext = false;
public async addModule(
url: string,
_options?: { credentials?: string }
): Promise<void> {
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<void> {
this.state = "running";
}
public async close(): Promise<void> {
this.state = "closed";
}
public async startRendering(): Promise<FakeAudioBuffer> {
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<T extends FakeAudioNode>(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,
};