Files
splatter-tennis/audio.js
T
wuwiandClaude Opus 5 4cf54603f3 Fly the modals in with a swoosh instead of cutting to them
m.in (0..1 over TUNE.modalIn, 0.34 s) is the entrance clock, ticked inside
update()'s modal early-return — the one thing that animates while the world
is frozen. Icon and title fly in from the right trailing two ghost copies as
a speed streak, the body lines cascade in from the left one after another,
everything on easeOutBack so it overshoots and settles rather than
decelerating politely into place. The dim washes in over the first third. A
descending noise sweep on arrival (swish is parameterised now — same six
lines of Web Audio, opposite reading) and a low pock as it lands.

Every x is rounded: a fractional x on a nearest-neighbour scaled image
re-snaps its pixel columns, which is the boiling that once read as HUD
flicker.

Dismissal is gated on the entrance: while m.in < 1 dismissModal swallows the
gesture but doesn't clear the modal, so a tap already in the air when a popup
appears can't skip it — and swallowing rather than returning false is what
keeps that tap from falling through into a serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:23:30 +02:00

228 lines
11 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;
}
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;
}
// 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) {}
}