/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
Browser-computed spectrograms in the shared player
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 248673f5d767 parent cc3c4cf Browse files
1 changed file+195−2
tools/challenge_tools/player.jsmodified+195−2View file
@@ -6,6 +6,11 @@
66 // Usage: <div class="cplayer" data-src="audio/x.wav"></div> elements, then
77 // ChallengePlayer.mount(); upgrade every .cplayer on the page
88 // 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.
914 (function () {
1015 "use strict";
1116 let audioCtx = null;
@@ -14,6 +19,62 @@
1419 let limiterOn = true;
1520 let gainDb = 20;
1621 const players = [];
22+ const spec = { nfft: 2048, hop: 512, fmax: 12000, rangeDb: 80 };
23+
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+ }
47+
48+ function spectrogramDb(samples, sampleRate) {
49+ const { nfft, hop } = spec;
50+ const nframes = Math.max(0, Math.floor((samples.length - nfft) / hop) + 1);
51+ const nbins = nfft / 2 + 1;
52+ const win = new Float32Array(nfft);
53+ for (let i = 0; i < nfft; i++) win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / nfft));
54+ const db = new Float32Array(nframes * nbins);
55+ const re = new Float64Array(nfft), im = new Float64Array(nfft);
56+ let max = -Infinity;
57+ for (let f = 0; f < nframes; f++) {
58+ const o = f * hop;
59+ for (let i = 0; i < nfft; i++) { re[i] = samples[o + i] * win[i]; im[i] = 0; }
60+ fftReal(re, im);
61+ for (let k = 0; k < nbins; k++) {
62+ const v = 10 * Math.log10(re[k] * re[k] + im[k] * im[k] + 1e-30);
63+ db[f * nbins + k] = v;
64+ if (v > max) max = v;
65+ }
66+ }
67+ return { db, nframes, nbins, max, binHz: sampleRate / nfft, frameSec: hop / sampleRate, duration: samples.length / sampleRate };
68+ }
69+
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]];
72+ 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];
77+ }
1778
1879 function ctx() {
1980 if (!audioCtx) {
@@ -57,9 +118,15 @@
57118 '<button class="cp-btn" type="button" title="Play">&#9654;</button>' +
58119 '<input class="cp-seek" type="range" min="0" max="1000" value="0" step="1">' +
59120 '<span class="cp-time">0.00 / …</span>';
121+ el.insertAdjacentHTML("beforeend", '<button class="cp-spec-btn" type="button" title="Show or hide the spectrogram">spectrogram</button>');
60122 this.btn = el.querySelector(".cp-btn");
61123 this.seek = el.querySelector(".cp-seek");
62124 this.time = el.querySelector(".cp-time");
125+ this.specBtn = el.querySelector(".cp-spec-btn");
126+ this.specPanel = null; // created on demand, inserted after the player element
127+ this.specData = null;
128+ this.specImage = null; // {canvas, fmax, rangeDb}
129+ this.specBtn.addEventListener("click", () => this.toggleSpectrogram());
63130 this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play()));
64131 this.seek.addEventListener("input", () => {
65132 if (!this.buffer) return;
@@ -143,20 +210,128 @@
143210 const pos = this.position();
144211 this.seek.value = String(Math.round((pos / this.buffer.duration) * 1000));
145212 this.time.textContent = fmt(pos) + " / " + fmt(this.buffer.duration) + " s";
213+ if (!this.playing && this.specImage && this.specPanel.style.display !== "none") this.drawPlayhead();
146214 }
147215
148216 tick() {
149217 if (!this.playing) return;
150218 this.updateTime();
219+ if (this.specPanel && this.specPanel.style.display !== "none") this.drawPlayhead();
151220 requestAnimationFrame(() => this.tick());
152221 }
222+
223+ // ---------------------------------------------------------------- spectrogram display
224+ async toggleSpectrogram(force) {
225+ const show = typeof force === "boolean" ? force : !(this.specPanel && this.specPanel.style.display !== "none");
226+ if (!show) {
227+ if (this.specPanel) this.specPanel.style.display = "none";
228+ return;
229+ }
230+ if (!this.specPanel) {
231+ this.specPanel = document.createElement("div");
232+ this.specPanel.className = "cp-spec";
233+ this.specPanel.innerHTML = '<canvas class="cp-spec-canvas"></canvas><div class="cp-spec-note">computing…</div>';
234+ this.el.insertAdjacentElement("afterend", this.specPanel);
235+ this.canvas = this.specPanel.querySelector(".cp-spec-canvas");
236+ this.canvas.addEventListener("click", (ev) => {
237+ if (!this.buffer) return;
238+ const r = this.canvas.getBoundingClientRect();
239+ const t = ((ev.clientX - r.left - LEFT) / (r.width - LEFT)) * this.buffer.duration;
240+ if (t < 0) return;
241+ const was = this.playing;
242+ if (was) this.stopSource();
243+ this.offset = Math.min(this.buffer.duration, t);
244+ this.updateTime();
245+ if (was) this.startSource(); else this.drawPlayhead();
246+ });
247+ }
248+ this.specPanel.style.display = "";
249+ if (!this.specData) {
250+ await this.load();
251+ // yield so the "computing" note paints before the analysis runs
252+ await new Promise((r) => setTimeout(r, 0));
253+ this.specData = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate);
254+ }
255+ this.renderSpectrogram();
256+ }
257+
258+ renderSpectrogram() {
259+ if (!this.specData || !this.specPanel || this.specPanel.style.display === "none") return;
260+ const d = this.specData;
261+ const rows = Math.min(d.nbins, Math.ceil(spec.fmax / d.binHz));
262+ const img = document.createElement("canvas");
263+ img.width = d.nframes;
264+ img.height = rows;
265+ const ictx = img.getContext("2d");
266+ const data = ictx.createImageData(d.nframes, rows);
267+ const lo = d.max - spec.rangeDb;
268+ for (let f = 0; f < d.nframes; f++) {
269+ for (let k = 0; k < rows; k++) {
270+ const u = (d.db[f * d.nbins + k] - lo) / spec.rangeDb;
271+ const c = color(u);
272+ const o = 4 * ((rows - 1 - k) * d.nframes + f);
273+ data.data[o] = c[0]; data.data[o + 1] = c[1]; data.data[o + 2] = c[2]; data.data[o + 3] = 255;
274+ }
275+ }
276+ ictx.putImageData(data, 0, 0);
277+ this.specImage = { canvas: img, rows };
278+ 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.";
281+ }
282+
283+ drawPlayhead() {
284+ if (!this.specImage) return;
285+ const c = this.canvas;
286+ const W = Math.max(300, Math.floor(c.parentElement.clientWidth || 600)), H = 170;
287+ if (c.width !== W || c.height !== H) { c.width = W; c.height = H; }
288+ const g = c.getContext("2d");
289+ const d = this.specData, rows = this.specImage.rows;
290+ const x0 = LEFT, y1 = H - BOTTOM, pw = W - x0, ph = y1;
291+ g.fillStyle = getComputedStyle(document.body).backgroundColor || "#fff";
292+ g.fillRect(0, 0, W, H);
293+ g.imageSmoothingEnabled = false;
294+ g.drawImage(this.specImage.canvas, x0, 0, pw, ph);
295+ // axes
296+ const fg = getComputedStyle(document.body).color || "#000";
297+ g.fillStyle = fg; g.strokeStyle = fg; g.font = "10px system-ui, sans-serif";
298+ g.textAlign = "right"; g.textBaseline = "middle";
299+ const fmaxShown = rows * d.binHz;
300+ const fstep = fmaxShown > 15000 ? 5000 : fmaxShown > 7000 ? 2000 : 1000;
301+ for (let fr = 0; fr <= fmaxShown + 1; fr += fstep) {
302+ const y = y1 - (fr / fmaxShown) * ph;
303+ if (y < 6) continue;
304+ g.fillText((fr / 1000).toFixed(0) + "k", x0 - 3, y);
305+ g.beginPath(); g.moveTo(x0 - 2, y); g.lineTo(x0, y); g.stroke();
306+ }
307+ g.textAlign = "center"; g.textBaseline = "top";
308+ const tstep = d.duration > 4 ? 1 : 0.5;
309+ const tspan = d.nframes * d.frameSec;
310+ for (let t = 0; t <= tspan + 1e-9; t += tstep) {
311+ const x = x0 + (t / tspan) * pw;
312+ g.fillText(t.toFixed(tstep < 1 ? 1 : 0) + " s", x, y1 + 3);
313+ g.beginPath(); g.moveTo(x, y1); g.lineTo(x, y1 + 2); g.stroke();
314+ }
315+ // playhead
316+ if (this.buffer) {
317+ const x = x0 + (this.position() / tspan) * pw;
318+ g.strokeStyle = "rgba(255,255,255,0.9)"; g.lineWidth = 1.5;
319+ g.beginPath(); g.moveTo(x, 0); g.lineTo(x, y1); g.stroke();
320+ g.lineWidth = 1;
321+ }
322+ }
153323 }
324+ const LEFT = 30, BOTTOM = 16;
325+ window.addEventListener("resize", () => { for (const p of players) if (p.specImage) p.drawPlayhead(); });
154326
155327 const style = document.createElement("style");
156328 style.textContent =
157329 ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" +
158330 ".cp-btn{font:inherit;width:2em;padding:2px 0;cursor:pointer}.cp-seek{width:110px}" +
159- ".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}";
331+ ".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}" +
332+ ".cp-spec-btn{font:inherit;font-size:0.9em;padding:2px 6px;cursor:pointer}" +
333+ ".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}";
160335 document.head.appendChild(style);
161336
162337 window.ChallengePlayer = {
@@ -171,6 +346,14 @@
171346 limiterOn = !!on;
172347 if (audioCtx) route();
173348 },
349+ setSpectrogram(opts) {
350+ if (typeof opts.fmax === "number") spec.fmax = opts.fmax;
351+ if (typeof opts.rangeDb === "number") spec.rangeDb = opts.rangeDb;
352+ for (const p of players) p.renderSpectrogram();
353+ },
354+ showSpectrograms(on) {
355+ for (const p of players) p.toggleSpectrogram(!!on);
356+ },
174357 controls(el, opts) {
175358 opts = opts || {};
176359 if (typeof opts.gainDb === "number") gainDb = opts.gainDb;
@@ -179,8 +362,18 @@
179362 el.innerHTML =
180363 '<label>Playback gain <input class="cp-gain" type="range" min="0" max="60" step="1"> <span class="cp-gain-label"></span></label>' +
181364 '<label><input class="cp-lim" type="checkbox"> limiter</label>' +
182- '<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>';
365+ '<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>' +
366+ '<span class="cp-break" style="flex-basis:100%;height:0"></span>' +
367+ '<label>Spectrograms <button class="cp-spec-all" type="button">show all</button> <button class="cp-spec-none" type="button">hide all</button></label>' +
368+ '<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>' +
369+ '<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>';
183370 const g = el.querySelector(".cp-gain"), gl = el.querySelector(".cp-gain-label"), lim = el.querySelector(".cp-lim");
371+ el.querySelector(".cp-spec-all").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(true));
372+ el.querySelector(".cp-spec-none").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(false));
373+ const fm = el.querySelector(".cp-spec-fmax"), rg = el.querySelector(".cp-spec-range");
374+ fm.value = String(spec.fmax); rg.value = String(spec.rangeDb);
375+ fm.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ fmax: parseFloat(fm.value) }));
376+ rg.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ rangeDb: parseFloat(rg.value) }));
184377 g.value = String(gainDb);
185378 lim.checked = limiterOn;
186379 const upd = () => { gl.textContent = "+" + g.value + " dB"; };
↑↓ move↵openescclose