// 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, 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; 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.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.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) { 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)); }, 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(); }, }; })();