diff --git a/HANDOFF.md b/HANDOFF.md index 8081bd7..d38c125 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -454,9 +454,21 @@ death summary is the end menu, and the zombie bed ducks out as it comes in. Two bed's gain at 0. Without it, a multi-megabyte file ducked mid-fetch comes up at full level several seconds later, over the match. -`background-music.wav` is 8.3 MB and now downloads on every page load. Converting it to m4a (as the -umpire lines already are) would cut that ~10x; `loopBed` hardcodes `.wav`, so that is a two-line -change if it ever matters. +**The theme is AAC** (`background-music.m4a`, 768 KB — the WAV was 8.3 MB, and this one downloads on +every page load before the player has decided to play). `loopBed` takes the FULL filename now, +because the beds are a mix of `.wav` and `.m4a`; `fadeBed`'s key must match it exactly, extension +included, or a duck silently no-ops. + +**A compressed loop needs an explicit `loopEnd`.** AAC pads the final packet, so Chrome decodes this +48.000 s master to a 48.0174 s buffer — 17 ms of silence spliced in at every seam, once a loop, on +the title screen. `menuMusic` passes `48`. Re-encode the file and re-measure: +`fetch(f).then(r=>r.arrayBuffer()).then(a=>new AudioContext().decodeAudioData(a)).then(b=>b.duration)`. +`zombie-mode.wav` (5.9 MB) is the other candidate for this treatment, and it loads mid-match rather +than up front, so it matters less. + +Measuring which audio files actually loaded: `performance.getEntriesByType('resource')` is USELESS +here — the ~200 sprite PNGs blow past the 250-entry resource-timing buffer and every later fetch is +silently dropped from it. Read the network log instead. That cost a wrong "the bed never loaded". ## Session log: the serve is the gesture (2026-08-01) diff --git a/assets/audio/background-music.m4a b/assets/audio/background-music.m4a new file mode 100644 index 0000000..ff647fe Binary files /dev/null and b/assets/audio/background-music.m4a differ diff --git a/assets/audio/background-music.wav b/assets/audio/background-music.wav deleted file mode 100644 index bb283a3..0000000 Binary files a/assets/audio/background-music.wav and /dev/null differ diff --git a/audio.js b/audio.js index a625a38..b74e1d4 100644 --- a/audio.js +++ b/audio.js @@ -77,7 +77,7 @@ 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) { +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 @@ -86,11 +86,16 @@ function loopBed(file, lowcut, vol) { const ac = audio(); if (beds.has(file)) return; beds.set(file, null); - fetch('assets/audio/' + file + '.wav') + 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 @@ -101,13 +106,18 @@ function loopBed(file, lowcut, 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 +export const ambient = () => loopBed('tennis-ambient-loop.wav', 500, 0.035); // whole-scene atmosphere, from first tap +export const zombieMusic = () => loopBed('zombie-mode.wav', 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.5); +// 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.5, 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) { @@ -119,10 +129,10 @@ function fadeBed(file, out, secs = 0.7) { 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); +export const zombieDuck = out => fadeBed('zombie-mode.wav', 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', out); +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 diff --git a/index.html b/index.html index 9e3b0ab..32bf864 100644 --- a/index.html +++ b/index.html @@ -117,6 +117,6 @@