// 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 tames the transient, and a hard clipper at -1 dBFS is the // last stage of the graph, so nothing above a normal 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; raising it above the normalized target, or switching the limiter off, asks for confirmation. // // 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, -1 / 20); // hard ceiling, -1 dBFS 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 = -1; 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() { 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 = '
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) { this.specData = await this.analysis(spec.nfft, spec.hop); } this.renderSpectrogram(); } async analysis(nfft, hop) { const key = nfft + "/" + hop; if (!this.specCache[key]) { await this.load(); // yield so any "computing" note paints before the analysis runs await new Promise((r) => setTimeout(r, 0)); this.specCache[key] = spectrogramDb(this.buffer.getChannelData(0), this.buffer.sampleRate, nfft, hop); } return this.specCache[key]; } 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(); const note = this.specPanel.querySelector(".cp-spec-note"); note.innerHTML = "Hann 2048, hop 512; 0 to " + (rows * d.binHz / 1000).toFixed(1) + " kHz; " + spec.rangeDb + ' dB below the file\'s maximum. Click to seek. Detailed view'; note.querySelector(".cp-spec-expand").addEventListener("click", (ev) => { ev.preventDefault(); openDetail(this); }); } 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; const tspan = d.duration; const xa = x0 + ((d.offsetSec - d.frameSec / 2) / tspan) * pw, xb = x0 + ((d.offsetSec + (d.nframes - 0.5) * d.frameSec) / tspan) * pw; g.drawImage(this.specImage.canvas, xa, 0, xb - xa, 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; 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(); }); // ---------------------------------------------------------------- detailed view const det = { nfft: 2048, hopDiv: 4, fmin: 0, fmax: 12000, logf: false, bright: 0, range: 80, gamma: 1, cmap: "inferno", t0: 0, t1: null }; let detail = null; // {overlay, canvas, player, data, image, lut, raf} function el(tag, attrs, html) { const e = document.createElement(tag); for (const k in attrs || {}) e.setAttribute(k, attrs[k]); if (html !== undefined) e.innerHTML = html; return e; } function buildDetail() { const overlay = el("div", { class: "cp-detail" }); overlay.innerHTML = '
' + ' ' + '' + '
' + '
' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + ''; document.body.appendChild(overlay); const q = (c) => overlay.querySelector(c); detail = { overlay, canvas: q(".cp-d-canvas"), player: null, data: null, image: null, lut: colorLut(det.cmap), raf: 0, q }; q(".cp-d-close").addEventListener("click", closeDetail); overlay.addEventListener("keydown", (ev) => { if (ev.key === "Escape") closeDetail(); }); q(".cp-d-play").addEventListener("click", () => { const p = detail.player; if (p) (p.playing ? p.pause() : p.play()); }); const rerender = () => renderDetail(false); const reanalyze = () => renderDetail(true); q(".cp-d-nfft").addEventListener("change", (e) => { det.nfft = parseInt(e.target.value, 10); reanalyze(); }); q(".cp-d-hop").addEventListener("change", (e) => { det.hopDiv = parseInt(e.target.value, 10); reanalyze(); }); q(".cp-d-fmin").addEventListener("change", (e) => { det.fmin = Math.max(0, parseFloat(e.target.value) || 0); rerender(); }); q(".cp-d-fmax").addEventListener("change", (e) => { det.fmax = Math.max(100, parseFloat(e.target.value) || 12000); rerender(); }); q(".cp-d-logf").addEventListener("change", (e) => { det.logf = e.target.checked; rerender(); }); q(".cp-d-bright").addEventListener("input", (e) => { det.bright = parseFloat(e.target.value); rerender(); }); q(".cp-d-range").addEventListener("input", (e) => { det.range = parseFloat(e.target.value); rerender(); }); q(".cp-d-gamma").addEventListener("input", (e) => { det.gamma = parseFloat(e.target.value); rerender(); }); q(".cp-d-cmap").addEventListener("change", (e) => { det.cmap = e.target.value; detail.lut = colorLut(det.cmap); rerender(); }); q(".cp-d-t0").addEventListener("input", (e) => { det.t0 = parseFloat(e.target.value) * detail.data.duration; if (det.t1 - det.t0 < 0.02) det.t0 = det.t1 - 0.02; rerender(); }); q(".cp-d-t1").addEventListener("input", (e) => { det.t1 = parseFloat(e.target.value) * detail.data.duration; if (det.t1 - det.t0 < 0.02) det.t1 = det.t0 + 0.02; rerender(); }); detail.canvas.addEventListener("click", (ev) => { const p = detail.player; if (!p || !p.buffer) return; const r = detail.canvas.getBoundingClientRect(); const frac = (ev.clientX - r.left - DLEFT) / (r.width - DLEFT); if (frac < 0 || frac > 1) return; const t = det.t0 + frac * (det.t1 - det.t0); const was = p.playing; if (was) p.stopSource(); p.offset = Math.min(p.buffer.duration, t); p.updateTime(); if (was) p.startSource(); }); window.addEventListener("resize", () => { if (detail.player) renderDetail(false); }); } async function openDetail(player) { if (!detail) buildDetail(); detail.player = player; detail.overlay.style.display = "flex"; detail.overlay.tabIndex = -1; detail.overlay.focus(); detail.q(".cp-d-title").textContent = player.src.split("/").pop(); await player.load(); if (det.t1 === null || det.t1 > player.buffer.duration) det.t1 = player.buffer.duration; det.t0 = Math.min(det.t0, det.t1 - 0.02); await renderDetail(true); cancelAnimationFrame(detail.raf); const loop = () => { if (detail.player) { drawDetail(); detail.raf = requestAnimationFrame(loop); } }; loop(); } function closeDetail() { if (!detail) return; cancelAnimationFrame(detail.raf); detail.overlay.style.display = "none"; detail.player = null; } const DLEFT = 44, DBOTTOM = 22; async function renderDetail(reanalyze) { const p = detail.player; if (!p) return; if (reanalyze || !detail.data) { detail.q(".cp-d-note").textContent = "computing…"; detail.data = await p.analysis(det.nfft, Math.max(1, Math.round(det.nfft / det.hopDiv))); } const d = detail.data; const c = detail.canvas; const W = Math.max(400, c.parentElement.clientWidth - 24); const H = Math.max(200, window.innerHeight - detail.q(".cp-detail-bar").offsetHeight - detail.q(".cp-detail-ctl").offsetHeight - 40); const pw = W - DLEFT, ph = H - DBOTTOM; // image in display resolution: per pixel column the max over the frames it covers, per pixel row the max over the bins const img = document.createElement("canvas"); img.width = pw; img.height = ph; const ictx = img.getContext("2d"); const data = ictx.createImageData(pw, ph); const t0 = det.t0, t1 = det.t1 === null ? d.duration : det.t1; const fmin = det.fmin, fmax = Math.min(det.fmax, d.binHz * (d.nbins - 1)); const flo = det.logf ? Math.max(fmin, 20) : fmin; const top = d.max - det.bright, lo = top - det.range; const lut = detail.lut; const rowBin = new Int32Array(ph + 1); for (let r = 0; r <= ph; r++) { const u = 1 - r / ph; // bottom row is fmin const f = det.logf ? flo * Math.pow(fmax / flo, u) : flo + (fmax - flo) * u; rowBin[r] = Math.max(0, Math.min(d.nbins - 1, Math.round(f / d.binHz))); } for (let x = 0; x < pw; x++) { const fa = Math.round((t0 + (x / pw) * (t1 - t0) - d.offsetSec) / d.frameSec); const fb = Math.max(fa, Math.round((t0 + ((x + 1) / pw) * (t1 - t0) - d.offsetSec) / d.frameSec) - 1); for (let r = 0; r < ph; r++) { const b0 = Math.min(rowBin[r], rowBin[r + 1]), b1 = Math.max(rowBin[r], rowBin[r + 1]); let v = -Infinity; for (let f = Math.max(0, fa); f <= Math.min(d.nframes - 1, fb); f++) { const base = f * d.nbins; for (let b = b0; b <= b1; b++) { const w = d.db[base + b]; if (w > v) v = w; } } let u = (v - lo) / det.range; u = u <= 0 ? 0 : u >= 1 ? 1 : Math.pow(u, det.gamma); const n = Math.round(u * 255) * 3, o = 4 * (r * pw + x); data.data[o] = lut[n]; data.data[o + 1] = lut[n + 1]; data.data[o + 2] = lut[n + 2]; data.data[o + 3] = 255; } } ictx.putImageData(data, 0, 0); detail.image = { canvas: img, W, H, t0, t1, flo, fmax }; detail.q(".cp-d-bright-l").textContent = (det.bright >= 0 ? "+" : "") + det.bright + " dB"; detail.q(".cp-d-range-l").textContent = det.range + " dB"; detail.q(".cp-d-gamma-l").textContent = det.gamma.toFixed(2); detail.q(".cp-d-time-l").textContent = t0.toFixed(2) + " to " + t1.toFixed(2) + " s"; detail.q(".cp-d-t0").value = String(t0 / d.duration); detail.q(".cp-d-t1").value = String(t1 / d.duration); detail.q(".cp-d-note").textContent = "Hann " + det.nfft + " (" + (det.nfft / p.buffer.sampleRate * 1e3).toFixed(1) + " ms, " + d.binHz.toFixed(1) + " Hz per bin), hop " + Math.round(det.nfft / det.hopDiv) + "; 0 dB is the file's maximum; the ceiling is " + det.bright + " dB below it and the floor " + (det.bright + det.range) + " dB. Click to seek."; drawDetail(); } function drawDetail() { const p = detail.player, im = detail.image; if (!p || !im) return; const c = detail.canvas; if (c.width !== im.W || c.height !== im.H) { c.width = im.W; c.height = im.H; } const g = c.getContext("2d"); const pw = im.W - DLEFT, ph = im.H - DBOTTOM; g.fillStyle = "#111"; g.fillRect(0, 0, im.W, im.H); g.drawImage(im.canvas, DLEFT, 0); g.fillStyle = "#ddd"; g.strokeStyle = "#ddd"; g.font = "11px system-ui, sans-serif"; // frequency ticks g.textAlign = "right"; g.textBaseline = "middle"; const ticks = []; if (det.logf) { for (const f of [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]) if (f >= im.flo && f <= im.fmax) ticks.push(f); } else { const span = im.fmax - im.flo, step = span > 15000 ? 5000 : span > 7000 ? 2000 : span > 3000 ? 1000 : span > 1200 ? 500 : span > 500 ? 200 : 100; for (let f = Math.ceil(im.flo / step) * step; f <= im.fmax; f += step) ticks.push(f); } for (const f of ticks) { const u = det.logf ? Math.log(f / im.flo) / Math.log(im.fmax / im.flo) : (f - im.flo) / (im.fmax - im.flo); const y = ph - u * ph; if (y < 7 || y > ph - 2) continue; g.fillText(f >= 1000 ? (f / 1000) + "k" : String(f), DLEFT - 4, y); g.beginPath(); g.moveTo(DLEFT - 3, y); g.lineTo(DLEFT, y); g.stroke(); } // time ticks g.textAlign = "center"; g.textBaseline = "top"; const span = im.t1 - im.t0, tstep = span > 4 ? 1 : span > 2 ? 0.5 : span > 0.8 ? 0.2 : span > 0.4 ? 0.1 : span > 0.15 ? 0.05 : 0.01; for (let t = Math.ceil(im.t0 / tstep) * tstep; t <= im.t1 + 1e-9; t += tstep) { const x = DLEFT + ((t - im.t0) / span) * pw; g.fillText(t.toFixed(tstep < 0.1 ? 2 : tstep < 1 ? 1 : 0) + " s", x, ph + 5); g.beginPath(); g.moveTo(x, ph); g.lineTo(x, ph + 3); g.stroke(); } // playhead and transport if (p.buffer) { const pos = p.position(); if (pos >= im.t0 && pos <= im.t1) { const x = DLEFT + ((pos - im.t0) / span) * pw; g.strokeStyle = "rgba(255,255,255,0.9)"; g.lineWidth = 1.5; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, ph); g.stroke(); g.lineWidth = 1; } detail.q(".cp-d-play").innerHTML = p.playing ? "❚❚" : "▶"; detail.q(".cp-d-time").textContent = fmt(pos) + " / " + fmt(p.buffer.duration) + " s"; } } 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}" + ".cp-detail{display:none;position:fixed;inset:0;z-index:1000;background:#111;color:#ddd;flex-direction:column;padding:8px 12px;box-sizing:border-box;font-size:0.9em;outline:none}" + ".cp-detail-bar{display:flex;gap:12px;align-items:center;margin-bottom:6px}.cp-detail-bar .cp-d-title{flex:1;text-align:center;opacity:0.8}" + ".cp-detail button{font:inherit;padding:2px 8px;cursor:pointer;background:#333;color:#eee;border:1px solid #555;border-radius:3px}" + ".cp-detail-ctl{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:center;margin-bottom:6px}.cp-detail-ctl label{display:flex;gap:5px;align-items:center;white-space:nowrap}" + ".cp-detail-ctl input[type=range]{width:110px}.cp-detail-ctl select,.cp-detail-ctl input[type=number]{font:inherit;background:#222;color:#eee;border:1px solid #555}" + ".cp-d-note{font-size:0.85em;opacity:0.7;flex-basis:100%}.cp-d-canvas{display:block;flex:1;cursor:crosshair}"; document.head.appendChild(style); window.ChallengePlayer = { mount(root) { (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el)); }, setLevel(db) { levelDb = Math.max(LEVEL_MIN, Math.min(LEVEL_MAX, db)); try { localStorage.setItem("cp-level-db", String(levelDb)); } catch (_) {} for (const p of players) p.setGain(); }, setGain(db) { window.ChallengePlayer.setLevel(db); }, 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.limiter === "boolean") limiterOn = opts.limiter; el.classList.add("cp-controls"); el.innerHTML = '' + '' + 'Playback is loudness-normalized (every file\'s loudest 400 ms at -16 dBFS) and hard-limited at -1 dBFS; the level offset moves all files together and starts low. Set your device volume with headphones off, then raise the level if needed. The wav files themselves are exact physical signals and are not changed.' + '' + '' + '' + ''; 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(levelDb); lim.checked = limiterOn; const upd = () => { const v = parseFloat(g.value); gl.textContent = (v > 0 ? "+" : "") + v + " dB" + (v > 0 ? " (above the normalized level)" : v === 0 ? " (normalized level)" : ""); }; let confirmedLoud = levelDb > 0; g.addEventListener("input", () => { const v = parseFloat(g.value); if (v > 0 && !confirmedLoud) { const ok = window.confirm("This raises playback above the normalized level, which is already as loud as a music track at your current volume. With headphones or earbuds this can be uncomfortable or harmful. Continue?"); if (!ok) { g.value = "0"; upd(); window.ChallengePlayer.setLevel(0); return; } confirmedLoud = true; } upd(); window.ChallengePlayer.setLevel(v); }); lim.addEventListener("change", () => { if (!lim.checked) { const ok = window.confirm("Switching the limiter off lets the strike transients through at full strength (still hard-clipped at -1 dBFS). With headphones this can be unpleasant. Continue?"); if (!ok) { lim.checked = true; return; } } window.ChallengePlayer.setLimiter(lim.checked); }); upd(); }, }; })();