feat: restore restrained drive texture

This commit is contained in:
2026-09-04 15:50:37 +03:00
parent b427d101fa
commit 6fce20a228
3 changed files with 473 additions and 0 deletions

151
src/audio/drive-texture.ts Normal file
View File

@@ -0,0 +1,151 @@
import type { DriveEnergySample } from '@/audio/types';
const NOISE_SECONDS = 2;
const NOISE_SEED = 0x4e494748;
const ATTACK_SECONDS = 0.22;
const RELEASE_SECONDS = 0.9;
const CRUISE_ENGINE_GAIN = 0.055;
const MAX_ENGINE_GAIN = 0.12;
const CRUISE_ROAD_GAIN = 0.006;
const MAX_ROAD_GAIN = 0.024;
export interface DriveTexture {
setEnergy(sample: DriveEnergySample): void;
dispose(): void;
}
export type DriveTextureFactory = (
context: AudioContext,
destination: AudioNode,
) => DriveTexture;
function createNoiseBuffer(context: BaseAudioContext, seed: number): AudioBuffer {
const length = Math.max(1, Math.round(context.sampleRate * NOISE_SECONDS));
const buffer = context.createBuffer(2, length, context.sampleRate);
let state = seed >>> 0;
for (let channel = 0; channel < 2; channel += 1) {
const samples = buffer.getChannelData(channel);
let previous = 0;
for (let index = 0; index < samples.length; index += 1) {
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
const white = state / 0xffff_ffff * 2 - 1;
previous = previous * 0.9 + white * 0.1;
samples[index] = previous;
}
}
return buffer;
}
function createOscillator(
context: AudioContext,
type: OscillatorType,
frequency: number,
level: number,
destination: AudioNode,
): { readonly source: OscillatorNode; readonly gain: GainNode } {
const source = context.createOscillator();
const gain = context.createGain();
source.type = type;
source.frequency.setValueAtTime(frequency, context.currentTime);
gain.gain.setValueAtTime(level, context.currentTime);
source.connect(gain);
gain.connect(destination);
return { source, gain };
}
function holdParameter(parameter: AudioParam, atTime: number): void {
if (typeof parameter.cancelAndHoldAtTime === 'function') {
parameter.cancelAndHoldAtTime(atTime);
return;
}
parameter.cancelScheduledValues(atTime);
parameter.setValueAtTime(parameter.value, atTime);
}
export const createDriveTexture: DriveTextureFactory = (context, destination) => {
const engineFilter = context.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 240;
engineFilter.Q.value = 1.1;
const engineGain = context.createGain();
engineGain.gain.value = CRUISE_ENGINE_GAIN;
engineFilter.connect(engineGain);
engineGain.connect(destination);
const fundamental = createOscillator(context, 'sawtooth', 42, 0.1, engineFilter);
const harmonic = createOscillator(context, 'sine', 84, 0.035, engineFilter);
const ignition = createOscillator(context, 'triangle', 120, 0.0001, engineFilter);
ignition.gain.gain.setValueAtTime(0.0001, context.currentTime);
ignition.gain.gain.linearRampToValueAtTime(0.008, context.currentTime + 0.12);
ignition.gain.gain.linearRampToValueAtTime(0.0001, context.currentTime + 1.15);
const roadSource = context.createBufferSource();
roadSource.buffer = createNoiseBuffer(context, NOISE_SEED);
roadSource.loop = true;
const roadHighpass = context.createBiquadFilter();
roadHighpass.type = 'highpass';
roadHighpass.frequency.value = 180;
const roadLowpass = context.createBiquadFilter();
roadLowpass.type = 'lowpass';
roadLowpass.frequency.value = 1_600;
const roadGain = context.createGain();
roadGain.gain.value = CRUISE_ROAD_GAIN;
roadSource.connect(roadHighpass);
roadHighpass.connect(roadLowpass);
roadLowpass.connect(roadGain);
roadGain.connect(destination);
const sources: readonly AudioScheduledSourceNode[] = [
fundamental.source,
harmonic.source,
ignition.source,
roadSource,
];
const ownedNodes: readonly AudioNode[] = [
...sources,
fundamental.gain,
harmonic.gain,
ignition.gain,
engineFilter,
engineGain,
roadHighpass,
roadLowpass,
roadGain,
];
for (const source of sources) source.start();
let currentEnergy = 0;
let disposed = false;
const automate = (parameter: AudioParam, value: number, duration: number): void => {
const now = context.currentTime;
holdParameter(parameter, now);
parameter.linearRampToValueAtTime(value, now + duration);
};
return {
setEnergy(sample) {
if (disposed) return;
const next = Math.min(1, Math.max(0, sample.energy));
const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS;
automate(fundamental.source.frequency, 42 + next * 20, duration);
automate(harmonic.source.frequency, 84 + next * 40, duration);
automate(engineFilter.frequency, 240 + next * 460, duration);
automate(roadLowpass.frequency, 1_600 + next * 1_800, duration);
automate(engineGain.gain, CRUISE_ENGINE_GAIN + next * (MAX_ENGINE_GAIN - CRUISE_ENGINE_GAIN), duration);
automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration);
currentEnergy = next;
},
dispose() {
if (disposed) return;
disposed = true;
for (const source of sources) {
try {
source.stop();
} catch {
// A source that has already ended needs no further teardown.
}
}
for (const node of ownedNodes) node.disconnect();
},
};
};