Files

121 lines
9.0 KiB
JavaScript

(() => {
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;
})();