203 lines
7.6 KiB
TypeScript
203 lines
7.6 KiB
TypeScript
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;
|
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
|
|
|
type AudioContextFactory = () => AudioContext;
|
|
|
|
export function createBrowserSynthEngine(): AudioGraph {
|
|
if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) {
|
|
recordTestFaultEvent('audio-start-rejected');
|
|
throw new DOMException('Injected audio start rejection', 'NotAllowedError');
|
|
}
|
|
const AudioContextConstructor = window.AudioContext
|
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
|
|
const graph = createSynthEngine(() => new AudioContextConstructor());
|
|
if (!TEST_BUILD) return graph;
|
|
let resumeCount = 0;
|
|
return {
|
|
...graph,
|
|
async resume() {
|
|
resumeCount += 1;
|
|
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
|
|
recordTestFaultEvent('audio-resume-rejected');
|
|
throw new DOMException('Injected audio resume rejection', 'NotAllowedError');
|
|
}
|
|
if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) {
|
|
recordTestFaultEvent('audio-graph-invalid');
|
|
throw new DOMException('Injected invalid audio graph', 'InvalidStateError');
|
|
}
|
|
await graph.resume();
|
|
},
|
|
async suspend() {
|
|
if (isTestFault('F13-audio-suspend-rejection')) {
|
|
recordTestFaultEvent('audio-suspend-rejected');
|
|
throw new DOMException('Injected audio suspend rejection', 'InvalidStateError');
|
|
}
|
|
await graph.suspend();
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
|
|
const context = createContext();
|
|
const master = context.createGain();
|
|
const dynamics = context.createDynamicsCompressor();
|
|
master.gain.setValueAtTime(0, context.currentTime);
|
|
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';
|
|
engineFilter.frequency.setValueAtTime(260, context.currentTime);
|
|
engineFilter.Q.setValueAtTime(1.25, context.currentTime);
|
|
|
|
const engineGain = context.createGain();
|
|
engineGain.gain.setValueAtTime(0.24, context.currentTime);
|
|
engineFilter.connect(engineGain);
|
|
engineGain.connect(dynamics);
|
|
|
|
const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter);
|
|
const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter);
|
|
|
|
const roadFilter = context.createBiquadFilter();
|
|
roadFilter.type = 'bandpass';
|
|
roadFilter.frequency.setValueAtTime(410, context.currentTime);
|
|
roadFilter.Q.setValueAtTime(0.55, context.currentTime);
|
|
const roadGain = context.createGain();
|
|
roadGain.gain.setValueAtTime(0.045, context.currentTime);
|
|
roadFilter.connect(roadGain);
|
|
roadGain.connect(dynamics);
|
|
|
|
const noise = context.createBufferSource();
|
|
noise.buffer = createNoiseBuffer(context, 2);
|
|
noise.loop = true;
|
|
noise.connect(roadFilter);
|
|
|
|
const ignition = context.createOscillator();
|
|
ignition.type = 'triangle';
|
|
ignition.frequency.setValueAtTime(68, context.currentTime);
|
|
const ignitionGain = context.createGain();
|
|
ignitionGain.gain.setValueAtTime(0.0001, context.currentTime);
|
|
ignitionGain.gain.exponentialRampToValueAtTime(0.07, context.currentTime + 0.12);
|
|
ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15);
|
|
ignition.connect(ignitionGain);
|
|
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;
|
|
|
|
return {
|
|
resume: () => context.resume(),
|
|
suspend: () => context.suspend(),
|
|
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) {
|
|
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;
|
|
master.connect(context.destination);
|
|
outputConnected = true;
|
|
},
|
|
disconnectOutput() {
|
|
if (!outputConnected) return;
|
|
master.disconnect(context.destination);
|
|
outputConnected = false;
|
|
},
|
|
setOutputGain(value) {
|
|
const next = clamp(value);
|
|
master.gain.cancelScheduledValues(context.currentTime);
|
|
master.gain.setValueAtTime(next, context.currentTime);
|
|
},
|
|
setIntensity(value) {
|
|
const next = clamp(value);
|
|
const time = context.currentTime;
|
|
fundamental.source.frequency.setTargetAtTime(42 + next * 28, time, 0.32);
|
|
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);
|
|
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);
|
|
context.addEventListener('statechange', onStateChange);
|
|
return () => context.removeEventListener('statechange', onStateChange);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createOscillator(
|
|
context: AudioContext,
|
|
type: OscillatorType,
|
|
frequency: number,
|
|
level: number,
|
|
destination: AudioNode,
|
|
): { source: OscillatorNode; gain: GainNode } {
|
|
const source = context.createOscillator();
|
|
source.type = type;
|
|
source.frequency.setValueAtTime(frequency, context.currentTime);
|
|
const gain = context.createGain();
|
|
gain.gain.setValueAtTime(level, context.currentTime);
|
|
source.connect(gain);
|
|
gain.connect(destination);
|
|
return { source, gain };
|
|
}
|
|
|
|
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 previous = 0;
|
|
for (let index = 0; index < samples.length; index += 1) {
|
|
const white = Math.random() * 2 - 1;
|
|
previous = previous * 0.93 + white * 0.07;
|
|
samples[index] = previous;
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
function clamp(value: number): number {
|
|
if (!Number.isFinite(value)) return 0;
|
|
return Math.min(1, Math.max(0, value));
|
|
}
|