/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
Detailed spectrogram view with analysis and display controls
Jeremy Magland <jmagland@flatironinstitute.org> committed commit e5d6267fff7e parent 248673f Browse files
1 changed file+245−18
tools/challenge_tools/player.jsmodified+245−18View file
@@ -45,8 +45,7 @@
4545 }
4646 }
4747
48- function spectrogramDb(samples, sampleRate) {
49- const { nfft, hop } = spec;
48+ function spectrogramDb(samples, sampleRate, nfft, hop) {
5049 const nframes = Math.max(0, Math.floor((samples.length - nfft) / hop) + 1);
5150 const nbins = nfft / 2 + 1;
5251 const win = new Float32Array(nfft);
@@ -64,16 +63,30 @@
6463 if (v > max) max = v;
6564 }
6665 }
67- return { db, nframes, nbins, max, binHz: sampleRate / nfft, frameSec: hop / sampleRate, duration: samples.length / sampleRate };
66+ return { db, nframes, nbins, max, binHz: sampleRate / nfft, frameSec: hop / sampleRate, offsetSec: nfft / 2 / sampleRate, duration: samples.length / sampleRate };
6867 }
6968
70- // a compact inferno-like colormap: anchors interpolated linearly
71- const CMAP = [[0, 0, 4], [40, 11, 84], [101, 21, 110], [159, 42, 99], [212, 72, 66], [245, 125, 21], [250, 193, 39], [252, 255, 164]];
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");
7287 function color(u) {
73- u = Math.max(0, Math.min(0.99999, u)) * (CMAP.length - 1);
74- const i = Math.floor(u), t = u - i;
75- const a = CMAP[i], b = CMAP[i + 1];
76- return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
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]];
7790 }
7891
7992 function ctx() {
@@ -125,7 +138,8 @@
125138 this.specBtn = el.querySelector(".cp-spec-btn");
126139 this.specPanel = null; // created on demand, inserted after the player element
127140 this.specData = null;
128- this.specImage = null; // {canvas, fmax, rangeDb}
141+ this.specCache = {}; // analyses keyed by "nfft/hop"
142+ this.specImage = null; // {canvas, rows}
129143 this.specBtn.addEventListener("click", () => this.toggleSpectrogram());
130144 this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play()));
131145 this.seek.addEventListener("input", () => {
@@ -247,12 +261,20 @@
247261 }
248262 this.specPanel.style.display = "";
249263 if (!this.specData) {
264+ this.specData = await this.analysis(spec.nfft, spec.hop);
265+ }
266+ this.renderSpectrogram();
267+ }
268+
269+ async analysis(nfft, hop) {
270+ const key = nfft + "/" + hop;
271+ if (!this.specCache[key]) {
250272 await this.load();
251- // yield so the "computing" note paints before the analysis runs
273+ // yield so any "computing" note paints before the analysis runs
252274 await new Promise((r) => setTimeout(r, 0));
253- this.specData = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate);
275+ this.specCache[key] = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate, nfft, hop);
254276 }
255- this.renderSpectrogram();
277+ return this.specCache[key];
256278 }
257279
258280 renderSpectrogram() {
@@ -276,8 +298,10 @@
276298 ictx.putImageData(data, 0, 0);
277299 this.specImage = { canvas: img, rows };
278300 this.drawPlayhead();
279- this.specPanel.querySelector(".cp-spec-note").textContent =
280- "Hann 2048, hop 512; 0 to " + (rows * d.binHz / 1000).toFixed(1) + " kHz; " + spec.rangeDb + " dB below the file's maximum. Click to seek.";
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); });
281305 }
282306
283307 drawPlayhead() {
@@ -291,7 +315,9 @@
291315 g.fillStyle = getComputedStyle(document.body).backgroundColor || "#fff";
292316 g.fillRect(0, 0, W, H);
293317 g.imageSmoothingEnabled = false;
294- g.drawImage(this.specImage.canvas, x0, 0, pw, ph);
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);
295321 // axes
296322 const fg = getComputedStyle(document.body).color || "#000";
297323 g.fillStyle = fg; g.strokeStyle = fg; g.font = "10px system-ui, sans-serif";
@@ -306,7 +332,6 @@
306332 }
307333 g.textAlign = "center"; g.textBaseline = "top";
308334 const tstep = d.duration > 4 ? 1 : 0.5;
309- const tspan = d.nframes * d.frameSec;
310335 for (let t = 0; t <= tspan + 1e-9; t += tstep) {
311336 const x = x0 + (t / tspan) * pw;
312337 g.fillText(t.toFixed(tstep < 1 ? 1 : 0) + " s", x, y1 + 3);
@@ -324,6 +349,202 @@
324349 const LEFT = 30, BOTTOM = 16;
325350 window.addEventListener("resize", () => { for (const p of players) if (p.specImage) p.drawPlayhead(); });
326351
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}
355+
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+ }
362+
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">&#9654;</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)">&#10005;</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+ }
417+
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+ }
433+
434+ function closeDetail() {
435+ if (!detail) return;
436+ cancelAnimationFrame(detail.raf);
437+ detail.overlay.style.display = "none";
438+ detail.player = null;
439+ }
440+
441+ const DLEFT = 44, DBOTTOM = 22;
442+
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+ }
499+
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 ? "&#10074;&#10074;" : "&#9654;";
544+ detail.q(".cp-d-time").textContent = fmt(pos) + " / " + fmt(p.buffer.duration) + " s";
545+ }
546+ }
547+
327548 const style = document.createElement("style");
328549 style.textContent =
329550 ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" +
@@ -331,7 +552,13 @@
331552 ".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}" +
332553 ".cp-spec-btn{font:inherit;font-size:0.9em;padding:2px 6px;cursor:pointer}" +
333554 ".cp-spec{display:block;margin:4px 0 6px;min-width:320px}.cp-spec canvas{display:block;width:100%;height:170px;cursor:crosshair}" +
334- ".cp-spec-note{font-size:0.78em;opacity:0.7;margin-top:2px}";
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}";
335562 document.head.appendChild(style);
336563
337564 window.ChallengePlayer = {
↑↓ move↵openescclose