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