// 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). Playback is made safe for
// headphones in three stages, none of which touches the files: each file is loudness-normalized so
// that its loudest 400 ms window sits at a fixed target (-16 dBFS rms, about the loudness of a
// streamed music track), a fast limiter holds the strike transient at -10 dBFS, and a hard clipper at
// -6 dBFS is the last stage of the graph, so nothing louder than a quiet music peak can reach the output
// whatever the settings. The listener adjusts a bounded level offset (-20 to +6 dB) that starts at -12 dB
// in a new browser; the first playback on a page asks for confirmation, and so does raising the level
// above the normalized target or switching the limiter off.
//
// Usage:
elements, then
// ChallengePlayer.mount(); upgrade every .cplayer on the page
// ChallengePlayer.controls(el, {limiter}) render the level slider and limiter switch into el
// (a gainDb option is accepted and ignored, for pages built before loudness normalization)
//
// 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 clipper = null;
let limiterOn = true;
const TARGET_DBFS = -16; // loudest 400 ms rms window of every file is placed here
const LEVEL_MIN = -20, LEVEL_MAX = 6, LEVEL_FIRST = -12;
const CLIP = Math.pow(10, -6 / 20); // hard ceiling, -6 dBFS
const LIMIT_DB = -10; // limiter threshold
let playConfirmed = false; // first playback on this page asks first
let levelDb = LEVEL_FIRST;
try {
const v = parseFloat(localStorage.getItem("cp-level-db"));
if (isFinite(v)) levelDb = Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, v));
} catch (_) {}
const players = [];
function shortTermPeakDb(samples, sampleRate) {
// the loudest 400 ms rms window (100 ms hop), in dBFS
const win = Math.max(1, Math.round(0.4 * sampleRate)), hop = Math.max(1, Math.round(0.1 * sampleRate));
let best = 0;
for (let s = 0; ; s += hop) {
const e = Math.min(samples.length, s + win);
let acc = 0;
for (let i = s; i < e; i++) acc += samples[i] * samples[i];
const ms = acc / Math.max(1, e - s);
if (ms > best) best = ms;
if (e >= samples.length) break;
}
return 10 * Math.log10(best + 1e-30);
}
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, nfft, hop) {
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, offsetSec: nfft / 2 / sampleRate, duration: samples.length / sampleRate };
}
// compact colormaps: anchors interpolated linearly
const CMAPS = {
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]],
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]],
gray: [[0, 0, 0], [255, 255, 255]],
};
function colorLut(name) {
const map = CMAPS[name] || CMAPS.inferno;
const lut = new Uint8ClampedArray(256 * 3);
for (let n = 0; n < 256; n++) {
const u = (n / 255) * (map.length - 1);
const i = Math.min(map.length - 2, Math.floor(u)), t = u - i;
const a = map[i], b = map[i + 1];
for (let c = 0; c < 3; c++) lut[3 * n + c] = a[c] + (b[c] - a[c]) * t;
}
return lut;
}
const LUT = colorLut("inferno");
function color(u) {
const n = Math.max(0, Math.min(255, Math.round(u * 255)));
return [LUT[3 * n], LUT[3 * n + 1], LUT[3 * n + 2]];
}
function ctx() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
bus = audioCtx.createGain();
limiter = audioCtx.createDynamicsCompressor();
limiter.threshold.value = LIMIT_DB;
limiter.knee.value = 0;
limiter.ratio.value = 20;
limiter.attack.value = 0.001;
limiter.release.value = 0.08;
clipper = audioCtx.createWaveShaper();
const n = 8192, curve = new Float32Array(n);
for (let i = 0; i < n; i++) { const x = (2 * i) / (n - 1) - 1; curve[i] = Math.max(-CLIP, Math.min(CLIP, x)); }
clipper.curve = curve;
clipper.oversample = "none";
clipper.connect(audioCtx.destination);
route();
}
return audioCtx;
}
function route() {
// bus -> [limiter] -> clipper -> destination; the clipper is never bypassed
bus.disconnect();
limiter.disconnect();
if (limiterOn) { bus.connect(limiter); limiter.connect(clipper); }
else bus.connect(clipper);
}
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.specCache = {}; // analyses keyed by "nfft/hop"
this.specImage = null; // {canvas, rows}
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.normDb = TARGET_DBFS - shortTermPeakDb(this.buffer.getChannelData(0), this.buffer.sampleRate);
this.updateTime();
return this.buffer;
}
startSource() {
const c = ctx();
this.gain = c.createGain();
this.gain.gain.value = Math.pow(10, (this.normDb + levelDb) / 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() {
if (!playConfirmed) {
const ok = window.confirm("Audio is about to play. Playback is loudness-normalized and limited (peaks at most -6 dBFS, the level of a quiet music track), but if you wear headphones or earbuds, lower your device volume first. Play?");
if (!ok) return;
playConfirmed = true;
}
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, (this.normDb + levelDb) / 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 = '