/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
Player: loudness-normalized, hard-limited playback with confirmations; README
Jeremy Magland <jmagland@flatironinstitute.org> committed commit e47abe9290d6 parent e5d6267 Browse files
2 changed files+72−19
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, so played raw they sound faint. The sites use `tools/challenge_tools/player.js`, a Web Audio player that applies a page-wide playback gain (default from `playback.gain_db` in challenge.json, adjustable) and a fast limiter so the transient does not clip. This affects only playback; a solution repository's site builder can copy the same file from the installed package (`challenge_tools.build.player_js()`).
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.
4545
4646 ## Development
4747
tools/challenge_tools/player.jsmodified+71−18View file
@@ -1,11 +1,16 @@
11 // Web Audio player for the challenge wav files. The files are exact, unnormalized signals with a
2-// large crest factor (a strike transient followed by a much quieter ring), so this player applies a
3-// page-wide gain, with a per-challenge default, and an optional fast limiter so that the ring can be
4-// brought up without the transient clipping. Presentation only: nothing here touches the files.
2+// large crest factor (a strike transient followed by a much quieter ring). Playback is made safe for
3+// headphones in three stages, none of which touches the files: each file is loudness-normalized so
4+// 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.
59 //
610 // Usage: <div class="cplayer" data-src="audio/x.wav"></div> elements, then
711 // ChallengePlayer.mount(); upgrade every .cplayer on the page
8-// ChallengePlayer.controls(el, {gainDb, limiter}) render the gain slider and limiter switch into el
12+// ChallengePlayer.controls(el, {limiter}) render the level slider and limiter switch into el
13+// (a gainDb option is accepted and ignored, for pages built before loudness normalization)
914 //
1015 // Each player can also show a spectrogram of its file, computed in the browser from the decoded
1116 // samples (periodic Hann window of 2048 samples, hop 512, the same analysis as the spectrogram error
@@ -16,9 +21,32 @@
1621 let audioCtx = null;
1722 let bus = null; // all players feed this
1823 let limiter = null;
24+ let clipper = null;
1925 let limiterOn = true;
20- let gainDb = 20;
26+ const TARGET_DBFS = -16; // loudest 400 ms rms window of every file is placed here
27+ const LEVEL_MIN = -20, LEVEL_MAX = 6, LEVEL_FIRST = -12;
28+ const CLIP = Math.pow(10, -1 / 20); // hard ceiling, -1 dBFS
29+ let levelDb = LEVEL_FIRST;
30+ try {
31+ const v = parseFloat(localStorage.getItem("cp-level-db"));
32+ if (isFinite(v)) levelDb = Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, v));
33+ } catch (_) {}
2134 const players = [];
35+
36+ function shortTermPeakDb(samples, sampleRate) {
37+ // the loudest 400 ms rms window (100 ms hop), in dBFS
38+ const win = Math.max(1, Math.round(0.4 * sampleRate)), hop = Math.max(1, Math.round(0.1 * sampleRate));
39+ let best = 0;
40+ for (let s = 0; ; s += hop) {
41+ const e = Math.min(samples.length, s + win);
42+ let acc = 0;
43+ for (let i = s; i < e; i++) acc += samples[i] * samples[i];
44+ const ms = acc / Math.max(1, e - s);
45+ if (ms > best) best = ms;
46+ if (e >= samples.length) break;
47+ }
48+ return 10 * Math.log10(best + 1e-30);
49+ }
2250 const spec = { nfft: 2048, hop: 512, fmax: 12000, rangeDb: 80 };
2351
2452 // ---------------------------------------------------------------- spectrogram analysis
@@ -99,16 +127,23 @@
99127 limiter.ratio.value = 20;
100128 limiter.attack.value = 0.001;
101129 limiter.release.value = 0.08;
130+ clipper = audioCtx.createWaveShaper();
131+ const n = 8192, curve = new Float32Array(n);
132+ for (let i = 0; i < n; i++) { const x = (2 * i) / (n - 1) - 1; curve[i] = Math.max(-CLIP, Math.min(CLIP, x)); }
133+ clipper.curve = curve;
134+ clipper.oversample = "none";
135+ clipper.connect(audioCtx.destination);
102136 route();
103137 }
104138 return audioCtx;
105139 }
106140
107141 function route() {
142+ // bus -> [limiter] -> clipper -> destination; the clipper is never bypassed
108143 bus.disconnect();
109144 limiter.disconnect();
110- if (limiterOn) { bus.connect(limiter); limiter.connect(audioCtx.destination); }
111- else bus.connect(audioCtx.destination);
145+ if (limiterOn) { bus.connect(limiter); limiter.connect(clipper); }
146+ else bus.connect(clipper);
112147 }
113148
114149 function fmt(t) {
@@ -160,6 +195,7 @@
160195 const resp = await fetch(this.src);
161196 const data = await resp.arrayBuffer();
162197 this.buffer = await ctx().decodeAudioData(data);
198+ this.normDb = TARGET_DBFS - shortTermPeakDb(this.buffer.getChannelData(0), this.buffer.sampleRate);
163199 this.updateTime();
164200 return this.buffer;
165201 }
@@ -167,7 +203,7 @@
167203 startSource() {
168204 const c = ctx();
169205 this.gain = c.createGain();
170- this.gain.gain.value = Math.pow(10, gainDb / 20);
206+ this.gain.gain.value = Math.pow(10, (this.normDb + levelDb) / 20);
171207 this.source = c.createBufferSource();
172208 this.source.buffer = this.buffer;
173209 this.source.connect(this.gain);
@@ -217,7 +253,7 @@
217253
218254 pause() { this.stopSource(); this.updateTime(); }
219255
220- setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, gainDb / 20); }
256+ setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, (this.normDb + levelDb) / 20); }
221257
222258 updateTime() {
223259 if (!this.buffer) return;
@@ -565,10 +601,12 @@
565601 mount(root) {
566602 (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el));
567603 },
568- setGain(db) {
569- gainDb = db;
604+ setLevel(db) {
605+ levelDb = Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, db));
606+ try { localStorage.setItem("cp-level-db", String(levelDb)); } catch (_) {}
570607 for (const p of players) p.setGain();
571608 },
609+ setGain(db) { window.ChallengePlayer.setLevel(db); },
572610 setLimiter(on) {
573611 limiterOn = !!on;
574612 if (audioCtx) route();
@@ -583,13 +621,12 @@
583621 },
584622 controls(el, opts) {
585623 opts = opts || {};
586- if (typeof opts.gainDb === "number") gainDb = opts.gainDb;
587624 if (typeof opts.limiter === "boolean") limiterOn = opts.limiter;
588625 el.classList.add("cp-controls");
589626 el.innerHTML =
590- '<label>Playback gain <input class="cp-gain" type="range" min="0" max="60" step="1"> <span class="cp-gain-label"></span></label>' +
627+ '<label>Listening level <input class="cp-gain" type="range" min="' + LEVEL_MIN + '" max="' + LEVEL_MAX + '" step="1"> <span class="cp-gain-label"></span></label>' +
591628 '<label><input class="cp-lim" type="checkbox"> limiter</label>' +
592- '<span class="cp-note">The wav files are exact physical signals with a loud strike and a quiet ring; the gain and limiter apply to playback only.</span>' +
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>' +
593630 '<span class="cp-break" style="flex-basis:100%;height:0"></span>' +
594631 '<label>Spectrograms <button class="cp-spec-all" type="button">show all</button> <button class="cp-spec-none" type="button">hide all</button></label>' +
595632 '<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>' +
@@ -601,11 +638,27 @@
601638 fm.value = String(spec.fmax); rg.value = String(spec.rangeDb);
602639 fm.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ fmax: parseFloat(fm.value) }));
603640 rg.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ rangeDb: parseFloat(rg.value) }));
604- g.value = String(gainDb);
641+ g.value = String(levelDb);
605642 lim.checked = limiterOn;
606- const upd = () => { gl.textContent = "+" + g.value + " dB"; };
607- g.addEventListener("input", () => { upd(); window.ChallengePlayer.setGain(parseFloat(g.value)); });
608- lim.addEventListener("change", () => window.ChallengePlayer.setLimiter(lim.checked));
643+ const upd = () => { const v = parseFloat(g.value); gl.textContent = (v > 0 ? "+" : "") + v + " dB" + (v > 0 ? " (above the normalized level)" : v === 0 ? " (normalized level)" : ""); };
644+ let confirmedLoud = levelDb > 0;
645+ g.addEventListener("input", () => {
646+ const v = parseFloat(g.value);
647+ if (v > 0 && !confirmedLoud) {
648+ const ok = window.confirm("This raises playback above the normalized level, which is already as loud as a music track at your current volume. With headphones or earbuds this can be uncomfortable or harmful. Continue?");
649+ if (!ok) { g.value = "0"; upd(); window.ChallengePlayer.setLevel(0); return; }
650+ confirmedLoud = true;
651+ }
652+ upd();
653+ window.ChallengePlayer.setLevel(v);
654+ });
655+ lim.addEventListener("change", () => {
656+ 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?");
658+ if (!ok) { lim.checked = true; return; }
659+ }
660+ window.ChallengePlayer.setLimiter(lim.checked);
661+ });
609662 upd();
610663 },
611664 };
↑↓ move↵openescclose