332 lines
10 KiB
TypeScript
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,
|
|
};
|