(() => { 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); disconnect(voice.panner); } function makeVoice(priority, durationMs, sourceFactory, settings = {}) { if (!preferences.enabled || hidden || !ensureContext() || voices.size >= MAX_VOICES && !replaceVoice(priority)) return false; const startedAt = context.currentTime + Math.max(0, settings.delayMs || 0) / 1000; const duration = Math.max(0.1, Math.min(durationMs, 2500) / 1000) * (preferences.reduced ? 0.65 : 1); let source; let gain; let filter; let panner; try { source = sourceFactory(); gain = context.createGain(); filter = context.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(settings.filterHz || 2400, startedAt); const peak = Math.min(0.75, Math.max(0.04, settings.gain || 0.55)); gain.gain.setValueAtTime(0.0001, startedAt); gain.gain.exponentialRampToValueAtTime(peak, startedAt + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, startedAt + duration); if (typeof context.createStereoPanner === 'function') { panner = context.createStereoPanner(); panner.pan.setValueAtTime(Math.max(-0.4, Math.min(0.4, settings.pan || 0)), startedAt); } source.connect(filter); filter.connect(gain); if (panner) { gain.connect(panner); panner.connect(master); } else gain.connect(master); source.start(startedAt); source.stop(startedAt + duration + 0.02); } catch (_) { disconnect(source); disconnect(filter); disconnect(gain); disconnect(panner); return false; } const voice = { source, filter, gain, panner, 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; }); } function playPreset(preset) { if (!preset || !preferences.enabled || hidden || !ensureContext()) return false; return makeVoice(preset.priority || 1, preset.durationMs || 180, () => { if (preset.noise) { const source = context.createBufferSource(); source.buffer = ensureNoise(); return source; } const oscillator = context.createOscillator(); oscillator.type = preset.wave || 'sine'; const startedAt = context.currentTime + Math.max(0, preset.delayMs || 0) / 1000; oscillator.frequency.setValueAtTime(Math.max(100, preset.frequency || 440), startedAt); oscillator.frequency.exponentialRampToValueAtTime(Math.max(100, preset.endFrequency || preset.frequency || 440), startedAt + Math.min(0.8, (preset.durationMs || 180) / 1000)); return oscillator; }, preset); } 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, playPreset, 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; })();