1// 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). 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.
9//
10// Usage: <div class="cplayer" data-src="audio/x.wav"></div> elements, then
11// ChallengePlayer.mount(); upgrade every .cplayer on the page
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)
14//
15// Each player can also show a spectrogram of its file, computed in the browser from the decoded
16// samples (periodic Hann window of 2048 samples, hop 512, the same analysis as the spectrogram error
17// measure), drawn in dB relative to the file's own maximum with a page-wide frequency limit and
18// dynamic range. The playhead is drawn over it during playback.
19(function () {
20 "use strict";
21 let audioCtx = null;
22 let bus = null; // all players feed this
23 let limiter = null;
24 let clipper = null;
25 let limiterOn = true;
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 (_) {}
34 const players = [];
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 }
50 const spec = { nfft: 2048, hop: 512, fmax: 12000, rangeDb: 80 };
52 // ---------------------------------------------------------------- spectrogram analysis
53 function fftReal(re, im) {
54 // in-place iterative radix-2 complex FFT (re, im of equal power-of-two length)
55 const n = re.length;
56 for (let i = 1, j = 0; i < n; i++) {
57 let bit = n >> 1;
58 for (; j & bit; bit >>= 1) j ^= bit;
59 j ^= bit;
60 if (i < j) { let t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; }
61 }
62 for (let len = 2; len <= n; len <<= 1) {
63 const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang);
64 for (let i = 0; i < n; i += len) {
65 let cr = 1, ci = 0;
66 for (let k = 0; k < len / 2; k++) {
67 const a = i + k, b = a + len / 2;
68 const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
69 re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
70 const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr;
71 }
72 }
73 }
74 }
76 function spectrogramDb(samples, sampleRate, nfft, hop) {
77 const nframes = Math.max(0, Math.floor((samples.length - nfft) / hop) + 1);
78 const nbins = nfft / 2 + 1;
79 const win = new Float32Array(nfft);
80 for (let i = 0; i < nfft; i++) win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / nfft));
81 const db = new Float32Array(nframes * nbins);
82 const re = new Float64Array(nfft), im = new Float64Array(nfft);
83 let max = -Infinity;
84 for (let f = 0; f < nframes; f++) {
85 const o = f * hop;
86 for (let i = 0; i < nfft; i++) { re[i] = samples[o + i] * win[i]; im[i] = 0; }
87 fftReal(re, im);
88 for (let k = 0; k < nbins; k++) {
89 const v = 10 * Math.log10(re[k] * re[k] + im[k] * im[k] + 1e-30);
90 db[f * nbins + k] = v;
91 if (v > max) max = v;
92 }
93 }
94 return { db, nframes, nbins, max, binHz: sampleRate / nfft, frameSec: hop / sampleRate, offsetSec: nfft / 2 / sampleRate, duration: samples.length / sampleRate };
95 }
97 // compact colormaps: anchors interpolated linearly
98 const CMAPS = {
99 inferno: [[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]],
100 viridis: [[68, 1, 84], [72, 40, 120], [62, 74, 137], [49, 104, 142], [38, 130, 142], [31, 158, 137], [53, 183, 121], [109, 205, 89], [180, 222, 44], [253, 231, 37]],
101 gray: [[0, 0, 0], [255, 255, 255]],
102 };
103 function colorLut(name) {
104 const map = CMAPS[name] || CMAPS.inferno;
105 const lut = new Uint8ClampedArray(256 * 3);
106 for (let n = 0; n < 256; n++) {
107 const u = (n / 255) * (map.length - 1);
108 const i = Math.min(map.length - 2, Math.floor(u)), t = u - i;
109 const a = map[i], b = map[i + 1];
110 for (let c = 0; c < 3; c++) lut[3 * n + c] = a[c] + (b[c] - a[c]) * t;
111 }
112 return lut;
113 }
114 const LUT = colorLut("inferno");
115 function color(u) {
116 const n = Math.max(0, Math.min(255, Math.round(u * 255)));
117 return [LUT[3 * n], LUT[3 * n + 1], LUT[3 * n + 2]];
118 }
120 function ctx() {
121 if (!audioCtx) {
122 audioCtx = new (window.AudioContext || window.webkitAudioContext)();
123 bus = audioCtx.createGain();
124 limiter = audioCtx.createDynamicsCompressor();
125 limiter.threshold.value = -1;
126 limiter.knee.value = 0;
127 limiter.ratio.value = 20;
128 limiter.attack.value = 0.001;
129 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);
136 route();
137 }
138 return audioCtx;
139 }
141 function route() {
142 // bus -> [limiter] -> clipper -> destination; the clipper is never bypassed
143 bus.disconnect();
144 limiter.disconnect();
145 if (limiterOn) { bus.connect(limiter); limiter.connect(clipper); }
146 else bus.connect(clipper);
147 }
149 function fmt(t) {
150 if (!isFinite(t)) return "0.00";
151 return t.toFixed(2);
152 }
154 class Player {
155 constructor(el) {
156 this.el = el;
157 this.src = el.dataset.src;
158 this.buffer = null;
159 this.source = null;
160 this.gain = null;
161 this.startCtxTime = 0;
162 this.offset = 0;
163 this.playing = false;
164 el.classList.add("cplayer-ready");
165 el.innerHTML =
166 '<button class="cp-btn" type="button" title="Play">▶</button>' +
167 '<input class="cp-seek" type="range" min="0" max="1000" value="0" step="1">' +
168 '<span class="cp-time">0.00 / …</span>';
169 el.insertAdjacentHTML("beforeend", '<button class="cp-spec-btn" type="button" title="Show or hide the spectrogram">spectrogram</button>');
170 this.btn = el.querySelector(".cp-btn");
171 this.seek = el.querySelector(".cp-seek");
172 this.time = el.querySelector(".cp-time");
173 this.specBtn = el.querySelector(".cp-spec-btn");
174 this.specPanel = null; // created on demand, inserted after the player element
175 this.specData = null;
176 this.specCache = {}; // analyses keyed by "nfft/hop"
177 this.specImage = null; // {canvas, rows}
178 this.specBtn.addEventListener("click", () => this.toggleSpectrogram());
179 this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play()));
180 this.seek.addEventListener("input", () => {
181 if (!this.buffer) return;
182 const t = (this.seek.value / 1000) * this.buffer.duration;
183 const was = this.playing;
184 if (was) this.stopSource();
185 this.offset = t;
186 this.updateTime();
187 if (was) this.startSource();
188 });
189 players.push(this);
190 }
192 async load() {
193 if (this.buffer) return this.buffer;
194 this.time.textContent = "loading…";
195 const resp = await fetch(this.src);
196 const data = await resp.arrayBuffer();
197 this.buffer = await ctx().decodeAudioData(data);
198 this.normDb = TARGET_DBFS - shortTermPeakDb(this.buffer.getChannelData(0), this.buffer.sampleRate);
199 this.updateTime();
200 return this.buffer;
201 }
203 startSource() {
204 const c = ctx();
205 this.gain = c.createGain();
206 this.gain.gain.value = Math.pow(10, (this.normDb + levelDb) / 20);
207 this.source = c.createBufferSource();
208 this.source.buffer = this.buffer;
209 this.source.connect(this.gain);
210 this.gain.connect(bus);
211 this.startCtxTime = c.currentTime;
212 this.source.start(0, this.offset);
213 this.playing = true;
214 this.btn.innerHTML = "❚❚";
215 this.btn.title = "Pause";
216 this.source.onended = () => {
217 if (this.playing && this.position() >= this.buffer.duration - 0.01) {
218 this.playing = false;
219 this.offset = 0;
220 this.btn.innerHTML = "▶";
221 this.btn.title = "Play";
222 this.updateTime();
223 }
224 };
225 this.tick();
226 }
228 stopSource() {
229 if (this.source) {
230 this.offset = this.position();
231 try { this.source.stop(); } catch (_) {}
232 this.source.disconnect();
233 this.gain.disconnect();
234 this.source = null;
235 }
236 this.playing = false;
237 this.btn.innerHTML = "▶";
238 this.btn.title = "Play";
239 }
241 position() {
242 if (!this.playing || !this.source) return this.offset;
243 return Math.min(this.buffer.duration, this.offset + (ctx().currentTime - this.startCtxTime));
244 }
246 async play() {
247 for (const p of players) if (p !== this && p.playing) p.pause();
248 await this.load();
249 if (ctx().state === "suspended") await ctx().resume();
250 if (this.offset >= this.buffer.duration - 0.01) this.offset = 0;
251 this.startSource();
252 }
254 pause() { this.stopSource(); this.updateTime(); }
256 setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, (this.normDb + levelDb) / 20); }
258 updateTime() {
259 if (!this.buffer) return;
260 const pos = this.position();
261 this.seek.value = String(Math.round((pos / this.buffer.duration) * 1000));
262 this.time.textContent = fmt(pos) + " / " + fmt(this.buffer.duration) + " s";
263 if (!this.playing && this.specImage && this.specPanel.style.display !== "none") this.drawPlayhead();
264 }
266 tick() {
267 if (!this.playing) return;
268 this.updateTime();
269 if (this.specPanel && this.specPanel.style.display !== "none") this.drawPlayhead();
270 requestAnimationFrame(() => this.tick());
271 }
273 // ---------------------------------------------------------------- spectrogram display
274 async toggleSpectrogram(force) {
275 const show = typeof force === "boolean" ? force : !(this.specPanel && this.specPanel.style.display !== "none");
276 if (!show) {
277 if (this.specPanel) this.specPanel.style.display = "none";
278 return;
279 }
280 if (!this.specPanel) {
281 this.specPanel = document.createElement("div");
282 this.specPanel.className = "cp-spec";
283 this.specPanel.innerHTML = '<canvas class="cp-spec-canvas"></canvas><div class="cp-spec-note">computing…</div>';
284 this.el.insertAdjacentElement("afterend", this.specPanel);
285 this.canvas = this.specPanel.querySelector(".cp-spec-canvas");
286 this.canvas.addEventListener("click", (ev) => {
287 if (!this.buffer) return;
288 const r = this.canvas.getBoundingClientRect();
289 const t = ((ev.clientX - r.left - LEFT) / (r.width - LEFT)) * this.buffer.duration;
290 if (t < 0) return;
291 const was = this.playing;
292 if (was) this.stopSource();
293 this.offset = Math.min(this.buffer.duration, t);
294 this.updateTime();
295 if (was) this.startSource(); else this.drawPlayhead();
296 });
297 }
298 this.specPanel.style.display = "";
299 if (!this.specData) {
300 this.specData = await this.analysis(spec.nfft, spec.hop);
301 }
302 this.renderSpectrogram();
303 }
305 async analysis(nfft, hop) {
306 const key = nfft + "/" + hop;
307 if (!this.specCache[key]) {
308 await this.load();
309 // yield so any "computing" note paints before the analysis runs
310 await new Promise((r) => setTimeout(r, 0));
311 this.specCache[key] = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate, nfft, hop);
312 }
313 return this.specCache[key];
314 }
316 renderSpectrogram() {
317 if (!this.specData || !this.specPanel || this.specPanel.style.display === "none") return;
318 const d = this.specData;
319 const rows = Math.min(d.nbins, Math.ceil(spec.fmax / d.binHz));
320 const img = document.createElement("canvas");
321 img.width = d.nframes;
322 img.height = rows;
323 const ictx = img.getContext("2d");
324 const data = ictx.createImageData(d.nframes, rows);
325 const lo = d.max - spec.rangeDb;
326 for (let f = 0; f < d.nframes; f++) {
327 for (let k = 0; k < rows; k++) {
328 const u = (d.db[f * d.nbins + k] - lo) / spec.rangeDb;
329 const c = color(u);
330 const o = 4 * ((rows - 1 - k) * d.nframes + f);
331 data.data[o] = c[0]; data.data[o + 1] = c[1]; data.data[o + 2] = c[2]; data.data[o + 3] = 255;
332 }
333 }
334 ictx.putImageData(data, 0, 0);
335 this.specImage = { canvas: img, rows };
336 this.drawPlayhead();
337 const note = this.specPanel.querySelector(".cp-spec-note");
338 note.innerHTML = "Hann 2048, hop 512; 0 to " + (rows * d.binHz / 1000).toFixed(1) + " kHz; " + spec.rangeDb +
339 ' dB below the file\'s maximum. Click to seek. <a href="#" class="cp-spec-expand">Detailed view</a>';
340 note.querySelector(".cp-spec-expand").addEventListener("click", (ev) => { ev.preventDefault(); openDetail(this); });
341 }
343 drawPlayhead() {
344 if (!this.specImage) return;
345 const c = this.canvas;
346 const W = Math.max(300, Math.floor(c.parentElement.clientWidth || 600)), H = 170;
347 if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
348 const g = c.getContext("2d");
349 const d = this.specData, rows = this.specImage.rows;
350 const x0 = LEFT, y1 = H - BOTTOM, pw = W - x0, ph = y1;
351 g.fillStyle = getComputedStyle(document.body).backgroundColor || "#fff";
352 g.fillRect(0, 0, W, H);
353 g.imageSmoothingEnabled = false;
354 const tspan = d.duration;
355 const xa = x0 + ((d.offsetSec - d.frameSec / 2) / tspan) * pw, xb = x0 + ((d.offsetSec + (d.nframes - 0.5) * d.frameSec) / tspan) * pw;
356 g.drawImage(this.specImage.canvas, xa, 0, xb - xa, ph);
357 // axes
358 const fg = getComputedStyle(document.body).color || "#000";
359 g.fillStyle = fg; g.strokeStyle = fg; g.font = "10px system-ui, sans-serif";
360 g.textAlign = "right"; g.textBaseline = "middle";
361 const fmaxShown = rows * d.binHz;
362 const fstep = fmaxShown > 15000 ? 5000 : fmaxShown > 7000 ? 2000 : 1000;
363 for (let fr = 0; fr <= fmaxShown + 1; fr += fstep) {
364 const y = y1 - (fr / fmaxShown) * ph;
365 if (y < 6) continue;
366 g.fillText((fr / 1000).toFixed(0) + "k", x0 - 3, y);
367 g.beginPath(); g.moveTo(x0 - 2, y); g.lineTo(x0, y); g.stroke();
368 }
369 g.textAlign = "center"; g.textBaseline = "top";
370 const tstep = d.duration > 4 ? 1 : 0.5;
371 for (let t = 0; t <= tspan + 1e-9; t += tstep) {
372 const x = x0 + (t / tspan) * pw;
373 g.fillText(t.toFixed(tstep < 1 ? 1 : 0) + " s", x, y1 + 3);
374 g.beginPath(); g.moveTo(x, y1); g.lineTo(x, y1 + 2); g.stroke();
375 }
376 // playhead
377 if (this.buffer) {
378 const x = x0 + (this.position() / tspan) * pw;
379 g.strokeStyle = "rgba(255,255,255,0.9)"; g.lineWidth = 1.5;
380 g.beginPath(); g.moveTo(x, 0); g.lineTo(x, y1); g.stroke();
381 g.lineWidth = 1;
382 }
383 }
384 }
385 const LEFT = 30, BOTTOM = 16;
386 window.addEventListener("resize", () => { for (const p of players) if (p.specImage) p.drawPlayhead(); });
388 // ---------------------------------------------------------------- detailed view
389 const det = { nfft: 2048, hopDiv: 4, fmin: 0, fmax: 12000, logf: false, bright: 0, range: 80, gamma: 1, cmap: "inferno", t0: 0, t1: null };
390 let detail = null; // {overlay, canvas, player, data, image, lut, raf}
392 function el(tag, attrs, html) {
393 const e = document.createElement(tag);
394 for (const k in attrs || {}) e.setAttribute(k, attrs[k]);
395 if (html !== undefined) e.innerHTML = html;
396 return e;
397 }
399 function buildDetail() {
400 const overlay = el("div", { class: "cp-detail" });
401 overlay.innerHTML =
402 '<div class="cp-detail-bar">' +
403 '<button class="cp-d-play" type="button">▶</button> <span class="cp-d-time"></span>' +
404 '<span class="cp-d-title"></span>' +
405 '<button class="cp-d-close" type="button" title="Close (Esc)">✕</button></div>' +
406 '<div class="cp-detail-ctl">' +
407 '<label>Window <select class="cp-d-nfft"><option>512</option><option>1024</option><option selected>2048</option><option>4096</option><option>8192</option></select></label>' +
408 '<label>Hop <select class="cp-d-hop"><option value="8">1/8</option><option value="4" selected>1/4</option><option value="2">1/2</option></select></label>' +
409 '<label>Frequency <input class="cp-d-fmin" type="number" min="0" step="100" value="0" style="width:5.5em"> to <input class="cp-d-fmax" type="number" min="100" step="100" value="12000" style="width:5.5em"> Hz</label>' +
410 '<label><input class="cp-d-logf" type="checkbox"> log frequency</label>' +
411 '<label>Brightness <input class="cp-d-bright" type="range" min="-40" max="60" step="1" value="0"> <span class="cp-d-bright-l"></span></label>' +
412 '<label>Range <input class="cp-d-range" type="range" min="20" max="140" step="1" value="80"> <span class="cp-d-range-l"></span></label>' +
413 '<label>Gamma <input class="cp-d-gamma" type="range" min="0.25" max="3" step="0.05" value="1"> <span class="cp-d-gamma-l"></span></label>' +
414 '<label>Colors <select class="cp-d-cmap"><option>inferno</option><option>viridis</option><option>gray</option></select></label>' +
415 '<label>Time <input class="cp-d-t0" type="range" min="0" max="1" step="0.001" value="0"> to <input class="cp-d-t1" type="range" min="0" max="1" step="0.001" value="1"> <span class="cp-d-time-l"></span></label>' +
416 '<span class="cp-d-note"></span>' +
417 '</div>' +
418 '<canvas class="cp-d-canvas"></canvas>';
419 document.body.appendChild(overlay);
420 const q = (c) => overlay.querySelector(c);
421 detail = { overlay, canvas: q(".cp-d-canvas"), player: null, data: null, image: null, lut: colorLut(det.cmap), raf: 0, q };
422 q(".cp-d-close").addEventListener("click", closeDetail);
423 overlay.addEventListener("keydown", (ev) => { if (ev.key === "Escape") closeDetail(); });
424 q(".cp-d-play").addEventListener("click", () => { const p = detail.player; if (p) (p.playing ? p.pause() : p.play()); });
425 const rerender = () => renderDetail(false);
426 const reanalyze = () => renderDetail(true);
427 q(".cp-d-nfft").addEventListener("change", (e) => { det.nfft = parseInt(e.target.value, 10); reanalyze(); });
428 q(".cp-d-hop").addEventListener("change", (e) => { det.hopDiv = parseInt(e.target.value, 10); reanalyze(); });
429 q(".cp-d-fmin").addEventListener("change", (e) => { det.fmin = Math.max(0, parseFloat(e.target.value) || 0); rerender(); });
430 q(".cp-d-fmax").addEventListener("change", (e) => { det.fmax = Math.max(100, parseFloat(e.target.value) || 12000); rerender(); });
431 q(".cp-d-logf").addEventListener("change", (e) => { det.logf = e.target.checked; rerender(); });
432 q(".cp-d-bright").addEventListener("input", (e) => { det.bright = parseFloat(e.target.value); rerender(); });
433 q(".cp-d-range").addEventListener("input", (e) => { det.range = parseFloat(e.target.value); rerender(); });
434 q(".cp-d-gamma").addEventListener("input", (e) => { det.gamma = parseFloat(e.target.value); rerender(); });
435 q(".cp-d-cmap").addEventListener("change", (e) => { det.cmap = e.target.value; detail.lut = colorLut(det.cmap); rerender(); });
436 q(".cp-d-t0").addEventListener("input", (e) => { det.t0 = parseFloat(e.target.value) * detail.data.duration; if (det.t1 - det.t0 < 0.02) det.t0 = det.t1 - 0.02; rerender(); });
437 q(".cp-d-t1").addEventListener("input", (e) => { det.t1 = parseFloat(e.target.value) * detail.data.duration; if (det.t1 - det.t0 < 0.02) det.t1 = det.t0 + 0.02; rerender(); });
438 detail.canvas.addEventListener("click", (ev) => {
439 const p = detail.player;
440 if (!p || !p.buffer) return;
441 const r = detail.canvas.getBoundingClientRect();
442 const frac = (ev.clientX - r.left - DLEFT) / (r.width - DLEFT);
443 if (frac < 0 || frac > 1) return;
444 const t = det.t0 + frac * (det.t1 - det.t0);
445 const was = p.playing;
446 if (was) p.stopSource();
447 p.offset = Math.min(p.buffer.duration, t);
448 p.updateTime();
449 if (was) p.startSource();
450 });
451 window.addEventListener("resize", () => { if (detail.player) renderDetail(false); });
452 }
454 async function openDetail(player) {
455 if (!detail) buildDetail();
456 detail.player = player;
457 detail.overlay.style.display = "flex";
458 detail.overlay.tabIndex = -1;
459 detail.overlay.focus();
460 detail.q(".cp-d-title").textContent = player.src.split("/").pop();
461 await player.load();
462 if (det.t1 === null || det.t1 > player.buffer.duration) det.t1 = player.buffer.duration;
463 det.t0 = Math.min(det.t0, det.t1 - 0.02);
464 await renderDetail(true);
465 cancelAnimationFrame(detail.raf);
466 const loop = () => { if (detail.player) { drawDetail(); detail.raf = requestAnimationFrame(loop); } };
467 loop();
468 }
470 function closeDetail() {
471 if (!detail) return;
472 cancelAnimationFrame(detail.raf);
473 detail.overlay.style.display = "none";
474 detail.player = null;
475 }
477 const DLEFT = 44, DBOTTOM = 22;
479 async function renderDetail(reanalyze) {
480 const p = detail.player;
481 if (!p) return;
482 if (reanalyze || !detail.data) {
483 detail.q(".cp-d-note").textContent = "computing…";
484 detail.data = await p.analysis(det.nfft, Math.max(1, Math.round(det.nfft / det.hopDiv)));
485 }
486 const d = detail.data;
487 const c = detail.canvas;
488 const W = Math.max(400, c.parentElement.clientWidth - 24);
489 const H = Math.max(200, window.innerHeight - detail.q(".cp-detail-bar").offsetHeight - detail.q(".cp-detail-ctl").offsetHeight - 40);
490 const pw = W - DLEFT, ph = H - DBOTTOM;
491 // image in display resolution: per pixel column the max over the frames it covers, per pixel row the max over the bins
492 const img = document.createElement("canvas");
493 img.width = pw; img.height = ph;
494 const ictx = img.getContext("2d");
495 const data = ictx.createImageData(pw, ph);
496 const t0 = det.t0, t1 = det.t1 === null ? d.duration : det.t1;
497 const fmin = det.fmin, fmax = Math.min(det.fmax, d.binHz * (d.nbins - 1));
498 const flo = det.logf ? Math.max(fmin, 20) : fmin;
499 const top = d.max - det.bright, lo = top - det.range;
500 const lut = detail.lut;
501 const rowBin = new Int32Array(ph + 1);
502 for (let r = 0; r <= ph; r++) {
503 const u = 1 - r / ph; // bottom row is fmin
504 const f = det.logf ? flo * Math.pow(fmax / flo, u) : flo + (fmax - flo) * u;
505 rowBin[r] = Math.max(0, Math.min(d.nbins - 1, Math.round(f / d.binHz)));
506 }
507 for (let x = 0; x < pw; x++) {
508 const fa = Math.round((t0 + (x / pw) * (t1 - t0) - d.offsetSec) / d.frameSec);
509 const fb = Math.max(fa, Math.round((t0 + ((x + 1) / pw) * (t1 - t0) - d.offsetSec) / d.frameSec) - 1);
510 for (let r = 0; r < ph; r++) {
511 const b0 = Math.min(rowBin[r], rowBin[r + 1]), b1 = Math.max(rowBin[r], rowBin[r + 1]);
512 let v = -Infinity;
513 for (let f = Math.max(0, fa); f <= Math.min(d.nframes - 1, fb); f++) {
514 const base = f * d.nbins;
515 for (let b = b0; b <= b1; b++) { const w = d.db[base + b]; if (w > v) v = w; }
516 }
517 let u = (v - lo) / det.range;
518 u = u <= 0 ? 0 : u >= 1 ? 1 : Math.pow(u, det.gamma);
519 const n = Math.round(u * 255) * 3, o = 4 * (r * pw + x);
520 data.data[o] = lut[n]; data.data[o + 1] = lut[n + 1]; data.data[o + 2] = lut[n + 2]; data.data[o + 3] = 255;
521 }
522 }
523 ictx.putImageData(data, 0, 0);
524 detail.image = { canvas: img, W, H, t0, t1, flo, fmax };
525 detail.q(".cp-d-bright-l").textContent = (det.bright >= 0 ? "+" : "") + det.bright + " dB";
526 detail.q(".cp-d-range-l").textContent = det.range + " dB";
527 detail.q(".cp-d-gamma-l").textContent = det.gamma.toFixed(2);
528 detail.q(".cp-d-time-l").textContent = t0.toFixed(2) + " to " + t1.toFixed(2) + " s";
529 detail.q(".cp-d-t0").value = String(t0 / d.duration);
530 detail.q(".cp-d-t1").value = String(t1 / d.duration);
531 detail.q(".cp-d-note").textContent = "Hann " + det.nfft + " (" + (det.nfft / p.buffer.sampleRate * 1e3).toFixed(1) + " ms, " + d.binHz.toFixed(1) +
532 " Hz per bin), hop " + Math.round(det.nfft / det.hopDiv) + "; 0 dB is the file's maximum; the ceiling is " + det.bright + " dB below it and the floor " + (det.bright + det.range) + " dB. Click to seek.";
533 drawDetail();
534 }
536 function drawDetail() {
537 const p = detail.player, im = detail.image;
538 if (!p || !im) return;
539 const c = detail.canvas;
540 if (c.width !== im.W || c.height !== im.H) { c.width = im.W; c.height = im.H; }
541 const g = c.getContext("2d");
542 const pw = im.W - DLEFT, ph = im.H - DBOTTOM;
543 g.fillStyle = "#111"; g.fillRect(0, 0, im.W, im.H);
544 g.drawImage(im.canvas, DLEFT, 0);
545 g.fillStyle = "#ddd"; g.strokeStyle = "#ddd"; g.font = "11px system-ui, sans-serif";
546 // frequency ticks
547 g.textAlign = "right"; g.textBaseline = "middle";
548 const ticks = [];
549 if (det.logf) {
550 for (const f of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) if (f >= im.flo && f <= im.fmax) ticks.push(f);
551 } else {
552 const span = im.fmax - im.flo, step = span > 15000 ? 5000 : span > 7000 ? 2000 : span > 3000 ? 1000 : span > 1200 ? 500 : span > 500 ? 200 : 100;
553 for (let f = Math.ceil(im.flo / step) * step; f <= im.fmax; f += step) ticks.push(f);
554 }
555 for (const f of ticks) {
556 const u = det.logf ? Math.log(f / im.flo) / Math.log(im.fmax / im.flo) : (f - im.flo) / (im.fmax - im.flo);
557 const y = ph - u * ph;
558 if (y < 7 || y > ph - 2) continue;
559 g.fillText(f >= 1000 ? (f / 1000) + "k" : String(f), DLEFT - 4, y);
560 g.beginPath(); g.moveTo(DLEFT - 3, y); g.lineTo(DLEFT, y); g.stroke();
561 }
562 // time ticks
563 g.textAlign = "center"; g.textBaseline = "top";
564 const span = im.t1 - im.t0, tstep = span > 4 ? 1 : span > 2 ? 0.5 : span > 0.8 ? 0.2 : span > 0.4 ? 0.1 : span > 0.15 ? 0.05 : 0.01;
565 for (let t = Math.ceil(im.t0 / tstep) * tstep; t <= im.t1 + 1e-9; t += tstep) {
566 const x = DLEFT + ((t - im.t0) / span) * pw;
567 g.fillText(t.toFixed(tstep < 0.1 ? 2 : tstep < 1 ? 1 : 0) + " s", x, ph + 5);
568 g.beginPath(); g.moveTo(x, ph); g.lineTo(x, ph + 3); g.stroke();
569 }
570 // playhead and transport
571 if (p.buffer) {
572 const pos = p.position();
573 if (pos >= im.t0 && pos <= im.t1) {
574 const x = DLEFT + ((pos - im.t0) / span) * pw;
575 g.strokeStyle = "rgba(255,255,255,0.9)"; g.lineWidth = 1.5;
576 g.beginPath(); g.moveTo(x, 0); g.lineTo(x, ph); g.stroke();
577 g.lineWidth = 1;
578 }
579 detail.q(".cp-d-play").innerHTML = p.playing ? "❚❚" : "▶";
580 detail.q(".cp-d-time").textContent = fmt(pos) + " / " + fmt(p.buffer.duration) + " s";
581 }
582 }
584 const style = document.createElement("style");
585 style.textContent =
586 ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" +
587 ".cp-btn{font:inherit;width:2em;padding:2px 0;cursor:pointer}.cp-seek{width:110px}" +
588 ".cp-controls{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin:8px 0}.cp-controls label{display:flex;gap:6px;align-items:center}" +
589 ".cp-spec-btn{font:inherit;font-size:0.9em;padding:2px 6px;cursor:pointer}" +
590 ".cp-spec{display:block;margin:4px 0 6px;min-width:320px}.cp-spec canvas{display:block;width:100%;height:170px;cursor:crosshair}" +
591 ".cp-spec-note{font-size:0.78em;opacity:0.7;margin-top:2px}" +
592 ".cp-detail{display:none;position:fixed;inset:0;z-index:1000;background:#111;color:#ddd;flex-direction:column;padding:8px 12px;box-sizing:border-box;font-size:0.9em;outline:none}" +
593 ".cp-detail-bar{display:flex;gap:12px;align-items:center;margin-bottom:6px}.cp-detail-bar .cp-d-title{flex:1;text-align:center;opacity:0.8}" +
594 ".cp-detail button{font:inherit;padding:2px 8px;cursor:pointer;background:#333;color:#eee;border:1px solid #555;border-radius:3px}" +
595 ".cp-detail-ctl{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;margin-bottom:6px}.cp-detail-ctl label{display:flex;gap:5px;align-items:center;white-space:nowrap}" +
596 ".cp-detail-ctl input[type=range]{width:110px}.cp-detail-ctl select,.cp-detail-ctl input[type=number]{font:inherit;background:#222;color:#eee;border:1px solid #555}" +
597 ".cp-d-note{font-size:0.85em;opacity:0.7;flex-basis:100%}.cp-d-canvas{display:block;flex:1;cursor:crosshair}";
598 document.head.appendChild(style);
600 window.ChallengePlayer = {
601 mount(root) {
602 (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el));
603 },
604 setLevel(db) {
605 levelDb = Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, db));
606 try { localStorage.setItem("cp-level-db", String(levelDb)); } catch (_) {}
607 for (const p of players) p.setGain();
608 },
609 setGain(db) { window.ChallengePlayer.setLevel(db); },
610 setLimiter(on) {
611 limiterOn = !!on;
612 if (audioCtx) route();
613 },
614 setSpectrogram(opts) {
615 if (typeof opts.fmax === "number") spec.fmax = opts.fmax;
616 if (typeof opts.rangeDb === "number") spec.rangeDb = opts.rangeDb;
617 for (const p of players) p.renderSpectrogram();
618 },
619 showSpectrograms(on) {
620 for (const p of players) p.toggleSpectrogram(!!on);
621 },
622 controls(el, opts) {
623 opts = opts || {};
624 if (typeof opts.limiter === "boolean") limiterOn = opts.limiter;
625 el.classList.add("cp-controls");
626 el.innerHTML =
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>' +
628 '<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>' +
630 '<span class="cp-break" style="flex-basis:100%;height:0"></span>' +
631 '<label>Spectrograms <button class="cp-spec-all" type="button">show all</button> <button class="cp-spec-none" type="button">hide all</button></label>' +
632 '<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>' +
633 '<label>range <select class="cp-spec-range"><option value="60">60 dB</option><option value="80" selected>80 dB</option><option value="100">100 dB</option><option value="120">120 dB</option></select></label>';
634 const g = el.querySelector(".cp-gain"), gl = el.querySelector(".cp-gain-label"), lim = el.querySelector(".cp-lim");
635 el.querySelector(".cp-spec-all").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(true));
636 el.querySelector(".cp-spec-none").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(false));
637 const fm = el.querySelector(".cp-spec-fmax"), rg = el.querySelector(".cp-spec-range");
638 fm.value = String(spec.fmax); rg.value = String(spec.rangeDb);
639 fm.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ fmax: parseFloat(fm.value) }));
640 rg.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ rangeDb: parseFloat(rg.value) }));
641 g.value = String(levelDb);
642 lim.checked = limiterOn;
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 });
662 upd();
663 },
664 };
665})();