'use strict'; (function(global) { global.LabFX = global.LabFX || {}; /* ── AudioContext lazy init ── */ var _ctx = null; var _masterGain = null; var _enabled = (function() { var stored = localStorage.getItem('labfx-sound'); return stored === null ? true : stored === 'true'; })(); function getCtx() { if (!_ctx) { _ctx = new (window.AudioContext || window.webkitAudioContext)(); _masterGain = _ctx.createGain(); _masterGain.gain.value = _enabled ? 1 : 0; _masterGain.connect(_ctx.destination); } if (_ctx.state === 'suspended') { _ctx.resume(); } return _ctx; } function master() { getCtx(); return _masterGain; } /* ── helper: connect chain to master ── */ function chain(nodes) { /* nodes: array of AudioNode — each is connected to the next, last → master */ for (var i = 0; i < nodes.length - 1; i++) { nodes[i].connect(nodes[i + 1]); } nodes[nodes.length - 1].connect(master()); } /* ── helper: create gain ── */ function mkGain(value) { var g = getCtx().createGain(); g.gain.value = value; return g; } /* ── helper: create oscillator ── */ function mkOsc(type, freq) { var o = getCtx().createOscillator(); o.type = type; o.frequency.value = freq; return o; } /* ── helper: create biquad ── */ function mkFilter(type, freq, Q) { var f = getCtx().createBiquadFilter(); f.type = type; f.frequency.value = freq; if (Q != null) f.Q.value = Q; return f; } /* ── helper: noise buffer (white) ── */ function makeNoise(color) { /* color: 'white' | 'pink' | 'brown' */ var ac = getCtx(); var length = ac.sampleRate * 2; var buffer = ac.createBuffer(1, length, ac.sampleRate); var data = buffer.getChannelData(0); var b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0; for (var i = 0; i < length; i++) { var white = Math.random() * 2 - 1; if (color === 'pink') { b0 = 0.99886 * b0 + white * 0.0555179; b1 = 0.99332 * b1 + white * 0.0750759; b2 = 0.96900 * b2 + white * 0.1538520; b3 = 0.86650 * b3 + white * 0.3104856; b4 = 0.55000 * b4 + white * 0.5329522; b5 = -0.7616 * b5 - white * 0.0168980; data[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11; b6 = white * 0.115926; } else if (color === 'brown') { b0 = (b0 + 0.02 * white) / 1.02; data[i] = b0 * 3.5; } else { data[i] = white; } } return buffer; } /* ── noise source ── */ function noiseSource(color) { var ac = getCtx(); var src = ac.createBufferSource(); src.buffer = makeNoise(color || 'white'); src.loop = true; return src; } /* ───────────────────────────────────────────── SYNTH RECIPES ───────────────────────────────────────────── */ var recipes = { click: function(opts) { /* short filtered noise burst, 30ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.6; var src = noiseSource('white'); var flt = mkFilter('bandpass', 1200 * (opts.pitch || 1), 8); var g = mkGain(vol); g.gain.setValueAtTime(vol, now); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.030); chain([src, flt, g]); src.start(now); src.stop(now + 0.035); }, tick: function(opts) { /* Geiger tick: brown noise + very short envelope, 15ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.4; var src = noiseSource('brown'); var flt = mkFilter('highpass', 800, 1); var g = mkGain(vol); g.gain.setValueAtTime(vol, now); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.015); chain([src, flt, g]); src.start(now); src.stop(now + 0.02); }, whoosh: function(opts) { /* descending swoosh: filtered noise sweep 800→200 Hz, 250ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.7; var src = noiseSource('pink'); var flt = mkFilter('bandpass', 800 * (opts.pitch || 1), 3); var g = mkGain(vol); flt.frequency.setValueAtTime(800 * (opts.pitch || 1), now); flt.frequency.exponentialRampToValueAtTime(200 * (opts.pitch || 1), now + 0.25); g.gain.setValueAtTime(0.0001, now); g.gain.linearRampToValueAtTime(vol, now + 0.03); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.25); chain([src, flt, g]); src.start(now); src.stop(now + 0.28); }, chime: function(opts) { /* bright bell: additive sines 880+1320+1760 Hz, exp decay, 600ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5); var pitch = opts.pitch || 1; var freqs = [880, 1320, 1760]; var pan = opts.pan || 0; freqs.forEach(function(f, idx) { var osc = mkOsc('sine', f * pitch); var g = mkGain(vol * (0.5 / (idx + 1))); var panner = ac.createStereoPanner ? ac.createStereoPanner() : null; g.gain.setValueAtTime(vol * (0.5 / (idx + 1)), now); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.6); if (panner) { panner.pan.value = pan; chain([osc, g, panner]); } else { chain([osc, g]); } osc.start(now); osc.stop(now + 0.65); }); }, fizz: function(opts) { /* pink noise, 300ms, low-pass sweep */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.6; var src = noiseSource('pink'); var flt = mkFilter('lowpass', 3000 * (opts.pitch || 1), 1); var g = mkGain(vol); flt.frequency.setValueAtTime(3000 * (opts.pitch || 1), now); flt.frequency.exponentialRampToValueAtTime(400 * (opts.pitch || 1), now + 0.3); g.gain.setValueAtTime(vol, now); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.3); chain([src, flt, g]); src.start(now); src.stop(now + 0.35); }, spark: function(opts) { /* white noise + triangle 2000Hz, 50ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.7; var src = noiseSource('white'); var osc = mkOsc('triangle', 2000 * (opts.pitch || 1)); var gn = mkGain(vol * 0.5); var go = mkGain(vol * 0.5); var mix = mkGain(1); gn.gain.setValueAtTime(vol * 0.5, now); gn.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); go.gain.setValueAtTime(vol * 0.5, now); go.gain.exponentialRampToValueAtTime(0.0001, now + 0.05); src.connect(gn); gn.connect(mix); osc.connect(go); go.connect(mix); mix.connect(master()); src.start(now); osc.start(now); src.stop(now + 0.06); osc.stop(now + 0.06); }, bounce: function(opts) { /* sine sweep 600→300 Hz, 100ms */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.6; var osc = mkOsc('sine', 600 * (opts.pitch || 1)); var g = mkGain(vol); osc.frequency.setValueAtTime(600 * (opts.pitch || 1), now); osc.frequency.exponentialRampToValueAtTime(300 * (opts.pitch || 1), now + 0.1); g.gain.setValueAtTime(vol, now); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.1); chain([osc, g]); osc.start(now); osc.stop(now + 0.12); }, pour: function(opts) { /* filtered noise with random pitch wobble, 200ms+ */ var ac = getCtx(); var now = ac.currentTime; var vol = (opts.volume || 0.5) * 0.5; var src = noiseSource('pink'); var flt = mkFilter('bandpass', 400 * (opts.pitch || 1), 2); var g = mkGain(vol); /* wobble frequency */ var steps = 8; for (var i = 0; i <= steps; i++) { var t = now + (i / steps) * 0.2; var freq = (350 + Math.random() * 150) * (opts.pitch || 1); flt.frequency.setValueAtTime(freq, t); } g.gain.setValueAtTime(0.0001, now); g.gain.linearRampToValueAtTime(vol, now + 0.04); g.gain.setValueAtTime(vol, now + 0.18); g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); chain([src, flt, g]); src.start(now); src.stop(now + 0.25); } }; /* ───────────────────────────────────────────── DRONE — sustained sound (separate lifecycle) ───────────────────────────────────────────── */ var _droneNodes = {}; var droneRecipes = { drone: function(ac, vol) { var sine = mkOsc('sine', 80); var saw = mkOsc('sawtooth', 160); var gs = mkGain(vol * 0.5); var gw = mkGain(vol * 0.25); var mix = mkGain(0.05); sine.connect(gs); gs.connect(mix); saw.connect(gw); gw.connect(mix); mix.connect(master()); sine.start(); saw.start(); return { nodes: [sine, saw, gs, gw, mix] }; } }; /* ───────────────────────────────────────────── PUBLIC API ───────────────────────────────────────────── */ global.LabFX.sound = { play: function(name, opts) { if (!_enabled) return; opts = opts || {}; getCtx(); var recipe = recipes[name]; if (recipe) { try { recipe(opts); } catch(e) { /* silent */ } } }, startDrone: function(name) { if (_droneNodes[name]) return _droneNodes[name]; /* already running */ var recipe = droneRecipes[name]; if (!recipe) return null; var ac = getCtx(); var vol = 0.5; var handle = recipe(ac, vol); _droneNodes[name] = handle; handle.stop = function() { handle.nodes.forEach(function(n) { try { n.stop(); } catch(e) {} try { n.disconnect(); } catch(e) {} }); delete _droneNodes[name]; }; return handle; }, setEnabled: function(bool) { _enabled = !!bool; localStorage.setItem('labfx-sound', _enabled ? 'true' : 'false'); if (_masterGain) { _masterGain.gain.cancelScheduledValues(0); _masterGain.gain.value = _enabled ? 1 : 0; } }, isEnabled: function() { return _enabled; } }; })(window);