SOURCE / PINNED RELEASE
Made of little things.
Big Rig Rush
- Release
- ec9b6549f752…
- Author-recorded commit
- 74b729258b1a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40
Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Procedural sound effects built with Web Audio. There are no audio files, so the
// napplet stays a single file and never needs network access. Every call does
// nothing until unlock() runs from a user gesture, as browser autoplay rules require.
//
// The crashes are meant to be loud and dramatic. crash-synth.ts renders each one
// sample by sample, with more chaos the faster the hit, and this file sends it
// through controlled saturation and a dark reverb tail.
import { renderCrash, type ImpactKind } from './crash-synth.js';
type Wave = OscillatorType;
const ENGINE_LEVEL = 0.11;
const MIN_CRASH_GAP = 0.07;
const MIN_BUMP_GAP = 0.16;
const MIN_PILEUP_GAP = 0.12;
/** Crashes render at a lower rate to stay cheap; glass tops out near 14 kHz. */
const CRASH_RATE = 32000;
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
function rand(lo: number, hi: number): number {
return lo + Math.random() * (hi - lo);
}
/** Some browsers expose cancellation but not the newer hold operation. */
function holdGain(param: AudioParam, time: number): void {
if (typeof param.cancelAndHoldAtTime === 'function') {
param.cancelAndHoldAtTime(time);
} else {
const current = param.value;
param.cancelScheduledValues(time);
param.setValueAtTime(current, time);
}
}
/** Fixed channel layouts avoid filter reconfiguration as voices connect/end. */
function createFilter(ctx: AudioContext, channels = 1): BiquadFilterNode {
const filter = ctx.createBiquadFilter();
filter.channelCount = channels;
filter.channelCountMode = 'explicit';
return filter;
}
interface Engine {
osc: OscillatorNode;
sub: OscillatorNode;
lfo: OscillatorNode;
lfoDepth: GainNode;
filter: BiquadFilterNode;
out: GainNode;
turbo: OscillatorNode;
turboGain: GainNode;
squeal: OscillatorNode;
squealGain: GainNode;
hissGain: GainNode;
hissFilter: BiquadFilterNode;
}
/** The A-10's twin turbofans: rumble, roar, the fan's hollow whine and the rush of air. */
interface Jet {
out: GainNode;
rumble: GainNode;
roar: GainNode;
roarTone: BiquadFilterNode;
whineA: OscillatorNode;
whineB: OscillatorNode;
whine: GainNode;
air: GainNode;
airTone: BiquadFilterNode;
}
/** The GAU-8's continuous voice: a buzz and chopped roar at the firing rate, and the shells' crackle. */
interface Gun {
buzz: OscillatorNode;
chop: OscillatorNode;
roar: GainNode;
crackle: GainNode;
out: GainNode;
}
/** What blew up: explosions by size, and buildings coming down. */
export type BlastKind = 'small' | 'medium' | 'huge' | 'collapse-wood' | 'collapse-stone';
export class Sfx {
private ctx: AudioContext | null = null;
private master!: GainNode;
private mix!: GainNode;
private ambient!: GainNode;
private slots = new Map<string, number[]>();
private duckUntil = 0;
private transient = new WeakMap<AudioNode, { users: number; nodes: AudioNode[] }>();
/** Engine and other continuous sounds. Ducked while a crash plays. */
private drive!: GainNode;
/** One-shot effects, dry. */
private fx!: GainNode;
/** Crash bus: distortion before the compressor. */
private crunch!: GainNode;
private verb!: GainNode;
private noise!: AudioBuffer;
private engine: Engine | null = null;
private gunVoice: Gun | null = null;
private fireVoice: GainNode | null = null;
private jetVoice: Jet | null = null;
private lastThrottle = 0;
/** The A-10's bank (-1..1) and airspeed (0..1), for the rush of air. */
private flightBank = 0;
private flightSpeed = 0;
private gunOn = false;
private volume = 0.8;
private muted = false;
private lastCrash = -1;
private lastBump = -1;
private lastPileup = -1;
private crashBusyUntil = 0;
private wasBraking = false;
private lastSpeed = 0;
get ready(): boolean {
return this.ctx !== null;
}
/** Call from a user gesture (key press, tap, button click). Safe to call often. */
unlock(): void {
if (!this.ctx) {
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) return;
try {
this.ctx = new Ctor();
} catch {
return;
}
this.build(this.ctx);
}
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {});
}
/** Pause all audio, e.g. when the frame is hidden. */
suspend(): void {
if (this.ctx?.state === 'running') void this.ctx.suspend().catch(() => {});
}
/** Set the volume from 0 to 1. */
setVolume(value: number): void {
this.volume = clamp(value, 0, 1);
this.applyVolume();
}
setMuted(muted: boolean): void {
this.muted = muted;
this.applyVolume();
}
toggleMute(): boolean {
this.setMuted(!this.muted);
return this.muted;
}
get isMuted(): boolean {
return this.muted;
}
private applyVolume(): void {
if (!this.ctx) return;
const target = this.muted ? 0 : this.volume * this.volume;
this.master.gain.setTargetAtTime(target, this.ctx.currentTime, 0.02);
}
private build(ctx: AudioContext): void {
// Mix dynamics run before user volume: turning down must not change the mix.
this.mix = ctx.createGain();
this.mix.gain.value = 0.65;
const lowCut = createFilter(ctx, 2);
lowCut.type = 'highpass';
lowCut.frequency.value = 25;
lowCut.Q.value = 0.707;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -12;
comp.knee.value = 12;
comp.ratio.value = 4;
comp.attack.value = 0.003;
comp.release.value = 0.18;
// Compressor attack can let transients through. A mostly linear safety curve
// catches only peaks, with a ceiling below full scale and no makeup gain.
const safety = ctx.createWaveShaper();
const curve = new Float32Array(4097);
for (let i = 0; i < curve.length; i++) {
const x = i / (curve.length - 1) * 2 - 1;
const a = Math.abs(x);
curve[i] = Math.sign(x) * (a <= 0.75 ? a : 0.75 + 0.15 * Math.tanh((a - 0.75) / 0.15));
}
safety.curve = curve;
this.master = ctx.createGain();
this.master.gain.value = 0;
this.mix.connect(lowCut).connect(comp).connect(safety).connect(this.master).connect(ctx.destination);
const len = ctx.sampleRate * 2;
this.noise = ctx.createBuffer(1, len, ctx.sampleRate);
const data = this.noise.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
// Long, dark reverb: the crash rings out across the city.
const convolver = ctx.createConvolver();
convolver.buffer = this.impulse(ctx, 1.8, 2.8);
const verbTone = createFilter(ctx, 2);
verbTone.type = 'lowpass';
verbTone.frequency.value = 5200;
this.verb = ctx.createGain();
this.verb.gain.value = 0.28;
this.verb.connect(convolver).connect(verbTone).connect(this.mix);
this.drive = ctx.createGain();
this.drive.connect(this.mix);
this.fx = ctx.createGain();
this.fx.gain.value = 0.7;
this.fx.connect(this.mix);
this.ambient = ctx.createGain();
this.ambient.gain.value = 0.55;
this.ambient.connect(this.mix);
const fxSend = ctx.createGain();
fxSend.gain.value = 0.12;
this.fx.connect(fxSend).connect(this.verb);
const shaper = ctx.createWaveShaper();
shaper.curve = this.driveCurve(3.2);
shaper.oversample = '2x';
this.crunch = ctx.createGain();
// Control the summed impact energy before saturation so overlapping booms
// do not pin the distortion at full scale for seconds.
const impactComp = ctx.createDynamicsCompressor();
impactComp.threshold.value = -10;
impactComp.knee.value = 8;
impactComp.ratio.value = 8;
impactComp.attack.value = 0.002;
impactComp.release.value = 0.12;
this.crunch.gain.value = 0.55;
this.crunch.connect(impactComp).connect(shaper);
const impactLevel = ctx.createGain();
impactLevel.gain.value = 0.5;
shaper.connect(impactLevel).connect(this.mix);
const crunchSend = ctx.createGain();
crunchSend.gain.value = 0.22;
shaper.connect(crunchSend).connect(this.verb);
this.engine = this.buildEngine(ctx);
this.applyVolume();
// Warm up the crash renderer so the first real crash doesn't stall a frame.
window.setTimeout(() => renderCrash(CRASH_RATE, 1.2, 'car'), 0);
}
private impulse(ctx: AudioContext, seconds: number, decay: number): AudioBuffer {
const len = Math.floor(ctx.sampleRate * seconds);
const buf = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
return buf;
}
private driveCurve(amount: number): Float32Array<ArrayBuffer> {
const n = 1024;
const curve = new Float32Array(new ArrayBuffer(n * 4));
const norm = Math.tanh(amount);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * 2 - 1;
curve[i] = Math.tanh(x * amount) / norm;
}
return curve;
}
// --- Diesel engine, brake squeal and air hiss: continuous voices.
private buildEngine(ctx: AudioContext): Engine {
const out = ctx.createGain();
out.gain.value = 0;
out.connect(this.drive);
const filter = createFilter(ctx);
filter.type = 'lowpass';
filter.frequency.value = 320;
filter.Q.value = 4;
// The chug: the engine's level pulses at the firing rate.
const body = ctx.createGain();
body.gain.value = 0.6;
const lfo = ctx.createOscillator();
lfo.type = 'square';
lfo.frequency.value = 12;
const lfoDepth = ctx.createGain();
lfoDepth.gain.value = 0.35;
lfo.connect(lfoDepth).connect(body.gain);
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = 42;
const sub = ctx.createOscillator();
sub.type = 'square';
sub.frequency.value = 21;
const subGain = ctx.createGain();
subGain.gain.value = 0.5;
osc.connect(filter);
sub.connect(subGain).connect(filter);
filter.connect(body).connect(out);
const turbo = ctx.createOscillator();
turbo.type = 'sine';
turbo.frequency.value = 1400;
const turboGain = ctx.createGain();
turboGain.gain.value = 0;
turbo.connect(turboGain).connect(out);
// Brake squeal goes to the drive bus directly so it still sounds when the engine is quiet.
const squeal = ctx.createOscillator();
squeal.type = 'triangle';
squeal.frequency.value = 2300;
const vibrato = ctx.createOscillator();
vibrato.frequency.value = 9;
const vibratoDepth = ctx.createGain();
vibratoDepth.gain.value = 40;
vibrato.connect(vibratoDepth).connect(squeal.frequency);
const squealGain = ctx.createGain();
squealGain.gain.value = 0;
squeal.connect(squealGain).connect(this.drive);
const hiss = ctx.createBufferSource();
hiss.buffer = this.noise;
hiss.loop = true;
const hissFilter = createFilter(ctx);
hissFilter.type = 'bandpass';
hissFilter.frequency.value = 3600;
hissFilter.Q.value = 0.8;
const hissGain = ctx.createGain();
hissGain.gain.value = 0;
hiss.connect(hissFilter).connect(hissGain).connect(this.drive);
for (const node of [osc, sub, lfo, turbo, squeal, vibrato, hiss]) node.start();
return { osc, sub, lfo, lfoDepth, filter, out, turbo, turboGain, squeal, squealGain, hissGain, hissFilter };
}
/**
* Update the truck's continuous sounds once per frame.
* speed: 0 to 1 (negative when reversing), active: false silences the engine.
* engineRpm (optional, 0 to 1 of redline) comes from the gearbox; without it the
* pitch is estimated from speed.
*/
/** Diesel rig or a high-revving sports car. */
private voice: 'diesel' | 'sports' | 'jet' = 'diesel';
setFlight(bank: number, speed: number): void {
this.flightBank = bank;
this.flightSpeed = speed;
}
setVoice(voice: 'diesel' | 'sports' | 'jet'): void {
this.voice = voice;
}
driveUpdate(speed: number, throttle: boolean, braking: boolean, active: boolean, engineRpm?: number): void {
const ctx = this.ctx;
const e = this.engine;
if (!ctx || !e) return;
const t = ctx.currentTime;
const s = clamp(Math.abs(speed), 0, 1);
const load = throttle ? 1 : 0;
const rpm = engineRpm === undefined ? 0.15 + s * 0.7 + load * 0.15 : 0.1 + clamp(engineRpm, 0, 1.05) * 0.9;
const sports = this.voice === 'sports';
if (this.voice === 'jet') {
e.out.gain.setTargetAtTime(0, t, 0.1);
e.turboGain.gain.setTargetAtTime(0, t, 0.1);
e.squealGain.gain.setTargetAtTime(0, t, 0.05);
e.hissGain.gain.setTargetAtTime(0, t, 0.1);
this.jetUpdate(clamp(engineRpm ?? 0.6, 0, 1), active);
this.wasBraking = false;
return;
}
if (this.jetVoice) this.jetVoice.out.gain.setTargetAtTime(0, t, 0.2);
if (e.hissFilter.frequency.value !== 3600) {
e.hissFilter.frequency.setTargetAtTime(3600, t, 0.1);
e.hissFilter.Q.setTargetAtTime(0.8, t, 0.1);
e.lfoDepth.gain.setTargetAtTime(0.35, t, 0.1);
e.hissGain.gain.setTargetAtTime(0, t, 0.1);
e.squeal.frequency.setTargetAtTime(2300, t, 0.1);
}
// A sports engine revs far higher and snarls; the diesel thrums low.
e.osc.frequency.setTargetAtTime(sports ? 60 + rpm * 300 : 32 + rpm * 70, t, sports ? 0.05 : 0.12);
e.sub.frequency.setTargetAtTime(sports ? 30 + rpm * 150 : 16 + rpm * 35, t, sports ? 0.05 : 0.12);
e.lfo.frequency.setTargetAtTime(sports ? 25 + rpm * 110 : 9 + rpm * 26, t, sports ? 0.05 : 0.12);
e.filter.frequency.setTargetAtTime(sports ? 500 + rpm * 2200 + load * 700 : 220 + rpm * 700 + load * 400, t, 0.1);
e.out.gain.setTargetAtTime(active ? ENGINE_LEVEL * (0.55 + load * 0.3 + s * 0.3) : 0, t, active ? 0.08 : 0.25);
e.turbo.frequency.setTargetAtTime(900 + rpm * 2200, t, 0.3);
e.turboGain.gain.setTargetAtTime(active && throttle && !sports ? 0.012 * s : 0, t, 0.25);
const squealing = active && braking && s > 0.25;
e.squealGain.gain.setTargetAtTime(squealing ? 0.035 * s : 0, t, squealing ? 0.06 : 0.04);
// Air brakes let go with a "pssht" after a hard stop.
if (active && !sports && this.wasBraking && this.lastSpeed > 0.05 && s <= 0.03) this.airRelease();
this.wasBraking = braking && active;
this.lastSpeed = s;
}
private airRelease(): void {
const ctx = this.ctx!;
const t = ctx.currentTime;
const e = this.engine!;
e.hissGain.gain.cancelScheduledValues(t);
e.hissGain.gain.setValueAtTime(0, t);
e.hissGain.gain.linearRampToValueAtTime(0.18, t + 0.03);
e.hissGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.7);
}
/** Briefly pull engine and background activity down so a crash owns the mix. */
private duck(seconds: number): void {
const t = this.ctx!.currentTime;
this.duckUntil = Math.max(this.duckUntil, t + seconds);
for (const [g, floor, resting] of [[this.drive.gain, 0.35, 1], [this.ambient.gain, 0.16, 0.55]] as const) {
holdGain(g, t);
g.linearRampToValueAtTime(floor, t + 0.02);
g.setTargetAtTime(resting, this.duckUntil, 0.25);
}
}
/** Bound whole events, including their scheduled tails, before allocating nodes.
* Separate pools preserve player feedback when background destruction is busy.
*/
private admit(group: string, length: number, max: number): boolean {
const t = this.ctx!.currentTime;
const live = (this.slots.get(group) ?? []).filter(end => end > t);
if (live.length >= max) return false;
live.push(t + length);
this.slots.set(group, live);
return true;
}
/** Release private nodes when the last scheduled voice using them finishes. */
private release(source: AudioScheduledSourceNode, dest: AudioNode, ...nodes: AudioNode[]): void {
const shared = this.transient.get(dest);
if (shared) shared.users++;
source.onended = () => {
source.disconnect();
for (const node of nodes) node.disconnect();
if (shared && --shared.users === 0) {
for (const node of shared.nodes) node.disconnect();
this.transient.delete(dest);
}
};
}
// --- Building blocks.
private out(pan: number, dest: AudioNode): AudioNode {
if (!pan || !this.ctx!.createStereoPanner) return dest;
const p = this.ctx!.createStereoPanner();
p.pan.value = clamp(pan, -1, 1);
p.connect(dest);
this.transient.set(p, { users: 0, nodes: [p] });
return p;
}
private tone(
dest: AudioNode, wave: Wave, f0: number, f1: number, t: number, dur: number,
peak: number, attack = 0.005,
): OscillatorNode {
const ctx = this.ctx!;
const osc = ctx.createOscillator();
osc.type = wave;
osc.frequency.setValueAtTime(f0, t);
if (f1 !== f0) osc.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(g).connect(dest);
this.release(osc, dest, g);
osc.start(t);
osc.stop(t + dur + 0.05);
return osc;
}
private burst(
dest: AudioNode, type: BiquadFilterType, f0: number, f1: number, q: number,
t: number, dur: number, peak: number, attack = 0.003,
): void {
const ctx = this.ctx!;
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.playbackRate.value = rand(0.9, 1.1);
const filter = createFilter(ctx);
filter.type = type;
filter.Q.value = q;
filter.frequency.setValueAtTime(f0, t);
if (f1 !== f0) filter.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(filter).connect(g).connect(dest);
this.release(src, dest, filter, g);
src.start(t, rand(0, 1.5));
src.stop(t + dur + 0.05);
}
// --- Crashes: rendered sample by sample in crash-synth.ts, then given room
// acoustics here (saturation, stereo position, reverb).
/** Play one rendered crash. Returns its severity (0 to ~1.25). */
private playCrash(intensity: number, kind: ImpactKind, pan: number, delay = 0, finale = false): number {
const ctx = this.ctx!;
const t = ctx.currentTime + delay;
const group = finale ? 'finale' : kind === 'pileup' ? 'pileup' : 'impact';
if (!this.admit(group, 5 + delay, finale ? 2 : kind === 'pileup' ? 3 : 5)) return 0;
const render = renderCrash(CRASH_RATE, intensity, kind);
const sev = render.severity;
// Small hits free their slot sooner; hold it through delayed playback too.
const ends = this.slots.get(group)!;
ends[ends.length - 1] = t + render.left.length / CRASH_RATE;
const buf = ctx.createBuffer(2, render.left.length, CRASH_RATE);
buf.copyToChannel(render.left, 0);
buf.copyToChannel(render.right, 1);
const src = ctx.createBufferSource();
src.buffer = buf;
// Harder crashes drive the saturation harder and fill more of the reverb.
const level = ctx.createGain();
level.gain.value = (kind === 'pileup' ? 0.55 : 1) * (0.7 + 1.1 * Math.min(1, sev));
const send = ctx.createGain();
send.gain.value = 0.15 + 0.45 * Math.min(1, sev) + (kind === 'pileup' ? 0.35 : 0);
const out = this.out(pan, level);
src.connect(out);
level.connect(this.crunch);
level.connect(send).connect(this.verb);
this.release(src, out, level, send);
src.start(t);
return sev;
}
/**
* A crash at the game's raw intensity (closing speed / 100): about 0.1 for a
* bump, 0.5 to 1 for a solid hit, 1.1 to 1.7 at highway speed. Higher speed means more chaos: more crumpling,
* glass and debris, longer scrapes, and cars that roll with repeated impacts.
* kind: 'car' (truck hits a car), 'wall' (truck hits a building) or 'pileup'
* (two AI cars, a little further away). pan: -1 (left) to 1 (right).
*/
impact(intensity: number, kind: ImpactKind = 'car', pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
// Each kind has its own limit, so a burst of AI wrecks never swallows the player's hit.
if (kind === 'wall') {
if (t - this.lastBump < MIN_BUMP_GAP) return;
this.lastBump = t;
} else if (kind === 'pileup') {
if (t - this.lastPileup < MIN_PILEUP_GAP) return;
this.lastPileup = t;
} else {
if (t - this.lastCrash < MIN_CRASH_GAP) return;
this.lastCrash = t;
}
// A crash on top of a fresh one is part of the same pile-up: keep it smaller.
const busy = t < this.crashBusyUntil;
const sev = this.playCrash(busy ? intensity * 0.6 : intensity, kind, pan);
if (kind === 'pileup') return;
this.crashBusyUntil = Math.max(this.crashBusyUntil, t + 0.25);
if (sev > 0.25) this.duck(0.3 + sev);
}
/** Truck hits a car. intensity 0 to 1. Prefer impact() with the game's raw value. */
crash(intensity = 0.6, pan = 0): void {
this.impact(intensity * 1.4, 'car', pan);
}
/** Truck hits a wall or building. intensity 0 to 1. Prefer impact(). */
bump(intensity = 0.5, pan = 0): void {
this.impact(intensity * 1.2, 'wall', pan);
}
/**
* The truck is totaled. The worst crash in the game, the trailer folding in a
* moment later, then the fuel tank goes up in a fireball, flames crackle and
* the horn sticks on as it dies.
*/
totaled(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.playCrash(1.8, 'wall', 0, 0, true);
// Render the trailer's crash on a later frame so the two renders don't stack into one stall.
window.setTimeout(() => {
if (this.ctx) this.playCrash(1.7, 'car', rand(-0.4, 0.4), Math.max(0, 0.5 - (this.ctx.currentTime - t)), true);
}, 120);
this.duck(4);
// Fireball.
const fire = t + 0.22;
this.tone(this.crunch, 'sine', 90, 18, fire, 2.6, 1.0, 0.02);
this.tone(this.crunch, 'sine', 55, 16, fire, 3.0, 0.8, 0.05);
this.burst(this.crunch, 'lowpass', 2400, 90, 0.9, fire, 3.2, 1.0, 0.03);
this.burst(this.crunch, 'bandpass', 600, 120, 1.2, fire + 0.05, 1.6, 0.7, 0.02);
// Metal groaning under heat.
this.tone(this.fx, 'sawtooth', 140, 70, t + 0.9, 2.2, 0.05, 0.5);
// Flames crackling, fading out.
for (let i = 0; i < 46; i++) {
const at = fire + 0.3 + Math.pow(Math.random(), 1.4) * 3.6;
const fade = 1 - (at - fire) / 4.2;
this.burst(this.fx, 'bandpass', rand(900, 4000), rand(600, 2000), rand(2, 8), at, rand(0.01, 0.04), rand(0.12, 0.35) * fade);
}
// The horn jams on and sags as the battery dies.
const horn = t + 0.4;
for (const f of [185, 233, 277]) {
const osc = this.tone(this.fx, 'sawtooth', f, f * 0.8, horn, 3.2, 0.06, 0.04);
osc.detune.setValueAtTime(0, horn + 1.8);
osc.detune.linearRampToValueAtTime(-260, horn + 3.2);
}
}
/** Big rig air horn: the player's horn and the start of a shift. */
airHorn(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const bp = createFilter(ctx);
bp.type = 'bandpass';
bp.frequency.value = 700;
bp.Q.value = 0.7;
bp.connect(this.fx);
this.transient.set(bp, { users: 0, nodes: [bp] });
for (const f of [220, 277, 330]) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.value = f;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.16, t + 0.03);
g.gain.setValueAtTime(0.16, t + 0.55);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.8);
osc.connect(g).connect(bp);
this.release(osc, bp, g);
osc.start(t);
osc.stop(t + 0.85);
}
}
/** An AI car honks. Rude drivers lean on it twice; polite ones give a short beep. */
honk(rude: boolean, pan = 0, distance = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('horns', 0.7, 4)) return;
const t = ctx.currentTime;
const level = clamp(1 - distance, 0.25, 1);
const dest = this.out(pan, this.ambient);
const pitch = rand(0.92, 1.08);
const pulses = rude ? [[0, 0.16], [0.22, 0.42]] : [[0, 0.12]];
const freqs = rude ? [392, 494] : [523, 659];
for (const [at, dur] of pulses) {
for (const f of freqs) {
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = f * pitch;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t + at);
g.gain.exponentialRampToValueAtTime(0.045 * level, t + at + 0.01);
g.gain.setValueAtTime(0.045 * level, t + at + dur - 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + at + dur);
osc.connect(g).connect(dest);
this.release(osc, dest, g);
osc.start(t + at);
osc.stop(t + at + dur + 0.02);
}
}
}
/** Cargo loaded: air hiss, a heavy clunk and a two-tone chime. */
loaded(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.burst(this.fx, 'bandpass', 3500, 2500, 0.8, t, 0.35, 0.12);
this.tone(this.fx, 'sine', 120, 50, t + 0.05, 0.25, 0.4);
this.burst(this.fx, 'bandpass', 500, 300, 3, t + 0.05, 0.1, 0.3);
this.tone(this.fx, 'square', 660, 660, t + 0.12, 0.14, 0.08);
this.tone(this.fx, 'square', 990, 990, t + 0.24, 0.24, 0.08);
}
/** Delivery complete: a cash-register ding and a rising arpeggio. */
delivered(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
[523, 659, 784, 1047].forEach((f, i) => this.tone(this.fx, 'square', f, f, t + i * 0.07, 0.18, 0.08));
this.tone(this.fx, 'sine', 2093, 2093, t + 0.3, 0.6, 0.12, 0.002);
this.tone(this.fx, 'sine', 3136, 3136, t + 0.3, 0.45, 0.06, 0.002);
}
/** Menu button press. */
click(): void {
const ctx = this.ctx;
if (!ctx) return;
this.tone(this.fx, 'square', 880, 440, ctx.currentTime, 0.06, 0.05, 0.002);
}
/** Gear change: a clutch thunk and a short air puff. */
shift(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.tone(this.fx, 'sine', 140, 60, t, 0.12, 0.16, 0.003);
this.burst(this.fx, 'bandpass', 900, 600, 4, t, 0.05, 0.12);
this.burst(this.fx, 'bandpass', 3200, 2400, 1, t + 0.04, 0.12, 0.04);
}
/** Missed shift: gear teeth grinding. */
grind(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.tone(this.fx, 'sawtooth', 210, 160, t, 0.35, 0.05, 0.01);
for (let i = 0; i < 9; i++) this.burst(this.fx, 'bandpass', rand(1500, 3200), rand(900, 1500), 6, t + i * 0.035, 0.03, 0.09);
}
/** A pedestrian gets bowled over: a soft thud and a yelp. */
pedHit(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
this.tone(dest, 'sine', 120, 50, t, 0.18, 0.35, 0.003);
this.burst(dest, 'lowpass', 900, 200, 0.7, t, 0.12, 0.3);
const yelp = rand(520, 760);
this.tone(dest, 'triangle', yelp, yelp * 1.5, t + 0.05, 0.12, 0.06, 0.01);
this.tone(dest, 'triangle', yelp * 1.5, yelp * 0.8, t + 0.17, 0.2, 0.05, 0.01);
}
/** One cycle of a wrecked car's alarm: a two-tone warble. Call about twice a second. */
alarmBeep(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.ambient);
this.tone(dest, 'square', 1250, 1250, t, 0.16, 0.022, 0.004);
this.tone(dest, 'square', 950, 950, t + 0.22, 0.16, 0.022, 0.004);
}
/**
* Extra weight on top of impact() for crashes above highway speed: a deeper,
* longer boom, a tearing metal shriek and a rain of parts. intensity is the
* game's raw value (1.1 to about 2.5), so the fastest crashes get the most.
*/
extremeHit(intensity: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('extreme', 3.5, 2)) return;
const t = ctx.currentTime;
const k = clamp((intensity - 1) / 1.3, 0, 1);
if (k <= 0) return;
const dest = this.out(pan, this.fx);
this.tone(this.crunch, 'sine', 70, 20, t, 1 + k * 1.4, 0.5 + 0.5 * k, 0.005);
this.tone(this.crunch, 'sine', 42, 16, t + 0.02, 1.4 + k * 1.6, 0.4 + 0.5 * k, 0.01);
// Metal tearing: a falling, distorted shriek.
this.tone(dest, 'sawtooth', rand(900, 1300), rand(180, 260), t + 0.03, 0.5 + k * 0.6, 0.05 + 0.07 * k, 0.01);
this.burst(dest, 'bandpass', 2600, 700, 3, t + 0.02, 0.6 + k * 0.5, 0.18 + 0.2 * k, 0.005);
// Parts raining down over the next seconds.
const pieces = Math.round(6 + k * 22);
for (let i = 0; i < pieces; i++) {
const at = t + 0.3 + Math.pow(Math.random(), 1.3) * (1 + k * 1.6);
const f = rand(300, 2400);
this.burst(dest, 'bandpass', f, f * 0.7, rand(3, 8), at, rand(0.02, 0.07), rand(0.08, 0.3) * (0.5 + k));
}
this.duck(0.8 + k);
}
/** A fuel tank explodes: a concussive blast, a roaring fireball and crackling flames. */
explosion(size = 1, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('explosions', 5.5, 4)) return;
const t = ctx.currentTime;
const s = clamp(size, 0.5, 1.6);
const dest = this.out(pan, this.fx);
this.burst(this.crunch, 'lowpass', 6000, 120, 0.8, t, 0.25, 1.0 * s, 0.001);
this.tone(this.crunch, 'sine', 110, 18, t, 2.2 * s, 1.0, 0.003);
this.tone(this.crunch, 'sine', 60, 15, t + 0.03, 2.6 * s, 0.85, 0.01);
this.burst(this.crunch, 'lowpass', 1800, 80, 0.9, t + 0.05, 2.4 * s, 0.8 * s, 0.03);
for (let i = 0; i < 30 * s; i++) {
const at = t + 0.2 + Math.pow(Math.random(), 1.5) * 2.6 * s;
const fade = 1 - (at - t) / (3 * s);
this.burst(dest, 'bandpass', rand(800, 4200), rand(500, 1800), rand(2, 7), at, rand(0.01, 0.04), rand(0.1, 0.3) * fade);
}
this.duck(1.5 * s);
}
/** A tumbling car's roof or side slamming into the road. */
rollThud(energy: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('rolls', 0.6, 4)) return;
const t = ctx.currentTime;
const k = clamp(energy / 90, 0.15, 1);
const dest = this.out(pan, this.fx);
this.tone(dest, 'sine', 95, 35, t, 0.3, 0.45 * k, 0.003);
this.burst(dest, 'lowpass', 3000, 300, 0.8, t, 0.18 + 0.2 * k, 0.45 * k, 0.002);
for (let i = 0; i < 4 + k * 8; i++) this.burst(dest, 'bandpass', rand(500, 2500), rand(300, 1200), rand(3, 7), t + rand(0, 0.15), rand(0.02, 0.06), rand(0.15, 0.35) * k);
if (k > 0.4) for (let i = 0; i < 10 * k; i++) this.tone(dest, 'sine', rand(3000, 9000), rand(2800, 8000), t + rand(0.01, 0.3), rand(0.01, 0.05), rand(0.02, 0.08), 0.001);
}
/** Wheels lifting in a corner: tyre scrub and the trailer creaking. */
tipping(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.burst(this.fx, 'bandpass', 1800, 1300, 2, t, 0.6, 0.15, 0.05);
this.tone(this.fx, 'sawtooth', 70, 55, t, 0.7, 0.04, 0.2);
}
// Type-specific crash layers. All are one-shots with fixed lengths, and at most
// MAX_FLAVOR may sound at once so a pile of police cars can't stack up sirens.
private flavorUntil: number[] = [];
private flavorSlot(t: number, length: number): boolean {
this.flavorUntil = this.flavorUntil.filter((u) => u > t);
if (this.flavorUntil.length >= 3) return false;
this.flavorUntil.push(t + length);
return true;
}
/** The signature sound of the kind of vehicle that got hit, louder with speed. */
carFlavor(model: string, intensity: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('flavors', 2.5, 4)) return;
const t = ctx.currentTime;
const k = clamp(intensity / 1.4, 0.2, 1.3);
const dest = this.out(pan, this.fx);
switch (model) {
case 'police': {
if (!this.flavorSlot(t, 1.6)) return;
// Siren whoop that dies as the lightbar shorts out, then radio static.
const osc = this.tone(dest, 'sawtooth', 650, 300, t + 0.05, 1.3, 0.06 * k, 0.02);
osc.frequency.setValueAtTime(650, t + 0.05);
osc.frequency.linearRampToValueAtTime(1300, t + 0.45);
osc.frequency.linearRampToValueAtTime(700, t + 0.85);
osc.frequency.exponentialRampToValueAtTime(140, t + 1.3);
this.burst(dest, 'bandpass', 2200, 1800, 2, t + 0.9, 0.5, 0.08 * k);
break;
}
case 'icecream': {
if (!this.flavorSlot(t, 2)) return;
// The jingle, warped and slowing down like a dying music box.
const notes = [784, 659, 784, 880, 784, 659, 587, 523];
notes.forEach((f, i) => {
const at = t + 0.1 + i * (0.14 + i * 0.02);
const sag = 1 - i * 0.04;
this.tone(dest, 'triangle', f * sag, f * sag * 0.97, at, 0.16, 0.06 * k, 0.005);
});
break;
}
case 'bus':
this.tone(dest, 'sine', 55, 25, t, 1.2, 0.5 * k, 0.005);
for (let i = 0; i < 40 * k; i++) this.tone(dest, 'sine', rand(3000, 9000), rand(2800, 8500), t + rand(0.02, 0.9), rand(0.01, 0.05), rand(0.02, 0.07), 0.001);
this.burst(dest, 'bandpass', 3800, 2600, 0.8, t + 0.3, 0.8, 0.12 * k, 0.02);
break;
case 'motorcycle':
this.tone(dest, 'sawtooth', 300, 900, t, 0.25, 0.06 * k, 0.01);
this.tone(dest, 'sawtooth', 900, 120, t + 0.25, 0.7, 0.05 * k, 0.01);
this.burst(dest, 'highpass', 4000, 3000, 0.7, t + 0.2, 1, 0.12 * k, 0.05);
break;
case 'limo':
// Champagne: a cork pop and a crash of bottles.
this.tone(dest, 'sine', 900, 200, t + 0.05, 0.08, 0.2 * k, 0.001);
for (let i = 0; i < 16; i++) this.tone(dest, 'triangle', rand(1800, 4200), rand(1600, 3800), t + rand(0.05, 0.6), rand(0.04, 0.12), rand(0.04, 0.1) * k, 0.001);
break;
case 'pickup':
case 'van':
// Cargo tumbling out of the back.
for (let i = 0; i < 6 + 6 * k; i++) {
const at = t + 0.15 + i * rand(0.06, 0.14);
this.tone(dest, 'sine', rand(90, 160), rand(40, 70), at, 0.12, rand(0.15, 0.3) * k, 0.002);
this.burst(dest, 'lowpass', 1200, 300, 1, at, 0.05, 0.12 * k);
}
break;
case 'beater':
// Rattling junk: a cascade of tinny clanks and a hubcap.
for (let i = 0; i < 10; i++) this.tone(dest, 'square', rand(400, 1400), rand(300, 900), t + rand(0.05, 0.9), 0.04, 0.04 * k, 0.001);
break;
case 'sports':
// The turbo spooling down with a whistle.
this.tone(dest, 'sine', 2600, 600, t + 0.05, 1.1, 0.05 * k, 0.02);
break;
case 'taxi':
this.tone(dest, 'sine', 1568, 1568, t + 0.2, 0.25, 0.06 * k, 0.003);
this.tone(dest, 'sine', 2093, 2093, t + 0.32, 0.3, 0.05 * k, 0.003);
break;
case 'compact':
// Cheap plastic cracking.
for (let i = 0; i < 12; i++) this.burst(dest, 'bandpass', rand(2000, 5000), rand(1500, 3000), 6, t + rand(0, 0.2), 0.02, 0.15 * k);
break;
}
}
/** An animal is struck: a heavy thud scaled by its size, and a cry. */
animalHit(mass: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
const big = clamp(mass / 4.5, 0.05, 1);
this.tone(dest, 'sine', 110 - big * 50, 30, t, 0.25 + big * 0.4, 0.3 + big * 0.5, 0.003);
this.burst(dest, 'lowpass', 1200, 200, 0.7, t, 0.15 + big * 0.2, 0.3 + big * 0.4);
const cry = 1200 - big * 900;
this.tone(dest, 'sawtooth', cry, cry * 0.6, t + 0.04, 0.25 + big * 0.3, 0.04, 0.01);
}
/** A market stall or party gear is flattened: splintering wood and splatting fruit. */
stallSmash(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('stalls', 0.8, 3)) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
for (let i = 0; i < 10; i++) this.burst(dest, 'bandpass', rand(600, 2400), rand(400, 1200), 4, t + rand(0, 0.25), rand(0.02, 0.06), 0.3);
for (let i = 0; i < 8; i++) this.burst(dest, 'lowpass', 900, 200, 1, t + rand(0.1, 0.6), 0.05, 0.2);
}
/** One beat of party music (kick, hat and a bass note). Call about every half second. */
partyBeat(pan = 0, volume = 1): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const v = clamp(volume, 0, 1) * 0.35;
if (v < 0.02 || !this.admit('music', 0.46, 2)) return;
const dest = this.out(pan, this.ambient);
this.tone(dest, 'sine', 120, 40, t, 0.18, v, 0.002);
this.burst(dest, 'highpass', 8000, 8000, 0.5, t + 0.24, 0.04, v * 0.3);
this.tone(dest, 'square', [55, 55, 65, 49][Math.floor(t * 2) % 4], 55, t, 0.3, v * 0.15, 0.01);
}
/** A stinger for each crash tier: from a bump to the apocalypse. */
dramaTier(tier: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx || tier < 2) return;
if (!this.admit('drama', 4, 2)) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
const k = (tier - 1) / 3;
// A rising whoosh into the impact and a long sub drop.
this.burst(dest, 'bandpass', 600, 3000, 1.2, t, 0.35, 0.15 + 0.2 * k, 0.2);
this.tone(this.crunch, 'sine', 50, 20, t + 0.05, 1 + k * 2.5, 0.4 + 0.5 * k, 0.01);
if (tier >= 4) {
for (const [f, at] of [
[220, 0],
[207, 0.4],
[196, 0.8],
]) this.tone(dest, 'sawtooth', f, f * 0.98, t + 0.3 + at, 0.6, 0.06, 0.05);
}
}
/** One beat of rave music: four-on-the-floor kick, open hat and an acid stab. Call every ~0.47s. */
raveBeat(pan = 0, volume = 1): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const v = clamp(volume, 0, 1) * 0.4;
if (v < 0.02 || !this.admit('music', 0.46, 2)) return;
const dest = this.out(pan, this.ambient);
this.tone(dest, 'sine', 140, 38, t, 0.22, v, 0.002);
this.burst(dest, 'highpass', 9000, 9000, 0.5, t + 0.235, 0.08, v * 0.35);
const step = Math.floor(t * 2.13) % 8;
const note = [110, 110, 165, 110, 131, 110, 196, 147][step];
this.tone(dest, 'sawtooth', note, note, t, 0.2, v * 0.18, 0.005);
}
/**
* About a second of siren from an emergency vehicle. The game calls this for at
* most two vehicles at a time, with volume falling off with distance.
*/
siren(kind: string, pan = 0, volume = 1): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const v = clamp(volume, 0, 1) * 0.05;
if (v < 0.004) return;
const dest = this.out(pan, this.ambient);
const osc = ctx.createOscillator();
osc.type = kind === 'fire' ? 'sawtooth' : 'square';
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(v, t + 0.05);
g.gain.setValueAtTime(v, t + 0.8);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
if (kind === 'police') {
// Fast yelp.
for (let i = 0; i < 4; i++) {
osc.frequency.setValueAtTime(700, t + i * 0.22);
osc.frequency.linearRampToValueAtTime(1500, t + i * 0.22 + 0.2);
}
} else if (kind === 'ambulance') {
// Hi-lo.
osc.frequency.setValueAtTime(960, t);
osc.frequency.setValueAtTime(770, t + 0.45);
} else {
// Slow wail.
osc.frequency.setValueAtTime(500, t);
osc.frequency.linearRampToValueAtTime(900, t + 0.45);
osc.frequency.linearRampToValueAtTime(500, t + 0.9);
}
const lp = createFilter(ctx);
lp.type = 'lowpass';
lp.frequency.value = 2400;
osc.connect(lp).connect(g).connect(dest);
this.release(osc, dest, lp, g);
osc.start(t);
osc.stop(t + 0.95);
}
/** The tow crew at work: winch whine and a wrench clank. */
towWinch(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('crews', 0.7, 3)) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.ambient);
this.tone(dest, 'sawtooth', 180, 320, t, 0.6, 0.03, 0.05);
this.tone(dest, 'square', rand(1400, 2200), rand(1000, 1400), t + 0.3, 0.05, 0.05, 0.001);
}
/** A vehicle smashing into a building: masonry cracking and crumbling. */
structureHit(impact: number, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('structures', 2, 4)) return;
const t = ctx.currentTime;
const k = clamp(impact / 120, 0.2, 1.3);
const dest = this.out(pan, this.fx);
this.burst(dest, 'lowpass', 1500, 120, 0.8, t + 0.02, 0.4 + k * 0.6, 0.4 * k, 0.005);
for (let i = 0; i < 6 + k * 18; i++) {
const at = t + 0.05 + Math.pow(Math.random(), 1.4) * (0.4 + k * 0.9);
this.burst(dest, 'bandpass', rand(250, 1400), rand(150, 700), rand(2, 5), at, rand(0.02, 0.07), rand(0.1, 0.3) * k);
}
}
/** A fire hose blast. */
hoseSpray(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('crews', 1, 3)) return;
const t = ctx.currentTime;
this.burst(this.out(pan, this.ambient), 'highpass', 2500, 3500, 0.6, t, 0.9, 0.08, 0.05);
}
/**
* A missile leaving the rail: the ignition crack, then the rocket motor's roar and
* hiss dropping in pitch and fading as it streaks away (a Doppler fall).
*/
missile(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
this.burst(dest, 'highpass', 3000, 1500, 0.8, t, 0.05, 0.4, 0.001);
this.burst(dest, 'lowpass', 700, 150, 0.8, t, 0.15, 0.4, 0.002);
this.burst(dest, 'bandpass', 1400, 380, 1.4, t + 0.02, 1.4, 0.3, 0.03);
this.burst(dest, 'bandpass', 5200, 1800, 2, t + 0.03, 1.2, 0.12, 0.03);
this.tone(dest, 'sawtooth', 220, 70, t + 0.02, 1.2, 0.04, 0.05);
}
/** A missile's warhead: a sharp crack, a punchy boom and a scatter of debris. */
missileHit(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
this.burst(this.crunch, 'highpass', 2200, 900, 0.7, t, 0.06, 0.8, 0.001);
this.tone(this.crunch, 'sine', 95, 22, t, 1.4, 0.9, 0.003);
this.burst(this.crunch, 'lowpass', 1400, 90, 0.8, t + 0.02, 1.6, 0.6, 0.02);
for (let i = 0; i < 16; i++) {
const at = t + 0.15 + Math.pow(Math.random(), 1.4) * 1.6;
this.burst(dest, 'bandpass', rand(900, 4200), rand(500, 1600), rand(2, 6), at, rand(0.01, 0.04), rand(0.06, 0.2) * (1 - (at - t) / 2));
}
this.duck(0.9);
}
private sirenUntil = 0;
/** The air-raid sirens: a slow rising and falling wail across the city. */
airRaid(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
if (t < this.sirenUntil) return;
this.sirenUntil = t + 12;
for (const [detune, gain] of [
[0, 0.05],
[7, 0.035],
]) {
const osc = ctx.createOscillator();
osc.type = 'sawtooth';
const g = ctx.createGain();
const f = createFilter(ctx);
f.type = 'lowpass';
f.frequency.value = 1400;
osc.frequency.setValueAtTime(180 + detune, t);
for (let k = 0; k < 3; k++) {
osc.frequency.linearRampToValueAtTime(620 + detune, t + k * 4 + 2);
osc.frequency.linearRampToValueAtTime(220 + detune, t + k * 4 + 4);
}
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain, t + 1);
g.gain.setValueAtTime(gain, t + 10.5);
g.gain.exponentialRampToValueAtTime(0.0001, t + 12);
osc.connect(f).connect(g).connect(this.verb);
this.release(osc, this.verb, f, g);
osc.start(t);
osc.stop(t + 12.1);
}
}
/** A surface-to-air missile leaving its rail: a crack and a rushing roar. */
samLaunch(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
this.burst(dest, 'highpass', 2200, 700, 0.6, t, 0.12, 0.3, 0.001);
this.burst(dest, 'bandpass', 900, 300, 0.7, t + 0.05, 1.6, 0.22, 0.05);
}
private lockNext = 0;
/** The missile-warning tone while something's tracking the jet (call every frame). */
missileLock(on: boolean): void {
const ctx = this.ctx;
if (!ctx || !on) return;
const t = ctx.currentTime;
if (t < this.lockNext) return;
this.lockNext = t + 0.22;
this.tone(this.fx, 'square', 1500, 1500, t, 0.1, 0.05, 0.002);
}
private gunsNext = 0;
/** Anti-aircraft guns hammering: a quick string of heavy thuds, fainter far off. */
aaGuns(pan = 0, distance = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
if (t < this.gunsNext) return;
this.gunsNext = t + 0.5;
const dest = this.out(pan, this.ambient);
const level = Math.max(0.05, 1 - distance / 1700);
for (let k = 0; k < 5; k++) {
this.tone(dest, 'sine', 120, 50, t + k * 0.09, 0.12, 0.2 * level, 0.002);
this.burst(dest, 'lowpass', 1600, 300, 0.7, t + k * 0.09, 0.08, 0.15 * level, 0.001);
}
}
/** Shells and fragments hitting the airframe. */
jetHit(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.burst(this.crunch, 'bandpass', rand(1500, 4000), rand(700, 1500), 3, t, 0.06, 0.35, 0.001);
this.tone(this.crunch, 'square', 300, 120, t, 0.06, 0.08, 0.001);
}
/** Flares popping off the jet. */
flares(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
for (let k = 0; k < 4; k++) this.burst(this.fx, 'highpass', 3000, 1200, 0.8, t + k * 0.07, 0.08, 0.18, 0.001);
}
/** A jet roaring past overhead: a rising then falling rush and whine. */
flyby(pan = 0, volume = 1): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.ambient);
this.burst(dest, 'bandpass', 500, 1400, 0.5, t, 1.4, 0.35 * volume, 0.5);
this.burst(dest, 'lowpass', 400, 120, 0.7, t + 0.6, 1.8, 0.3 * volume, 0.2);
this.tone(dest, 'sawtooth', 2400, 1700, t + 0.2, 1.6, 0.03 * volume, 0.4);
}
/** The GAU-8 heard from the ground: a tearing BRRRT, late and dulled with distance. */
brrt(pan = 0, distance = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime + distance / 1040;
const level = Math.max(0.08, 1 - distance / 1900);
const dest = this.out(pan, this.ambient);
const osc = ctx.createOscillator();
osc.type = 'square';
osc.frequency.value = 65;
const f = createFilter(ctx);
f.type = 'lowpass';
f.frequency.value = 900 - distance * 0.3;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.25 * level, t + 0.05);
g.gain.setValueAtTime(0.25 * level, t + 0.7);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
osc.connect(f).connect(g).connect(dest);
this.release(osc, dest, f, g);
osc.start(t);
osc.stop(t + 0.95);
this.burst(dest, 'bandpass', 1800, 900, 1, t, 0.85, 0.12 * level, 0.03);
}
/** The ballute popping out of its can and slamming full of air. */
ballute(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.burst(this.fx, 'lowpass', 900, 200, 0.7, t, 0.25, 0.22, 0.005);
this.tone(this.fx, 'sine', 120, 60, t + 0.02, 0.18, 0.12, 0.004);
}
/** Ordnance clunking onto the racks. */
rearmed(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
for (let i = 0; i < 4; i++) {
this.tone(this.fx, 'square', 140, 80, t + i * 0.14, 0.08, 0.12, 0.002);
this.burst(this.fx, 'bandpass', 2600, 1800, 2, t + i * 0.14, 0.05, 0.08, 0.001);
}
}
/** The gun overheats: the barrels whir down and the breech hisses. */
gunOverheat(): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
this.tone(this.fx, 'sawtooth', 140, 18, t, 1.3, 0.08, 0.01);
this.burst(this.fx, 'highpass', 4500, 3000, 0.6, t + 0.1, 1.6, 0.12, 0.05);
this.tone(this.fx, 'square', 2400, 2100, t + 0.05, 0.04, 0.05, 0.001);
}
private buildJet(ctx: AudioContext): Jet {
const out = ctx.createGain();
out.gain.value = 0;
out.connect(this.drive);
const noiseSrc = (rate: number) => {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
src.playbackRate.value = rate;
src.start(0, Math.random());
return src;
};
// Deep rumble.
const rumbleTone = createFilter(ctx);
rumbleTone.type = 'lowpass';
rumbleTone.frequency.value = 140;
const rumble = ctx.createGain();
noiseSrc(0.5).connect(rumbleTone).connect(rumble).connect(out);
// The broad roar of the exhaust.
const roarTone = createFilter(ctx);
roarTone.type = 'bandpass';
roarTone.frequency.value = 700;
roarTone.Q.value = 0.5;
const roar = ctx.createGain();
noiseSrc(1).connect(roarTone).connect(roar).connect(out);
// The fan's whine: two tones a few hertz apart beat into a hollow, wavering whistle.
const whine = ctx.createGain();
whine.gain.value = 0;
const whineA = ctx.createOscillator();
whineA.type = 'sine';
const whineB = ctx.createOscillator();
whineB.type = 'triangle';
const bTone = ctx.createGain();
bTone.gain.value = 0.5;
whineA.connect(whine);
whineB.connect(bTone).connect(whine);
whine.connect(out);
whineA.start();
whineB.start();
// Air rushing over the airframe.
const airTone = createFilter(ctx);
airTone.type = 'highpass';
airTone.frequency.value = 3000;
const air = ctx.createGain();
noiseSrc(1.3).connect(airTone).connect(air).connect(out);
return { out, rumble, roar, roarTone, whineA, whineB, whine, air, airTone };
}
/** The jet, once per frame: throttle 0..1. The turbines lag the throttle as they spool. */
private jetUpdate(throttle: number, active: boolean): void {
const ctx = this.ctx!;
if (!active && !this.jetVoice) return;
this.jetVoice ??= this.buildJet(ctx);
const j = this.jetVoice;
const t = ctx.currentTime;
const spool = 0.8;
const r = throttle;
const rush = this.flightSpeed * 0.5 + Math.abs(this.flightBank) * this.flightSpeed * 0.9;
j.out.gain.setTargetAtTime(active ? 1 : 0, t, active ? 0.3 : 0.2);
j.rumble.gain.setTargetAtTime(0.35 + r * 0.25, t, spool);
j.roar.gain.setTargetAtTime(0.1 + r * 0.16, t, spool);
j.roarTone.frequency.setTargetAtTime(500 + r * 700, t, spool);
j.whineA.frequency.setTargetAtTime(2600 + r * 2200, t, spool);
j.whineB.frequency.setTargetAtTime(2600 + r * 2200 + 6 + r * 5, t, spool);
j.whine.gain.setTargetAtTime(0.012 + r * 0.028, t, spool);
j.air.gain.setTargetAtTime(0.02 + rush * 0.09, t, 0.25);
j.airTone.frequency.setTargetAtTime(2400 + rush * 2400, t, 0.25);
// Slamming the throttle open: a rising whoosh as the engines spool up.
if (active && r >= 0.75 && this.lastThrottle < 0.75) {
const src = ctx.createBufferSource();
src.buffer = this.noise;
const bp = createFilter(ctx);
bp.type = 'bandpass';
bp.Q.value = 1.2;
bp.frequency.setValueAtTime(500, t);
bp.frequency.exponentialRampToValueAtTime(3500, t + 1.4);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.12, t + 0.8);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6);
src.connect(bp).connect(g).connect(this.drive);
this.release(src, this.drive, bp, g);
src.start(t, Math.random());
src.stop(t + 1.7);
}
this.lastThrottle = r;
}
private buildGun(ctx: AudioContext): Gun {
const out = ctx.createGain();
out.gain.value = 0;
const shaper = ctx.createWaveShaper();
shaper.curve = this.driveCurve(2.5);
shaper.oversample = '2x';
const gunLevel = ctx.createGain();
gunLevel.gain.value = 0.5;
out.connect(shaper).connect(gunLevel).connect(this.fx);
const send = ctx.createGain();
send.gain.value = 0.12;
shaper.connect(send).connect(this.verb);
// The buzz: one pulse per round, a sawtooth at the firing rate, through a low-pass.
const buzz = ctx.createOscillator();
buzz.type = 'sawtooth';
buzz.frequency.value = 40;
const buzzTone = createFilter(ctx);
buzzTone.type = 'lowpass';
buzzTone.frequency.value = 1400;
const buzzGain = ctx.createGain();
buzzGain.gain.value = 0.55;
buzz.connect(buzzTone).connect(buzzGain).connect(out);
// The roar: broadband noise chopped at the same rate (each round's report).
const noise = ctx.createBufferSource();
noise.buffer = this.noise;
noise.loop = true;
const roarTone = createFilter(ctx);
roarTone.type = 'bandpass';
roarTone.frequency.value = 650;
roarTone.Q.value = 0.6;
const roar = ctx.createGain();
roar.gain.value = 0.5;
const chop = ctx.createOscillator();
chop.type = 'square';
chop.frequency.value = 40;
const chopDepth = ctx.createGain();
chopDepth.gain.value = 0.45;
chop.connect(chopDepth).connect(roar.gain);
noise.connect(roarTone).connect(roar).connect(out);
// The shells landing: a sharper crackle that starts and stops a beat after the gun.
const crackleNoise = ctx.createBufferSource();
crackleNoise.buffer = this.noise;
crackleNoise.loop = true;
crackleNoise.playbackRate.value = 0.7;
const crackleTone = createFilter(ctx);
crackleTone.type = 'bandpass';
crackleTone.frequency.value = 2200;
crackleTone.Q.value = 1.2;
const crackle = ctx.createGain();
crackle.gain.value = 0;
crackleNoise.connect(crackleTone).connect(crackle).connect(this.fx);
for (const node of [buzz, noise, chop, crackleNoise]) node.start();
return { buzz, chop, roar, crackle, out };
}
/**
* The A-10's GAU-8 Avenger, once per frame. It fires 65 rounds a second once the
* barrels are spun up (fewer as they spin up), so it isn't a rattle but one tearing
* BRRRRT: the pitch of the buzz is the firing rate itself.
*/
gunUpdate(firing: boolean, rate: number): void {
const ctx = this.ctx;
if (!ctx || (!firing && !this.gunVoice)) return;
this.gunVoice ??= this.buildGun(ctx);
const g = this.gunVoice;
const t = ctx.currentTime;
if (firing) {
g.buzz.frequency.setTargetAtTime(rate, t, 0.08);
g.chop.frequency.setTargetAtTime(rate, t, 0.08);
g.out.gain.setTargetAtTime(0.42, t, this.gunOn ? 0.05 : 0.02);
if (!this.gunOn) {
holdGain(g.crackle.gain, t);
g.crackle.gain.setTargetAtTime(0.09, t + 0.25, 0.08);
}
} else if (this.gunOn) {
g.out.gain.setTargetAtTime(0, t, 0.06);
g.buzz.frequency.setTargetAtTime(25, t, 0.2);
holdGain(g.crackle.gain, t);
g.crackle.gain.setTargetAtTime(0, t + 0.3, 0.1);
}
this.gunOn = firing;
}
/**
* A bomb going off: the crack of the blast wave, a deep boom you feel, a long rolling
* rumble, then debris raining down and secondary pops.
*/
bigBlast(pan = 0, size = 1): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('bombs', 12, 2)) return;
const t = ctx.currentTime;
const s = clamp(size, 0.6, 2.5);
const dest = this.out(pan, this.fx);
this.burst(this.crunch, 'highpass', 2500, 800, 0.7, t, 0.09, 1.1, 0.001);
this.tone(this.crunch, 'sine', 70, 14, t, 3.2 * s, 1.2, 0.004);
this.tone(this.crunch, 'sine', 42, 10, t + 0.04, 4 * s, 1, 0.02);
this.burst(this.crunch, 'lowpass', 900, 60, 0.9, t + 0.05, 4.5 * s, 0.9, 0.05);
this.burst(dest, 'lowpass', 300, 80, 0.7, t + 0.6, 3.5 * s, 0.35, 0.4);
// The boom rolling away across the city.
this.burst(this.verb, 'lowpass', 500, 70, 0.7, t + 0.1, 3 * s, 0.6, 0.05);
this.tone(this.crunch, 'sine', 30, 9, t, 1.2, 0.8, 0.002);
for (let i = 0; i < 45 * s; i++) {
const at = t + 0.4 + Math.pow(Math.random(), 1.3) * 3.5 * s;
const fade = 1 - (at - t) / (4.5 * s);
this.burst(dest, 'bandpass', rand(600, 4000), rand(300, 1500), rand(2, 6), at, rand(0.01, 0.05), rand(0.08, 0.28) * fade);
}
for (let i = 0; i < 3; i++) {
const at = t + rand(0.5, 2.5);
this.burst(this.crunch, 'lowpass', 1600, 100, 0.8, at, 0.5, 0.35, 0.005);
}
this.duck(2.2);
}
/**
* An explosion or a collapse, heard from a distance (map units). Kept simple and
* punchy: a noise burst whose filter sweeps down from bright to dark, and a sub-bass
* thump that falls in pitch (plus a crack when it's close). Sound takes its time to
* arrive (about a second per 1,000 units), and distance dulls it and adds echo.
*/
blast(kind: BlastKind, pan: number, distance: number): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('world-blasts', 4.5, 8)) return;
const t = ctx.currentTime + distance / 1040;
// Everything goes through one distance stage: muffle, level, position, echo.
const muffle = createFilter(ctx);
muffle.type = 'lowpass';
muffle.frequency.value = clamp(16000 * Math.exp(-distance / 380), 400, 16000);
const level = ctx.createGain();
level.gain.value = 1 / (1 + distance / 320);
const send = ctx.createGain();
send.gain.value = 0.2 + Math.min(0.6, distance / 700);
const position = this.out(pan, level);
muffle.connect(position);
this.transient.set(muffle, { users: 0, nodes: [muffle, position, level, send] });
level.connect(distance < 250 ? this.crunch : this.fx);
level.connect(send).connect(this.verb);
if (kind === 'collapse-wood' || kind === 'collapse-stone') {
// A long dark rumble with a few heavy thuds as it comes down.
const stone = kind === 'collapse-stone';
this.noiseSweep(muffle, t, stone ? 500 : 800, 120, 0.15, 2.6, 0.7);
for (const [at, f] of [[0, stone ? 48 : 62], [0.35, stone ? 40 : 55], [0.8, stone ? 34 : 48]] as [number, number][]) {
this.tone(muffle, 'sine', f, f * 0.6, t + at, 0.35, 0.55, 0.004);
}
for (let i = 0; i < 8; i++) this.burst(muffle, 'bandpass', stone ? rand(500, 1500) : rand(700, 2200), 400, 3, t + rand(0.05, 1.6), 0.03, 0.12);
if (distance < 400) this.duck(0.8);
return;
}
const size = kind === 'huge' ? 2.2 : kind === 'medium' ? 1.3 : 0.8;
const dur = 0.7 + size * 0.9;
// The burst: bright to dark.
this.noiseSweep(muffle, t, 3200, 110, 0.005, dur, 0.9 * Math.min(1.4, size));
// The thump.
this.tone(muffle, 'sine', 50 + 30 / size, 22 + 8 / size, t, 0.35 + size * 0.35, 1.1 * Math.min(1.3, size), 0.003);
// The crack, only when it's close enough to hear it.
if (distance < 300) this.burst(muffle, 'highpass', 2500, 1200, 0.7, t, 0.04, 0.7, 0.001);
if (distance < 400) this.duck(0.5 + size * 0.5);
}
/** Noise through a low-pass that sweeps from f0 down to f1, with a quick rise and a long fade. */
private noiseSweep(dest: AudioNode, t: number, f0: number, f1: number, attack: number, dur: number, peak: number): void {
const ctx = this.ctx!;
const src = ctx.createBufferSource();
src.buffer = this.noise;
const lp = createFilter(ctx);
lp.type = 'lowpass';
lp.frequency.setValueAtTime(f0, t);
lp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.7);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(lp).connect(g).connect(dest);
this.release(src, dest, lp, g);
src.start(t, rand(0, 1));
src.stop(t + dur + 0.05);
}
/** The steady roar of the fires around the listener, 0 (none) to 1 (an inferno). */
fireLevel(level: number): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.fireVoice && level <= 0) return;
if (!this.fireVoice) {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
src.playbackRate.value = 0.5;
const lp = createFilter(ctx);
lp.type = 'lowpass';
lp.frequency.value = 520;
const g = ctx.createGain();
g.gain.value = 0;
src.connect(lp).connect(g).connect(this.ambient);
src.start();
this.fireVoice = g;
}
this.fireVoice.gain.setTargetAtTime(clamp(level, 0, 1) * 0.22, ctx.currentTime, 0.5);
}
/** A big fire nearby: a low roar and the crack and pop of burning timber. */
fireCrackle(volume: number): void {
const ctx = this.ctx;
if (!ctx || volume < 0.05) return;
const t = ctx.currentTime;
this.burst(this.ambient, 'lowpass', 380, 200, 0.6, t, 0.3, 0.12 * volume, 0.08);
for (let i = 0; i < 3 + Math.random() * 5; i++) {
this.burst(this.ambient, 'bandpass', rand(1200, 4500), rand(800, 2000), rand(3, 9), t + Math.random() * 0.25, rand(0.005, 0.02), rand(0.05, 0.2) * volume);
}
}
/** A bomb coming off the rack (a heavy clunk) and whistling down, louder as it falls. */
bombDrop(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
this.burst(dest, 'lowpass', 500, 180, 0.8, t, 0.08, 0.35, 0.002);
this.tone(dest, 'square', 95, 60, t, 0.07, 0.12, 0.002);
const osc = ctx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(1700, t + 0.05);
osc.frequency.exponentialRampToValueAtTime(420, t + 1);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t + 0.05);
g.gain.exponentialRampToValueAtTime(0.06, t + 0.95);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.02);
osc.connect(g).connect(dest);
this.release(osc, dest, g);
osc.start(t + 0.05);
osc.stop(t + 1.05);
}
/** One thump of a helicopter rotor. */
rotor(pan = 0, volume = 1): void {
const ctx = this.ctx;
if (!ctx || volume < 0.03) return;
if (!this.admit('rotors', 0.12, 3)) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.ambient);
this.burst(dest, 'lowpass', 260, 90, 1.2, t, 0.09, 0.22 * volume, 0.004);
this.tone(dest, 'sine', 70, 48, t, 0.08, 0.08 * volume, 0.004);
}
/** A crew at work: wrench clanks or a jackhammer rattle. */
tools(pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
if (!this.admit('crews', 0.6, 3)) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.ambient);
if (Math.random() < 0.5) {
for (let i = 0; i < 2; i++) this.tone(dest, 'square', rand(1800, 2600), rand(1200, 1600), t + i * 0.14, 0.05, 0.04, 0.001);
} else {
for (let i = 0; i < 10; i++) this.burst(dest, 'bandpass', rand(500, 900), rand(300, 600), 3, t + i * 0.05, 0.035, 0.09, 0.001);
}
}
/** A lamp post, hydrant or trash can knocked flying. */
knock(kind: string, pan = 0): void {
const ctx = this.ctx;
if (!ctx) return;
const t = ctx.currentTime;
const dest = this.out(pan, this.fx);
if (kind === 'lamp') {
this.tone(dest, 'triangle', 420, 380, t, 0.9, 0.1, 0.002);
this.tone(dest, 'triangle', 1130, 1080, t, 0.6, 0.05, 0.002);
this.burst(dest, 'highpass', 4000, 6000, 0.7, t + 0.35, 0.25, 0.12);
} else if (kind === 'hydrant') {
this.tone(dest, 'square', 220, 140, t, 0.12, 0.12, 0.002);
this.burst(dest, 'highpass', 1800, 2600, 0.5, t + 0.05, 1.4, 0.1, 0.05);
} else {
this.tone(dest, 'triangle', 260, 170, t, 0.2, 0.14, 0.002);
this.burst(dest, 'bandpass', 900, 500, 1.5, t, 0.25, 0.12);
}
}
}
/** Shared instance. Calls are no-ops until unlock() runs from a user gesture. */
export const sfx = new Sfx();
