Initial Commit
This commit is contained in:
331
packages/media/audio/audio-engine-browser/tests/fake-context.ts
Normal file
331
packages/media/audio/audio-engine-browser/tests/fake-context.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user