// Web Audio player for the challenge wav files. The files are exact, unnormalized signals with a // large crest factor (a strike transient followed by a much quieter ring), so this player applies a // page-wide gain, with a per-challenge default, and an optional fast limiter so that the ring can be // brought up without the transient clipping. Presentation only: nothing here touches the files. // // Usage:
elements, then // ChallengePlayer.mount(); upgrade every .cplayer on the page // ChallengePlayer.controls(el, {gainDb, limiter}) render the gain slider and limiter switch into el // // Each player can also show a spectrogram of its file, computed in the browser from the decoded // samples (periodic Hann window of 2048 samples, hop 512, the same analysis as the spectrogram error // measure), drawn in dB relative to the file's own maximum with a page-wide frequency limit and // dynamic range. The playhead is drawn over it during playback. (function () { "use strict"; let audioCtx = null; let bus = null; // all players feed this let limiter = null; let limiterOn = true; let gainDb = 20; const players = []; const spec = { nfft: 2048, hop: 512, fmax: 12000, rangeDb: 80 }; // ---------------------------------------------------------------- spectrogram analysis function fftReal(re, im) { // in-place iterative radix-2 complex FFT (re, im of equal power-of-two length) const n = re.length; for (let i = 1, j = 0; i < n; i++) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { let t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; } } for (let len = 2; len <= n; len <<= 1) { const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang); for (let i = 0; i < n; i += len) { let cr = 1, ci = 0; for (let k = 0; k < len / 2; k++) { const a = i + k, b = a + len / 2; const tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr; re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti; const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr; } } } } function spectrogramDb(samples, sampleRate) { const { nfft, hop } = spec; const nframes = Math.max(0, Math.floor((samples.length - nfft) / hop) + 1); const nbins = nfft / 2 + 1; const win = new Float32Array(nfft); for (let i = 0; i < nfft; i++) win[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / nfft)); const db = new Float32Array(nframes * nbins); const re = new Float64Array(nfft), im = new Float64Array(nfft); let max = -Infinity; for (let f = 0; f < nframes; f++) { const o = f * hop; for (let i = 0; i < nfft; i++) { re[i] = samples[o + i] * win[i]; im[i] = 0; } fftReal(re, im); for (let k = 0; k < nbins; k++) { const v = 10 * Math.log10(re[k] * re[k] + im[k] * im[k] + 1e-30); db[f * nbins + k] = v; if (v > max) max = v; } } return { db, nframes, nbins, max, binHz: sampleRate / nfft, frameSec: hop / sampleRate, duration: samples.length / sampleRate }; } // a compact inferno-like colormap: anchors interpolated linearly 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]]; function color(u) { u = Math.max(0, Math.min(0.99999, u)) * (CMAP.length - 1); const i = Math.floor(u), t = u - i; const a = CMAP[i], b = CMAP[i + 1]; return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } function ctx() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); bus = audioCtx.createGain(); limiter = audioCtx.createDynamicsCompressor(); limiter.threshold.value = -1; limiter.knee.value = 0; limiter.ratio.value = 20; limiter.attack.value = 0.001; limiter.release.value = 0.08; route(); } return audioCtx; } function route() { bus.disconnect(); limiter.disconnect(); if (limiterOn) { bus.connect(limiter); limiter.connect(audioCtx.destination); } else bus.connect(audioCtx.destination); } function fmt(t) { if (!isFinite(t)) return "0.00"; return t.toFixed(2); } class Player { constructor(el) { this.el = el; this.src = el.dataset.src; this.buffer = null; this.source = null; this.gain = null; this.startCtxTime = 0; this.offset = 0; this.playing = false; el.classList.add("cplayer-ready"); el.innerHTML = '' + '' + '0.00 / …'; el.insertAdjacentHTML("beforeend", ''); this.btn = el.querySelector(".cp-btn"); this.seek = el.querySelector(".cp-seek"); this.time = el.querySelector(".cp-time"); this.specBtn = el.querySelector(".cp-spec-btn"); this.specPanel = null; // created on demand, inserted after the player element this.specData = null; this.specImage = null; // {canvas, fmax, rangeDb} this.specBtn.addEventListener("click", () => this.toggleSpectrogram()); this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play())); this.seek.addEventListener("input", () => { if (!this.buffer) return; const t = (this.seek.value / 1000) * this.buffer.duration; const was = this.playing; if (was) this.stopSource(); this.offset = t; this.updateTime(); if (was) this.startSource(); }); players.push(this); } async load() { if (this.buffer) return this.buffer; this.time.textContent = "loading…"; const resp = await fetch(this.src); const data = await resp.arrayBuffer(); this.buffer = await ctx().decodeAudioData(data); this.updateTime(); return this.buffer; } startSource() { const c = ctx(); this.gain = c.createGain(); this.gain.gain.value = Math.pow(10, gainDb / 20); this.source = c.createBufferSource(); this.source.buffer = this.buffer; this.source.connect(this.gain); this.gain.connect(bus); this.startCtxTime = c.currentTime; this.source.start(0, this.offset); this.playing = true; this.btn.innerHTML = "❚❚"; this.btn.title = "Pause"; this.source.onended = () => { if (this.playing && this.position() >= this.buffer.duration - 0.01) { this.playing = false; this.offset = 0; this.btn.innerHTML = "▶"; this.btn.title = "Play"; this.updateTime(); } }; this.tick(); } stopSource() { if (this.source) { this.offset = this.position(); try { this.source.stop(); } catch (_) {} this.source.disconnect(); this.gain.disconnect(); this.source = null; } this.playing = false; this.btn.innerHTML = "▶"; this.btn.title = "Play"; } position() { if (!this.playing || !this.source) return this.offset; return Math.min(this.buffer.duration, this.offset + (ctx().currentTime - this.startCtxTime)); } async play() { for (const p of players) if (p !== this && p.playing) p.pause(); await this.load(); if (ctx().state === "suspended") await ctx().resume(); if (this.offset >= this.buffer.duration - 0.01) this.offset = 0; this.startSource(); } pause() { this.stopSource(); this.updateTime(); } setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, gainDb / 20); } updateTime() { if (!this.buffer) return; const pos = this.position(); this.seek.value = String(Math.round((pos / this.buffer.duration) * 1000)); this.time.textContent = fmt(pos) + " / " + fmt(this.buffer.duration) + " s"; if (!this.playing && this.specImage && this.specPanel.style.display !== "none") this.drawPlayhead(); } tick() { if (!this.playing) return; this.updateTime(); if (this.specPanel && this.specPanel.style.display !== "none") this.drawPlayhead(); requestAnimationFrame(() => this.tick()); } // ---------------------------------------------------------------- spectrogram display async toggleSpectrogram(force) { const show = typeof force === "boolean" ? force : !(this.specPanel && this.specPanel.style.display !== "none"); if (!show) { if (this.specPanel) this.specPanel.style.display = "none"; return; } if (!this.specPanel) { this.specPanel = document.createElement("div"); this.specPanel.className = "cp-spec"; this.specPanel.innerHTML = '
computing…
'; this.el.insertAdjacentElement("afterend", this.specPanel); this.canvas = this.specPanel.querySelector(".cp-spec-canvas"); this.canvas.addEventListener("click", (ev) => { if (!this.buffer) return; const r = this.canvas.getBoundingClientRect(); const t = ((ev.clientX - r.left - LEFT) / (r.width - LEFT)) * this.buffer.duration; if (t < 0) return; const was = this.playing; if (was) this.stopSource(); this.offset = Math.min(this.buffer.duration, t); this.updateTime(); if (was) this.startSource(); else this.drawPlayhead(); }); } this.specPanel.style.display = ""; if (!this.specData) { await this.load(); // yield so the "computing" note paints before the analysis runs await new Promise((r) => setTimeout(r, 0)); this.specData = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate); } this.renderSpectrogram(); } renderSpectrogram() { if (!this.specData || !this.specPanel || this.specPanel.style.display === "none") return; const d = this.specData; const rows = Math.min(d.nbins, Math.ceil(spec.fmax / d.binHz)); const img = document.createElement("canvas"); img.width = d.nframes; img.height = rows; const ictx = img.getContext("2d"); const data = ictx.createImageData(d.nframes, rows); const lo = d.max - spec.rangeDb; for (let f = 0; f < d.nframes; f++) { for (let k = 0; k < rows; k++) { const u = (d.db[f * d.nbins + k] - lo) / spec.rangeDb; const c = color(u); const o = 4 * ((rows - 1 - k) * d.nframes + f); data.data[o] = c[0]; data.data[o + 1] = c[1]; data.data[o + 2] = c[2]; data.data[o + 3] = 255; } } ictx.putImageData(data, 0, 0); this.specImage = { canvas: img, rows }; this.drawPlayhead(); this.specPanel.querySelector(".cp-spec-note").textContent = "Hann 2048, hop 512; 0 to " + (rows * d.binHz / 1000).toFixed(1) + " kHz; " + spec.rangeDb + " dB below the file's maximum. Click to seek."; } drawPlayhead() { if (!this.specImage) return; const c = this.canvas; const W = Math.max(300, Math.floor(c.parentElement.clientWidth || 600)), H = 170; if (c.width !== W || c.height !== H) { c.width = W; c.height = H; } const g = c.getContext("2d"); const d = this.specData, rows = this.specImage.rows; const x0 = LEFT, y1 = H - BOTTOM, pw = W - x0, ph = y1; g.fillStyle = getComputedStyle(document.body).backgroundColor || "#fff"; g.fillRect(0, 0, W, H); g.imageSmoothingEnabled = false; g.drawImage(this.specImage.canvas, x0, 0, pw, ph); // axes const fg = getComputedStyle(document.body).color || "#000"; g.fillStyle = fg; g.strokeStyle = fg; g.font = "10px system-ui, sans-serif"; g.textAlign = "right"; g.textBaseline = "middle"; const fmaxShown = rows * d.binHz; const fstep = fmaxShown > 15000 ? 5000 : fmaxShown > 7000 ? 2000 : 1000; for (let fr = 0; fr <= fmaxShown + 1; fr += fstep) { const y = y1 - (fr / fmaxShown) * ph; if (y < 6) continue; g.fillText((fr / 1000).toFixed(0) + "k", x0 - 3, y); g.beginPath(); g.moveTo(x0 - 2, y); g.lineTo(x0, y); g.stroke(); } g.textAlign = "center"; g.textBaseline = "top"; const tstep = d.duration > 4 ? 1 : 0.5; const tspan = d.nframes * d.frameSec; for (let t = 0; t <= tspan + 1e-9; t += tstep) { const x = x0 + (t / tspan) * pw; g.fillText(t.toFixed(tstep < 1 ? 1 : 0) + " s", x, y1 + 3); g.beginPath(); g.moveTo(x, y1); g.lineTo(x, y1 + 2); g.stroke(); } // playhead if (this.buffer) { const x = x0 + (this.position() / tspan) * pw; g.strokeStyle = "rgba(255,255,255,0.9)"; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, y1); g.stroke(); g.lineWidth = 1; } } } const LEFT = 30, BOTTOM = 16; window.addEventListener("resize", () => { for (const p of players) if (p.specImage) p.drawPlayhead(); }); const style = document.createElement("style"); style.textContent = ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" + ".cp-btn{font:inherit;width:2em;padding:2px 0;cursor:pointer}.cp-seek{width:110px}" + ".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}" + ".cp-spec-btn{font:inherit;font-size:0.9em;padding:2px 6px;cursor:pointer}" + ".cp-spec{display:block;margin:4px 0 6px;min-width:320px}.cp-spec canvas{display:block;width:100%;height:170px;cursor:crosshair}" + ".cp-spec-note{font-size:0.78em;opacity:0.7;margin-top:2px}"; document.head.appendChild(style); window.ChallengePlayer = { mount(root) { (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el)); }, setGain(db) { gainDb = db; for (const p of players) p.setGain(); }, setLimiter(on) { limiterOn = !!on; if (audioCtx) route(); }, setSpectrogram(opts) { if (typeof opts.fmax === "number") spec.fmax = opts.fmax; if (typeof opts.rangeDb === "number") spec.rangeDb = opts.rangeDb; for (const p of players) p.renderSpectrogram(); }, showSpectrograms(on) { for (const p of players) p.toggleSpectrogram(!!on); }, controls(el, opts) { opts = opts || {}; if (typeof opts.gainDb === "number") gainDb = opts.gainDb; if (typeof opts.limiter === "boolean") limiterOn = opts.limiter; el.classList.add("cp-controls"); el.innerHTML = '' + '' + 'The wav files are exact physical signals with a loud strike and a quiet ring; the gain and limiter apply to playback only.' + '' + '' + '' + ''; const g = el.querySelector(".cp-gain"), gl = el.querySelector(".cp-gain-label"), lim = el.querySelector(".cp-lim"); el.querySelector(".cp-spec-all").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(true)); el.querySelector(".cp-spec-none").addEventListener("click", () => window.ChallengePlayer.showSpectrograms(false)); const fm = el.querySelector(".cp-spec-fmax"), rg = el.querySelector(".cp-spec-range"); fm.value = String(spec.fmax); rg.value = String(spec.rangeDb); fm.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ fmax: parseFloat(fm.value) })); rg.addEventListener("change", () => window.ChallengePlayer.setSpectrogram({ rangeDb: parseFloat(rg.value) })); g.value = String(gainDb); lim.checked = limiterOn; const upd = () => { gl.textContent = "+" + g.value + " dB"; }; g.addEventListener("input", () => { upd(); window.ChallengePlayer.setGain(parseFloat(g.value)); }); lim.addEventListener("change", () => window.ChallengePlayer.setLimiter(lim.checked)); upd(); }, }; })();