/ challenges-old / challenge-tools
Sign in
challenges-old / challenge-tools
challenge-tools / tools / challenge_tools / player.js
385 lines · 16.8 KBCodeBlameHistory
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 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
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.
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 = [];
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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) {
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 }
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 }
79 function ctx() {
80 if (!audioCtx) {
81 audioCtx = new (window.AudioContext || window.webkitAudioContext)();
82 bus = audioCtx.createGain();
83 limiter = audioCtx.createDynamicsCompressor();
84 limiter.threshold.value = -1;
85 limiter.knee.value = 0;
86 limiter.ratio.value = 20;
87 limiter.attack.value = 0.001;
88 limiter.release.value = 0.08;
89 route();
90 }
91 return audioCtx;
92 }
94 function route() {
95 bus.disconnect();
96 limiter.disconnect();
97 if (limiterOn) { bus.connect(limiter); limiter.connect(audioCtx.destination); }
98 else bus.connect(audioCtx.destination);
99 }
101 function fmt(t) {
102 if (!isFinite(t)) return "0.00";
103 return t.toFixed(2);
104 }
106 class Player {
107 constructor(el) {
108 this.el = el;
109 this.src = el.dataset.src;
110 this.buffer = null;
111 this.source = null;
112 this.gain = null;
113 this.startCtxTime = 0;
114 this.offset = 0;
115 this.playing = false;
116 el.classList.add("cplayer-ready");
117 el.innerHTML =
118 '<button class="cp-btn" type="button" title="Play">&#9654;</button>' +
119 '<input class="cp-seek" type="range" min="0" max="1000" value="0" step="1">' +
120 '<span class="cp-time">0.00 / …</span>';
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 121 el.insertAdjacentHTML("beforeend", '<button class="cp-spec-btn" type="button" title="Show or hide the spectrogram">spectrogram</button>');
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 122 this.btn = el.querySelector(".cp-btn");
123 this.seek = el.querySelector(".cp-seek");
124 this.time = el.querySelector(".cp-time");
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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());
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 130 this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play()));
131 this.seek.addEventListener("input", () => {
132 if (!this.buffer) return;
133 const t = (this.seek.value / 1000) * this.buffer.duration;
134 const was = this.playing;
135 if (was) this.stopSource();
136 this.offset = t;
137 this.updateTime();
138 if (was) this.startSource();
139 });
140 players.push(this);
141 }
143 async load() {
144 if (this.buffer) return this.buffer;
145 this.time.textContent = "loading…";
146 const resp = await fetch(this.src);
147 const data = await resp.arrayBuffer();
148 this.buffer = await ctx().decodeAudioData(data);
149 this.updateTime();
150 return this.buffer;
151 }
153 startSource() {
154 const c = ctx();
155 this.gain = c.createGain();
156 this.gain.gain.value = Math.pow(10, gainDb / 20);
157 this.source = c.createBufferSource();
158 this.source.buffer = this.buffer;
159 this.source.connect(this.gain);
160 this.gain.connect(bus);
161 this.startCtxTime = c.currentTime;
162 this.source.start(0, this.offset);
163 this.playing = true;
164 this.btn.innerHTML = "&#10074;&#10074;";
165 this.btn.title = "Pause";
166 this.source.onended = () => {
167 if (this.playing && this.position() >= this.buffer.duration - 0.01) {
168 this.playing = false;
169 this.offset = 0;
170 this.btn.innerHTML = "&#9654;";
171 this.btn.title = "Play";
172 this.updateTime();
173 }
174 };
175 this.tick();
176 }
178 stopSource() {
179 if (this.source) {
180 this.offset = this.position();
181 try { this.source.stop(); } catch (_) {}
182 this.source.disconnect();
183 this.gain.disconnect();
184 this.source = null;
185 }
186 this.playing = false;
187 this.btn.innerHTML = "&#9654;";
188 this.btn.title = "Play";
189 }
191 position() {
192 if (!this.playing || !this.source) return this.offset;
193 return Math.min(this.buffer.duration, this.offset + (ctx().currentTime - this.startCtxTime));
194 }
196 async play() {
197 for (const p of players) if (p !== this && p.playing) p.pause();
198 await this.load();
199 if (ctx().state === "suspended") await ctx().resume();
200 if (this.offset >= this.buffer.duration - 0.01) this.offset = 0;
201 this.startSource();
202 }
204 pause() { this.stopSource(); this.updateTime(); }
206 setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, gainDb / 20); }
208 updateTime() {
209 if (!this.buffer) return;
210 const pos = this.position();
211 this.seek.value = String(Math.round((pos / this.buffer.duration) * 1000));
212 this.time.textContent = fmt(pos) + " / " + fmt(this.buffer.duration) + " s";
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 213 if (!this.playing && this.specImage && this.specPanel.style.display !== "none") this.drawPlayhead();
216 tick() {
217 if (!this.playing) return;
218 this.updateTime();
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 219 if (this.specPanel && this.specPanel.style.display !== "none") this.drawPlayhead();
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 220 requestAnimationFrame(() => this.tick());
221 }
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 }
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 }
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 }
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 324 const LEFT = 30, BOTTOM = 16;
325 window.addEventListener("resize", () => { for (const p of players) if (p.specImage) p.drawPlayhead(); });
327 const style = document.createElement("style");
328 style.textContent =
329 ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" +
330 ".cp-btn{font:inherit;width:2em;padding:2px 0;cursor:pointer}.cp-seek{width:110px}" +
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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}";
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 335 document.head.appendChild(style);
337 window.ChallengePlayer = {
338 mount(root) {
339 (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el));
340 },
341 setGain(db) {
342 gainDb = db;
343 for (const p of players) p.setGain();
344 },
345 setLimiter(on) {
346 limiterOn = !!on;
347 if (audioCtx) route();
348 },
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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 },
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 357 controls(el, opts) {
358 opts = opts || {};
359 if (typeof opts.gainDb === "number") gainDb = opts.gainDb;
360 if (typeof opts.limiter === "boolean") limiterOn = opts.limiter;
361 el.classList.add("cp-controls");
362 el.innerHTML =
363 '<label>Playback gain <input class="cp-gain" type="range" min="0" max="60" step="1"> <span class="cp-gain-label"></span></label>' +
364 '<label><input class="cp-lim" type="checkbox"> limiter</label>' +
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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>';
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 370 const g = el.querySelector(".cp-gain"), gl = el.querySelector(".cp-gain-label"), lim = el.querySelector(".cp-lim");
248673fBrowser-computed spectrograms in the shared playerJeremy Magland 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) }));
cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 377 g.value = String(gainDb);
378 lim.checked = limiterOn;
379 const upd = () => { gl.textContent = "+" + g.value + " dB"; };
380 g.addEventListener("input", () => { upd(); window.ChallengePlayer.setGain(parseFloat(g.value)); });
381 lim.addEventListener("change", () => window.ChallengePlayer.setLimiter(lim.checked));
382 upd();
383 },
384 };
385})();
↑↓ move↵openescclose