File size: 9,915 Bytes
9759528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// 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(() => {}); });