// Sound effects synthesised with Web Audio (no audio files) + haptics. // // Browsers only allow audio and navigator.vibrate after a "user activation". // A touch *down* doesn't count (only lifting a finger, a tap or a click), so // we try to unlock on every interaction until it works. let ctx = null; let master = null; let noiseBuf = null; let muted = false; let hapticsOn = true; export function setMuted(m) { muted = m; if (master) master.gain.setTargetAtTime(m ? 0 : 0.8, ctx.currentTime, 0.02); } export function setHaptics(on) { hapticsOn = on; } export const canVibrate = typeof navigator.vibrate === 'function'; export function buzz(pattern) { if (!canVibrate || !hapticsOn) return; try { navigator.vibrate(pattern); } catch { /* blocked before user activation */ } } export function unlockAudio() { try { if (!ctx) { const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; ctx = new AC(); master = ctx.createGain(); master.gain.value = muted ? 0 : 0.8; const comp = ctx.createDynamicsCompressor(); master.connect(comp); comp.connect(ctx.destination); noiseBuf = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; } if (ctx.state !== 'running') { ctx.resume().catch(() => {}); // Older iOS Safari only unlocks once a sound starts inside the gesture. const src = ctx.createBufferSource(); src.buffer = ctx.createBuffer(1, 1, ctx.sampleRate); src.connect(ctx.destination); src.start(); } } catch { /* audio unavailable */ } } export function installAudioUnlock() { const opts = { capture: true, passive: true }; for (const t of ['pointerdown', 'pointerup', 'touchend', 'click', 'keydown']) window.addEventListener(t, unlockAudio, opts); } /** False until the browser has let us start audio (needs a tap, not just a touch down). */ export const audioUnlocked = () => !!ctx && ctx.state === 'running'; const live = () => audioUnlocked() && !muted; function tone({ type = 'sine', freq, freqEnd, at = 0, dur, gain = 0.2, attack = 0.005, filter }) { const t0 = ctx.currentTime + at; const o = ctx.createOscillator(); const g = ctx.createGain(); o.type = type; o.frequency.setValueAtTime(freq, t0); if (freqEnd) o.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(gain, t0 + attack); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); let node = o; if (filter) { const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = filter; o.connect(f); node = f; } node.connect(g); g.connect(master); o.start(t0); o.stop(t0 + dur + 0.05); } function noise({ at = 0, dur, gain = 0.2, type = 'bandpass', freq = 1000, freqEnd, q = 1 }) { const t0 = ctx.currentTime + at; const src = ctx.createBufferSource(); src.buffer = noiseBuf; const f = ctx.createBiquadFilter(); f.type = type; f.Q.value = q; f.frequency.setValueAtTime(freq, t0); if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t0 + dur); const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(gain, t0 + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); src.connect(f); f.connect(g); g.connect(master); src.start(t0); src.stop(t0 + dur + 0.05); } const PENTA = [0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24]; const note = (base, semis) => base * Math.pow(2, semis / 12); export const sfx = { join(i) { if (!live()) return; tone({ freq: note(523, PENTA[i % PENTA.length]), dur: 0.14, gain: 0.12 }); }, beat(i) { if (!live()) return; tone({ type: 'triangle', freq: note(392, i * 5), dur: 0.18, gain: 0.25 }); }, /** Roulette tick, rising in pitch as the wheel slows. */ tick(progress, final) { if (!live()) return; if (final) { tone({ type: 'square', freq: 1320, dur: 0.12, gain: 0.08, filter: 4000 }); return; } tone({ type: 'square', freq: 420 + progress * 900, dur: 0.035, gain: 0.07, filter: 3500 }); noise({ dur: 0.02, gain: 0.05, type: 'highpass', freq: 3000 }); }, reveal() { if (!live()) return; // whoosh + boom + bright major arpeggio noise({ dur: 0.5, gain: 0.25, type: 'bandpass', freq: 400, freqEnd: 4000, q: 0.8 }); tone({ freq: 140, freqEnd: 45, dur: 0.6, gain: 0.5 }); [0, 4, 7, 12, 16].forEach((s, i) => { tone({ type: 'triangle', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.9, gain: 0.16 }); tone({ type: 'sawtooth', freq: note(523, s), at: 0.05 + i * 0.06, dur: 0.7, gain: 0.04, filter: 2200 }); }); }, crack() { if (!live()) return; noise({ dur: 0.3, gain: 0.45, type: 'highpass', freq: 1800 }); noise({ at: 0.04, dur: 0.18, gain: 0.25, type: 'bandpass', freq: 5000, q: 4 }); tone({ freq: 220, freqEnd: 55, dur: 0.35, gain: 0.35 }); tone({ type: 'triangle', freq: 2637, at: 0.02, dur: 0.25, gain: 0.05 }); tone({ type: 'triangle', freq: 3520, at: 0.07, dur: 0.2, gain: 0.04 }); }, /** Sad trombone. */ chicken() { if (!live()) return; const parts = [[392, 370, 0], [370, 349, 0.28], [349, 330, 0.56]]; for (const [a, b, at] of parts) tone({ type: 'sawtooth', freq: a, freqEnd: b, at, dur: 0.26, gain: 0.12, filter: 1200, attack: 0.03 }); tone({ type: 'sawtooth', freq: 330, freqEnd: 247, at: 0.84, dur: 0.75, gain: 0.12, filter: 1000, attack: 0.03 }); }, pop(n) { if (!live()) return; tone({ freq: note(660, PENTA[(n - 1) % PENTA.length]), freqEnd: note(330, PENTA[(n - 1) % PENTA.length]), dur: 0.14, gain: 0.25 }); }, };