// 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 = '
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), the strike is limited at -10 dBFS and nothing above -6 dBFS can pass; 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 -6 dBFS). With headphones this can be unpleasant. Continue?"); if (!ok) { lim.checked = true; return; } } window.ChallengePlayer.setLimiter(lim.checked); }); upd(); }, }; })();