87 lines
4.8 KiB
TypeScript
87 lines
4.8 KiB
TypeScript
import { SOUNDS, SOUND_EVENT_HISTORY, SOUND_MASTER_VOLUME, SOUND_MAX_VOICES, SOUND_MUTED_KEY, type SoundId, type SoundPart } from '../../constants/sound.js';
|
|
|
|
export function createSound(options: { context?: () => AudioContext; storage?: Pick<Storage, 'getItem' | 'setItem'> } = {}) {
|
|
let context: AudioContext | undefined, master: GainNode | undefined, muted = false;
|
|
const events = new Set<string>(), voices = new Set<AudioScheduledSourceNode>();
|
|
const storage = () => options.storage ?? globalThis.localStorage;
|
|
try { muted = storage()?.getItem(SOUND_MUTED_KEY) === 'true'; } catch { /* Optional preference storage. */ }
|
|
function stop() {
|
|
for (const voice of voices) { try { voice.stop(); } catch { /* Already ended. */ } }
|
|
voices.clear();
|
|
}
|
|
return {
|
|
get muted() { return muted; },
|
|
async unlock() {
|
|
if (muted) return;
|
|
try {
|
|
if (!context) {
|
|
context = options.context?.() ?? new AudioContext();
|
|
master = context.createGain(); master.gain.value = SOUND_MASTER_VOLUME;
|
|
const compressor = context.createDynamicsCompressor();
|
|
master.connect(compressor); compressor.connect(context.destination);
|
|
}
|
|
if (context.state === 'suspended') await context.resume();
|
|
} catch { /* Audio support/autoplay failures must not interrupt gameplay. */ }
|
|
},
|
|
setMuted(value: boolean) {
|
|
muted = value; if (muted) stop();
|
|
try { storage()?.setItem(SOUND_MUTED_KEY, String(value)); } catch { /* Keep the in-memory preference. */ }
|
|
},
|
|
stop,
|
|
play(id: SoundId, key?: string, pan = 0) {
|
|
if (key) {
|
|
if (events.has(key)) return;
|
|
events.add(key);
|
|
if (events.size > SOUND_EVENT_HISTORY) events.delete(events.values().next().value!);
|
|
}
|
|
if (muted || !context || !master || context.state !== 'running' || (typeof document !== 'undefined' && document.hidden)) return;
|
|
const ctx = context;
|
|
for (const part of SOUNDS[id] as readonly SoundPart[]) {
|
|
if (voices.size >= SOUND_MAX_VOICES) break;
|
|
const at = ctx.currentTime + (part.delay ?? 0), end = at + part.duration;
|
|
const envelope = ctx.createGain(), panner = ctx.createStereoPanner();
|
|
panner.pan.value = Math.max(-1, Math.min(1, pan));
|
|
envelope.gain.setValueAtTime(0, at);
|
|
envelope.gain.linearRampToValueAtTime(part.volume, at + Math.min(.012, part.duration / 4));
|
|
envelope.gain.exponentialRampToValueAtTime(.0001, end);
|
|
envelope.gain.linearRampToValueAtTime(0, end + .01);
|
|
envelope.connect(panner); panner.connect(master);
|
|
let source: AudioScheduledSourceNode, filter: BiquadFilterNode | undefined;
|
|
if (part.wave === 'noise') {
|
|
const buffer = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * (part.duration + .02)), ctx.sampleRate);
|
|
const data = buffer.getChannelData(0);
|
|
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
|
|
const noise = ctx.createBufferSource(); noise.buffer = buffer; source = noise;
|
|
filter = ctx.createBiquadFilter(); filter.type = 'lowpass';
|
|
filter.frequency.setValueAtTime(part.from, at); filter.frequency.exponentialRampToValueAtTime(part.to, end);
|
|
source.connect(filter); filter.connect(envelope);
|
|
} else {
|
|
const oscillator = ctx.createOscillator(); source = oscillator; oscillator.type = part.wave;
|
|
oscillator.frequency.setValueAtTime(part.from, at); oscillator.frequency.exponentialRampToValueAtTime(part.to, end);
|
|
source.connect(envelope);
|
|
}
|
|
voices.add(source);
|
|
source.onended = () => { voices.delete(source); source.disconnect(); filter?.disconnect(); envelope.disconnect(); panner.disconnect(); };
|
|
source.start(at); source.stop(end + .02);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
export const sound = createSound();
|
|
|
|
export function installSoundControls() {
|
|
const button = document.getElementById('sound-toggle') as HTMLButtonElement;
|
|
const refresh = () => { button.textContent = sound.muted ? '소리 켜기' : '소리 끄기'; button.setAttribute('aria-pressed', String(!sound.muted)); };
|
|
refresh();
|
|
button.onclick = () => { sound.setMuted(!sound.muted); refresh(); void sound.unlock(); };
|
|
document.addEventListener('pointerdown', () => { void sound.unlock(); }, { passive: true });
|
|
document.addEventListener('keydown', () => { void sound.unlock(); });
|
|
document.addEventListener('click', event => {
|
|
const target = event.target instanceof Element ? event.target.closest('button, summary') : null;
|
|
if (!target || target.id === 'sound-toggle' || target.matches(':disabled')) return;
|
|
void sound.unlock().then(() => sound.play('click'));
|
|
}, true);
|
|
document.addEventListener('visibilitychange', () => { if (document.hidden) sound.stop(); });
|
|
window.addEventListener('pagehide', () => sound.stop());
|
|
}
|