(() => { const supportsAudio = () => typeof Audio !== "undefined"; const supportsWebAudio = () => typeof AudioContext !== "undefined" || typeof webkitAudioContext !== "undefined"; const DEFAULT_SOUND_CONFIG = { enabled: true, basePath: "./assets/sfx", channels: { buzz: { mode: "procedural", procedural: "buzz", file: "drag-buzz.ogg", volume: 0.06, }, click: { mode: "procedural", procedural: "click", file: "link-click.ogg", volume: 0.18, }, pop: { mode: "procedural", procedural: "pop", file: "clear-pop.ogg", volume: 0.22, }, }, }; const createAudio = (src, { volume = 1, loop = false, rate = 1 } = {}) => { const audio = new Audio(src); audio.preload = "auto"; audio.loop = loop; audio.volume = volume; audio.playbackRate = rate; return audio; }; const createPoolPlayer = (src, { volume = 1, rate = 1, max = 6 } = {}) => { const pool = []; return () => { if (!supportsAudio()) return; let audio = pool.find((item) => item.paused || item.ended); if (!audio) { if (pool.length >= max) return; audio = createAudio(src, { volume, rate }); pool.push(audio); } audio.currentTime = 0; audio.play().catch(() => {}); }; }; const normalizeSoundConfig = (sounds = {}) => { const defaultChannels = DEFAULT_SOUND_CONFIG.channels; const overrideChannels = sounds.channels || {}; const mergedChannels = { ...defaultChannels }; Object.keys(overrideChannels).forEach((key) => { mergedChannels[key] = { ...(defaultChannels[key] || {}), ...(overrideChannels[key] || {}), }; }); return { ...DEFAULT_SOUND_CONFIG, ...sounds, channels: mergedChannels, }; }; const create = ({ sounds = {} } = {}) => { let unlocked = false; let buzzing = false; let buzzLoop = null; let audioCtx = null; let masterGain = null; let buzzNodes = null; let soundConfig = normalizeSoundConfig(sounds); const samplePlayers = new Map(); let sampleBuzzKey = null; const ensureContext = () => { if (!supportsWebAudio()) return false; if (audioCtx) return true; const Ctx = typeof AudioContext !== "undefined" ? AudioContext : webkitAudioContext; audioCtx = new Ctx(); masterGain = audioCtx.createGain(); masterGain.gain.value = 0.35; masterGain.connect(audioCtx.destination); return true; }; const resolveSampleSrc = (file) => { if (!file) return null; if (file.includes("/") || file.startsWith("http")) { return file; } return `${soundConfig.basePath}/${file}`; }; const getSamplePlayer = ( channelKey, { file, volume = 1, rate = 1, max = 6 } = {}, ) => { const src = resolveSampleSrc(file); if (!src) return null; const key = `${channelKey}|${src}|${volume}|${rate}|${max}`; if (!samplePlayers.has(key)) { samplePlayers.set( key, createPoolPlayer(src, { volume, rate, max }), ); } return samplePlayers.get(key); }; const ensureBuzzLoop = ({ file, volume = 0.06, rate = 1 } = {}) => { if (!supportsAudio()) return; const src = resolveSampleSrc(file); if (!src) return; const key = `${src}|${volume}|${rate}`; if (buzzLoop && sampleBuzzKey === key) return; if (buzzLoop) { buzzLoop.pause(); } buzzLoop = createAudio(src, { volume, loop: true, rate, }); sampleBuzzKey = key; }; const resumeContext = () => { if (!audioCtx || audioCtx.state !== "suspended") return; audioCtx.resume().catch(() => {}); }; const createNoiseBuffer = (durationSec) => { const buffer = audioCtx.createBuffer( 1, durationSec * audioCtx.sampleRate, audioCtx.sampleRate, ); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i += 1) { data[i] = (Math.random() * 2 - 1) * 0.9; } return buffer; }; const playClickProcedural = (volume = 0.12) => { if (!ensureContext()) return; resumeContext(); const now = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = "triangle"; osc.frequency.setValueAtTime(860, now); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(volume, now + 0.008); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.08); osc.connect(gain); gain.connect(masterGain); osc.start(now); osc.stop(now + 0.1); }; const playPopProcedural = (volume = 0.2) => { if (!ensureContext()) return; resumeContext(); const now = audioCtx.currentTime; const duration = 0.18; const buffer = createNoiseBuffer(duration); const source = audioCtx.createBufferSource(); source.buffer = buffer; const filter = audioCtx.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.setValueAtTime(1200, now); const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(volume, now + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); source.connect(filter); filter.connect(gain); gain.connect(masterGain); source.start(now); source.stop(now + duration); }; const startBuzzProcedural = (volume = 0.02) => { if (!ensureContext()) return; resumeContext(); const now = audioCtx.currentTime; const osc = audioCtx.createOscillator(); const lfo = audioCtx.createOscillator(); const lfoGain = audioCtx.createGain(); const gate = audioCtx.createGain(); const gateLfo = audioCtx.createOscillator(); const gateLfoGain = audioCtx.createGain(); const gain = audioCtx.createGain(); osc.type = "sawtooth"; osc.frequency.setValueAtTime(120, now); lfo.type = "sine"; lfo.frequency.setValueAtTime(0.8, now); lfoGain.gain.setValueAtTime(0.015, now); gate.gain.setValueAtTime(0.0001, now); gateLfo.type = "sine"; gateLfo.frequency.setValueAtTime(0.22, now); gateLfoGain.gain.setValueAtTime(0.5, now); gain.gain.setValueAtTime(0.0001, now); gain.gain.exponentialRampToValueAtTime(volume, now + 0.2); lfo.connect(lfoGain); lfoGain.connect(gain.gain); gateLfo.connect(gateLfoGain); gateLfoGain.connect(gate.gain); osc.connect(gain); gain.connect(gate); gate.connect(masterGain); osc.start(now); lfo.start(now); gateLfo.start(now); buzzNodes = { osc, lfo, gain, gate, gateLfo }; }; const stopBuzzProcedural = () => { if (!buzzNodes) return; const now = audioCtx.currentTime; buzzNodes.gain.gain.cancelScheduledValues(now); buzzNodes.gain.gain.setValueAtTime(buzzNodes.gain.gain.value, now); buzzNodes.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); buzzNodes.gate.gain.cancelScheduledValues(now); buzzNodes.gate.gain.setValueAtTime(buzzNodes.gate.gain.value, now); buzzNodes.gate.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); buzzNodes.osc.stop(now + 0.06); buzzNodes.lfo.stop(now + 0.06); buzzNodes.gateLfo.stop(now + 0.06); buzzNodes = null; }; const unlock = () => { unlocked = true; if (!soundConfig.enabled) return; if (!ensureContext()) return; resumeContext(); }; const playClick = () => { const channel = soundConfig.channels.click || {}; if (!soundConfig.enabled || !unlocked || channel.mode === "off") return; if (channel.mode === "procedural" && supportsWebAudio()) { const procKey = channel.procedural || "click"; if (procKey === "click") { playClickProcedural(channel.volume ?? 0.12); } return; } if (channel.mode === "sample") { const player = getSamplePlayer("click", { file: channel.file, volume: channel.volume ?? 0.18, max: 8, }); player?.(); } }; const playPop = () => { const channel = soundConfig.channels.pop || {}; if (!soundConfig.enabled || !unlocked || channel.mode === "off") return; if (channel.mode === "procedural" && supportsWebAudio()) { const procKey = channel.procedural || "pop"; if (procKey === "pop") { playPopProcedural(channel.volume ?? 0.2); } return; } if (channel.mode === "sample") { const player = getSamplePlayer("pop", { file: channel.file, volume: channel.volume ?? 0.22, max: 8, }); player?.(); } }; const startDrag = () => { const channel = soundConfig.channels.buzz || {}; if (!soundConfig.enabled || !unlocked || buzzing) return; if (channel.mode === "off") return; buzzing = true; if (channel.mode === "procedural" && supportsWebAudio()) { const procKey = channel.procedural || "buzz"; if (procKey === "buzz") { startBuzzProcedural(channel.volume ?? 0.05); return; } } if (channel.mode === "sample") { ensureBuzzLoop({ file: channel.file, volume: channel.volume ?? 0.06, }); if (!buzzLoop) { buzzing = false; return; } buzzLoop.currentTime = 0; buzzLoop.play().catch(() => { buzzing = false; }); } }; const stopDrag = () => { if (!buzzing) return; buzzing = false; const channel = soundConfig.channels.buzz || {}; if (channel.mode === "procedural" && supportsWebAudio()) { stopBuzzProcedural(); return; } if (!buzzLoop) return; buzzLoop.pause(); buzzLoop.currentTime = 0; }; const setConfig = (nextSounds = {}) => { soundConfig = normalizeSoundConfig(nextSounds); samplePlayers.clear(); sampleBuzzKey = null; if (buzzLoop) { buzzLoop.pause(); buzzLoop = null; } if (!soundConfig.enabled) stopDrag(); }; return { unlock, playClick, playPop, startDrag, stopDrag, setConfig, }; }; window.PhysilinksSound = { create }; })();