cc3c4cfWeb Audio playback with per-challenge gain and limiterJeremy Magland 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 = [];
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 }
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 }
40 function fmt(t) {
41 if (!isFinite(t)) return "0.00";
42 return t.toFixed(2);
43 }
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 }
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 }
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 }
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 }
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 }
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 }
137 pause() { this.stopSource(); this.updateTime(); }
139 setGain() { if (this.gain) this.gain.gain.value = Math.pow(10, gainDb / 20); }
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 }
148 tick() {
149 if (!this.playing) return;
150 this.updateTime();
151 requestAnimationFrame(() => this.tick());
152 }
153 }
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);
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})();