Player: lower ceilings (limiter -10 dBFS, clip -6 dBFS), confirm before the first playback, cache-busted script tag
3 changed files+25−10
README.mdmodified+1−1View file
@@ -41,7 +41,7 @@ The error measures live in `tools/challenge_tools/metrics.py` and the reference
4141
4242 ## Playback
4343
44-The wav files are exact signals with a loud strike transient and a much quieter ring, and they are at physical scale, so playing them raw is either faint or, with a gain, a hearing hazard on headphones. The sites use `tools/challenge_tools/player.js`, a Web Audio player built for safety first: each file is loudness-normalized at load time so that its loudest 400 ms window sits at -16 dBFS rms (about the loudness of a streamed music track), a fast limiter tames the transient, and a hard clipper at -1 dBFS is the last stage of the graph and cannot be bypassed, so nothing louder than a normal music peak reaches the output whatever the settings. The listener adjusts a bounded level offset (-20 to +6 dB, starting at -12 dB in a new browser and remembered there); raising it above the normalized level, or switching the limiter off, asks for confirmation. The player also draws a spectrogram of each file on request (Hann 2048, hop 512, the analysis of the spectrogram error measure) with a detailed view whose window, hop, frequency range, brightness, dynamic range, gamma, colormap and time zoom can be changed. All of this affects only playback and display; the files are not touched. A solution repository's site builder can copy the same script from the installed package (`challenge_tools.build.player_js()`). The `playback.gain_db` field of challenge.json is no longer used by the player and may be omitted.
44+The wav files are exact signals with a loud strike transient and a much quieter ring, and they are at physical scale, so playing them raw is either faint or, with a gain, a hearing hazard on headphones. The sites use `tools/challenge_tools/player.js`, a Web Audio player built for safety first: each file is loudness-normalized at load time so that its loudest 400 ms window sits at -16 dBFS rms (about the loudness of a streamed music track), a fast limiter holds the strike transient at -10 dBFS, and a hard clipper at -6 dBFS is the last stage of the graph and cannot be bypassed, so nothing louder than a quiet music peak reaches the output whatever the settings. The first playback on a page asks for confirmation. The listener adjusts a bounded level offset (-20 to +6 dB, starting at -12 dB in a new browser and remembered there); raising it above the normalized level, or switching the limiter off, asks for confirmation. The player also draws a spectrogram of each file on request (Hann 2048, hop 512, the analysis of the spectrogram error measure) with a detailed view whose window, hop, frequency range, brightness, dynamic range, gamma, colormap and time zoom can be changed. All of this affects only playback and display; the files are not touched. A solution repository's site builder can copy the same script from the installed package (`challenge_tools.build.player_js()`) and should reference it with the cache-busting query `player.js?v=` followed by `challenge_tools.build.player_version()`, as the comparison pages do, so that a rebuilt site never serves a stale player from the browser cache. The `playback.gain_db` field of challenge.json is no longer used by the player and may be omitted.
4545
4646 ## Development
4747
tools/challenge_tools/build.pymodified+8−1View file
@@ -215,7 +215,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
215215 </details>
216216 <details><summary>Pairwise errors (row vs column as reference)</summary>{"".join(pair_tables)}</details>
217217 </main>
218-<script src="../player.js"></script>
218+<script src="../player.js?v={player_version()}"></script>
219219 <script>ChallengePlayer.controls(document.getElementById("playback"), {{gainDb: {playback_gain}, limiter: true}}); ChallengePlayer.mount();</script>
220220 </body></html>
221221 """
@@ -270,6 +270,13 @@ def build(challenges_dir: Path, sources: list[Source], out: Path, challenge_repo
270270 return results
271271
272272
273+def player_version() -> str:
274+ """Short hash of the player script, used as a cache-busting query on the script tag."""
275+ import hashlib
276+
277+ return hashlib.sha1(player_js().encode()).hexdigest()[:10]
278+
279+
273280 def player_js() -> str:
274281 """The Web Audio player shipped with every site (also copied by solution repositories' site builders)."""
275282 from importlib import resources
tools/challenge_tools/player.jsmodified+16−8View file
@@ -2,10 +2,11 @@
22 // large crest factor (a strike transient followed by a much quieter ring). Playback is made safe for
33 // headphones in three stages, none of which touches the files: each file is loudness-normalized so
44 // that its loudest 400 ms window sits at a fixed target (-16 dBFS rms, about the loudness of a
5-// streamed music track), a fast limiter tames the transient, and a hard clipper at -1 dBFS is the
6-// last stage of the graph, so nothing above a normal music peak can reach the output whatever the
7-// settings. The listener adjusts a bounded level offset (-20 to +6 dB) that starts at -12 dB in a new
8-// browser; raising it above the normalized target, or switching the limiter off, asks for confirmation.
5+// streamed music track), a fast limiter holds the strike transient at -10 dBFS, and a hard clipper at
6+// -6 dBFS is the last stage of the graph, so nothing louder than a quiet music peak can reach the output
7+// whatever the settings. The listener adjusts a bounded level offset (-20 to +6 dB) that starts at -12 dB
8+// in a new browser; the first playback on a page asks for confirmation, and so does raising the level
9+// above the normalized target or switching the limiter off.
910 //
1011 // Usage: <div class="cplayer" data-src="audio/x.wav"></div> elements, then
1112 // ChallengePlayer.mount(); upgrade every .cplayer on the page
@@ -25,7 +26,9 @@
2526 let limiterOn = true;
2627 const TARGET_DBFS = -16; // loudest 400 ms rms window of every file is placed here
2728 const LEVEL_MIN = -20, LEVEL_MAX = 6, LEVEL_FIRST = -12;
28- const CLIP = Math.pow(10, -1 / 20); // hard ceiling, -1 dBFS
29+ const CLIP = Math.pow(10, -6 / 20); // hard ceiling, -6 dBFS
30+ const LIMIT_DB = -10; // limiter threshold
31+ let playConfirmed = false; // first playback on this page asks first
2932 let levelDb = LEVEL_FIRST;
3033 try {
3134 const v = parseFloat(localStorage.getItem("cp-level-db"));
@@ -122,7 +125,7 @@
122125 audioCtx = new (window.AudioContext || window.webkitAudioContext)();
123126 bus = audioCtx.createGain();
124127 limiter = audioCtx.createDynamicsCompressor();
125- limiter.threshold.value = -1;
128+ limiter.threshold.value = LIMIT_DB;
126129 limiter.knee.value = 0;
127130 limiter.ratio.value = 20;
128131 limiter.attack.value = 0.001;
@@ -244,6 +247,11 @@
244247 }
245248
246249 async play() {
250+ if (!playConfirmed) {
251+ const ok = window.confirm("Audio is about to play. Playback is loudness-normalized and limited (peaks at most -6 dBFS, the level of a quiet music track), but if you wear headphones or earbuds, lower your device volume first. Play?");
252+ if (!ok) return;
253+ playConfirmed = true;
254+ }
247255 for (const p of players) if (p !== this && p.playing) p.pause();
248256 await this.load();
249257 if (ctx().state === "suspended") await ctx().resume();
@@ -626,7 +634,7 @@
626634 el.innerHTML =
627635 '<label>Listening level <input class="cp-gain" type="range" min="' + LEVEL_MIN + '" max="' + LEVEL_MAX + '" step="1"> <span class="cp-gain-label"></span></label>' +
628636 '<label><input class="cp-lim" type="checkbox"> limiter</label>' +
629- '<span class="cp-note">Playback is loudness-normalized (every file\'s loudest 400 ms at -16 dBFS) and hard-limited at -1 dBFS; the level offset moves all files together and starts low. Set your device volume with headphones off, then raise the level if needed. The wav files themselves are exact physical signals and are not changed.</span>' +
637+ '<span class="cp-note">Playback is loudness-normalized (every file\'s loudest 400 ms at -16 dBFS), the strike is limited at -10 dBFS and nothing above -6 dBFS can pass; the level offset moves all files together and starts low. Set your device volume with headphones off, then raise the level if needed. The wav files themselves are exact physical signals and are not changed.</span>' +
630638 '<span class="cp-break" style="flex-basis:100%;height:0"></span>' +
631639 '<label>Spectrograms <button class="cp-spec-all" type="button">show all</button> <button class="cp-spec-none" type="button">hide all</button></label>' +
632640 '<label>up to <select class="cp-spec-fmax"><option value="3000">3 kHz</option><option value="6000">6 kHz</option><option value="12000" selected>12 kHz</option><option value="24000">24 kHz</option></select></label>' +
@@ -654,7 +662,7 @@
654662 });
655663 lim.addEventListener("change", () => {
656664 if (!lim.checked) {
657- const ok = window.confirm("Switching the limiter off lets the strike transients through at full strength (still hard-clipped at -1 dBFS). With headphones this can be unpleasant. Continue?");
665+ const ok = window.confirm("Switching the limiter off lets the strike transients through at full strength (still hard-clipped at -6 dBFS). With headphones this can be unpleasant. Continue?");
658666 if (!ok) { lim.checked = true; return; }
659667 }
660668 window.ChallengePlayer.setLimiter(lim.checked);