Files
splatter-tennis/audio.js
T
wuwiandClaude Fable 5 e07196f247 Paint the stands once
- The opaque menu no longer has the whole court rendered behind it at full
  resolution — minutes of battery burn nobody could see.
- Stands, ads, rot tint, court, lines and the run-off are one cached blit,
  rebuilt only when the palette actually moves; the clap ripple stays live.
- RES derives from devicePixelRatio (capped at 4), so a 2x phone stops
  painting double the pixels it can show.
- One shared noise buffer for every synthesized sound — applause alone was
  allocating ~200 fresh buffers per cheer.
- The touch-steer path stops re-measuring a fixed canvas rect per move event.

Skipped as not-quick: the z-sort closure churn and swap-remove splices —
real but small, and each is a behavior-preserving refactor for another day.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:36:17 +02:00

368 lines
21 KiB
JavaScript

// All sound: WebAudio context, synthesized SFX, recorded umpire lines, speech fallback.
let AC = null;
export const audio = () => {
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
if (AC.state === 'suspended') AC.resume();
return AC;
};
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
const VOICE = {};
let reverbNode = null;
function reverb(ac) { // stadium PA tail: exponentially decaying noise as impulse response
if (reverbNode) return reverbNode;
const len = ac.sampleRate * 1.3, ir = ac.createBuffer(2, len, ac.sampleRate);
for (let c = 0; c < 2; c++) {
const d = ir.getChannelData(c);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.8);
}
reverbNode = ac.createConvolver(); reverbNode.buffer = ir;
const wet = ac.createGain(); wet.gain.value = 0.35;
reverbNode.connect(wet).connect(ac.destination);
return reverbNode;
}
// The umpire is one man with one microphone. Several triggers landing together used to play two or
// three lines on top of each other; a line that would start while he is still talking is DROPPED.
// The check happens at play time (not call time) so a first-play fetch/decode is judged fairly.
let voiceUntil = 0;
export function voice(name, delay = 0, rate = 1) {
const ac = audio();
const play = buf => {
const at = ac.currentTime + delay;
if (at < voiceUntil) return;
voiceUntil = at + buf.duration / rate + 0.2; // + a breath, so lines never butt against each other
const src = ac.createBufferSource(); src.buffer = buf;
src.playbackRate.value = rate; // < 1 = slower and lower — the umpire's dark register
src.connect(ac.destination); // dry
const send = ac.createGain(); send.gain.value = 1.8; // umpire rides a wetter PA tail than the crowd sfx
src.connect(send).connect(reverb(ac)); // extra reverb send — the big-stadium announcer echo
src.start(ac.currentTime + delay);
};
if (VOICE[name]) return play(VOICE[name]);
fetch('assets/audio/' + name + '.m4a')
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => { VOICE[name] = buf; play(buf); })
.catch(() => say(name.replace(/_/g, ' '), delay));
}
// Recorded one-shot SFX (assets/audio/tennis-*.wav): ball hits, applause, the crowd's aww.
// Same load-once-then-cache pattern as voice(), but dry (no PA reverb) and with a volume knob.
const SFX = {};
export function sfx(name, vol = 1, opts = {}) {
const ac = audio();
const play = buf => {
const src = ac.createBufferSource(); src.buffer = buf;
if (opts.rate) src.playbackRate.value = opts.rate; // < 1 stretches it a touch longer (and lower)
const g = ac.createGain(); g.gain.value = vol;
src.connect(g).connect(ac.destination);
if (opts.reverb) g.connect(reverb(ac)); // wet stadium tail on top of the dry sample
src.start();
};
if (SFX[name]) return play(SFX[name]);
fetch('assets/audio/' + name + '.wav')
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => { SFX[name] = buf; play(buf); })
.catch(() => {}); // missing sample = silence, never a thrown error mid-rally
}
// the crowd's groan — stretched a touch and pushed through the PA reverb for a longer, wetter sigh
export function aww(vol = 1) { sfx('tennis-aww', vol * 0.5, { reverb: true }); }
// Looping background beds (scene atmosphere, mode music): each starts once, highpass 'lowcut' + quiet gain.
const beds = new Map(); // file -> {ac, g, vol} once playing (null while loading); see fadeBed
const ducked = new Set(); // beds asked to duck while they were STILL LOADING — see fadeBed
function loopBed(file, lowcut, vol, loopEnd = 0) {
// audio() FIRST, before the already-started early return: it is also what RESUMES a context the
// browser suspended under its autoplay policy. The menu theme is started at boot, before any
// gesture exists, so it plays into a suspended context and stays silent — and the call that has
// to wake it up is a second menuMusic() from the first tap, which would otherwise return here
// without ever reaching audio().
const ac = audio();
if (beds.has(file)) return;
beds.set(file, null);
fetch('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
.then(ab => ac.decodeAudioData(ab))
.then(buf => {
const src = ac.createBufferSource(); src.buffer = buf; src.loop = true;
// loopEnd = the source's TRUE length, for a bed that is compressed. AAC pads the last packet,
// and Chrome hands back a buffer ~17 ms longer than the master — silence spliced in at every
// seam, once a loop, on the title screen. Trimming the seam is what keeps the loop seamless.
// Zero (the default, and every uncompressed bed) leaves the whole buffer in play.
if (loopEnd) src.loopEnd = loopEnd;
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = lowcut;
// start SILENT if it was ducked while loading: a multi-megabyte file takes seconds to fetch
// and decode, and a player who taps straight through the picker would otherwise have the
// menu theme come up over the match, several seconds late, at full level.
const g = ac.createGain(); g.gain.value = ducked.has(file) ? 0 : vol;
src.connect(hp).connect(g).connect(ac.destination); src.start();
beds.set(file, { ac, g, vol });
})
.catch(() => beds.delete(file)); // failed start (decode/network) can retry next call
}
export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap
// ...and the splatter bed, same treatment: 5.9 MB of 24-bit WAV down to 287 KB. Its master is
// 982597 frames at 48 kHz, which is the un-round 20.47077 s below — an AAC loop needs the SOURCE
// length as loopEnd, not the decoder's padded one. See the note on menuMusic.
export const zombieMusic = () => loopBed('zombie-mode.m4a', 800, 0.2, 20.47077);
// Menu theme: every menu screen and the death summary. Barely highpassed — this one is MUSIC, not
// an atmosphere bed, so the low end is the point. Started by the FIRST TAP (main.js `go`), never at
// boot: a title screen that starts playing at you before you have touched it is rude, and on a phone
// the autoplay policy would have held it silent until that tap anyway.
// AAC, not WAV: 48 s of stereo music is 8.3 MB as a WAV and this one downloads on every page load,
// before the player has decided to play anything. 768 KB encoded, and the master is exactly 48.000 s
// — which is the loopEnd, because the decoded buffer is NOT (measured 48.0174 s in Chrome; see the
// padding note in loopBed). Re-encode the file and you must re-measure that number:
// fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)
export const menuMusic = () => loopBed('background-music.m4a', 30, 0.65, 48);
// duck a bed to silence (out=true) or back to its base level over `secs` — the sudden-death duel mutes the music
function fadeBed(file, out, secs = 0.7) {
if (out) ducked.add(file); else ducked.delete(file);
const b = beds.get(file);
if (!b) return; // still loading — the flag above is the level it will start at
const t = b.ac.currentTime;
b.g.gain.cancelScheduledValues(t);
b.g.gain.setValueAtTime(b.g.gain.value, t); // anchor at current level so a mid-fade toggle stays smooth
b.g.gain.linearRampToValueAtTime(out ? 0 : b.vol, t + secs);
}
export const zombieDuck = out => fadeBed('zombie-mode.m4a', out); // key must match loopBed's, filename and all
// The menu theme is never stopped, only faded — the buffer is already decoded, so fading it back up
// on the death screen costs nothing and can't miss its cue waiting on a re-fetch.
export const menuDuck = out => fadeBed('background-music.m4a', out);
let sysVoice = null;
export function say(text, delay = 0) { // fallback when a recorded umpire line is missing
try {
if (!sysVoice) sysVoice = speechSynthesis.getVoices().find(v => v.lang.startsWith('en-GB')) || null;
const u = new SpeechSynthesisUtterance(text);
if (sysVoice) u.voice = sysVoice; u.rate = 0.88; u.pitch = 0.75; u.volume = 0.9;
setTimeout(() => speechSynthesis.speak(u), delay * 1000);
} catch (e) {}
}
// ONE noise buffer, shared. Every swish/pock/thud/splat/clap used to fill a fresh Float32 buffer
// (thousands of Math.random() calls, ~30 KB) per sound — during a horde the gun did that a dozen
// times a second, and applause() did it ~200 times per cheer. Callers play a random slice instead
// (src.start's offset + duration), so repeats never phase-align and the node still stops on time.
const NOISE_LEN = 1.5;
let noise = null, noiseAc = null;
function noiseBuf(ac) {
if (noiseAc !== ac) { // a fresh context invalidates the old buffer
noise = ac.createBuffer(1, ac.sampleRate * NOISE_LEN, ac.sampleRate);
const d = noise.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
noiseAc = ac;
}
return noise;
}
const nOff = len => Math.random() * (NOISE_LEN - len); // a random slice into the shared buffer
// Filtered noise sweep. Default = the racket cutting air (rising, short) — the sound of a whiff.
// The sweep is parameterised because a DESCENDING, longer one is a panel swooshing in and settling:
// same six lines of Web Audio, opposite reading. See whoosh() below.
export function swish(vol = 0.3, f0 = 1600, f1 = 3200, dur = 0.16) {
try {
const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(f0, t);
bp.frequency.exponentialRampToValueAtTime(f1, t + dur * 0.75); bp.Q.value = 1.5;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
} catch (e) {}
}
export const whoosh = (vol = 0.34) => swish(vol, 4200, 700, 0.3); // a panel flying in and settling
export function pock(vol = 0.8, freq = 900) { // racket contact / bounce thock
try {
const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = freq; bp.Q.value = 1.2;
const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.06), 0.06);
const o = ac.createOscillator(), og = ac.createGain(); // low thump body
o.frequency.setValueAtTime(180, t); o.frequency.exponentialRampToValueAtTime(70, t + 0.08);
og.gain.setValueAtTime(vol * 0.5, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.1);
} catch (e) {}
}
// Ball meets BODY, without killing it. This was borrowing pock() — the racket thock — which is a
// bright, dry, stationary-band click: exactly right for gut on felt and exactly wrong for felt on a
// torso, so every non-fatal hit sounded like the ball had struck a wall. `size` is zombieH / 1.8, so
// a shambler is 1, a brute ~1.5 and a late chapter boss ~2.4: the thump under it drops from 150 Hz
// to around 60, and the whole hit slows down with it.
export function thud(vol = 0.6, size = 1) {
try {
const ac = audio(), t = ac.currentTime;
const k = Math.max(0.7, Math.min(2.4, size));
const dur = 0.15 * k;
// the slap. The band FALLS, where pock's sits still — a collapsing resonance is the wet part,
// and it is the same trick splat() uses, just shorter and much less of it.
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.4;
bp.frequency.setValueAtTime(780 / k, t);
bp.frequency.exponentialRampToValueAtTime(190 / k, t + dur);
const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
// the weight behind it — THIS is what actually carries the size difference
const o = ac.createOscillator(), og = ac.createGain();
o.frequency.setValueAtTime(150 / k, t); o.frequency.exponentialRampToValueAtTime(52 / k, t + 0.11 * k);
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.13 * k);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.15 * k);
// one short bright tick just behind the slap: something gave. Quiet on purpose — this is a hit,
// not a burst, and at splat()'s spatter level every rally would sound like a kill.
const d = 0.03 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac);
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 1100;
const spg = ac.createGain();
spg.gain.setValueAtTime(vol * 0.18, t + d);
spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.07 * k);
sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d, nOff(0.07 * k), 0.07 * k);
} catch (e) {}
}
// Wet burst — a body meeting a first serve. `size` is roughly zombieH / 1.8, so 1 is a shambler,
// ~1.5 a brute and ~2.4 a late chapter boss: everything below pitches DOWN and lasts LONGER with it.
// A runner pops; a boss comes apart. Same sound, and that is the point — it should read as the same
// event happening to a bigger body, not as a different event.
export function splat(vol = 0.7, size = 1) {
try {
const ac = audio(), t = ac.currentTime;
const k = Math.max(0.7, Math.min(2.4, size)); // clamp: nothing gets so big it stops being a splat
const dur = 0.22 * k;
// The wetness lives HERE. The old filter was a plain lowpass with no resonance, which is a dry
// hiss with the top taken off; a RESONANT band collapsing down the spectrum is a squelch.
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.Q.value = 3.2;
bp.frequency.setValueAtTime(1100 / k, t);
bp.frequency.exponentialRampToValueAtTime(130 / k, t + dur * 0.85);
const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + dur);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(dur), dur);
const o = ac.createOscillator(), og = ac.createGain(); // body thud, heavier on a heavier body
o.frequency.setValueAtTime(120 / k, t); o.frequency.exponentialRampToValueAtTime(45 / k, t + 0.15 * k);
og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16 * k);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18 * k);
// ...and what came out of it lands a moment later. This second, brighter burst is most of what
// reads as "wet" — one clean hit is a thud, a thud followed by spatter is a body.
const d = 0.06 * k, sp = ac.createBufferSource(); sp.buffer = noiseBuf(ac);
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 900;
const spg = ac.createGain();
spg.gain.setValueAtTime(0.001, t + d);
spg.gain.exponentialRampToValueAtTime(vol * 0.3, t + d + 0.02);
spg.gain.exponentialRampToValueAtTime(0.001, t + d + 0.18 * k);
sp.connect(hp).connect(spg).connect(ac.destination); sp.start(t + d, nOff(0.18 * k), 0.18 * k);
} catch (e) {}
}
export function boom(vol = 0.9) { // bomb ball — sub-bass thump under a filtered noise blast
try {
const ac = audio(), t = ac.currentTime;
const o = ac.createOscillator(), og = ac.createGain(); // 80Hz sine diving to 30Hz
o.frequency.setValueAtTime(80, t); o.frequency.exponentialRampToValueAtTime(30, t + 0.35);
og.gain.setValueAtTime(vol, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.42);
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac); // the blast
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
lp.frequency.setValueAtTime(2200, t); lp.frequency.exponentialRampToValueAtTime(200, t + 0.35);
const g = ac.createGain();
g.gain.setValueAtTime(vol * 0.8, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
src.connect(lp).connect(g).connect(ac.destination); src.start(t, nOff(0.4), 0.4);
} catch (e) {}
}
// The laser on release. It was borrowing boom() — the BOMB — which is why the showiest weapon in
// the game sounded like the least showy one. This is a plane of light cutting a crowd in half, so:
// a crack, a dive, weight underneath it, and a ring after it has gone.
export function zap(vol = 0.5) {
try {
const ac = audio(), t = ac.currentTime, dur = 0.42;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac); // the crack: air leaving
const hp = ac.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 2200;
const ng = ac.createGain();
ng.gain.setValueAtTime(vol * 0.85, t); ng.gain.exponentialRampToValueAtTime(0.001, t + 0.09);
src.connect(hp).connect(ng).connect(ac.destination); src.start(t, nOff(0.09), 0.09);
// the body: two detuned saws DIVING through a resonant lowpass. The direction is the whole
// reading — a rising sweep is a charge-up, and this thing never charges, it discharges.
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.Q.value = 12;
lp.frequency.setValueAtTime(5200, t); lp.frequency.exponentialRampToValueAtTime(220, t + dur * 0.8);
const bg = ac.createGain();
bg.gain.setValueAtTime(vol, t); bg.gain.exponentialRampToValueAtTime(0.001, t + dur);
lp.connect(bg).connect(ac.destination);
for (const det of [1, 1.008]) { // the detune IS the width: one saw alone is a game-show buzzer
const o = ac.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(1300 * det, t);
o.frequency.exponentialRampToValueAtTime(120 * det, t + dur * 0.7);
o.connect(lp); o.start(t); o.stop(t + dur);
}
const s = ac.createOscillator(), sg = ac.createGain(); // weight, or it is only hiss on a phone
s.frequency.setValueAtTime(150, t); s.frequency.exponentialRampToValueAtTime(48, t + 0.3);
sg.gain.setValueAtTime(vol * 0.7, t); sg.gain.exponentialRampToValueAtTime(0.001, t + 0.34);
s.connect(sg).connect(ac.destination); s.start(t); s.stop(t + 0.36);
const r = ac.createOscillator(), rg = ac.createGain(); // ...and it rings after it has gone
r.type = 'triangle'; r.frequency.setValueAtTime(3100, t);
r.frequency.exponentialRampToValueAtTime(1900, t + dur);
rg.gain.setValueAtTime(0.001, t); rg.gain.exponentialRampToValueAtTime(vol * 0.2, t + 0.05);
rg.gain.exponentialRampToValueAtTime(0.001, t + dur + 0.12);
r.connect(rg).connect(ac.destination); r.start(t); r.stop(t + dur + 0.14);
} catch (e) {}
}
export function shriek(vol = 0.35) { // a runner announces itself — harsh rising cry, the fairness contract
try {
const ac = audio(), t = ac.currentTime, dur = 0.7;
const o = ac.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(300, t);
o.frequency.exponentialRampToValueAtTime(950, t + 0.25);
o.frequency.exponentialRampToValueAtTime(500, t + dur);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1400; bp.Q.value = 0.8;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.06);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(bp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
} catch (e) {}
}
export function moan(vol = 0.1) { // distant undead ambience
try {
const ac = audio(), t = ac.currentTime, dur = 1.2 + Math.random() * 0.8;
const o = ac.createOscillator(); o.type = 'sawtooth';
const f0 = 65 + Math.random() * 45;
o.frequency.setValueAtTime(f0, t);
o.frequency.linearRampToValueAtTime(f0 * 1.25, t + dur * 0.4);
o.frequency.linearRampToValueAtTime(f0 * 0.9, t + dur);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 320;
const g = ac.createGain();
g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + dur * 0.3);
g.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(lp).connect(g).connect(ac.destination); o.start(t); o.stop(t + dur + 0.05);
} catch (e) {}
}
export function applause(seconds = 2.4, vol = 0.5) { // ponytail: synthesized claps; swap for a sample if it reads flat
try {
const ac = audio(), t0 = ac.currentTime, n = 90 * seconds;
for (let i = 0; i < n; i++) {
const t = t0 + Math.random() * seconds;
const env = Math.min(1, 4 * (1 - Math.abs(2 * (t - t0) / seconds - 1))); // fade in/out
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac);
const bp = ac.createBiquadFilter(); bp.type = 'bandpass';
bp.frequency.value = 1000 + Math.random() * 1600; bp.Q.value = 2.5;
const g = ac.createGain();
g.gain.setValueAtTime(vol * env * (0.3 + Math.random() * 0.7), t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.03);
src.connect(bp).connect(g).connect(ac.destination); src.start(t, nOff(0.025), 0.025);
}
} catch (e) {}
}