From 17ccc0cb0fb62eb83843befe52961e1651bf18b5 Mon Sep 17 00:00:00 2001 From: sasa Date: Mon, 31 Aug 2026 23:32:43 +0300 Subject: [PATCH] feat: build a bounded browser Web Audio engine and sound controls --- PLANS.md | 12 +++- data/app.js | 35 +++++++++- data/index.html | 6 +- data/ship-sprite.svg | 1 + data/styles.css | 3 +- data/web_audio.js | 107 +++++++++++++++++++++++++++++ docs/RESOURCE_BUDGET.md | 7 ++ scripts/compress_web_assets.py | 2 +- src/main.c | 3 +- test/web/test_web_audio.js | 121 +++++++++++++++++++++++++++++++++ 10 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 data/web_audio.js create mode 100644 test/web/test_web_audio.js diff --git a/PLANS.md b/PLANS.md index 2a2b698..15fa9eb 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1848,7 +1848,7 @@ When all criteria pass, set Milestone 023 to `DONE` and append its execution rec ## Milestone 024 — Build a bounded browser Web Audio engine and sound controls -**Status:** `BLOCKED` +**Status:** `DONE` **Depends on:** Milestone 023 ### Objective @@ -1946,11 +1946,19 @@ Add browser/unit tests with a fake or instrumented audio context covering at lea When all criteria pass, set Milestone 024 to `DONE`, append its execution record, and change Milestone 025 from `BLOCKED` to `READY`. Do not start Milestone 025 in the same task unless explicitly requested. +### Execution record + +- Date: 2026-08-31 +- Result: PASS. +- Evidence: Added a framework-free `web_audio.js` module with deferred user-gesture activation, one reusable noise buffer, an eight-voice limit with priority replacement, capped master gain and compressor, duplicate-version suppression, page-visibility handling, cancellation/reset cleanup, and a silent unsupported-browser fallback. Added Russian sound on/off, volume, reduced-intensity, and recovery-screen mute controls without changing game transport or role behavior. The ESP32 static-file allowlist now serves `/web_audio.js`; the user confirmed HTTP 200 delivery after the firmware and LittleFS update. +- Verification: All ten host suites passed. All six browser suites, including fake/instrumented AudioContext coverage, passed; `node --check data/app.js` and `node --check data/web_audio.js` passed; `git diff --check` passed. Firmware and LittleFS builds passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,956 / 2,097,152 B flash (48.7%). `web_audio.js` is 7,863 B raw and 2,072 B gzip; all compressed browser assets total 24,947 B, within the 250,000 B LittleFS budget. +- Next action: Milestone 025 is ready. Do not start Milestone 026 as part of this task. + --- ## Milestone 025 — Create a large randomized library of playful game sounds and audio combos -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 024 ### Objective diff --git a/data/app.js b/data/app.js index 5f0b7c8..9c0592e 100644 --- a/data/app.js +++ b/data/app.js @@ -13,8 +13,14 @@ result: el('result-description'), error: el('error-description'), lobbyIcon: el('lobby-icon'), resultIcon: el('result-icon'), cue: el('action-cue'), cueIcon: el('action-cue-icon'), cueText: el('action-cue-text'), effects: el('game-effects'), effectIcon: el('effect-icon'), effectText: el('effect-text'), effectBurst: el('effect-burst'), effectBadge: el('effect-badge'), - recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live') + recoveryButton: el('recovery-menu-button'), recoveryDialog: el('recovery-dialog'), recoveryClose: el('recovery-close'), recoveryChoices: el('recovery-choices'), recoveryGlobal: el('recovery-global-choice'), recoveryConfirmation: el('recovery-confirmation'), recoveryCancel: el('recovery-cancel'), recoveryConfirm: el('recovery-confirm'), recoveryConfirmTitle: el('recovery-confirm-title'), recoveryConfirmText: el('recovery-confirm-text'), recoveryConfirmLabel: el('recovery-confirm-label'), recoveryHoldNote: el('recovery-hold-note'), recoveryScopeIcon: el('recovery-scope-icon'), recoveryLive: el('recovery-live'), + soundToggle: el('sound-toggle'), soundToggleLabel: el('sound-toggle-label'), soundSettings: el('sound-settings'), soundVolume: el('sound-volume'), soundReduced: el('sound-reduced'), recoverySoundMute: el('recovery-sound-mute') }; + const silentAudio = { + getPreferences: () => ({ enabled: false, volume: 'normal', reduced: false }), + setEnabled: async () => false, setVolume: () => {}, setReduced: () => {}, play: () => false, setPageHidden: () => {}, cleanup: () => {} + }; + const audio = globalThis.BattleshipAudio?.createAudioEngine?.() || silentAudio; let token = localStorage.getItem(storage.token) || ''; let role = ''; let info; @@ -212,9 +218,10 @@ clearRecoveryTimers(); if (socket) { socket.onclose = null; socket.close(); socket = undefined; } selectedTarget = undefined; firePending = false; cumulativeStatistics = undefined; statisticsGameId = undefined; - activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; + activeBoard = 'own'; hitStreak = 0; state = undefined; role = ''; info = undefined; targetActivator = undefined; audio.cleanup(); document.body.classList.remove('fx-hit', 'fx-damage'); ui.effects.hidden = true; notify(''); - Object.keys(localStorage).filter(key => key.startsWith('battleship.')).forEach(key => localStorage.removeItem(key)); + const soundKeys = new Set(['battleship.soundEnabled', 'battleship.soundVolume', 'battleship.soundReduced']); + Object.keys(localStorage).filter(key => key.startsWith('battleship.') && !soundKeys.has(key)).forEach(key => localStorage.removeItem(key)); if (!profile) { const rememberedName = ui.name.value.trim(); if (rememberedName) localStorage.setItem(storage.name, rememberedName); } token = ''; ui.name.value = profile ? '' : (localStorage.getItem(storage.name) || ''); closeRecovery(); showScreen('connect'); setConnection('Готово к подключению'); @@ -271,6 +278,16 @@ ui.notice.classList.toggle('error', error); } + function syncSoundControls() { + const preferences = audio.getPreferences(); + ui.soundToggle.setAttribute('aria-pressed', String(preferences.enabled)); + ui.soundToggleLabel.textContent = preferences.enabled ? 'Звук включён' : 'Звук выключен'; + ui.soundSettings.hidden = !preferences.enabled; + ui.recoverySoundMute.hidden = !preferences.enabled; + ui.soundVolume.value = preferences.volume; + ui.soundReduced.checked = preferences.reduced; + } + function apiError(payload, fallback) { return payload?.message || fallback || 'Сервер временно недоступен.'; } @@ -696,6 +713,15 @@ ui.rematch.addEventListener('click', () => post('/api/game/rematch', { token, gameId: state.gameId })); el('retry-button').addEventListener('click', () => { refreshInfo(); pollState(); connectSocket(); }); ui.recoveryButton.addEventListener('click', openRecovery); + ui.soundToggle.addEventListener('click', async () => { + const nextEnabled = !audio.getPreferences().enabled; + await audio.setEnabled(nextEnabled); + if (nextEnabled) audio.play('test'); + syncSoundControls(); + }); + ui.soundVolume.addEventListener('change', () => { audio.setVolume(ui.soundVolume.value); syncSoundControls(); }); + ui.soundReduced.addEventListener('change', () => { audio.setReduced(ui.soundReduced.checked); syncSoundControls(); }); + ui.recoverySoundMute.addEventListener('click', async () => { await audio.setEnabled(false); syncSoundControls(); }); ui.recoveryClose.addEventListener('click', closeRecovery); ui.recoveryCancel.addEventListener('click', () => { recoveryAction = undefined; ui.recoveryConfirmation.hidden = true; ui.recoveryChoices.hidden = false; ui.recoveryClose.focus(); }); ui.recoveryChoices.addEventListener('click', event => { @@ -720,8 +746,11 @@ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } }); + document.addEventListener('visibilitychange', () => { audio.setPageHidden(document.hidden); }); + window.addEventListener('pagehide', () => audio.cleanup(), { once: true }); async function boot() { + syncSoundControls(); ui.name.value = localStorage.getItem(storage.name) || ''; document.querySelectorAll('.avatar-button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.name === ui.name.value))); setConnection('Проверяем ESP32…'); diff --git a/data/index.html b/data/index.html index 3851d8f..3686ab8 100644 --- a/data/index.html +++ b/data/index.html @@ -15,8 +15,9 @@

