// 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; } export function voice(name, delay = 0, rate = 1) { const ac = audio(); const play = buf => { 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 function loopBed(file, lowcut, vol) { if (beds.has(file)) return; beds.set(file, null); const ac = audio(); 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; const g = ac.createGain(); g.gain.value = 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 // 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) { const b = beds.get(file); if (!b) return; 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); 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; } export function swish(vol = 0.3) { // racket cutting air — the sound of a whiff try { const ac = audio(), t = ac.currentTime; const src = ac.createBufferSource(); src.buffer = noiseBuf(ac, 0.16); const bp = ac.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.setValueAtTime(1600, t); bp.frequency.exponentialRampToValueAtTime(3200, t + 0.12); bp.Q.value = 1.5; const g = ac.createGain(); g.gain.setValueAtTime(0.001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.05); g.gain.exponentialRampToValueAtTime(0.001, t + 0.16); src.connect(bp).connect(g).connect(ac.destination); src.start(); } catch (e) {} } 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) {} }