feat: add the procedural synthwave soundtrack
This commit is contained in:
215
src/audio/music-sequencer.ts
Normal file
215
src/audio/music-sequencer.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { MusicSequencer } from '@/audio/types';
|
||||
|
||||
export const MUSIC_BPM = 86;
|
||||
export const MUSIC_SCHEDULER_INTERVAL_MS = 100;
|
||||
export const MUSIC_LOOKAHEAD_SECONDS = 0.25;
|
||||
|
||||
export const MUSIC_PROGRESSION = [
|
||||
{ name: 'F minor', notes: [174.61, 207.65, 261.63], bass: [87.31, 130.81] },
|
||||
{ name: 'D-flat major', notes: [138.59, 174.61, 207.65], bass: [69.3, 103.83] },
|
||||
{ name: 'A-flat major', notes: [207.65, 261.63, 311.13], bass: [103.83, 155.56] },
|
||||
{ name: 'E-flat major', notes: [155.56, 196, 233.08], bass: [77.78, 116.54] },
|
||||
] as const;
|
||||
|
||||
const SIXTEENTH_SECONDS = 60 / MUSIC_BPM / 4;
|
||||
const STEPS_PER_BAR = 16;
|
||||
const LOOP_STEPS = STEPS_PER_BAR * MUSIC_PROGRESSION.length;
|
||||
const MAX_STEPS_PER_SCHEDULE = LOOP_STEPS * 2;
|
||||
const SILENCE = 0.0001;
|
||||
|
||||
interface VoiceRecord {
|
||||
source: AudioScheduledSourceNode;
|
||||
nodes: AudioNode[];
|
||||
}
|
||||
|
||||
export function createMusicSequencer(
|
||||
context: AudioContext,
|
||||
destination: AudioNode,
|
||||
): MusicSequencer {
|
||||
const voices = new Map<AudioScheduledSourceNode, VoiceRecord>();
|
||||
const clapFilter = context.createBiquadFilter();
|
||||
clapFilter.type = 'bandpass';
|
||||
clapFilter.frequency.setValueAtTime(1_650, context.currentTime);
|
||||
clapFilter.Q.setValueAtTime(0.7, context.currentTime);
|
||||
clapFilter.connect(destination);
|
||||
const clapBuffer = createNoiseBuffer(context, 0.12);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let playing = false;
|
||||
let intensity = 0;
|
||||
let stepCursor = 0;
|
||||
let nextStepTime = 0;
|
||||
|
||||
const cleanupVoice = (source: AudioScheduledSourceNode): void => {
|
||||
const voice = voices.get(source);
|
||||
if (!voice) return;
|
||||
voices.delete(source);
|
||||
source.disconnect();
|
||||
for (const node of voice.nodes) node.disconnect();
|
||||
};
|
||||
|
||||
const registerVoice = (
|
||||
source: AudioScheduledSourceNode,
|
||||
nodes: AudioNode[],
|
||||
atTime: number,
|
||||
duration: number,
|
||||
): void => {
|
||||
voices.set(source, { source, nodes });
|
||||
source.addEventListener('ended', () => cleanupVoice(source), { once: true });
|
||||
source.start(atTime);
|
||||
source.stop(atTime + duration);
|
||||
};
|
||||
|
||||
const scheduleTone = (
|
||||
frequency: number,
|
||||
type: OscillatorType,
|
||||
atTime: number,
|
||||
duration: number,
|
||||
peak: number,
|
||||
attack: number,
|
||||
output: AudioNode = destination,
|
||||
): void => {
|
||||
const source = context.createOscillator();
|
||||
source.type = type;
|
||||
source.frequency.setValueAtTime(frequency, atTime);
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(SILENCE, atTime);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, atTime + attack);
|
||||
gain.gain.setValueAtTime(peak, Math.max(atTime + attack, atTime + duration * 0.72));
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + duration);
|
||||
source.connect(gain);
|
||||
gain.connect(output);
|
||||
registerVoice(source, [gain], atTime, duration);
|
||||
};
|
||||
|
||||
const schedulePad = (notes: readonly number[], atTime: number): void => {
|
||||
const duration = SIXTEENTH_SECONDS * 15.6;
|
||||
for (const [index, frequency] of notes.entries()) {
|
||||
scheduleTone(frequency, index === 1 ? 'triangle' : 'sawtooth', atTime, duration, 0.012, 0.34);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleBass = (frequency: number, atTime: number): void => {
|
||||
scheduleTone(frequency, 'triangle', atTime, SIXTEENTH_SECONDS * 6.8, 0.052, 0.035);
|
||||
};
|
||||
|
||||
const scheduleKick = (atTime: number, accent = 1): void => {
|
||||
const source = context.createOscillator();
|
||||
source.type = 'sine';
|
||||
source.frequency.setValueAtTime(118, atTime);
|
||||
source.frequency.exponentialRampToValueAtTime(46, atTime + 0.16);
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(0.07 * accent, atTime);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.22);
|
||||
source.connect(gain);
|
||||
gain.connect(destination);
|
||||
registerVoice(source, [gain], atTime, 0.23);
|
||||
};
|
||||
|
||||
const scheduleClap = (atTime: number): void => {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = clapBuffer;
|
||||
const gain = context.createGain();
|
||||
gain.gain.setValueAtTime(SILENCE, atTime);
|
||||
gain.gain.linearRampToValueAtTime(0.026, atTime + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.11);
|
||||
source.connect(gain);
|
||||
gain.connect(clapFilter);
|
||||
registerVoice(source, [gain], atTime, 0.12);
|
||||
};
|
||||
|
||||
const schedulePulse = (atTime: number, step: number): void => {
|
||||
const level = 0.006 + intensity * 0.016;
|
||||
const frequency = step % 8 === 2 ? 2_140 : 1_820;
|
||||
scheduleTone(frequency, 'square', atTime, 0.045, level, 0.003);
|
||||
};
|
||||
|
||||
const scheduleMotif = (atTime: number, frequency: number): void => {
|
||||
scheduleTone(frequency, 'sine', atTime, SIXTEENTH_SECONDS * 2.7, 0.019, 0.025);
|
||||
};
|
||||
|
||||
const scheduleStep = (absoluteStep: number, atTime: number): void => {
|
||||
const loopStep = absoluteStep % LOOP_STEPS;
|
||||
const stepInBar = loopStep % STEPS_PER_BAR;
|
||||
const bar = Math.floor(loopStep / STEPS_PER_BAR);
|
||||
const chord = MUSIC_PROGRESSION[bar]!;
|
||||
|
||||
if (stepInBar === 0) {
|
||||
schedulePad(chord.notes, atTime);
|
||||
scheduleBass(chord.bass[0], atTime);
|
||||
scheduleKick(atTime, bar === 0 ? 1 : 0.82);
|
||||
}
|
||||
if (stepInBar === 8) {
|
||||
scheduleBass(chord.bass[1], atTime);
|
||||
scheduleClap(atTime);
|
||||
}
|
||||
if (stepInBar === 10 && bar % 2 === 0) scheduleKick(atTime, 0.56);
|
||||
if (stepInBar === 2 || stepInBar === 6 || stepInBar === 10 || stepInBar === 14) {
|
||||
schedulePulse(atTime, stepInBar);
|
||||
}
|
||||
if (loopStep === 52) scheduleMotif(atTime, 466.16);
|
||||
if (loopStep === 60) scheduleMotif(atTime, 523.25);
|
||||
};
|
||||
|
||||
const scheduleThrough = (horizonTime: number): void => {
|
||||
if (!playing || !Number.isFinite(horizonTime)) return;
|
||||
if (nextStepTime < context.currentTime - SIXTEENTH_SECONDS * 2) {
|
||||
const skipped = Math.floor((context.currentTime - nextStepTime) / SIXTEENTH_SECONDS);
|
||||
stepCursor += skipped;
|
||||
nextStepTime += skipped * SIXTEENTH_SECONDS;
|
||||
}
|
||||
let scheduled = 0;
|
||||
while (nextStepTime <= horizonTime + Number.EPSILON && scheduled < MAX_STEPS_PER_SCHEDULE) {
|
||||
scheduleStep(stepCursor, nextStepTime);
|
||||
stepCursor += 1;
|
||||
nextStepTime += SIXTEENTH_SECONDS;
|
||||
scheduled += 1;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
start(atTime) {
|
||||
if (playing) return;
|
||||
playing = true;
|
||||
stepCursor = 0;
|
||||
nextStepTime = Math.max(context.currentTime, Number.isFinite(atTime) ? atTime : context.currentTime);
|
||||
scheduleThrough(nextStepTime + MUSIC_LOOKAHEAD_SECONDS);
|
||||
timer = setInterval(() => {
|
||||
scheduleThrough(context.currentTime + MUSIC_LOOKAHEAD_SECONDS);
|
||||
}, MUSIC_SCHEDULER_INTERVAL_MS);
|
||||
},
|
||||
scheduleThrough,
|
||||
setIntensity(value, _atTime) {
|
||||
intensity = clamp(value);
|
||||
},
|
||||
stop() {
|
||||
if (!playing && timer === null) return;
|
||||
playing = false;
|
||||
if (timer !== null) clearInterval(timer);
|
||||
timer = null;
|
||||
for (const source of [...voices.keys()]) {
|
||||
try { source.stop(context.currentTime); } catch { /* The voice may already have ended. */ }
|
||||
cleanupVoice(source);
|
||||
}
|
||||
clapFilter.disconnect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNoiseBuffer(context: AudioContext, durationSeconds: number): AudioBuffer {
|
||||
const length = Math.max(1, Math.floor(context.sampleRate * durationSeconds));
|
||||
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||
const samples = buffer.getChannelData(0);
|
||||
let state = 0x9e3779b9;
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
state ^= state << 13;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5;
|
||||
samples[index] = ((state >>> 0) / 0xffff_ffff) * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function clamp(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
|
||||
import { createMusicSequencer } from '@/audio/music-sequencer';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
@@ -46,7 +47,16 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
const master = context.createGain();
|
||||
const dynamics = context.createDynamicsCompressor();
|
||||
master.gain.setValueAtTime(0, context.currentTime);
|
||||
master.connect(dynamics);
|
||||
dynamics.connect(master);
|
||||
|
||||
const musicFilter = context.createBiquadFilter();
|
||||
musicFilter.type = 'lowpass';
|
||||
musicFilter.frequency.setValueAtTime(900, context.currentTime);
|
||||
musicFilter.Q.setValueAtTime(0.72, context.currentTime);
|
||||
const musicGain = context.createGain();
|
||||
musicGain.gain.setValueAtTime(0.38, context.currentTime);
|
||||
musicFilter.connect(musicGain);
|
||||
musicGain.connect(dynamics);
|
||||
|
||||
const engineFilter = context.createBiquadFilter();
|
||||
engineFilter.type = 'lowpass';
|
||||
@@ -54,9 +64,9 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
engineFilter.Q.setValueAtTime(1.25, context.currentTime);
|
||||
|
||||
const engineGain = context.createGain();
|
||||
engineGain.gain.setValueAtTime(0.045, context.currentTime);
|
||||
engineGain.gain.setValueAtTime(0.24, context.currentTime);
|
||||
engineFilter.connect(engineGain);
|
||||
engineGain.connect(master);
|
||||
engineGain.connect(dynamics);
|
||||
|
||||
const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter);
|
||||
const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter);
|
||||
@@ -66,9 +76,9 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
roadFilter.frequency.setValueAtTime(410, context.currentTime);
|
||||
roadFilter.Q.setValueAtTime(0.55, context.currentTime);
|
||||
const roadGain = context.createGain();
|
||||
roadGain.gain.setValueAtTime(0.015, context.currentTime);
|
||||
roadGain.gain.setValueAtTime(0.045, context.currentTime);
|
||||
roadFilter.connect(roadGain);
|
||||
roadGain.connect(master);
|
||||
roadGain.connect(dynamics);
|
||||
|
||||
const noise = context.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(context, 2);
|
||||
@@ -83,10 +93,12 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
ignitionGain.gain.exponentialRampToValueAtTime(0.07, context.currentTime + 0.12);
|
||||
ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15);
|
||||
ignition.connect(ignitionGain);
|
||||
ignitionGain.connect(master);
|
||||
ignitionGain.connect(engineFilter);
|
||||
|
||||
const scheduledNodes: Array<AudioScheduledSourceNode> = [fundamental.source, harmonic.source, noise, ignition];
|
||||
for (const node of scheduledNodes) node.start();
|
||||
const music = createMusicSequencer(context, musicFilter);
|
||||
music.start(context.currentTime);
|
||||
|
||||
let outputConnected = false;
|
||||
let closed = false;
|
||||
@@ -97,24 +109,36 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
music.stop();
|
||||
for (const node of scheduledNodes) {
|
||||
try { node.stop(); } catch { /* A source may already have ended. */ }
|
||||
node.disconnect();
|
||||
}
|
||||
if (outputConnected) {
|
||||
dynamics.disconnect(context.destination);
|
||||
master.disconnect(context.destination);
|
||||
outputConnected = false;
|
||||
}
|
||||
fundamental.gain.disconnect();
|
||||
harmonic.gain.disconnect();
|
||||
ignitionGain.disconnect();
|
||||
musicFilter.disconnect();
|
||||
musicGain.disconnect();
|
||||
engineFilter.disconnect();
|
||||
engineGain.disconnect();
|
||||
roadFilter.disconnect();
|
||||
roadGain.disconnect();
|
||||
dynamics.disconnect();
|
||||
master.disconnect();
|
||||
await context.close();
|
||||
},
|
||||
connectOutput() {
|
||||
if (closed || outputConnected) return;
|
||||
dynamics.connect(context.destination);
|
||||
master.connect(context.destination);
|
||||
outputConnected = true;
|
||||
},
|
||||
disconnectOutput() {
|
||||
if (!outputConnected) return;
|
||||
dynamics.disconnect(context.destination);
|
||||
master.disconnect(context.destination);
|
||||
outputConnected = false;
|
||||
},
|
||||
setOutputGain(value) {
|
||||
@@ -129,8 +153,10 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap
|
||||
harmonic.source.frequency.setTargetAtTime(84 + next * 56, time, 0.32);
|
||||
engineFilter.frequency.setTargetAtTime(260 + next * 670, time, 0.38);
|
||||
roadFilter.frequency.setTargetAtTime(410 + next * 520, time, 0.42);
|
||||
engineGain.gain.setTargetAtTime(0.045 + next * 0.035, time, 0.4);
|
||||
roadGain.gain.setTargetAtTime(0.015 + next * 0.03, time, 0.45);
|
||||
musicFilter.frequency.setTargetAtTime(900 + next * 2_400, time, 0.42);
|
||||
engineGain.gain.setTargetAtTime(0.24 + next * 0.12, time, 0.4);
|
||||
roadGain.gain.setTargetAtTime(0.045 + next * 0.06, time, 0.45);
|
||||
music.setIntensity(next, time);
|
||||
},
|
||||
onContextStateChange(listener) {
|
||||
const onStateChange = (): void => listener(context.state as AudioContextLifecycleState);
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface AudioGraph {
|
||||
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
|
||||
}
|
||||
|
||||
export interface MusicSequencer {
|
||||
start(atTime: number): void;
|
||||
scheduleThrough(horizonTime: number): void;
|
||||
setIntensity(value: number, atTime: number): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export type AudioGraphFactory = () => AudioGraph;
|
||||
|
||||
export interface AudioControllerOptions {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createAudioController } from '@/audio/create-audio-controller';
|
||||
import {
|
||||
createMusicSequencer,
|
||||
MUSIC_BPM,
|
||||
MUSIC_LOOKAHEAD_SECONDS,
|
||||
MUSIC_PROGRESSION,
|
||||
MUSIC_SCHEDULER_INTERVAL_MS,
|
||||
} from '@/audio/music-sequencer';
|
||||
import { createSynthEngine } from '@/audio/synth-engine';
|
||||
import type { AudioGraph, AudioGraphFactory } from '@/audio/types';
|
||||
|
||||
@@ -205,31 +212,104 @@ describe('audio controller lifecycle', () => {
|
||||
});
|
||||
|
||||
describe('procedural audio graph', () => {
|
||||
it('builds an original oscillator, filtered-noise, gain, and dynamics bed while output stays disconnected', async () => {
|
||||
it('routes independent music, engine, and road buses through compression before the master output', async () => {
|
||||
vi.useFakeTimers();
|
||||
const context = new FakeAudioContext();
|
||||
|
||||
try {
|
||||
const graph = createSynthEngine(() => context as unknown as AudioContext);
|
||||
context.currentTime = 1.4;
|
||||
vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS);
|
||||
|
||||
expect(context.oscillators).toHaveLength(3);
|
||||
expect(context.bufferSources).toHaveLength(1);
|
||||
expect(context.filters).toHaveLength(2);
|
||||
expect(context.gains.length).toBeGreaterThanOrEqual(4);
|
||||
expect(context.compressors).toHaveLength(1);
|
||||
expect(context.startedNodes).toBe(4);
|
||||
const [master, musicGain, engineGain, , , roadGain] = context.gains;
|
||||
const [musicFilter, engineFilter, roadFilter] = context.filters;
|
||||
const [compressor] = context.compressors;
|
||||
expect(musicFilter?.connections.has(musicGain)).toBe(true);
|
||||
expect(engineFilter?.connections.has(engineGain)).toBe(true);
|
||||
expect(roadFilter?.connections.has(roadGain)).toBe(true);
|
||||
expect(musicGain?.connections.has(compressor)).toBe(true);
|
||||
expect(engineGain?.connections.has(compressor)).toBe(true);
|
||||
expect(roadGain?.connections.has(compressor)).toBe(true);
|
||||
expect(compressor?.connections.has(master)).toBe(true);
|
||||
expect(context.destinationConnections).toBe(0);
|
||||
expect(context.startedNodes).toBeGreaterThan(4);
|
||||
expect(context.bufferSources.length).toBeGreaterThanOrEqual(2);
|
||||
expect(context.filters.length).toBeGreaterThanOrEqual(4);
|
||||
|
||||
graph.connectOutput();
|
||||
graph.setOutputGain(0.6);
|
||||
graph.setIntensity(1);
|
||||
expect(context.destinationConnections).toBe(1);
|
||||
expect(context.parameters.some((parameter) => parameter.targetValues.includes(930))).toBe(true);
|
||||
expect(context.parameters.some((parameter) => parameter.targetValues.includes(3_300))).toBe(true);
|
||||
|
||||
graph.disconnectOutput();
|
||||
await graph.close();
|
||||
await graph.close();
|
||||
expect(context.destinationConnections).toBe(0);
|
||||
expect(context.closeCalls).toBe(1);
|
||||
expect(context.stoppedNodes).toBe(4);
|
||||
expect(context.stoppedNodes).toBe(context.startedNodes);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('procedural music sequencer', () => {
|
||||
it('schedules the fixed four-bar progression through a 100ms/250ms look-ahead clock', () => {
|
||||
vi.useFakeTimers();
|
||||
const context = new FakeAudioContext();
|
||||
const destination = context.createGain();
|
||||
|
||||
try {
|
||||
expect(MUSIC_BPM).toBe(86);
|
||||
expect(MUSIC_SCHEDULER_INTERVAL_MS).toBe(100);
|
||||
expect(MUSIC_LOOKAHEAD_SECONDS).toBe(0.25);
|
||||
expect(MUSIC_PROGRESSION.map((chord) => chord.name)).toEqual([
|
||||
'F minor', 'D-flat major', 'A-flat major', 'E-flat major',
|
||||
]);
|
||||
|
||||
const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode);
|
||||
expect(context.startedNodes).toBe(0);
|
||||
sequencer.start(0);
|
||||
const initialStarted = context.startedNodes;
|
||||
expect(initialStarted).toBeGreaterThan(0);
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
|
||||
context.currentTime = 0.1;
|
||||
vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS);
|
||||
expect(context.startedNodes).toBeGreaterThan(initialStarted);
|
||||
|
||||
sequencer.scheduleThrough(11.2);
|
||||
const frequencies = new Set(context.oscillators.map((oscillator) => Number(oscillator.frequency.value.toFixed(2))));
|
||||
for (const root of [174.61, 138.59, 207.65, 155.56]) expect(frequencies.has(root)).toBe(true);
|
||||
|
||||
sequencer.stop();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
expect(context.stoppedNodes).toBe(context.startedNodes);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('raises future pulse energy with intensity without changing the musical clock', () => {
|
||||
vi.useFakeTimers();
|
||||
const context = new FakeAudioContext();
|
||||
const destination = context.createGain();
|
||||
|
||||
try {
|
||||
const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode);
|
||||
sequencer.start(0);
|
||||
sequencer.setIntensity(1, 0);
|
||||
sequencer.scheduleThrough(0.6);
|
||||
|
||||
expect(context.parameters.some((parameter) => parameter.scheduledValues.includes(0.022))).toBe(true);
|
||||
expect(MUSIC_BPM).toBe(86);
|
||||
sequencer.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,11 +378,12 @@ function deferred<T>(): Deferred<T> {
|
||||
class FakeAudioParam {
|
||||
value = 0;
|
||||
readonly targetValues: number[] = [];
|
||||
readonly scheduledValues: number[] = [];
|
||||
|
||||
setValueAtTime(value: number): void { this.value = value; }
|
||||
setValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; }
|
||||
setTargetAtTime(value: number): void { this.targetValues.push(value); this.value = value; }
|
||||
linearRampToValueAtTime(value: number): void { this.value = value; }
|
||||
exponentialRampToValueAtTime(value: number): void { this.value = value; }
|
||||
linearRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; }
|
||||
exponentialRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; }
|
||||
cancelScheduledValues(): void {}
|
||||
}
|
||||
|
||||
@@ -325,14 +406,24 @@ class FakeNode extends EventTarget {
|
||||
}
|
||||
|
||||
class FakeScheduledNode extends FakeNode {
|
||||
start(): void { this.owner.startedNodes += 1; }
|
||||
stop(): void { this.owner.stoppedNodes += 1; }
|
||||
private started = false;
|
||||
private stopped = false;
|
||||
start(): void {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.owner.startedNodes += 1;
|
||||
}
|
||||
stop(): void {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this.owner.stoppedNodes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext extends EventTarget {
|
||||
readonly destination = {};
|
||||
readonly sampleRate = 8_000;
|
||||
readonly currentTime = 0;
|
||||
currentTime = 0;
|
||||
readonly parameters: FakeAudioParam[] = [];
|
||||
readonly oscillators: Array<FakeScheduledNode & { type: string; frequency: FakeAudioParam }> = [];
|
||||
readonly bufferSources: Array<FakeScheduledNode & { buffer: unknown; loop: boolean }> = [];
|
||||
|
||||
Reference in New Issue
Block a user