// Stoopshake sound: everything synthesised in Web Audio (no samples, original): dice clacks on concrete / wall / each other, // the shake in the hand, UI taps, result stings, and "Stoop Night", an original 88 BPM boom-bap loop (kick, snare, swung hats, // a warm electric-piano chord loop and a bass line, with soft vinyl crackle). Music and effects toggle separately. // (c) 2026 CyberMax. All rights reserved. let ac = null, master = null, sfxBus = null, musBus = null, noiseBuf = null; const st = { sfx: true, music: true }; try { const s = JSON.parse(localStorage.getItem('ssk-audio') || 'null'); if (s) Object.assign(st, s); } catch { /* private mode */ } const save = () => { try { localStorage.setItem('ssk-audio', JSON.stringify(st)); } catch { /* */ } }; export function ensure() { if (ac) { if (ac.state === 'suspended') ac.resume().catch(() => {}); return ac; } const C = window.AudioContext || window.webkitAudioContext; if (!C) return null; ac = new C(); master = ac.createGain(); master.gain.value = 0.9; const comp = ac.createDynamicsCompressor(); comp.threshold.value = -14; comp.ratio.value = 3; master.connect(comp).connect(ac.destination); sfxBus = ac.createGain(); sfxBus.gain.value = st.sfx ? 1 : 0; sfxBus.connect(master); musBus = ac.createGain(); musBus.gain.value = st.music ? 0.42 : 0; musBus.connect(master); noiseBuf = ac.createBuffer(1, ac.sampleRate * 2, ac.sampleRate); const d = noiseBuf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1; return ac; } export const settings = () => ({ ...st }); export function setSfx(on) { st.sfx = !!on; save(); if (sfxBus) sfxBus.gain.value = on ? 1 : 0; } export function setMusic(on) { st.music = !!on; save(); if (musBus) musBus.gain.setTargetAtTime(on ? 0.42 : 0, ac.currentTime, 0.2); if (on) startMusic(); } /** Expose the mix as a MediaStream for clip recording (optional). */ export function stream() { if (!ensure()) return null; if (!master._dest) { master._dest = ac.createMediaStreamDestination(); master.connect(master._dest); } return master._dest.stream; } function noise(t, dur, { f = 2000, q = 1, type = 'bandpass', g = 0.5, bus = sfxBus, attack = 0.001 } = {}) { const s = ac.createBufferSource(); s.buffer = noiseBuf; s.playbackRate.value = 0.8 + Math.random() * 0.4; const fl = ac.createBiquadFilter(); fl.type = type; fl.frequency.value = f; fl.Q.value = q; const e = ac.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + attack); e.gain.exponentialRampToValueAtTime(0.0008, t + dur); s.connect(fl).connect(e).connect(bus); s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.05); } function tone(t, f, dur, { type = 'sine', g = 0.3, bus = sfxBus, f2 = null, attack = 0.004 } = {}) { const o = ac.createOscillator(); o.type = type; o.frequency.setValueAtTime(f, t); if (f2) o.frequency.exponentialRampToValueAtTime(f2, t + dur); const e = ac.createGain(); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(g, t + attack); e.gain.exponentialRampToValueAtTime(0.0008, t + dur); o.connect(e).connect(bus); o.start(t); o.stop(t + dur + 0.05); } // ---------- dice let lastHit = 0; /** One impact: kind 'floor' | 'wall' | 'die', v = impact speed. Rate-limited so piles don't buzz. */ export function hit(kind, v) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; if (t - lastHit < 0.018) return; lastHit = t; const k = Math.min(1, v / 14); if (kind === 'die') { noise(t, 0.05, { f: 3800 + Math.random() * 900, q: 7, g: 0.55 * k }); tone(t, 2600 + Math.random() * 500, 0.035, { type: 'triangle', g: 0.12 * k }); } else if (kind === 'wall') { noise(t, 0.09, { f: 1700, q: 2.5, g: 0.7 * k }); tone(t, 220, 0.08, { g: 0.2 * k, f2: 120 }); } else { noise(t, 0.07, { f: 2300 + Math.random() * 600, q: 3.2, g: 0.6 * k }); tone(t, 1300 + Math.random() * 300, 0.03, { type: 'triangle', g: 0.08 * k }); } } let rattleT = 0; /** Dice shaken in the hand (call every frame while held; k = shake strength 0..1). */ export function rattle(k = 1) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; if (t - rattleT < 0.055 + Math.random() * 0.05) return; rattleT = t; for (let i = 0; i < 2; i++) noise(t + i * 0.012, 0.03, { f: 3200 + Math.random() * 1500, q: 6, g: 0.18 * k }); } export function whoosh(power = 0.6) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; noise(t, 0.28, { f: 900 + power * 900, q: 0.7, g: 0.25, attack: 0.06 }); } export function tap() { if (!ensure() || !st.sfx) return; const t = ac.currentTime; tone(t, 880, 0.06, { type: 'square', g: 0.05 }); tone(t + 0.03, 1320, 0.05, { type: 'square', g: 0.04 }); } export function chips(n = 4) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; for (let i = 0; i < n; i++) { noise(t + i * 0.045, 0.04, { f: 5200, q: 9, g: 0.22 }); tone(t + i * 0.045, 3100 + i * 60, 0.03, { type: 'triangle', g: 0.05 }); } } /** Result stings: '456' horn stab + crowd-ish burst, 'trips' rising chord, 'point' two notes, '123' low drop, 'none' shrug. */ export function sting(kind) { if (!ensure() || !st.sfx) return; const t = ac.currentTime; if (kind === '456') { [0, 4, 7, 12].forEach((s, i) => tone(t + i * 0.06, 349.2 * 2 ** (s / 12), 0.5, { type: 'sawtooth', g: 0.07 })); noise(t, 1.1, { f: 1500, q: 0.4, g: 0.18, attack: 0.15 }); tone(t, 55, 0.6, { g: 0.5, f2: 40 }); } else if (kind === 'trips') { [0, 3, 7, 10, 14].forEach((s, i) => tone(t + i * 0.07, 293.7 * 2 ** (s / 12), 0.55, { type: 'triangle', g: 0.09 })); } else if (kind === 'point') { tone(t, 523.3, 0.16, { type: 'triangle', g: 0.12 }); tone(t + 0.12, 659.3, 0.22, { type: 'triangle', g: 0.12 }); } else if (kind === '123') { tone(t, 220, 0.7, { type: 'sawtooth', g: 0.09, f2: 70 }); tone(t, 110, 0.7, { g: 0.3, f2: 45 }); } else if (kind === 'win') { [0, 7, 12].forEach((s, i) => tone(t + i * 0.08, 440 * 2 ** (s / 12), 0.3, { type: 'square', g: 0.05 })); } else if (kind === 'lose') { tone(t, 330, 0.25, { type: 'square', g: 0.05, f2: 220 }); } else { tone(t, 392, 0.1, { type: 'triangle', g: 0.07 }); tone(t + 0.1, 370, 0.14, { type: 'triangle', g: 0.07 }); } } // ---------- "Stoop Night": original boom-bap loop (scheduled ahead with the audio clock) const BPM = 88, SPB = 60 / BPM, STEP = SPB / 4; // two-bar pattern, 32 sixteenths. K = kick, S = snare, H = hat const KICK = [1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0]; const SNARE = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1]; // chords (MIDI notes): Fm9 -> Db maj7 -> Bbm9 -> C7(b9) one per half bar... over 2 bars: one per bar half const CHORDS = [[53, 56, 60, 63, 67], [49, 53, 56, 60, 65], [46, 49, 53, 56, 60], [48, 52, 55, 58, 61]]; const BASS = [41, 0, 0, 41, 0, 0, 44, 0, 37, 0, 0, 37, 0, 0, 39, 0, 34, 0, 0, 34, 0, 0, 37, 0, 36, 0, 0, 36, 0, 0, 40, 43]; const mf = (m) => 440 * 2 ** ((m - 69) / 12); let playing = false, nextT = 0, stepI = 0, timer = 0, crackle = null; function kick(t) { const o = ac.createOscillator(), e = ac.createGain(); o.frequency.setValueAtTime(130, t); o.frequency.exponentialRampToValueAtTime(42, t + 0.14); e.gain.setValueAtTime(0.95, t); e.gain.exponentialRampToValueAtTime(0.001, t + 0.42); o.connect(e).connect(musBus); o.start(t); o.stop(t + 0.45); noise(t, 0.02, { f: 3000, q: 1, g: 0.15, bus: musBus }); } function snare(t) { noise(t, 0.2, { f: 1900, q: 0.9, g: 0.55, bus: musBus }); tone(t, 190, 0.12, { type: 'triangle', g: 0.28, bus: musBus, f2: 150 }); } function hat(t, open) { noise(t, open ? 0.16 : 0.035, { f: 8200, q: 1.3, type: 'highpass', g: open ? 0.13 : 0.1, bus: musBus }); } function keys(t, notes, dur) { // warm EP: sine + soft FM bell, slow tremolo for (const n of notes) { const f = mf(n); const o = ac.createOscillator(), m = ac.createOscillator(), mg = ac.createGain(), e = ac.createGain(); o.type = 'sine'; o.frequency.value = f; m.frequency.value = f * 2; mg.gain.value = f * 0.9; m.connect(mg).connect(o.frequency); e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(0.045, t + 0.02); e.gain.exponentialRampToValueAtTime(0.012, t + dur * 0.7); e.gain.exponentialRampToValueAtTime(0.0005, t + dur); mg.gain.setValueAtTime(f * 0.9, t); mg.gain.exponentialRampToValueAtTime(f * 0.05, t + 0.4); o.connect(e).connect(musBus); o.start(t); m.start(t); o.stop(t + dur + 0.05); m.stop(t + dur + 0.05); } } function bass(t, n) { if (!n) return; const o = ac.createOscillator(), fl = ac.createBiquadFilter(), e = ac.createGain(); o.type = 'sawtooth'; o.frequency.value = mf(n - 12); fl.type = 'lowpass'; fl.frequency.value = 260; e.gain.setValueAtTime(0, t); e.gain.linearRampToValueAtTime(0.26, t + 0.01); e.gain.exponentialRampToValueAtTime(0.001, t + STEP * 2.6); o.connect(fl).connect(e).connect(musBus); o.start(t); o.stop(t + STEP * 3); } function schedule() { while (nextT < ac.currentTime + 0.25) { const i = stepI % 32, swing = i % 2 ? STEP * 0.18 : 0, t = nextT + swing; if (KICK[i]) kick(t); if (SNARE[i]) snare(t); hat(t, i % 8 === 6); if (i % 8 === 0) keys(t, CHORDS[(i / 8) | 0], STEP * 7.5); bass(t, BASS[i]); nextT += STEP; stepI++; } } export function startMusic() { if (!ensure() || !st.music || playing) return; playing = true; nextT = ac.currentTime + 0.1; stepI = 0; timer = setInterval(schedule, 60); schedule(); if (!crackle) { crackle = ac.createBufferSource(); crackle.buffer = noiseBuf; crackle.loop = true; const f = ac.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000; const g = ac.createGain(); g.gain.value = 0.012; crackle.connect(f).connect(g).connect(musBus); crackle.start(); } } export function stopMusic() { playing = false; clearInterval(timer); } document.addEventListener('visibilitychange', () => { if (!ac) return; if (document.hidden) ac.suspend().catch(() => {}); else ac.resume().catch(() => {}); });