Files
splatter-tennis/audio.js
T
wuwiandClaude Opus 5 60a74f6a99 Give the menus a theme
background-music.wav loops under the title screen and the court picker, ducks
out when a court is picked, and fades back in at the death summary — the end
screen is a menu too, so the zombie bed ducks out as this one comes in.

It starts at BOOT, not on the first tap, so the title screen already has music.
That needed loopBed to call audio() before its already-started early return: the
theme plays into a context the autoplay policy holds suspended, and the call
that wakes it is a second menuMusic() from the first tap, which used to return
at the beds.has guard without reaching AC.resume().

fadeBed also now records a duck for a bed that is still loading, and loopBed
starts that bed silent. An 8 MB file ducked mid-fetch would otherwise come up at
full level, seconds later, over the match. Same latent bug applied to zombieDuck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:42:10 +02:00

254 lines
13 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) {
// 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 + '.wav')
.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;
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', 500, 0.035); // whole-scene atmosphere, from first tap
export const zombieMusic = () => loopBed('zombie-mode', 800, 0.2); // background music once splatter kicks in
// Menu theme: the splash, the court picker, the death summary. Barely highpassed — this one is
// MUSIC, not an atmosphere bed, so the low end is the point. Started at boot so it is already
// running by the time the title is on screen; on a phone the autoplay policy holds the context
// suspended until the first tap, and it becomes audible then (see loopBed).
export const menuMusic = () => loopBed('background-music', 30, 0.3);
// 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', out);
// 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', 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) {}
}
function noiseBuf(ac, len) {
const b = ac.createBuffer(1, ac.sampleRate * len, ac.sampleRate), d = b.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
return b;
}
// 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, dur);
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();
} 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, 0.06);
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();
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) {}
}
export function splat(vol = 0.7) { // wet burst — a zombie meets a first serve
try {
const ac = audio(), t = ac.currentTime;
const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.22);
const lp = ac.createBiquadFilter(); lp.type = 'lowpass';
lp.frequency.setValueAtTime(1400, t); lp.frequency.exponentialRampToValueAtTime(180, t + 0.2);
const g = ac.createGain();
g.gain.setValueAtTime(vol, t); g.gain.exponentialRampToValueAtTime(0.001, t + 0.22);
src.connect(lp).connect(g).connect(ac.destination); src.start();
const o = ac.createOscillator(), og = ac.createGain(); // body thud
o.frequency.setValueAtTime(120, t); o.frequency.exponentialRampToValueAtTime(45, t + 0.15);
og.gain.setValueAtTime(vol * 0.7, t); og.gain.exponentialRampToValueAtTime(0.001, t + 0.16);
o.connect(og).connect(ac.destination); o.start(t); o.stop(t + 0.18);
} 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, 0.4); // 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);
} 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, 0.025);
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);
}
} catch (e) {}
}