268 lines
8.4 KiB
TypeScript
268 lines
8.4 KiB
TypeScript
export type AudioParamEvent =
|
|
| { readonly kind: 'set'; readonly value: number; readonly at: number }
|
|
| { readonly kind: 'linear'; readonly value: number; readonly at: number }
|
|
| { readonly kind: 'exponential'; readonly value: number; readonly at: number }
|
|
| { readonly kind: 'target'; readonly value: number; readonly at: number; readonly constant: number }
|
|
| { readonly kind: 'cancel'; readonly at: number }
|
|
| { readonly kind: 'hold'; readonly at: number };
|
|
|
|
export interface FakeAudioParamRecord {
|
|
readonly param: AudioParam;
|
|
readonly events: AudioParamEvent[];
|
|
}
|
|
|
|
export interface FakeSourceRecord {
|
|
readonly kind: 'buffer' | 'oscillator';
|
|
readonly node: AudioScheduledSourceNode;
|
|
readonly starts: Array<{ at: number; offset: number; duration: number | null }>;
|
|
readonly stops: number[];
|
|
disconnectCount: number;
|
|
emitEnded(): void;
|
|
}
|
|
|
|
export interface FakeAudioHarness {
|
|
readonly context: AudioContext;
|
|
readonly params: FakeAudioParamRecord[];
|
|
readonly sources: FakeSourceRecord[];
|
|
readonly gainNodes: readonly GainNode[];
|
|
readonly buffers: AudioBuffer[];
|
|
readonly destination: AudioNode;
|
|
setCurrentTime(seconds: number): void;
|
|
decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer;
|
|
emitContextState(state: AudioContextState | 'interrupted'): void;
|
|
}
|
|
|
|
const createParam = (
|
|
records: FakeAudioParamRecord[],
|
|
initial = 0,
|
|
): AudioParam => {
|
|
const events: AudioParamEvent[] = [];
|
|
const parameter = {
|
|
value: initial,
|
|
defaultValue: initial,
|
|
minValue: Number.NEGATIVE_INFINITY,
|
|
maxValue: Number.POSITIVE_INFINITY,
|
|
automationRate: 'a-rate' as const,
|
|
setValueAtTime(this: { value: number }, value: number, at: number) {
|
|
this.value = value;
|
|
events.push({ kind: 'set', value, at });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
linearRampToValueAtTime(this: { value: number }, value: number, at: number) {
|
|
this.value = value;
|
|
events.push({ kind: 'linear', value, at });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
exponentialRampToValueAtTime(this: { value: number }, value: number, at: number) {
|
|
this.value = value;
|
|
events.push({ kind: 'exponential', value, at });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
setTargetAtTime(this: { value: number }, value: number, at: number, constant: number) {
|
|
this.value = value;
|
|
events.push({ kind: 'target', value, at, constant });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
cancelScheduledValues(at: number) {
|
|
events.push({ kind: 'cancel', at });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
cancelAndHoldAtTime(at: number) {
|
|
events.push({ kind: 'hold', at });
|
|
return this as unknown as AudioParam;
|
|
},
|
|
setValueCurveAtTime() {
|
|
throw new Error('Unexpected value-curve scheduling in focused audio tests.');
|
|
},
|
|
} as unknown as AudioParam;
|
|
records.push({ param: parameter, events });
|
|
return parameter;
|
|
};
|
|
|
|
interface DisconnectableNode extends AudioNode {
|
|
disconnectCount: number;
|
|
}
|
|
|
|
function createNode(): DisconnectableNode {
|
|
const node = {
|
|
disconnectCount: 0,
|
|
connect(destination: AudioNode | AudioParam) {
|
|
return destination;
|
|
},
|
|
disconnect(this: { disconnectCount: number }) {
|
|
this.disconnectCount += 1;
|
|
},
|
|
} as unknown as DisconnectableNode;
|
|
return node;
|
|
}
|
|
|
|
export function createFakeAudioHarness(): FakeAudioHarness {
|
|
const params: FakeAudioParamRecord[] = [];
|
|
const sources: FakeSourceRecord[] = [];
|
|
const gainNodes: GainNode[] = [];
|
|
const buffers: AudioBuffer[] = [];
|
|
const stateListeners = new Set<() => void>();
|
|
const sampleRate = 48_000;
|
|
let currentTime = 0;
|
|
let state: AudioContextState | 'interrupted' = 'suspended';
|
|
|
|
const decodedBuffer = (durationSeconds: number, channels = 2): AudioBuffer => {
|
|
const length = Math.max(1, Math.round(durationSeconds * sampleRate));
|
|
const data = Array.from({ length: channels }, () => new Float32Array(length));
|
|
const buffer = {
|
|
sampleRate,
|
|
length,
|
|
duration: length / sampleRate,
|
|
numberOfChannels: channels,
|
|
getChannelData(channel: number) {
|
|
const samples = data[channel];
|
|
if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`);
|
|
return samples;
|
|
},
|
|
} as unknown as AudioBuffer;
|
|
buffers.push(buffer);
|
|
return buffer;
|
|
};
|
|
|
|
const destination = createNode();
|
|
|
|
const createScheduledSource = (kind: FakeSourceRecord['kind']): AudioScheduledSourceNode => {
|
|
const node = createNode() as AudioScheduledSourceNode & DisconnectableNode;
|
|
const starts: FakeSourceRecord['starts'] = [];
|
|
const stops: number[] = [];
|
|
const endedListeners = new Set<() => void>();
|
|
const record: FakeSourceRecord = {
|
|
kind,
|
|
node,
|
|
starts,
|
|
stops,
|
|
get disconnectCount() {
|
|
return node.disconnectCount;
|
|
},
|
|
set disconnectCount(value: number) {
|
|
node.disconnectCount = value;
|
|
},
|
|
emitEnded() {
|
|
for (const listener of endedListeners) listener();
|
|
endedListeners.clear();
|
|
},
|
|
};
|
|
Object.assign(node, {
|
|
start(at = 0, offset = 0, duration?: number) {
|
|
starts.push({ at, offset, duration: duration ?? null });
|
|
},
|
|
stop(at = 0) {
|
|
stops.push(at);
|
|
},
|
|
addEventListener(type: string, listener: () => void, options?: AddEventListenerOptions) {
|
|
if (type === 'ended') endedListeners.add(listener);
|
|
if (options?.once !== true) return;
|
|
},
|
|
removeEventListener(type: string, listener: () => void) {
|
|
if (type === 'ended') endedListeners.delete(listener);
|
|
},
|
|
});
|
|
sources.push(record);
|
|
return node;
|
|
};
|
|
|
|
const context = {
|
|
get currentTime() {
|
|
return currentTime;
|
|
},
|
|
get state() {
|
|
return state;
|
|
},
|
|
sampleRate,
|
|
destination,
|
|
createGain() {
|
|
const node = Object.assign(createNode(), { gain: createParam(params, 1) }) as GainNode;
|
|
gainNodes.push(node);
|
|
return node;
|
|
},
|
|
createBiquadFilter() {
|
|
return Object.assign(createNode(), {
|
|
type: 'lowpass' as BiquadFilterType,
|
|
frequency: createParam(params),
|
|
detune: createParam(params),
|
|
Q: createParam(params),
|
|
gain: createParam(params),
|
|
}) as unknown as BiquadFilterNode;
|
|
},
|
|
createStereoPanner() {
|
|
return Object.assign(createNode(), { pan: createParam(params) }) as StereoPannerNode;
|
|
},
|
|
createBuffer(numberOfChannels: number, length: number, bufferSampleRate: number) {
|
|
const data = Array.from(
|
|
{ length: numberOfChannels },
|
|
() => new Float32Array(length),
|
|
);
|
|
const buffer = {
|
|
sampleRate: bufferSampleRate,
|
|
length,
|
|
duration: length / bufferSampleRate,
|
|
numberOfChannels,
|
|
getChannelData(channel: number) {
|
|
const samples = data[channel];
|
|
if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`);
|
|
return samples;
|
|
},
|
|
} as unknown as AudioBuffer;
|
|
buffers.push(buffer);
|
|
return buffer;
|
|
},
|
|
createBufferSource() {
|
|
return Object.assign(createScheduledSource('buffer'), {
|
|
buffer: null as AudioBuffer | null,
|
|
loop: false,
|
|
loopStart: 0,
|
|
loopEnd: 0,
|
|
playbackRate: createParam(params, 1),
|
|
detune: createParam(params),
|
|
}) as AudioBufferSourceNode;
|
|
},
|
|
createOscillator() {
|
|
return Object.assign(createScheduledSource('oscillator'), {
|
|
type: 'sine' as OscillatorType,
|
|
frequency: createParam(params, 440),
|
|
detune: createParam(params),
|
|
}) as OscillatorNode;
|
|
},
|
|
async decodeAudioData() {
|
|
return decodedBuffer(1);
|
|
},
|
|
async resume() {
|
|
state = 'running';
|
|
},
|
|
async suspend() {
|
|
state = 'suspended';
|
|
},
|
|
async close() {
|
|
state = 'closed';
|
|
},
|
|
addEventListener(type: string, listener: () => void) {
|
|
if (type === 'statechange') stateListeners.add(listener);
|
|
},
|
|
removeEventListener(type: string, listener: () => void) {
|
|
if (type === 'statechange') stateListeners.delete(listener);
|
|
},
|
|
} as unknown as AudioContext;
|
|
|
|
return {
|
|
context,
|
|
params,
|
|
sources,
|
|
gainNodes,
|
|
buffers,
|
|
destination,
|
|
setCurrentTime(seconds) {
|
|
currentTime = seconds;
|
|
},
|
|
decodedBuffer,
|
|
emitContextState(nextState) {
|
|
state = nextState;
|
|
for (const listener of stateListeners) listener();
|
|
},
|
|
};
|
|
}
|