ESP32-C6 · локальная игра

Морской бой

-

Подключение…

+

Подключение…

+
@@ -73,8 +74,9 @@
- + + diff --git a/data/ship-sprite.svg b/data/ship-sprite.svg index bafbc7c..5576491 100644 --- a/data/ship-sprite.svg +++ b/data/ship-sprite.svg @@ -21,4 +21,5 @@ + diff --git a/data/styles.css b/data/styles.css index 427e775..e325c9f 100644 --- a/data/styles.css +++ b/data/styles.css @@ -35,6 +35,7 @@ h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: .2rem; font-size: clamp(1.7 .ui-icon { display: inline-block; width: 1.6em; height: 1.6em; flex: 0 0 auto; fill: currentColor; vertical-align: -.32em; } .title-icon { width: 1.25em; } .icon-button { display: inline-flex; align-items: center; justify-content: center; gap: .55rem; } .icon-button > .ui-icon { width: 1.55rem; height: 1.55rem; } .header-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: end; gap: .5rem; } .recovery-menu-button { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 2.75rem; min-height: 2.75rem; padding: .45rem .65rem; background: #31536e; color: #f1f7ff; } .recovery-menu-button .ui-icon { width: 1.25rem; height: 1.25rem; } +.sound-toggle { display: inline-flex; align-items: center; justify-content: center; gap: .35rem; min-width: 2.75rem; min-height: 2.75rem; padding: .45rem .65rem; background: #31536e; color: #f1f7ff; } .sound-toggle[aria-pressed="true"] { background: #145b4b; color: #d9fff0; } .sound-toggle .ui-icon { width: 1.25rem; height: 1.25rem; } .sound-settings { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem .8rem; margin: .5rem 0 0 auto; padding: .55rem .7rem; border: 1px solid #315a75; border-radius: .7rem; background: rgb(7 31 50 / 90%); color: #c8e9f8; font-size: .88rem; } .sound-settings select { min-height: 2.3rem; padding: .3rem .45rem; border: 1px solid #6096b5; border-radius: .45rem; background: #082a41; color: #f1f7ff; } .sound-reduced { display: inline-flex; align-items: center; gap: .35rem; } .recovery-overlay { position: fixed; z-index: 120; inset: 0; display: grid; place-items: center; padding: max(1rem, env(safe-area-inset-top)) max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); background: rgb(2 13 23 / 76%); } .recovery-overlay[hidden] { display: none; } .recovery-dialog { position: relative; width: min(100%, 31rem); max-height: min(42rem, 100svh - 2rem); overflow: auto; padding: 1.25rem; border: 1px solid #6096b5; border-radius: 1rem; background: #08273e; box-shadow: 0 1.5rem 4rem #000; } .recovery-dialog h2 { margin-right: 2rem; } .recovery-close { position: absolute; top: .55rem; right: .75rem; color: #d9edf7 !important; font-size: 1.8rem; text-decoration: none !important; } .recovery-choices { display: grid; gap: .65rem; } .recovery-choices button { display: grid; grid-template-columns: 2.4rem minmax(0, 1fr); align-items: center; gap: .65rem; min-height: 4.35rem; border: 1px solid #4d7f9d; background: #123b58; color: #f1f7ff; text-align: left; } .recovery-choices button .ui-icon { width: 2rem; height: 2rem; color: #b9ecff; } .recovery-choices small { display: block; margin-top: .12rem; color: #bfd9e8; font-weight: 500; } .recovery-global { margin-top: .5rem; border-style: dashed !important; background: #293a49 !important; } .recovery-global .ui-icon { color: #ffd48b !important; } .recovery-confirmation { display: grid; gap: .75rem; } .recovery-scope-icon .ui-icon { width: 2.75rem; height: 2.75rem; color: #b9ecff; } .recovery-confirm-actions { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; } .recovery-confirm { position: relative; overflow: hidden; background: #c75c4e; color: #fff; } .recovery-hold-progress { position: absolute; inset: auto 0 0; height: .28rem; width: 0; background: #ffe56a; } .recovery-confirm.holding .recovery-hold-progress { width: 100%; transition: width 2s linear; } .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .kid-hint { display: flex; align-items: center; gap: .5rem; margin: .8rem 0 .55rem; color: #dff6ff; font-size: 1.05rem; font-weight: 800; } .step-number { display: inline-grid; place-items: center; width: 1.7rem; height: 1.7rem; border-radius: 50%; background: #ffe56a; color: #08233d; font-size: 1rem; } .avatar-picker { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .55rem; margin-bottom: .85rem; } @@ -65,5 +66,5 @@ body.fx-damage .app-shell { animation: ship-shake .5s ease-in-out 2; } body.fx-h @keyframes action-pulse { 50% { filter: brightness(1.16); transform: scale(1.025); } } @keyframes cue-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 229 106 / 18%); transform: scale(1.015); } } @keyframes target-ripple { 50% { filter: brightness(1.35); } } @keyframes fire-pulse { 50% { box-shadow: 0 0 0 .35rem rgb(255 157 61 / 25%); transform: scale(1.035); } } @keyframes reward-pop { 0% { opacity: 0; transform: scale(.35) rotate(-8deg); } 45% { opacity: 1; transform: scale(1.12) rotate(3deg); } 100% { transform: scale(1) rotate(0); } } @keyframes reward-bounce { 0% { opacity: 0; transform: translateY(55vh) scale(.8); } 55% { opacity: 1; transform: translateY(-1.2rem) scale(1.06); } 75% { transform: translateY(.5rem) scale(.98); } 100% { transform: none; } } @keyframes reward-spin { 0% { opacity: 0; transform: scale(.2) rotate(-210deg); } 65% { opacity: 1; transform: scale(1.12) rotate(12deg); } 100% { transform: none; } } @keyframes reward-swoop { 0% { opacity: 0; transform: translateX(-110vw) rotate(-14deg); } 65% { opacity: 1; transform: translateX(1rem) rotate(2deg); } 100% { transform: none; } } @keyframes reward-zoom { 0% { opacity: 0; transform: scale(2.4); filter: blur(.5rem); } 65% { opacity: 1; transform: scale(.92); filter: blur(0); } 100% { transform: scale(1); } } @keyframes reward-combo { 0% { opacity: 0; transform: scale(.1) rotate(-18deg); } 35% { opacity: 1; transform: scale(1.28) rotate(7deg); } 58% { transform: scale(.9) rotate(-3deg); } 78% { transform: scale(1.08) rotate(1deg); } 100% { transform: none; } } @keyframes badge-slam { from { opacity: 0; transform: translateY(-4rem) scale(2) rotate(8deg); } } @keyframes combo-icon { to { transform: scale(1.18) rotate(8deg); filter: brightness(1.35); } } @keyframes screen-danger { 50% { background: rgb(255 20 20 / 58%); } } @keyframes ship-shake { 20%, 60% { transform: translateX(-.45rem); } 40%, 80% { transform: translateX(.45rem); } } @keyframes screen-glow { 50% { filter: brightness(1.28) saturate(1.25); } } @keyframes burst-away { to { opacity: 0; translate: var(--x) var(--y); rotate: var(--spin); scale: .7; } } @keyframes bubble-away { to { opacity: 0; translate: var(--x) calc(var(--y) - 22vh); scale: 1.7; } } @media (max-width: 43.99rem) { .app-shell { padding-top: .75rem; } .app-header .eyebrow { display: none; } .app-header h1 { font-size: 1.65rem; } .avatar-picker { gap: .35rem; } .avatar-button { min-height: 4.8rem; padding-inline: .2rem; } .connection-support { display: none; } #screen-game .shot-controls { grid-template-columns: 1fr 1fr; position: sticky; z-index: 8; top: .25rem; margin-bottom: .6rem; box-shadow: 0 .5rem 1.4rem rgb(0 0 0 / 35%); } #screen-game .shot-controls p { grid-column: 1 / -1; text-align: center; } #screen-game #fire-button, #screen-game #cancel-target { min-height: 3.4rem; } } -@media (max-width: 43.99rem) { .header-actions { justify-content: space-between; width: 100%; } .recovery-menu-button span { font-size: .85rem; } .recovery-dialog { width: 100%; padding: 1rem; } .recovery-confirm-actions { grid-template-columns: 1fr; } } +@media (max-width: 43.99rem) { .header-actions { justify-content: space-between; width: 100%; } .recovery-menu-button span, .sound-toggle span { font-size: .85rem; } .sound-settings { margin-inline: 0; } .recovery-dialog { width: 100%; padding: 1rem; } .recovery-confirm-actions { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; } } diff --git a/data/web_audio.js b/data/web_audio.js new file mode 100644 index 0000000..35fd68b --- /dev/null +++ b/data/web_audio.js @@ -0,0 +1,107 @@ +(() => { + const MAX_VOICES = 8; + const MAX_GAIN = 0.28; + const VOLUME_GAINS = Object.freeze({ quiet: 0.1, normal: 0.18, loud: MAX_GAIN }); + const PREFERENCES = Object.freeze({ enabled: 'battleship.soundEnabled', volume: 'battleship.soundVolume', reduced: 'battleship.soundReduced' }); + + function safeGet(storage, key) { try { return storage?.getItem(key); } catch (_) { return null; } } + function safeSet(storage, key, value) { try { storage?.setItem(key, value); } catch (_) { /* Preferences are optional. */ } } + function validVolume(value) { return Object.hasOwn(VOLUME_GAINS, value) ? value : 'normal'; } + + function createSilentEngine(storage) { + const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' }; + const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); }; + return { + getPreferences: () => ({ ...preferences }), + enable: async () => { preferences.enabled = true; save(); return false; }, + setEnabled: async enabled => { preferences.enabled = Boolean(enabled); save(); return false; }, + setVolume: volume => { preferences.volume = validVolume(volume); save(); }, + setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, + play: () => false, cancel: () => {}, setPageHidden: () => {}, cleanup: () => {}, getVoiceCount: () => 0, getNoiseBuffer: () => undefined + }; + } + + function createAudioEngine({ AudioContext: Context = globalThis.AudioContext || globalThis.webkitAudioContext, storage = globalThis.localStorage, timer = globalThis, now = () => Date.now() } = {}) { + if (!Context) return createSilentEngine(storage); + const preferences = { enabled: safeGet(storage, PREFERENCES.enabled) === 'true', volume: validVolume(safeGet(storage, PREFERENCES.volume)), reduced: safeGet(storage, PREFERENCES.reduced) === 'true' }; + let context; let master; let limiter; let noiseBuffer; let hidden = false; let lastVersion = -1; + const voices = new Set(); + const save = () => { safeSet(storage, PREFERENCES.enabled, String(preferences.enabled)); safeSet(storage, PREFERENCES.volume, preferences.volume); safeSet(storage, PREFERENCES.reduced, String(preferences.reduced)); }; + const disconnect = node => { try { node?.disconnect(); } catch (_) { /* Node was already released. */ } }; + + function updateGain() { if (master) master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime); } + function ensureContext() { + if (context) return true; + try { + context = new Context(); + master = context.createGain(); master.gain.setValueAtTime(VOLUME_GAINS[preferences.volume], context.currentTime); + limiter = context.createDynamicsCompressor(); limiter.threshold.setValueAtTime(-18, context.currentTime); limiter.knee.setValueAtTime(12, context.currentTime); limiter.ratio.setValueAtTime(8, context.currentTime); + master.connect(limiter); limiter.connect(context.destination); + return true; + } catch (_) { context = undefined; master = undefined; limiter = undefined; return false; } + } + function ensureNoise() { + if (noiseBuffer) return noiseBuffer; + noiseBuffer = context.createBuffer(1, Math.max(1, Math.floor(context.sampleRate * 0.18)), context.sampleRate); + const samples = noiseBuffer.getChannelData(0); + for (let index = 0; index < samples.length; index += 1) samples[index] = ((index * 1103515245 + 12345) >>> 16) / 32768 - 0.5; + return noiseBuffer; + } + function release(voice) { + if (!voices.delete(voice)) return; + timer.clearTimeout(voice.timeout); + try { voice.source.stop(); } catch (_) { /* It may have ended naturally. */ } + disconnect(voice.source); disconnect(voice.filter); disconnect(voice.gain); + } + function makeVoice(priority, durationMs, sourceFactory) { + if (!preferences.enabled || hidden || !ensureContext() || voices.size >= MAX_VOICES && !replaceVoice(priority)) return false; + const startedAt = context.currentTime; + const duration = Math.max(0.1, Math.min(durationMs, 2500) / 1000) * (preferences.reduced ? 0.65 : 1); + let source; let gain; let filter; + try { + source = sourceFactory(); gain = context.createGain(); filter = context.createBiquadFilter(); + filter.type = 'lowpass'; filter.frequency.setValueAtTime(2400, startedAt); + gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(0.23, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration); + source.connect(filter); filter.connect(gain); gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02); + } catch (_) { disconnect(source); disconnect(filter); disconnect(gain); return false; } + const voice = { source, filter, gain, priority, timeout: undefined }; + voice.timeout = timer.setTimeout(() => release(voice), Math.ceil(duration * 1000) + 80); + voices.add(voice); return true; + } + function replaceVoice(priority) { + let lowest; + voices.forEach(voice => { if (!lowest || voice.priority < lowest.priority) lowest = voice; }); + if (!lowest || lowest.priority > priority) return false; + release(lowest); return true; + } + async function enable() { + preferences.enabled = true; save(); + if (!ensureContext()) return false; + try { if (context.state === 'suspended') await context.resume(); } catch (_) { return false; } + return context.state !== 'closed'; + } + async function setEnabled(enabled) { + if (!enabled) { preferences.enabled = false; save(); cancel(); return true; } + return enable(); + } + function cancel() { [...voices].forEach(release); } + function play(kind = 'test', version) { + if (Number.isInteger(version)) { if (version <= lastVersion) return false; lastVersion = version; } + if (kind === 'noise') return makeVoice(1, 160, () => { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; }); + return makeVoice(kind === 'major' ? 2 : 1, kind === 'major' ? 700 : 180, () => { + const oscillator = context.createOscillator(); oscillator.type = kind === 'major' ? 'triangle' : 'sine'; oscillator.frequency.setValueAtTime(kind === 'major' ? 392 : 660, context.currentTime); oscillator.frequency.exponentialRampToValueAtTime(kind === 'major' ? 196 : 440, context.currentTime + 0.16); return oscillator; + }); + } + async function setPageHidden(nextHidden) { + hidden = Boolean(nextHidden); cancel(); + if (!context) return; + try { if (hidden) await context.suspend(); else if (preferences.enabled && context.state === 'suspended') await context.resume(); } catch (_) { /* Sound remains optional. */ } + } + function cleanup() { cancel(); lastVersion = -1; if (context) { try { context.close(); } catch (_) { /* Already closed. */ } } context = undefined; master = undefined; limiter = undefined; noiseBuffer = undefined; } + return { getPreferences: () => ({ ...preferences }), enable, setEnabled, setVolume: volume => { preferences.volume = validVolume(volume); save(); updateGain(); }, setReduced: reduced => { preferences.reduced = Boolean(reduced); save(); }, play, cancel, setPageHidden, cleanup, getVoiceCount: () => voices.size, getNoiseBuffer: () => noiseBuffer, getMaxGain: () => MAX_GAIN, now }; + } + + const api = { createAudioEngine, MAX_VOICES, MAX_GAIN, VOLUME_GAINS, PREFERENCES }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else globalThis.BattleshipAudio = api; +})(); diff --git a/docs/RESOURCE_BUDGET.md b/docs/RESOURCE_BUDGET.md index f16a13b..4c89b13 100644 --- a/docs/RESOURCE_BUDGET.md +++ b/docs/RESOURCE_BUDGET.md @@ -40,3 +40,10 @@ bounded snapshot at a time into the 768-byte transport buffer. Each gate is a maximum permitted consumption or minimum required remaining heap. A missed gate blocks the next milestone pending a documented decision. + +## Milestone 024 browser-audio asset measurement + +The bounded Web Audio foundation adds `web_audio.js`: 7,863 B raw and 2,072 B +gzip. The current six compressed web assets total 24,947 B, well below the +250,000 B LittleFS ceiling. The browser performs synthesis locally, so this +change adds no ESP32 RAM allocation, API payload, or firmware-code cost. diff --git a/scripts/compress_web_assets.py b/scripts/compress_web_assets.py index e9ddfd4..5822899 100644 --- a/scripts/compress_web_assets.py +++ b/scripts/compress_web_assets.py @@ -7,7 +7,7 @@ from pathlib import Path ROOT = Path(env.subst("$PROJECT_DIR")) DATA = ROOT / "data" -ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "ship-sprite.svg") +ASSETS = ("index.html", "styles.css", "app.js", "target_interaction.js", "web_audio.js", "ship-sprite.svg") def minify_html(source): diff --git a/src/main.c b/src/main.c index b4fccab..a3a57ea 100644 --- a/src/main.c +++ b/src/main.c @@ -360,7 +360,8 @@ static esp_err_t static_file_handler(httpd_req_t *request) { return httpd_resp_send(request, "LittleFS is unavailable", HTTPD_RESP_USE_STRLEN); } if (strcmp(request->uri, "/styles.css") != 0 && strcmp(request->uri, "/app.js") != 0 && - strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found"); + strcmp(request->uri, "/target_interaction.js") != 0 && strcmp(request->uri, "/web_audio.js") != 0 && + strcmp(request->uri, "/ship-sprite.svg") != 0) return httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "asset not found"); return send_static_file(request, request->uri); } diff --git a/test/web/test_web_audio.js b/test/web/test_web_audio.js new file mode 100644 index 0000000..5008b9b --- /dev/null +++ b/test/web/test_web_audio.js @@ -0,0 +1,121 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const { createAudioEngine, MAX_VOICES, MAX_GAIN } = require('../../data/web_audio.js'); + +function memoryStorage() { + const values = new Map(); + return { getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, String(value)) }; +} + +function fakeTimer() { + let next = 1; + const tasks = new Map(); + return { setTimeout: callback => { const id = next++; tasks.set(id, callback); return id; }, clearTimeout: id => tasks.delete(id), runAll: () => [...tasks.values()].forEach(callback => callback()), size: () => tasks.size }; +} + +function fakeAudioContext() { + const contexts = []; + const parameter = () => ({ setValueAtTime() {}, exponentialRampToValueAtTime() {} }); + const node = () => ({ connected: 0, disconnected: 0, connect() { this.connected += 1; }, disconnect() { this.disconnected += 1; } }); + class FakeAudioContext { + constructor() { this.currentTime = 0; this.sampleRate = 1000; this.destination = node(); this.state = 'suspended'; contexts.push(this); } + createGain() { return { ...node(), gain: parameter() }; } + createDynamicsCompressor() { return { ...node(), threshold: parameter(), knee: parameter(), ratio: parameter() }; } + createBiquadFilter() { return { ...node(), frequency: parameter(), type: '' }; } + createOscillator() { return { ...node(), frequency: parameter(), start() {}, stop() { this.stopped = true; } }; } + createBufferSource() { return { ...node(), start() {}, stop() { this.stopped = true; } }; } + createBuffer(_channels, length) { const samples = new Float32Array(length); return { getChannelData: () => samples }; } + async resume() { this.state = 'running'; } + async suspend() { this.state = 'suspended'; } + close() { this.state = 'closed'; } + } + return { FakeAudioContext, contexts }; +} + +test('does not create an audio context before explicit enablement', () => { + const { FakeAudioContext, contexts } = fakeAudioContext(); + const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage() }); + assert.equal(contexts.length, 0); + assert.equal(engine.play('test'), false); + assert.equal(contexts.length, 0); +}); + +test('enables through a user gesture, persists safe preferences, and caps gain', async () => { + const { FakeAudioContext, contexts } = fakeAudioContext(); + const storage = memoryStorage(); + const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage }); + assert.equal(await engine.enable(), true); + engine.setVolume('loud'); engine.setReduced(true); + assert.equal(contexts.length, 1); + assert.equal(engine.getPreferences().enabled, true); + assert.equal(engine.getPreferences().volume, 'loud'); + assert.equal(engine.getPreferences().reduced, true); + assert.equal(engine.getMaxGain(), MAX_GAIN); + assert.ok(MAX_GAIN <= 0.3); + assert.equal(storage.getItem('battleship.soundVolume'), 'loud'); +}); + +test('uses a bounded voice pool, priority replacement, one noise buffer, and cleanup', async () => { + const { FakeAudioContext } = fakeAudioContext(); + const timer = fakeTimer(); + const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer }); + await engine.enable(); + for (let index = 0; index < MAX_VOICES; index += 1) assert.equal(engine.play('test'), true); + assert.equal(engine.getVoiceCount(), MAX_VOICES); + assert.equal(engine.play('test'), true); + assert.equal(engine.getVoiceCount(), MAX_VOICES); + assert.equal(engine.play('noise'), true); + const noise = engine.getNoiseBuffer(); + assert.ok(noise); + engine.play('noise'); + assert.equal(engine.getNoiseBuffer(), noise); + timer.runAll(); + assert.equal(engine.getVoiceCount(), 0); + assert.equal(timer.size(), 0); +}); + +test('suppresses duplicate versions and cancels audio for mute, page hide, and reset cleanup', async () => { + const { FakeAudioContext, contexts } = fakeAudioContext(); + const engine = createAudioEngine({ AudioContext: FakeAudioContext, storage: memoryStorage(), timer: fakeTimer() }); + await engine.enable(); + assert.equal(engine.play('test', 4), true); + assert.equal(engine.play('test', 4), false); + assert.equal(engine.getVoiceCount(), 1); + await engine.setPageHidden(true); + assert.equal(engine.getVoiceCount(), 0); + await engine.setPageHidden(false); + await engine.setEnabled(false); + assert.equal(engine.play('test'), false); + engine.cleanup(); + assert.equal(contexts[0].state, 'closed'); +}); + +test('falls back silently when Web Audio is unsupported or initialization fails', async () => { + const silent = createAudioEngine({ AudioContext: undefined, storage: memoryStorage() }); + assert.equal(await silent.enable(), false); + assert.equal(silent.play('test'), false); + class FailingContext { constructor() { throw new Error('no audio'); } } + const failing = createAudioEngine({ AudioContext: FailingContext, storage: memoryStorage() }); + assert.equal(await failing.enable(), false); + assert.equal(failing.play('test'), false); +}); + +test('the application exposes an explicit Russian sound control and cleans audio without changing transport', () => { + const app = fs.readFileSync(path.join(__dirname, '../../data/app.js'), 'utf8'); + const page = fs.readFileSync(path.join(__dirname, '../../data/index.html'), 'utf8'); + const firmware = fs.readFileSync(path.join(__dirname, '../../src/main.c'), 'utf8'); + assert.match(page, /id="sound-toggle"[^>]*aria-pressed="false"/); + assert.match(page, /id="sound-volume"/); + assert.match(page, /id="sound-reduced"/); + assert.match(page, /id="recovery-sound-mute"/); + assert.match(page, /src="\/web_audio\.js"/); + assert.match(firmware, /strcmp\(request->uri, "\/web_audio\.js"\)/); + assert.match(app, /audio\.cleanup\(\)/); + assert.match(app, /globalThis\.BattleshipAudio\?\.createAudioEngine\?\.\(\) \|\| silentAudio/); + assert.match(app, /visibilitychange/); + assert.match(app, /soundKeys = new Set/); + assert.match(app, /ui\.recoverySoundMute\.hidden = !preferences\.enabled/); + assert.match(app, /\/api\/game\/shot/); +});