Web Audio playback with per-challenge gain and limiter
4 changed files+212−4
README.mdmodified+4−0View file
@@ -39,6 +39,10 @@ A directory `challenges/<challenge-id>/` with a `README.md` (the specification)
3939
4040 The error measures live in `tools/challenge_tools/metrics.py` and the reference rule in `tools/challenge_tools/reference.py`. The challenge READMEs are the specification; this code is intended to follow them exactly. The rule: within a submission, `more_accurate_than` asserts a partial order; candidates for reference are the maximal elements of each submission's order; a candidate is scored by the fraction of asserted pairs from other submissions whose order it reproduces under the primary error; ties are broken by the candidate's convergence gap (error to its nearest asserted-worse sibling) and then by mean error to the other candidates.
4141
42+## Playback
43+
44+The wav files are exact signals with a loud strike transient and a much quieter ring, so played raw they sound faint. The sites use `tools/challenge_tools/player.js`, a Web Audio player that applies a page-wide playback gain (default from `playback.gain_db` in challenge.json, adjustable) and a fast limiter so the transient does not clip. This affects only playback; a solution repository's site builder can copy the same file from the installed package (`challenge_tools.build.player_js()`).
45+
4246 ## Development
4347
4448 ```
pyproject.tomlmodified+1−1View file
@@ -17,4 +17,4 @@ package-dir = {"" = "tools"}
1717 packages = ["challenge_tools"]
1818
1919 [tool.setuptools.package-data]
20-challenge_tools = ["*.json"]
20+challenge_tools = ["*.json", "*.js"]
tools/challenge_tools/build.pymodified+15−3View file
@@ -120,7 +120,6 @@ td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: now
120120 tr.ref { background: var(--ref); }
121121 .muted { color: var(--muted); }
122122 .wrap { overflow-x: auto; }
123-audio { width: 180px; height: 28px; }
124123 details { margin: 1em 0; }
125124 code { font-size: 0.9em; }
126125 """
@@ -151,7 +150,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
151150 errs = "".join(f'<td class="num">{_fmt(r["errors"].get(n))}</td>' for n in names)
152151 params = html.escape(json.dumps(r["parameters"])) if r["parameters"] else ""
153152 mat = ", ".join(html.escape(k.rsplit("/", 1)[1]) for k in r["more_accurate_than"])
154- audio = f'<audio controls preload="none" src="{html.escape(r["audio"])}"></audio>' if r["audio"] else ""
153+ audio = f'<div class="cplayer" data-src="{html.escape(r["audio"])}"></div>' if r["audio"] else ""
155154 linkbits = " ".join(x for x in [
156155 _link(r["audio"], "wav"), _link(r["fields_url"], "fields"), _link(r["code_url"], "code"), _link(r["viz_url"], "viz"),
157156 ] if x)
@@ -195,6 +194,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
195194 )
196195 pair_tables.append(f"<h4>{html.escape(n)}</h4><div class='wrap'><table><tr><th></th>{hdr}</tr>{rws}</table></div>")
197196 spec_link = links.get("spec")
197+ playback_gain = float((challenge.get("playback") or {}).get("gain_db", 20))
198198 return f"""<!doctype html>
199199 <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
200200 <title>{html.escape(res["challenge_id"])} results</title><style>{_CSS}</style></head>
@@ -203,6 +203,7 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
203203 <h1>{html.escape(res["challenge_id"])}</h1>
204204 <p>{html.escape(res.get("title") or "")}. {"<a href='" + html.escape(spec_link) + "'>Challenge specification</a>. " if spec_link else ""}<a href="results.json">results.json</a>. Generated {html.escape(res["generated"])}.</p>
205205 <p>Errors are relative to the reference solution <b>{html.escape(res["reference"] or "none")}</b>, chosen by rule <i>{html.escape(sel["rule"])}</i>. Rows are sorted by the primary metric. Submissions are named as <i>repository/submission</i>.</p>
206+<div id="playback"></div>
206207 <div class="wrap"><table>
207208 <tr><th>Submission</th><th>Solution</th><th>Method</th>{head}<th>Time (s)</th><th>Hardware</th><th>Claimed more accurate than</th><th>Output</th></tr>
208209 {"".join(body)}
@@ -213,7 +214,10 @@ def render_challenge_page(res: dict, challenge: dict, links: dict) -> str:
213214 <h4>Order check against the chosen reference</h4>{viol_html}
214215 </details>
215216 <details><summary>Pairwise errors (row vs column as reference)</summary>{"".join(pair_tables)}</details>
216-</main></body></html>
217+</main>
218+<script src="../player.js"></script>
219+<script>ChallengePlayer.controls(document.getElementById("playback"), {{gainDb: {playback_gain}, limiter: true}}); ChallengePlayer.mount();</script>
220+</body></html>
217221 """
218222
219223
@@ -262,4 +266,12 @@ def build(challenges_dir: Path, sources: list[Source], out: Path, challenge_repo
262266 (cdir / "index.html").write_text(render_challenge_page(res, ch, links))
263267 results.append(res)
264268 (out / "index.html").write_text(render_index(results, sources, {"challenge_repo": challenge_repo_url}))
269+ (out / "player.js").write_text(player_js())
265270 return results
271+
272+
273+def player_js() -> str:
274+ """The Web Audio player shipped with every site (also copied by solution repositories' site builders)."""
275+ from importlib import resources
276+
277+ return resources.files("challenge_tools").joinpath("player.js").read_text()
tools/challenge_tools/player.jsadded+192−0View file
@@ -0,0 +1,192 @@
1+// Web Audio player for the challenge wav files. The files are exact, unnormalized signals with a
2+// large crest factor (a strike transient followed by a much quieter ring), so this player applies a
3+// page-wide gain, with a per-challenge default, and an optional fast limiter so that the ring can be
4+// brought up without the transient clipping. Presentation only: nothing here touches the files.
5+//
6+// Usage: <div class="cplayer" data-src="audio/x.wav"></div> elements, then
7+// ChallengePlayer.mount(); upgrade every .cplayer on the page
8+// ChallengePlayer.controls(el, {gainDb, limiter}) render the gain slider and limiter switch into el
9+(function () {
10+ "use strict";
11+ let audioCtx = null;
12+ let bus = null; // all players feed this
13+ let limiter = null;
14+ let limiterOn = true;
15+ let gainDb = 20;
16+ const players = [];
17+
18+ function ctx() {
19+ if (!audioCtx) {
20+ audioCtx = new (window.AudioContext || window.webkitAudioContext)();
21+ bus = audioCtx.createGain();
22+ limiter = audioCtx.createDynamicsCompressor();
23+ limiter.threshold.value = -1;
24+ limiter.knee.value = 0;
25+ limiter.ratio.value = 20;
26+ limiter.attack.value = 0.001;
27+ limiter.release.value = 0.08;
28+ route();
29+ }
30+ return audioCtx;
31+ }
32+
33+ function route() {
34+ bus.disconnect();
35+ limiter.disconnect();
36+ if (limiterOn) { bus.connect(limiter); limiter.connect(audioCtx.destination); }
37+ else bus.connect(audioCtx.destination);
38+ }
39+
40+ function fmt(t) {
41+ if (!isFinite(t)) return "0.00";
42+ return t.toFixed(2);
43+ }
44+
45+ class Player {
46+ constructor(el) {
47+ this.el = el;
48+ this.src = el.dataset.src;
49+ this.buffer = null;
50+ this.source = null;
51+ this.gain = null;
52+ this.startCtxTime = 0;
53+ this.offset = 0;
54+ this.playing = false;
55+ el.classList.add("cplayer-ready");
56+ el.innerHTML =
57+ '<button class="cp-btn" type="button" title="Play">▶</button>' +
58+ '<input class="cp-seek" type="range" min="0" max="1000" value="0" step="1">' +
59+ '<span class="cp-time">0.00 / …</span>';
60+ this.btn = el.querySelector(".cp-btn");
61+ this.seek = el.querySelector(".cp-seek");
62+ this.time = el.querySelector(".cp-time");
63+ this.btn.addEventListener("click", () => (this.playing ? this.pause() : this.play()));
64+ this.seek.addEventListener("input", () => {
65+ if (!this.buffer) return;
66+ const t = (this.seek.value / 1000) * this.buffer.duration;
67+ const was = this.playing;
68+ if (was) this.stopSource();
69+ this.offset = t;
70+ this.updateTime();
71+ if (was) this.startSource();
72+ });
73+ players.push(this);
74+ }
75+
76+ async load() {
77+ if (this.buffer) return this.buffer;
78+ this.time.textContent = "loading…";
79+ const resp = await fetch(this.src);
80+ const data = await resp.arrayBuffer();
81+ this.buffer = await ctx().decodeAudioData(data);
82+ this.updateTime();
83+ return this.buffer;
84+ }
85+
86+ startSource() {
87+ const c = ctx();
88+ this.gain = c.createGain();
89+ this.gain.gain.value = Math.pow(10, gainDb / 20);
90+ this.source = c.createBufferSource();
91+ this.source.buffer = this.buffer;
92+ this.source.connect(this.gain);
93+ this.gain.connect(bus);
94+ this.startCtxTime = c.currentTime;
95+ this.source.start(0, this.offset);
96+ this.playing = true;
97+ this.btn.innerHTML = "❚❚";
98+ this.btn.title = "Pause";
99+ this.source.onended = () => {
100+ if (this.playing && this.position() >= this.buffer.duration - 0.01) {
101+ this.playing = false;
102+ this.offset = 0;
103+ this.btn.innerHTML = "▶";
104+ this.btn.title = "Play";
105+ this.updateTime();
106+ }
107+ };
108+ this.tick();
109+ }
110+
111+ stopSource() {
112+ if (this.source) {
113+ this.offset = this.position();
114+ try { this.source.stop(); } catch (_) {}
115+ this.source.disconnect();
116+ this.gain.disconnect();
117+ this.source = null;
118+ }
119+ this.playing = false;
120+ this.btn.innerHTML = "▶";
121+ this.btn.title = "Play";
122+ }
123+
124+ position() {
125+ if (!this.playing || !this.source) return this.offset;
126+ return Math.min(this.buffer.duration, this.offset + (ctx().currentTime - this.startCtxTime));
127+ }
128+
129+ async play() {
130+ for (const p of players) if (p !== this && p.playing) p.pause();
131+ await this.load();
132+ if (ctx().state === "suspended") await ctx().resume();
133+ if (this.offset >= this.buffer.duration - 0.01) this.offset = 0;
134+ this.startSource();
135+ }
136+
137+ pause() { this.stopSource(); this.updateTime(); }
138+
139+ setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, gainDb / 20); }
140+
141+ updateTime() {
142+ if (!this.buffer) return;
143+ const pos = this.position();
144+ this.seek.value = String(Math.round((pos / this.buffer.duration) * 1000));
145+ this.time.textContent = fmt(pos) + " / " + fmt(this.buffer.duration) + " s";
146+ }
147+
148+ tick() {
149+ if (!this.playing) return;
150+ this.updateTime();
151+ requestAnimationFrame(() => this.tick());
152+ }
153+ }
154+
155+ const style = document.createElement("style");
156+ style.textContent =
157+ ".cplayer{display:inline-flex;align-items:center;gap:6px;font-size:0.85em;white-space:nowrap}" +
158+ ".cp-btn{font:inherit;width:2em;padding:2px 0;cursor:pointer}.cp-seek{width:110px}" +
159+ ".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}";
160+ document.head.appendChild(style);
161+
162+ window.ChallengePlayer = {
163+ mount(root) {
164+ (root || document).querySelectorAll(".cplayer:not(.cplayer-ready)").forEach((el) => new Player(el));
165+ },
166+ setGain(db) {
167+ gainDb = db;
168+ for (const p of players) p.setGain();
169+ },
170+ setLimiter(on) {
171+ limiterOn = !!on;
172+ if (audioCtx) route();
173+ },
174+ controls(el, opts) {
175+ opts = opts || {};
176+ if (typeof opts.gainDb === "number") gainDb = opts.gainDb;
177+ if (typeof opts.limiter === "boolean") limiterOn = opts.limiter;
178+ el.classList.add("cp-controls");
179+ el.innerHTML =
180+ '<label>Playback gain <input class="cp-gain" type="range" min="0" max="60" step="1"> <span class="cp-gain-label"></span></label>' +
181+ '<label><input class="cp-lim" type="checkbox"> limiter</label>' +
182+ '<span class="cp-note">The wav files are exact physical signals with a loud strike and a quiet ring; the gain and limiter apply to playback only.</span>';
183+ const g = el.querySelector(".cp-gain"), gl = el.querySelector(".cp-gain-label"), lim = el.querySelector(".cp-lim");
184+ g.value = String(gainDb);
185+ lim.checked = limiterOn;
186+ const upd = () => { gl.textContent = "+" + g.value + " dB"; };
187+ g.addEventListener("input", () => { upd(); window.ChallengePlayer.setGain(parseFloat(g.value)); });
188+ lim.addEventListener("change", () => window.ChallengePlayer.setLimiter(lim.checked));
189+ upd();
190+ },
191+ };
192+})();