← back to Model Wars
feat: procedural Web Audio battle sound + mute toggle (v1.2.0)
75a84baf0d475890c8c0812dccbdf5d98c32d3af · 2026-08-24 10:45:34 -0700 · Steve Abrams
Zero-asset, zero-cost SFX engine (public/sound.js) synthesized from oscillators
+ noise: charge horn + galloping hooves, lance clash, arrow thwip, trebuchet
boom, sword clang, dragon roar, victory fanfare / draw chime. Fired once-per-
event from the bouts (battles.js) and on outcome (app.js). Audio unlocks on the
Begin gesture (autoplay policy); persisted 🔊/🔇 mute toggle in the header.
Serves the brief's 'make benchmarks feel like games people want to watch.'
Verified: loads, inits, plays, mutes — zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M package-lock.jsonM package.jsonM public/app.jsM public/battles.jsM public/index.htmlA public/sound.jsM public/styles.css
Diff
commit 75a84baf0d475890c8c0812dccbdf5d98c32d3af
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 10:45:34 2026 -0700
feat: procedural Web Audio battle sound + mute toggle (v1.2.0)
Zero-asset, zero-cost SFX engine (public/sound.js) synthesized from oscillators
+ noise: charge horn + galloping hooves, lance clash, arrow thwip, trebuchet
boom, sword clang, dragon roar, victory fanfare / draw chime. Fired once-per-
event from the bouts (battles.js) and on outcome (app.js). Audio unlocks on the
Begin gesture (autoplay policy); persisted 🔊/🔇 mute toggle in the header.
Serves the brief's 'make benchmarks feel like games people want to watch.'
Verified: loads, inits, plays, mutes — zero console errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
package-lock.json | 4 ++--
package.json | 2 +-
public/app.js | 8 +++++++
public/battles.js | 21 ++++++++++++-----
public/index.html | 2 ++
public/sound.js | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
public/styles.css | 5 ++++
7 files changed, 101 insertions(+), 9 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index c49eea9..b11ded9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "model-wars",
- "version": "1.1.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "model-wars",
- "version": "1.1.0",
+ "version": "1.2.0",
"license": "MIT",
"dependencies": {
"express": "^4.19.2"
diff --git a/package.json b/package.json
index 4ffa299..05cb353 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "model-wars",
- "version": "1.1.0",
+ "version": "1.2.0",
"description": "MODEL WARS — leading AI models battle head-to-head in a medieval tournament. Real API battles, AI judges, and a persistent medieval ELO ladder.",
"type": "module",
"main": "server.js",
diff --git a/public/app.js b/public/app.js
index 531d1f0..a169fad 100644
--- a/public/app.js
+++ b/public/app.js
@@ -142,6 +142,7 @@
const a = champById($('#selA').value), b = champById($('#selB').value);
if (a.id === b.id) { toast('Choose two different champions'); return; }
battling = true;
+ window.SFX && (SFX.init(), SFX.horn()); // unlock audio on this user gesture
const prompt = $('#prompt').value.trim() || mode.prompt;
const btn = $('#beginBtn'); btn.disabled = true; btn.textContent = 'Battling…';
$('#results').style.display = 'none';
@@ -189,6 +190,7 @@
});
last = { a, b, A, B, dimsA, dimsB, cA, cB, pA, pB, outcome, exhibition, scoreMode, prompt };
+ if (window.SFX) (outcome === 0.5 ? SFX.draw() : SFX.fanfare());
renderResults();
// Only LIVE, measured battles move the persistent King's Table.
@@ -308,6 +310,12 @@
$('#selA').onchange = () => renderFighter('A');
$('#selB').onchange = () => renderFighter('B');
$('#beginBtn').onclick = begin;
+ const muteBtn = $('#muteBtn');
+ if (muteBtn && window.SFX) {
+ const paint = () => { const m = SFX.isMuted(); muteBtn.textContent = m ? '🔇 Muted' : '🔊 Sound'; muteBtn.classList.toggle('muted', m); };
+ paint();
+ muteBtn.onclick = () => { SFX.init(); SFX.toggleMute(); if (!SFX.isMuted()) SFX.click(); paint(); };
+ }
$('#voteA').onclick = () => { if (last) vote(last.a.id); };
$('#voteB').onclick = () => { if (last) vote(last.b.id); };
$('#evidenceBtn').onclick = () => {
diff --git a/public/battles.js b/public/battles.js
index aab6535..aeb40fc 100644
--- a/public/battles.js
+++ b/public/battles.js
@@ -105,6 +105,13 @@ window.Stage = (function () {
function banner(txt) { document.getElementById('stageBanner').textContent = txt; }
+ // Fire a named SFX at most once per unique key per bout (frames repeat).
+ let played = new Set();
+ function sfx(key, name) {
+ if (played.has(key)) return; played.add(key);
+ try { window.SFX && window.SFX[name] && window.SFX[name](); } catch { /* audio best-effort */ }
+ }
+
function healthBars(cfg, aFrac, bFrac, tagA, tagB) {
const pad = 24, bw = W * 0.3, bh = 12;
function bar(x, frac, color, name, right) {
@@ -152,7 +159,7 @@ window.Stage = (function () {
const k = (t - meet) / (1 - meet);
if (!draw && aWins) { bRot = 0.5 * k; bY = midY + 40 * k * k; bx += 30 * k; bAlpha = 1 - 0.3 * k; }
else if (!draw) { aRot = -0.5 * k; aY = midY + 40 * k * k; ax -= 30 * k; aAlpha = 1 - 0.3 * k; }
- if (Math.abs(t - meet) < 0.03) burst(W / 2, midY - 60, '#ffd27a', 46, 6.5, 'spark');
+ if (Math.abs(t - meet) < 0.03) { burst(W / 2, midY - 60, '#ffd27a', 46, 6.5, 'spark'); sfx('clash', 'clash'); }
}
aura(ax, midY - 50, cfg.aColor, 90); aura(bx, midY - 50, cfg.bColor, 90);
drawSprite(sprite.knight, ax, aY, size, { facing: 1, rot: aRot, alpha: aAlpha, glow: cfg.aColor });
@@ -184,7 +191,7 @@ window.Stage = (function () {
const x = lerp(96, cxT - 4, ease(lt));
const y = lerp(sy, ty, ease(lt));
ctx.strokeStyle = col; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(x - 16, y); ctx.lineTo(x, y); ctx.stroke();
- if (lt >= 1) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 3, 0, 7); ctx.fill(); }
+ if (lt >= 1) { ctx.fillStyle = col; ctx.beginPath(); ctx.arc(x, y, 3, 0, 7); ctx.fill(); sfx('arrow' + i, 'arrow'); }
}
}
});
@@ -211,7 +218,7 @@ window.Stage = (function () {
ctx.fillStyle = '#ff7a2a'; ctx.shadowColor = '#ff7a2a'; ctx.shadowBlur = 16;
ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
burst(x, y, '#ff9a3a', 2, 1.5, 'smoke');
- if (p > 0.95) burst(ex, H - 120, '#ffb347', 24, 6, 'spark');
+ if (p > 0.95) { burst(ex, H - 120, '#ffb347', 24, 6, 'spark'); sfx('boom' + Math.floor(t * 3), 'thud'); }
});
banner(draw ? '🏰 Both keeps stand scarred!' : `🏰 ${aWins ? cfg.aName : cfg.bName} breaches the keep!`);
},
@@ -229,7 +236,7 @@ window.Stage = (function () {
aura(ax, H - 60, cfg.aColor, 70); aura(bx, H - 60, cfg.bColor, 70);
drawSprite(sprite.swordsman, ax, H - 20, size, { facing: 1, glow: cfg.aColor });
drawSprite(sprite.swordsman, bx, H - 20, size, { facing: -1, glow: cfg.bColor });
- if (clash > 0.46 && clash < 0.56) burst(W / 2, H - 110, '#fff2c0', 12, 4.5, 'spark');
+ if (clash > 0.46 && clash < 0.56) { burst(W / 2, H - 110, '#fff2c0', 12, 4.5, 'spark'); sfx('clang' + Math.floor(t * 8), 'clang'); }
});
banner(dr ? '⚔ Blades locked — a draw!' : `⚔ ${aw ? cfg.aName : cfg.bName} lands the final blow!`);
},
@@ -252,7 +259,8 @@ window.Stage = (function () {
ctx.fillStyle = col; ctx.shadowColor = col; ctx.shadowBlur = 14;
ctx.beginPath(); ctx.arc(x, H / 2 + 30, 5, 0, 7); ctx.fill(); ctx.shadowBlur = 0;
}
- if (p > 0.94) burst(W / 2, H / 2, '#c58bff', 14, 5, 'spark');
+ sfx('dragon-roar', 'roar');
+ if (p > 0.94) { burst(W / 2, H / 2, '#c58bff', 14, 5, 'spark'); sfx('bolt' + Math.floor(t * 4), 'clang'); }
});
banner(dr ? '🐉 Both fell the dragon as one!' : `🐉 ${aw ? cfg.aName : cfg.bName} slays the dragon first!`);
},
@@ -266,7 +274,8 @@ window.Stage = (function () {
return {
fit,
async play(mode, cfg) {
- fit(); parts = []; currentScene = sceneImg[mode] ? mode : null;
+ fit(); parts = []; played = new Set(); currentScene = sceneImg[mode] ? mode : null;
+ if (mode === 'joust') sfx('start', 'charge'); // galloping hooves
const fn = MODES[mode] || MODES.joust;
await fn(cfg);
},
diff --git a/public/index.html b/public/index.html
index dcc9d33..e123f63 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,6 +22,7 @@
<h1 class="wordmark">MODEL WARS</h1>
<div class="subtitle">Where Champions of Silicon Clash for Glory</div>
<div id="modeFlag" class="mode-flag mode-exh"><span class="dot"></span><span id="modeText">Checking champions…</span></div>
+ <button id="muteBtn" class="mute" title="Toggle battle sound">🔊 Sound</button>
</header>
<nav class="tabs">
@@ -131,6 +132,7 @@
<div class="toast" id="toast"></div>
<script src="scene.js"></script>
+<script src="sound.js"></script>
<script src="battles.js"></script>
<script src="app.js"></script>
</body>
diff --git a/public/sound.js b/public/sound.js
new file mode 100644
index 0000000..e7f7fc3
--- /dev/null
+++ b/public/sound.js
@@ -0,0 +1,68 @@
+// sound.js — procedural Web Audio SFX for the bouts. Zero assets, zero cost.
+// All sounds are synthesized from oscillators + noise; nothing is loaded.
+window.SFX = (function () {
+ 'use strict';
+ let ctx = null, master = null;
+ let muted = localStorage.getItem('mw-muted') === '1';
+
+ // Audio must start from a user gesture (autoplay policy). Call on first click.
+ function init() {
+ if (ctx) { if (ctx.state === 'suspended') ctx.resume(); return; }
+ const AC = window.AudioContext || window.webkitAudioContext;
+ if (!AC) return;
+ ctx = new AC();
+ master = ctx.createGain();
+ master.gain.value = muted ? 0 : 0.5;
+ master.connect(ctx.destination);
+ }
+ const on = () => ctx && !muted;
+ const now = () => ctx.currentTime;
+
+ function toggleMute() {
+ muted = !muted;
+ localStorage.setItem('mw-muted', muted ? '1' : '0');
+ if (master) master.gain.setTargetAtTime(muted ? 0 : 0.5, now(), 0.02);
+ return muted;
+ }
+ const isMuted = () => muted;
+
+ // ── primitives ────────────────────────────────────────
+ function tone(freq, t0, dur, { type = 'sine', gain = 0.3, glideTo = null, dest = null } = {}) {
+ const o = ctx.createOscillator(), g = ctx.createGain();
+ o.type = type; o.frequency.setValueAtTime(freq, t0);
+ if (glideTo) o.frequency.exponentialRampToValueAtTime(glideTo, t0 + dur);
+ g.gain.setValueAtTime(0, t0);
+ g.gain.linearRampToValueAtTime(gain, t0 + 0.01);
+ g.gain.exponentialRampToValueAtTime(0.0008, t0 + dur);
+ o.connect(g).connect(dest || master);
+ o.start(t0); o.stop(t0 + dur + 0.02);
+ }
+ function noise(t0, dur, { gain = 0.3, freq = 1200, q = 0.7, type = 'bandpass' } = {}) {
+ const n = Math.floor(ctx.sampleRate * dur);
+ const buf = ctx.createBuffer(1, n, ctx.sampleRate);
+ const d = buf.getChannelData(0);
+ for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
+ const src = ctx.createBufferSource(); src.buffer = buf;
+ const f = ctx.createBiquadFilter(); f.type = type; f.frequency.value = freq; f.Q.value = q;
+ const g = ctx.createGain(); g.gain.value = gain;
+ src.connect(f).connect(g).connect(master);
+ src.start(t0);
+ }
+
+ // ── named effects ─────────────────────────────────────
+ const E = {
+ horn() { if (!on()) return; const t = now(); tone(180, t, 0.5, { type: 'sawtooth', gain: 0.22, glideTo: 240 }); tone(240, t + 0.12, 0.5, { type: 'sawtooth', gain: 0.18 }); },
+ charge() { if (!on()) return; const t = now(); for (let i = 0; i < 6; i++) noise(t + i * 0.11, 0.08, { gain: 0.14, freq: 90, q: 1.2, type: 'lowpass' }); }, // hoofbeats
+ clash() { if (!on()) return; const t = now(); noise(t, 0.16, { gain: 0.4, freq: 2600, q: 0.6 }); tone(1500, t, 0.25, { type: 'triangle', gain: 0.2, glideTo: 600 }); },
+ arrow() { if (!on()) return; const t = now(); tone(1800, t, 0.14, { type: 'sine', gain: 0.16, glideTo: 700 }); noise(t, 0.06, { gain: 0.1, freq: 3000 }); },
+ thud() { if (!on()) return; const t = now(); tone(70, t, 0.3, { type: 'sine', gain: 0.5, glideTo: 40 }); noise(t, 0.12, { gain: 0.25, freq: 200, type: 'lowpass' }); }, // trebuchet
+ clang() { if (!on()) return; const t = now(); tone(900, t, 0.18, { type: 'square', gain: 0.14, glideTo: 500 }); noise(t, 0.09, { gain: 0.2, freq: 3200 }); },
+ roar() { if (!on()) return; const t = now(); tone(120, t, 0.55, { type: 'sawtooth', gain: 0.3, glideTo: 60 }); noise(t, 0.5, { gain: 0.18, freq: 300, q: 0.5, type: 'lowpass' }); },
+ fanfare() { if (!on()) return; const t = now(); [523, 659, 784, 1047].forEach((f, i) => tone(f, t + i * 0.12, 0.4, { type: 'triangle', gain: 0.22 })); },
+ defeat() { if (!on()) return; const t = now(); [400, 340, 260].forEach((f, i) => tone(f, t + i * 0.14, 0.4, { type: 'sawtooth', gain: 0.16 })); },
+ draw() { if (!on()) return; const t = now(); tone(392, t, 0.5, { type: 'triangle', gain: 0.18 }); tone(523, t + 0.05, 0.5, { type: 'triangle', gain: 0.14 }); },
+ click() { if (!on()) return; const t = now(); tone(660, t, 0.05, { type: 'square', gain: 0.08 }); },
+ };
+
+ return { init, toggleMute, isMuted, ...E };
+})();
diff --git a/public/styles.css b/public/styles.css
index b06c9f8..8ecb34c 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -69,6 +69,11 @@ h1.wordmark {
backdrop-filter:blur(14px);
}
.mode-flag .dot { width:8px; height:8px; border-radius:50%; }
+.mute { margin-left:10px; font-family:'JetBrains Mono',monospace; font-size:12px; padding:7px 12px;
+ border-radius:999px; border:1px solid var(--stroke); background:var(--glass); color:var(--ink);
+ backdrop-filter:blur(14px); cursor:pointer; transition:.2s; }
+.mute:hover { border-color:var(--gold); }
+.mute.muted { color:var(--muted); opacity:.7; }
.mode-live .dot { background:var(--emerald); box-shadow:0 0 12px var(--emerald); }
.mode-exh .dot { background:var(--ember); box-shadow:0 0 12px var(--ember); }
← 8b56730 chore: lint, refactor, v1.1.0 (session close)
·
back to Model Wars
·
(newest)