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