// Procedural sound engine (Web Audio) — no audio files. // Synthesizes UI/game SFX and a gentle ambient background loop. export type Sfx = | "click" | "cardPlay" | "deal" | "trump" | "trickWin" | "win" | "lose" | "message" | "notify" | "award" | "levelUp" | "purchase" | "kot"; const LS_SFX = "hokm.sfx"; const LS_MUSIC = "hokm.music"; function loadBool(key: string, def = true): boolean { if (typeof window === "undefined") return def; const v = localStorage.getItem(key); return v == null ? def : v === "1"; } class SoundManager { private ctx: AudioContext | null = null; private master: GainNode | null = null; private musicGain: GainNode | null = null; private musicTimer: ReturnType | null = null; private step = 0; sfxEnabled = loadBool(LS_SFX); musicEnabled = loadBool(LS_MUSIC); /** Must be called from a user gesture to unlock audio. */ init() { if (typeof window === "undefined") return; if (!this.ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; if (!AC) return; this.ctx = new AC(); this.master = this.ctx.createGain(); this.master.gain.value = 0.5; this.master.connect(this.ctx.destination); this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.12; this.musicGain.connect(this.master); } if (this.ctx.state === "suspended") void this.ctx.resume(); if (this.musicEnabled) this.startMusic(); } setSfxEnabled(b: boolean) { this.sfxEnabled = b; if (typeof window !== "undefined") localStorage.setItem(LS_SFX, b ? "1" : "0"); if (b) this.init(); } setMusicEnabled(b: boolean) { this.musicEnabled = b; if (typeof window !== "undefined") localStorage.setItem(LS_MUSIC, b ? "1" : "0"); if (b) { this.init(); this.startMusic(); } else { this.stopMusic(); } } private tone( freq: number, start: number, dur: number, opts: { type?: OscillatorType; gain?: number; to?: number } = {} ) { if (!this.ctx || !this.master) return; const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); osc.type = opts.type ?? "sine"; osc.frequency.setValueAtTime(freq, start); if (opts.to) osc.frequency.exponentialRampToValueAtTime(opts.to, start + dur); const peak = opts.gain ?? 0.3; g.gain.setValueAtTime(0.0001, start); g.gain.exponentialRampToValueAtTime(peak, start + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, start + dur); osc.connect(g); g.connect(this.master); osc.start(start); osc.stop(start + dur + 0.02); } private seq(notes: [number, number][], gap = 0.11, opts?: { type?: OscillatorType; gain?: number }) { if (!this.ctx) return; const t0 = this.ctx.currentTime; notes.forEach(([freq, dur], i) => this.tone(freq, t0 + i * gap, dur, opts)); } play(name: Sfx) { if (!this.sfxEnabled) return; this.init(); if (!this.ctx) return; const t = this.ctx.currentTime; switch (name) { case "click": this.tone(520, t, 0.06, { type: "square", gain: 0.12 }); break; case "cardPlay": this.tone(360, t, 0.09, { type: "triangle", gain: 0.18, to: 220 }); break; case "deal": for (let i = 0; i < 4; i++) this.tone(320 + i * 20, t + i * 0.08, 0.07, { type: "triangle", gain: 0.14, to: 200 }); break; case "trump": this.seq([[440, 0.12], [660, 0.18]], 0.1, { type: "sine", gain: 0.25 }); break; case "trickWin": this.seq([[880, 0.12], [1320, 0.16]], 0.08, { type: "sine", gain: 0.22 }); break; case "win": this.seq([[523, 0.16], [659, 0.16], [784, 0.16], [1047, 0.4]], 0.13, { type: "sine", gain: 0.3 }); break; case "lose": this.seq([[440, 0.2], [392, 0.2], [311, 0.45]], 0.16, { type: "triangle", gain: 0.25 }); break; case "message": this.tone(720, t, 0.1, { type: "sine", gain: 0.2, to: 900 }); break; case "notify": this.seq([[660, 0.12], [880, 0.14]], 0.1, { type: "sine", gain: 0.2 }); break; case "award": this.seq([[784, 0.1], [988, 0.1], [1319, 0.25]], 0.09, { type: "sine", gain: 0.25 }); break; case "levelUp": this.seq([[523, 0.1], [659, 0.1], [784, 0.1], [988, 0.1], [1319, 0.35]], 0.09, { type: "sine", gain: 0.28 }); break; case "purchase": this.seq([[1047, 0.08], [1319, 0.12]], 0.07, { type: "square", gain: 0.16 }); break; case "kot": this.seq([[330, 0.14], [262, 0.14], [196, 0.4]], 0.12, { type: "sawtooth", gain: 0.2 }); break; } } // Gentle ambient loop on a Persian-flavored scale (Dastgah-ish). private MUSIC = [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66]; startMusic() { if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return; const playNote = () => { if (!this.ctx || !this.musicGain) return; const freq = this.MUSIC[this.step % this.MUSIC.length]; this.step++; const osc = this.ctx.createOscillator(); const g = this.ctx.createGain(); const t = this.ctx.currentTime; osc.type = "sine"; osc.frequency.value = freq; g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.5, t + 0.3); g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6); osc.connect(g); g.connect(this.musicGain); osc.start(t); osc.stop(t + 1.7); // soft fifth harmony every other note if (this.step % 2 === 0) { const o2 = this.ctx.createOscillator(); const g2 = this.ctx.createGain(); o2.type = "sine"; o2.frequency.value = freq * 1.5; g2.gain.setValueAtTime(0.0001, t); g2.gain.exponentialRampToValueAtTime(0.22, t + 0.3); g2.gain.exponentialRampToValueAtTime(0.0001, t + 1.4); o2.connect(g2); g2.connect(this.musicGain); o2.start(t); o2.stop(t + 1.5); } }; playNote(); this.musicTimer = setInterval(playNote, 900); } stopMusic() { if (this.musicTimer) { clearInterval(this.musicTimer); this.musicTimer = null; } } } export const sound = new SoundManager();