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; const PASS_BY_GAIN = 0.05; const PASS_BY_SECONDS = 1.6; /** Engine off at the destination: a low-pass sweep down to a parked idle. */ const PARK_SECONDS = 1.2; export interface DriveTexture { setEnergy(sample: DriveEnergySample): void; /** Passing under the gantry: a filtered air sweep across the stereo field. */ passBy?(): void; /** Parked at the final exit (true) or driving again (false). */ park?(parked: boolean): 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); // Pass-by: preallocated and silent until an exit, so a chapter change // schedules automation only and never creates nodes. const passSource = context.createBufferSource(); passSource.buffer = roadSource.buffer; passSource.loop = true; const passBand = context.createBiquadFilter(); passBand.type = 'bandpass'; passBand.frequency.value = 500; passBand.Q.value = 0.9; const passPan = context.createStereoPanner(); const passGain = context.createGain(); passGain.gain.value = 0; passSource.connect(passBand); passBand.connect(passPan); passPan.connect(passGain); passGain.connect(destination); const sources: readonly AudioScheduledSourceNode[] = [ fundamental.source, harmonic.source, ignition.source, roadSource, passSource, ]; const ownedNodes: readonly AudioNode[] = [ ...sources, fundamental.gain, harmonic.gain, ignition.gain, engineFilter, engineGain, roadHighpass, roadLowpass, roadGain, passBand, passPan, passGain, ]; for (const source of sources) source.start(); let currentEnergy = 0; let disposed = false; let parked = 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)); if (parked) { currentEnergy = next; return; } 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; }, park(next) { if (disposed || next === parked) return; parked = next; if (!parked) { const energy = currentEnergy; currentEnergy = -1; this.setEnergy({ energy, source: 'idle' }); return; } const now = context.currentTime; for (const parameter of [engineFilter.frequency, roadLowpass.frequency, engineGain.gain, roadGain.gain]) { holdParameter(parameter, now); } engineFilter.frequency.exponentialRampToValueAtTime(70, now + PARK_SECONDS); roadLowpass.frequency.exponentialRampToValueAtTime(260, now + PARK_SECONDS); engineGain.gain.linearRampToValueAtTime(CRUISE_ENGINE_GAIN * 0.3, now + PARK_SECONDS); roadGain.gain.linearRampToValueAtTime(CRUISE_ROAD_GAIN * 0.25, now + PARK_SECONDS); }, passBy() { if (disposed) return; const now = context.currentTime; for (const parameter of [passGain.gain, passBand.frequency, passPan.pan]) { holdParameter(parameter, now); } passGain.gain.linearRampToValueAtTime(PASS_BY_GAIN, now + 0.5); passGain.gain.linearRampToValueAtTime(0, now + PASS_BY_SECONDS); passBand.frequency.linearRampToValueAtTime(2_600, now + 0.5); passBand.frequency.linearRampToValueAtTime(380, now + PASS_BY_SECONDS); passPan.pan.linearRampToValueAtTime(-0.75, now + 0.05); passPan.pan.linearRampToValueAtTime(0.75, now + PASS_BY_SECONDS); }, 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(); }, }; };