feat: add opt-in procedural drive audio

This commit is contained in:
2026-09-02 05:22:13 +03:00
parent 190ac1ce62
commit 130ff2d8bb
9 changed files with 1155 additions and 3 deletions

144
src/audio/synth-engine.ts Normal file
View File

@@ -0,0 +1,144 @@
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
type AudioContextFactory = () => AudioContext;
export function createBrowserSynthEngine(): AudioGraph {
const AudioContextConstructor = window.AudioContext
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
return createSynthEngine(() => new AudioContextConstructor());
}
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
const context = createContext();
const master = context.createGain();
const dynamics = context.createDynamicsCompressor();
master.gain.setValueAtTime(0, context.currentTime);
master.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.045, context.currentTime);
engineFilter.connect(engineGain);
engineGain.connect(master);
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.015, context.currentTime);
roadFilter.connect(roadGain);
roadGain.connect(master);
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(master);
const scheduledNodes: Array<AudioScheduledSourceNode> = [fundamental.source, harmonic.source, noise, ignition];
for (const node of scheduledNodes) node.start();
let outputConnected = false;
let closed = false;
return {
resume: () => context.resume(),
suspend: () => context.suspend(),
async close() {
if (closed) return;
closed = true;
for (const node of scheduledNodes) {
try { node.stop(); } catch { /* A source may already have ended. */ }
}
if (outputConnected) {
dynamics.disconnect(context.destination);
outputConnected = false;
}
master.disconnect();
await context.close();
},
connectOutput() {
if (closed || outputConnected) return;
dynamics.connect(context.destination);
outputConnected = true;
},
disconnectOutput() {
if (!outputConnected) return;
dynamics.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);
engineGain.gain.setTargetAtTime(0.045 + next * 0.035, time, 0.4);
roadGain.gain.setTargetAtTime(0.015 + next * 0.03, time, 0.45);
},
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));
}