[object Object]

← 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

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)