On arrival the drive winds down to a stop, the tail and running lamps drop to parking level, the sky darkens about 10% with more stars and the grid fades. The engine and road noise take a 1.2 s low-pass sweep to idle. The letterbox stays within 8vh and the closing credit types into the lower bar; reduced motion keeps only the bars and dimming.
217 lines
7.6 KiB
TypeScript
217 lines
7.6 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
};
|