SOURCE / PINNED RELEASE
Made of little things.
City Breaker
- Release
- 94d2b8664b10…
- Author-recorded commit
- df23103cbcc1…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-cf8b858653e
Archive hash verified: eeeed725668c1ece…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Synthesized, positional audio. Every sound is generated from period numbers:
// NK-12 contra-props (8 blades a side, ~735 rpm, supersonic tips), turbojet
// compressor whine, Bofors at ~2 rounds/s, Vulcan at ~100 rounds/s, a Fan Song
// tracking beam heard as a 16 Hz rattle that turns into a steady tone when the
// missile is guided, flak fragments "like hail on a tin roof". World sounds are
// panned by bearing, muffled and delayed by distance, and sent to an echo.
import { CONFIG } from './config';
import { prefs } from './prefs';
type EngineModel = 'jet' | 'turboprop' | 'fighter';
interface EngineBank {
model: EngineModel;
out: GainNode;
lp: BiquadFilterNode;
roarGain: GainNode;
whine: OscillatorNode[];
whineGain: GainNode;
props: OscillatorNode[];
propGain: GainNode;
burnerGain: GainNode;
sources: AudioScheduledSourceNode[];
}
interface Beds {
src: AudioScheduledSourceNode[];
wind: GainNode;
windLp: BiquadFilterNode;
hiss: GainNode;
rattle: GainNode;
siren: GainNode;
growl: GainNode;
fan: GainNode;
guide: GainNode;
guideOsc: OscillatorNode;
bell: GainNode;
crackle: GainNode;
alt: GainNode;
}
export interface AudioFrame {
engine: number;
speed: number;
lowAlt: boolean;
searchCount: number;
locked: boolean;
launchClose: number;
aaaGrowl: number;
siren: number;
critical: number;
engineFire: boolean;
intensity?: number;
damage?: number;
doors?: number;
turn?: number;
throttle?: number;
flamedOut?: boolean;
pullUp?: boolean;
}
export class Audio {
ctx: AudioContext | null = null;
private master!: GainNode;
private radioIn!: GainNode;
private sfx!: GainNode;
private cockpit!: GainNode;
private rwrBus!: GainNode;
private verbSend!: GainNode;
private noise!: AudioBuffer;
private engine: EngineBank | null = null;
private beds: Beds | null = null;
private timers = { search: 0, launch: 0, heart: 0, breath: 0, beat: 0, bar: 0, creak: 0 };
private listener = { x: 0, y: 0, hdg: 0 };
muted = false;
/** Must be called from a user gesture. */
start(): void {
if (this.ctx) {
void this.ctx.resume();
return;
}
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
if (!AC) return;
const ctx = new AC();
this.ctx = ctx;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14;
comp.ratio.value = 5;
comp.attack.value = 0.003;
comp.release.value = 0.25;
comp.connect(ctx.destination);
this.master = ctx.createGain();
this.master.gain.value = CONFIG.audio.master * prefs.volume;
this.master.connect(comp);
this.sfx = ctx.createGain();
this.sfx.connect(this.master);
// Cockpit sounds are heard through a helmet: slightly muffled.
this.cockpit = ctx.createGain();
const cockLp = ctx.createBiquadFilter();
cockLp.type = 'lowpass';
cockLp.frequency.value = 5200;
this.cockpit.connect(cockLp).connect(this.master);
this.rwrBus = ctx.createGain();
this.rwrBus.gain.value = CONFIG.audio.rwr;
this.rwrBus.connect(this.master);
// Outdoor echo: a decaying noise impulse (ground reflections, rolling thunder).
const verb = ctx.createConvolver();
const len = Math.floor(ctx.sampleRate * 2.6);
const ir = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = ir.getChannelData(ch);
for (let i = 0; i < len; i++) {
const t = i / ctx.sampleRate;
const early = t > 0.05 && t < 0.09 ? 0.6 : 0;
d[i] = (Math.random() * 2 - 1) * (Math.exp(-t * 2.2) * 0.7 + early);
}
}
verb.buffer = ir;
this.verbSend = ctx.createGain();
this.verbSend.gain.value = 0.55;
const verbLp = ctx.createBiquadFilter();
verbLp.type = 'lowpass';
verbLp.frequency.value = 1800;
this.verbSend.connect(verb).connect(verbLp).connect(this.master);
// Radio: band-limited, overdriven, hissy.
this.radioIn = ctx.createGain();
const hp = ctx.createBiquadFilter();
hp.type = 'highpass';
hp.frequency.value = 450;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 2600;
const shaper = ctx.createWaveShaper();
shaper.curve = this.curve(3.5);
const rg = ctx.createGain();
rg.gain.value = CONFIG.audio.radio;
this.radioIn.connect(hp).connect(shaper).connect(lp).connect(rg).connect(this.master);
const nlen = ctx.sampleRate * 2;
this.noise = ctx.createBuffer(1, nlen, ctx.sampleRate);
const nd = this.noise.getChannelData(0);
for (let i = 0; i < nlen; i++) nd[i] = Math.random() * 2 - 1;
}
setMuted(m: boolean): void {
this.muted = m;
this.applyVolume();
}
applyVolume(): void {
if (this.ctx) this.master.gain.setTargetAtTime(this.muted ? 0 : CONFIG.audio.master * prefs.volume, this.ctx.currentTime, 0.05);
}
private curve(k: number): Float32Array<ArrayBuffer> {
const c = new Float32Array(new ArrayBuffer(1024 * 4));
for (let i = 0; i < c.length; i++) c[i] = Math.tanh(((i / c.length) * 2 - 1) * k);
return c;
}
private noiseSrc(): AudioBufferSourceNode {
const s = this.ctx!.createBufferSource();
s.buffer = this.noise;
s.loop = true;
s.loopStart = Math.random();
return s;
}
private env(g: GainNode, t: number, peak: number, attack: number, decay: number): void {
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
}
// ── Space ──
setListener(x: number, y: number, hdg: number): void {
this.listener = { x, y, hdg };
}
/** A chain placed in the world: distance muffling, bearing pan, speed-of-sound delay, echo send. */
private place(x: number, y: number, vol: number, verbAmt = 0.35): { input: AudioNode; t: number } | null {
if (!this.ctx) return null;
const ctx = this.ctx;
const L = this.listener;
const d = Math.hypot(x - L.x, y - L.y);
const rel = Math.atan2(y - L.y, x - L.x) - L.hdg;
const pan = Math.max(-1, Math.min(1, Math.sin(rel) * Math.min(1, d / 0.6)));
const g = vol / (1 + d * d * 0.06);
if (g < 0.004) return null;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 600 + 9000 / (1 + d * 0.9);
const panner = ctx.createStereoPanner();
panner.pan.value = pan;
const out = ctx.createGain();
out.gain.value = g;
lp.connect(panner).connect(out).connect(this.sfx);
const send = ctx.createGain();
send.gain.value = verbAmt * Math.min(1, 0.3 + d * 0.15) * g;
panner.connect(send).connect(this.verbSend);
// Sound travels: compressed so a distant burst still arrives within a second.
return { input: lp, t: ctx.currentTime + Math.min(1.1, d * 0.07) };
}
// ── Mission beds ──
startMission(kind = 'Tu-16'): void {
if (!this.ctx) return;
this.stopMission();
const ctx = this.ctx;
const model: EngineModel = kind === 'Tu-95' ? 'turboprop' : ['MiG-21', 'Su-7', 'MiG-25R'].includes(kind) ? 'fighter' : 'jet';
const out = ctx.createGain();
out.gain.value = 0;
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 700;
out.connect(this.cockpit);
lp.connect(out);
const sources: AudioScheduledSourceNode[] = [];
// Roar: broadband jet / propwash noise.
const roar = this.noiseSrc();
const roarLp = ctx.createBiquadFilter();
roarLp.type = 'lowpass';
roarLp.frequency.value = model === 'turboprop' ? 500 : 900;
const roarGain = ctx.createGain();
roarGain.gain.value = 0.5;
roar.connect(roarLp).connect(roarGain).connect(lp);
roar.start();
sources.push(roar);
// Structure rumble.
const rumble = ctx.createOscillator();
rumble.frequency.value = model === 'turboprop' ? 24.5 : 38;
const rgn = ctx.createGain();
rgn.gain.value = 0.25;
rumble.connect(rgn).connect(out);
rumble.start();
sources.push(rumble);
// Compressor whine (two spools).
const whineGain = ctx.createGain();
whineGain.gain.value = 0.02;
const whineBp = ctx.createBiquadFilter();
whineBp.type = 'bandpass';
whineBp.frequency.value = 2400;
whineBp.Q.value = 3;
const whine: OscillatorNode[] = [];
for (const f of model === 'turboprop' ? [2600] : [2150, 3210]) {
const o = ctx.createOscillator();
o.type = 'triangle';
o.frequency.value = f;
const vib = ctx.createOscillator();
vib.frequency.value = 5 + Math.random() * 2;
const vg = ctx.createGain();
vg.gain.value = f * 0.004;
vib.connect(vg).connect(o.frequency);
o.connect(whineBp);
o.start();
vib.start();
whine.push(o);
sources.push(o, vib);
}
whineBp.connect(whineGain).connect(out);
// NK-12 contra-rotating props: two rows at slightly different blade-pass
// rates beat against each other → the Bear's throbbing howl, driven hard.
const propGain = ctx.createGain();
propGain.gain.value = 0;
const props: OscillatorNode[] = [];
if (model === 'turboprop') {
const shaper = ctx.createWaveShaper();
shaper.curve = this.curve(5);
const plp = ctx.createBiquadFilter();
plp.type = 'lowpass';
plp.frequency.value = 1600;
for (const f of [49, 51.5, 98.5]) {
const o = ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.value = f;
o.connect(shaper);
o.start();
props.push(o);
sources.push(o);
}
shaper.connect(plp).connect(propGain).connect(out);
}
// Afterburner crackle for fighters.
const burnerGain = ctx.createGain();
burnerGain.gain.value = 0;
if (model === 'fighter') {
const b = this.noiseSrc();
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.value = 300;
const am = ctx.createOscillator();
am.type = 'square';
am.frequency.value = 23;
const amg = ctx.createGain();
amg.gain.value = 0.5;
am.connect(amg).connect(burnerGain.gain);
b.connect(bp).connect(burnerGain).connect(out);
b.start();
am.start();
sources.push(b, am);
}
this.engine = { model, out, lp, roarGain, whine, whineGain, props, propGain, burnerGain, sources };
// Environment beds.
const bedSrc: AudioScheduledSourceNode[] = [];
const noiseBed = (type: BiquadFilterType, f: number, q: number, dest: AudioNode) => {
const s = this.noiseSrc();
const flt = ctx.createBiquadFilter();
flt.type = type;
flt.frequency.value = f;
flt.Q.value = q;
const g = ctx.createGain();
g.gain.value = 0;
s.connect(flt).connect(g).connect(dest);
s.start();
bedSrc.push(s);
return { g, flt };
};
const wind = noiseBed('bandpass', 700, 0.5, this.cockpit);
const hiss = noiseBed('bandpass', 1700, 2.5, this.cockpit); // air shrieking through holes
const rattle = noiseBed('bandpass', 180, 1.2, this.cockpit);
const crackle = noiseBed('highpass', 2500, 0.7, this.cockpit); // engine fire
const tone = (type: OscillatorType, f: number) => {
const o = ctx.createOscillator();
o.type = type;
o.frequency.value = f;
o.start();
bedSrc.push(o);
return o;
};
const gateBy = (src: AudioNode, rate: number, dest: AudioNode) => {
const gate = ctx.createGain();
gate.gain.value = 0;
const l = tone('square', rate);
const lg = ctx.createGain();
lg.gain.value = 0.5;
l.connect(lg).connect(gate.gain);
src.connect(gate).connect(dest);
};
// Mechanical air-raid siren: a rising and falling wail with a hard harmonic.
const siren = ctx.createGain();
siren.gain.value = 0;
const sirenLp = ctx.createBiquadFilter();
sirenLp.type = 'lowpass';
sirenLp.frequency.value = 1400;
siren.connect(sirenLp).connect(this.sfx);
const sendS = ctx.createGain();
sendS.gain.value = 0.6;
sirenLp.connect(sendS).connect(this.verbSend);
for (const k of [1, 2.01]) {
const o = tone('sawtooth', 330 * k);
const lfo = tone('triangle', 0.09);
const lg = ctx.createGain();
lg.gain.value = 150 * k;
lfo.connect(lg).connect(o.frequency);
const og = ctx.createGain();
og.gain.value = k === 1 ? 1 : 0.35;
o.connect(og).connect(siren);
}
// Flak fire-control radar growl.
const growl = ctx.createGain();
growl.gain.value = 0;
growl.connect(this.rwrBus);
gateBy(tone('sawtooth', 95), 11, growl);
// Fan Song tracking beam: a 16 Hz rattle while a SAM radar holds you…
const fan = ctx.createGain();
fan.gain.value = 0;
fan.connect(this.rwrBus);
gateBy(tone('square', 1100), 16, fan);
// …and a steady, rising tone once a missile is guided at you.
const guide = ctx.createGain();
guide.gain.value = 0;
const guideOsc = tone('square', 1600);
const glp = ctx.createBiquadFilter();
glp.type = 'lowpass';
glp.frequency.value = 3500;
guideOsc.connect(glp).connect(guide).connect(this.rwrBus);
// Fire bell: two clangorous partials hammered at 18 Hz.
const bell = ctx.createGain();
bell.gain.value = 0;
bell.connect(this.cockpit);
const bellMix = ctx.createGain();
tone('sine', 2210).connect(bellMix);
tone('sine', 3170).connect(bellMix);
gateBy(bellMix, 18, bell);
// Radar altimeter "dangerous altitude" tone.
const alt = ctx.createGain();
alt.gain.value = 0;
alt.connect(this.cockpit);
gateBy(tone('square', 820), 5, alt);
this.beds = { src: bedSrc, wind: wind.g, windLp: wind.flt, hiss: hiss.g, rattle: rattle.g, siren, growl, fan, guide, guideOsc, bell, crackle: crackle.g, alt };
}
stopMission(): void {
const stop = (n: AudioScheduledSourceNode) => {
try {
n.stop();
} catch {
/* already stopped */
}
};
this.engine?.sources.forEach(stop);
this.beds?.src.forEach(stop);
this.engine = null;
this.beds = null;
}
/** Per-frame state of the cockpit. */
update(dt: number, s: AudioFrame): void {
if (!this.ctx || !this.engine || !this.beds) return;
const t = this.ctx.currentTime;
const e = this.engine;
const B = this.beds;
const run = s.flamedOut ? 0 : s.engine;
const thr = Math.max(0, s.throttle ?? 0);
e.out.gain.setTargetAtTime(0.12 + run * 0.16, t, 0.3);
e.lp.frequency.setTargetAtTime(500 + s.speed * 500 + thr * 400, t, 0.3);
e.roarGain.gain.setTargetAtTime(run * (e.model === 'turboprop' ? 0.35 : 0.55) + 0.05, t, 0.3);
e.whineGain.gain.setTargetAtTime(run * (e.model === 'turboprop' ? 0.012 : 0.022) * (1 + thr * 0.6), t, 0.4);
e.whine.forEach((o, i) => o.frequency.setTargetAtTime((i ? 3210 : 2150) * (0.82 + run * 0.18 + thr * 0.06) * (e.model === 'turboprop' ? 1.2 : 1), t, s.flamedOut ? 2.5 : 0.6));
e.props.forEach((o, i) => o.frequency.setTargetAtTime([49, 51.5, 98.5][i] * (0.85 + run * 0.15), t, 0.5));
e.propGain.gain.setTargetAtTime(e.model === 'turboprop' ? 0.22 * run : 0, t, 0.3);
e.burnerGain.gain.setTargetAtTime(e.model === 'fighter' && thr > 0.3 ? 0.25 : 0, t, 0.15);
const doors = s.doors ?? 0;
B.wind.gain.setTargetAtTime(0.025 + s.speed * 0.035 + (s.lowAlt ? 0.03 : 0) + doors * 0.07, t, 0.3);
B.windLp.frequency.setTargetAtTime(500 + s.speed * 400 + doors * 600, t, 0.3);
const dmg = s.damage ?? 0;
B.hiss.gain.setTargetAtTime(dmg > 0.15 ? dmg * 0.09 * s.speed : 0, t, 0.4);
B.rattle.gain.setTargetAtTime(0.012 + dmg * 0.05 + (s.lowAlt ? 0.02 : 0), t, 0.3);
B.crackle.gain.setTargetAtTime(s.engineFire ? 0.05 + Math.random() * 0.06 : 0, t, 0.05);
B.bell.gain.setTargetAtTime(s.engineFire ? 0.05 : 0, t, 0.05);
B.alt.gain.setTargetAtTime(s.pullUp ? 0.12 : 0, t, 0.05);
B.siren.gain.setTargetAtTime(s.siren * 0.05, t, 0.8);
B.growl.gain.setTargetAtTime(s.aaaGrowl * 0.16, t, 0.1);
B.fan.gain.setTargetAtTime(s.locked && s.launchClose <= 0 ? 0.22 : 0, t, 0.03);
B.guide.gain.setTargetAtTime(s.launchClose > 0 ? 0.12 + s.launchClose * 0.25 : 0, t, 0.03);
B.guideOsc.frequency.setTargetAtTime(1300 + s.launchClose * 1700, t, 0.05);
const T = this.timers;
T.search -= dt;
if (s.searchCount > 0 && T.search <= 0) {
// A search radar's beam sweeps past: one soft ping per antenna rotation.
T.search = Math.max(0.4, 2.6 / s.searchCount);
this.beep(1250, 0.04, 0.2, 'sine');
}
T.launch -= dt;
if (s.launchClose > 0 && T.launch <= 0) {
T.launch = 0.18 - s.launchClose * 0.12;
this.beep(2900 + s.launchClose * 900, 0.03, 0.12 + s.launchClose * 0.3, 'square');
}
T.creak -= dt;
if (Math.abs(s.turn ?? 0) > 0.8 && s.speed > 0.7 && T.creak <= 0) {
T.creak = 1.2 + Math.random();
this.creak(0.12 + dmg * 0.15);
}
const I = s.intensity ?? 0;
T.beat -= dt;
if (I > 0.08 && T.beat <= 0) {
const bpm = 84 + I * 84;
T.beat = 60 / bpm;
T.bar = (T.bar + 1) % 4;
this.drum(T.bar === 0 ? 0.3 : 0.18, I);
if (I > 0.45 && T.bar % 2 === 1) setTimeout(() => this.snare(0.09 * I), (T.beat * 500) | 0);
}
// Oxygen-mask breathing follows the stress; a heartbeat when dying.
T.breath -= dt;
if (T.breath <= 0) {
T.breath = 3.2 - I * 1.6 - s.critical;
this.breath(0.025 + I * 0.04 + s.critical * 0.06);
}
T.heart -= dt;
if (s.critical > 0 && T.heart <= 0) {
T.heart = 0.9 - s.critical * 0.45;
this.thud(55, 0.35 * s.critical, 0.1);
setTimeout(() => this.thud(48, 0.25 * s.critical, 0.08), 160);
}
}
// ── One-shots: cockpit ──
beep(freq: number, dur: number, vol: number, type: OscillatorType): void {
if (!this.ctx) return;
const ctx = this.ctx;
const o = ctx.createOscillator();
o.type = type;
o.frequency.value = freq;
const g = ctx.createGain();
this.env(g, ctx.currentTime, vol, 0.004, dur);
o.connect(g).connect(this.rwrBus);
o.start();
o.stop(ctx.currentTime + dur + 0.05);
}
thud(freq: number, vol: number, dur: number): void {
if (!this.ctx) return;
this.thudAt(this.ctx.currentTime, freq, vol, dur);
}
private thudAt(t: number, freq: number, vol: number, dur = 0.06): void {
const ctx = this.ctx!;
const o = ctx.createOscillator();
o.frequency.setValueAtTime(freq * 1.6, t);
o.frequency.exponentialRampToValueAtTime(freq, t + dur);
const g = ctx.createGain();
this.env(g, t, vol, 0.004, dur * 2);
o.connect(g).connect(this.cockpit);
o.start(t);
o.stop(t + dur * 3 + 0.05);
}
private breath(vol: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = ctx.currentTime;
this.click(t, 0.04);
const src = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 1100;
f.Q.value = 1.6;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(vol, t + 0.45);
g.gain.linearRampToValueAtTime(0.0001, t + 1.1);
src.connect(f).connect(g).connect(this.cockpit);
src.start(t);
src.stop(t + 1.2);
}
private click(t: number, vol: number, dest?: AudioNode): void {
const ctx = this.ctx!;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 2500;
const g = ctx.createGain();
this.env(g, t, vol, 0.001, 0.02);
s.connect(f).connect(g).connect(dest ?? this.cockpit);
s.start(t);
s.stop(t + 0.05);
}
private creak(vol: number): void {
const ctx = this.ctx!;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(70, t);
o.frequency.linearRampToValueAtTime(52 + Math.random() * 20, t + 0.7);
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 240;
f.Q.value = 6;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(vol, t + 0.25);
g.gain.linearRampToValueAtTime(0.0001, t + 0.8);
o.connect(f).connect(g).connect(this.cockpit);
o.start(t);
o.stop(t + 0.85);
}
private drum(vol: number, intensity: number): void {
const ctx = this.ctx!;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.frequency.setValueAtTime(110 + intensity * 30, t);
o.frequency.exponentialRampToValueAtTime(38, t + 0.22);
const g = ctx.createGain();
this.env(g, t, vol, 0.004, 0.32);
o.connect(g).connect(this.sfx);
o.start(t);
o.stop(t + 0.45);
}
private snare(vol: number): void {
const ctx = this.ctx!;
const src = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 1800;
const g = ctx.createGain();
this.env(g, ctx.currentTime, vol, 0.002, 0.12);
src.connect(f).connect(g).connect(this.sfx);
src.start();
src.stop(ctx.currentTime + 0.2);
}
/** Flak fragments rattling through the skin: "like hail on a tin roof". */
shrapnel(amount: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const n = Math.round(6 + amount * 34);
const t0 = ctx.currentTime;
for (let i = 0; i < n; i++) {
const t = t0 + Math.random() * (0.25 + amount * 0.4);
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 2500 + Math.random() * 4500;
f.Q.value = 9;
const p = ctx.createStereoPanner();
p.pan.value = Math.random() * 2 - 1;
const g = ctx.createGain();
this.env(g, t, 0.25 + Math.random() * 0.35, 0.001, 0.025 + Math.random() * 0.03);
s.connect(f).connect(g).connect(p).connect(this.cockpit);
s.start(t);
s.stop(t + 0.08);
}
}
/** Metal struck: inharmonic partials ringing down, plus fragments. */
clang(vol: number): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = ctx.currentTime;
const base = 170 + Math.random() * 120;
for (const [r, a] of [[1, 1], [2.76, 0.6], [5.4, 0.35], [8.9, 0.2]] as [number, number][]) {
const o = ctx.createOscillator();
o.frequency.value = base * r;
const g = ctx.createGain();
this.env(g, t, vol * a, 0.002, 0.35 / Math.sqrt(r));
o.connect(g).connect(this.cockpit);
o.start(t);
o.stop(t + 0.6);
}
this.shrapnel(vol * 0.6);
}
/** Bomb shackle releasing: a mechanical thunk and a click. */
clunk(): void {
if (!this.ctx) return;
const t = this.ctx.currentTime;
this.thudAt(t, 85, 0.3, 0.05);
this.click(t + 0.01, 0.2);
}
/** Hydraulic actuators driving the bomb doors. */
doors(open: boolean): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(open ? 330 : 620, t);
o.frequency.linearRampToValueAtTime(open ? 640 : 340, t + 1.1);
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 900;
f.Q.value = 2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.06, t + 0.15);
g.gain.setValueAtTime(0.06, t + 1.0);
g.gain.linearRampToValueAtTime(0.0001, t + 1.25);
o.connect(f).connect(g).connect(this.cockpit);
o.start(t);
o.stop(t + 1.3);
this.thudAt(t, 60, 0.25);
this.thudAt(t + 1.15, 70, 0.3);
}
extinguisher(): void {
if (!this.ctx) return;
const ctx = this.ctx;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'highpass';
f.frequency.value = 1800;
const g = ctx.createGain();
this.env(g, ctx.currentTime, 0.25, 0.01, 1.6);
s.connect(f).connect(g).connect(this.cockpit);
s.start();
s.stop(ctx.currentTime + 1.8);
this.thud(70, 0.3, 0.05);
}
/** Engine flame-out: the spool winding down. */
spoolDown(): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t = ctx.currentTime;
const o = ctx.createOscillator();
o.type = 'triangle';
o.frequency.setValueAtTime(2200, t);
o.frequency.exponentialRampToValueAtTime(140, t + 4);
const g = ctx.createGain();
this.env(g, t, 0.08, 0.05, 4);
o.connect(g).connect(this.cockpit);
o.start(t);
o.stop(t + 4.2);
}
// ── One-shots: world ──
/** An explosion at a world point: crack, body, rolling tail and echo. */
boomAt(x: number, y: number, size: number): void {
const P = this.place(x, y, Math.min(1.2, 0.3 + size), 0.6);
if (!P) return;
const ctx = this.ctx!;
const t = P.t;
const crack = this.noiseSrc();
const cf = ctx.createBiquadFilter();
cf.type = 'highpass';
cf.frequency.value = 1500;
const cg = ctx.createGain();
this.env(cg, t, 0.5, 0.001, 0.05);
crack.connect(cf).connect(cg).connect(P.input);
crack.start(t);
crack.stop(t + 0.1);
const body = this.noiseSrc();
const bf = ctx.createBiquadFilter();
bf.type = 'lowpass';
bf.frequency.value = 260 + size * 500;
const bg = ctx.createGain();
this.env(bg, t, 1, 0.006, 0.4 + size * 1.4);
body.connect(bf).connect(bg).connect(P.input);
body.start(t);
body.stop(t + 2.5 + size);
const o = ctx.createOscillator();
o.frequency.setValueAtTime(75, t);
o.frequency.exponentialRampToValueAtTime(26, t + 0.9 + size * 0.6);
const og = ctx.createGain();
this.env(og, t, 0.9, 0.01, 0.9 + size * 0.6);
o.connect(og).connect(P.input);
o.start(t);
o.stop(t + 2 + size);
}
/** Explosion at a distance without a bearing (cockpit-relative). */
boom(distKm: number, size: number): void {
const L = this.listener;
this.boomAt(L.x + Math.cos(L.hdg) * distKm, L.y + Math.sin(L.hdg) * distKm, size);
}
/** Heavy flak: a sharp crack close by, a dull crump far away. */
flak(x: number, y: number, near: number, heavy: boolean): void {
const P = this.place(x, y, heavy ? 0.7 : 0.35, 0.5);
if (!P) return;
const ctx = this.ctx!;
const t = P.t;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = near < 1 ? 'bandpass' : 'lowpass';
f.frequency.value = near < 1 ? 900 : 300;
const g = ctx.createGain();
this.env(g, t, 1, 0.002, heavy ? 0.45 : 0.18);
s.connect(f).connect(g).connect(P.input);
s.start(t);
s.stop(t + 0.8);
if (near < 0.9) {
// The "baseball bat" slam on the airframe, then fragments.
this.thud(55, 0.5 * (1 - near), 0.08);
if (near < 0.55) this.shrapnel(1 - near);
}
}
/** Bofors: the double "pom-pom" at ~2 rounds a second. */
bofors(x: number, y: number): void {
for (let i = 0; i < 2; i++) {
const P = this.place(x, y, 0.35, 0.4);
if (!P) return;
const ctx = this.ctx!;
const t = P.t + i * 0.5;
const o = ctx.createOscillator();
o.frequency.setValueAtTime(140, t);
o.frequency.exponentialRampToValueAtTime(55, t + 0.12);
const g = ctx.createGain();
this.env(g, t, 0.9, 0.002, 0.2);
o.connect(g).connect(P.input);
o.start(t);
o.stop(t + 0.3);
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = 700;
const sg = ctx.createGain();
this.env(sg, t, 0.6, 0.001, 0.12);
s.connect(f).connect(sg).connect(P.input);
s.start(t);
s.stop(t + 0.2);
}
}
/** Vulcan: ~100 rounds a second blur into one tearing buzz. */
vulcan(x: number, y: number): void {
const P = this.place(x, y, 0.4, 0.3);
if (!P) return;
const ctx = this.ctx!;
const t = P.t;
const dur = 0.5 + Math.random() * 0.5;
const o = ctx.createOscillator();
o.type = 'sawtooth';
o.frequency.value = 100;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = 600;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.6, t + 0.03);
g.gain.setValueAtTime(0.6, t + dur);
g.gain.linearRampToValueAtTime(0.0001, t + dur + 0.08);
o.connect(f);
s.connect(f);
f.connect(g).connect(P.input);
o.start(t);
s.start(t);
o.stop(t + dur + 0.1);
s.stop(t + dur + 0.1);
}
/** Solid-fuel booster: a crackling roar, then (Nike) the booster separation pop. */
samLaunch(x: number, y: number, nike: boolean): void {
const P = this.place(x, y, 0.9, 0.7);
if (!P) return;
const ctx = this.ctx!;
const t = P.t;
const burn = nike ? 3.4 : 2.6;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(180, t);
f.frequency.linearRampToValueAtTime(1100, t + 0.6);
f.frequency.linearRampToValueAtTime(500, t + burn);
const crackle = ctx.createGain();
const am = this.noiseSrc();
const amf = ctx.createBiquadFilter();
amf.type = 'lowpass';
amf.frequency.value = 40;
const amg = ctx.createGain();
amg.gain.value = 2.5;
am.connect(amf).connect(amg).connect(crackle.gain);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(1, t + 0.15);
g.gain.setValueAtTime(0.9, t + burn - 0.3);
g.gain.exponentialRampToValueAtTime(0.0001, t + burn + 0.5);
s.connect(f).connect(crackle).connect(g).connect(P.input);
s.start(t);
am.start(t);
s.stop(t + burn + 0.6);
am.stop(t + burn + 0.6);
if (nike) setTimeout(() => this.boomAt(x, y, 0.05), burn * 1000);
}
/** Something fast going past: falling pitch and a pan sweep (Doppler). */
flyby(x: number, y: number, vol: number, missile: boolean): void {
if (!this.ctx) return;
const ctx = this.ctx;
const L = this.listener;
const side = Math.max(-1, Math.min(1, Math.sin(Math.atan2(y - L.y, x - L.x) - L.hdg)));
const t = ctx.currentTime;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.Q.value = missile ? 4 : 1.5;
f.frequency.setValueAtTime(missile ? 3200 : 1400, t);
f.frequency.exponentialRampToValueAtTime(missile ? 600 : 300, t + 1.1);
const p = ctx.createStereoPanner();
p.pan.setValueAtTime(side, t);
p.pan.linearRampToValueAtTime(-side, t + 1.1);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, vol), t + 0.35);
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.3);
s.connect(f).connect(g).connect(p).connect(this.sfx);
s.start(t);
s.stop(t + 1.4);
}
/** Aircraft guns: NR-30 thumps (fighters) or the AM-23 tail turret's stutter. */
gun(vol: number, tail = false): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t0 = ctx.currentTime;
const n = tail ? 4 : 1;
for (let i = 0; i < n; i++) {
const t = t0 + i * 0.045;
const s = this.noiseSrc();
const f = ctx.createBiquadFilter();
f.type = 'bandpass';
f.frequency.value = tail ? 1100 : 700;
const g = ctx.createGain();
this.env(g, t, vol, 0.002, 0.05);
s.connect(f).connect(g).connect(this.cockpit);
s.start(t);
s.stop(t + 0.08);
this.thudAt(t, tail ? 90 : 70, vol * 0.8, 0.04);
}
}
whoosh(vol: number): void {
const L = this.listener;
this.flyby(L.x + 1, L.y, vol, true);
}
/** A voice line through the radio: squelch, garbled formant buzz, squelch. */
radio(text: string, urgent: boolean): void {
if (!this.ctx) return;
const ctx = this.ctx;
const t0 = ctx.currentTime;
const dur = Math.min(3.2, 0.5 + text.length * 0.035);
this.click(t0, 0.35, this.radioIn);
const hiss = this.noiseSrc();
const hg = ctx.createGain();
hg.gain.setValueAtTime(0.06, t0);
hg.gain.setValueAtTime(0.0001, t0 + dur + 0.1);
hiss.connect(hg).connect(this.radioIn);
hiss.start(t0);
hiss.stop(t0 + dur + 0.15);
const o = ctx.createOscillator();
o.type = 'sawtooth';
const base = urgent ? 150 : 115;
o.frequency.setValueAtTime(base, t0);
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.Q.value = 5;
const f2 = ctx.createBiquadFilter();
f2.type = 'bandpass';
f2.Q.value = 6;
const vg = ctx.createGain();
vg.gain.setValueAtTime(0.0001, t0);
let t = t0 + 0.08;
const syll = Math.max(2, Math.round(dur / 0.12));
for (let i = 0; i < syll; i++) {
const on = Math.random() > 0.18;
vg.gain.setTargetAtTime(on ? 0.5 : 0.02, t, 0.015);
f1.frequency.setTargetAtTime(300 + Math.random() * 600, t, 0.03);
f2.frequency.setTargetAtTime(1000 + Math.random() * 1400, t, 0.03);
o.frequency.setTargetAtTime(base * (0.9 + Math.random() * (urgent ? 0.5 : 0.25)), t, 0.04);
t += 0.07 + Math.random() * 0.09;
}
vg.gain.setTargetAtTime(0.0001, t0 + dur, 0.02);
o.connect(f1);
o.connect(f2);
f1.connect(vg);
f2.connect(vg);
vg.connect(this.radioIn);
o.start(t0);
o.stop(t0 + dur + 0.1);
this.click(t0 + dur + 0.05, 0.35, this.radioIn);
}
uiClick(): void {
this.beep(880, 0.03, 0.12, 'triangle');
}
teletype(): void {
if (!this.ctx) return;
for (let i = 0; i < 5; i++) setTimeout(() => this.beep(300 + Math.random() * 120, 0.012, 0.08, 'square'), i * 45);
}
}
export const audio = new Audio();
