// 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 = '