// 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 (function () { "use strict"; let audioCtx = null; let bus = null; // all players feed this let limiter = null; let limiterOn = true; let gainDb = 20; const players = []; 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 / …'; this.btn = el.querySelector(".cp-btn"); this.seek = el.querySelector(".cp-seek"); this.time = el.querySelector(".cp-time"); 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"; } tick() { if (!this.playing) return; this.updateTime(); requestAnimationFrame(() => this.tick()); } } 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}"; 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(); }, 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"); 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(); }, }; })();