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>
254 lines
13 KiB
JavaScript
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) {}
|
|
}
|