// 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"; const LS_TRACK = "hokm.musicTrack"; export type MusicTrack = "santoor" | "playful"; 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); musicTrack: MusicTrack = (typeof window !== "undefined" && (localStorage.getItem(LS_TRACK) as MusicTrack)) || "santoor"; /** 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(); } } /** Switch the background music style; restarts the loop if playing. */ setMusicTrack(track: MusicTrack) { this.musicTrack = track; if (typeof window !== "undefined") localStorage.setItem(LS_TRACK, track); this.step = 0; if (this.musicEnabled) { this.stopMusic(); this.init(); this.startMusic(); } } 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)); } /** Filtered noise burst with a downward sweep — a card "swish" / draw sound. */ private swish(start: number, dur = 0.13, opts: { gain?: number; from?: number; to?: number } = {}) { if (!this.ctx || !this.master) return; const ctx = this.ctx; const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1; const src = ctx.createBufferSource(); src.buffer = buf; const bp = ctx.createBiquadFilter(); bp.type = "bandpass"; bp.Q.value = 0.9; bp.frequency.setValueAtTime(opts.from ?? 3200, start); bp.frequency.exponentialRampToValueAtTime(opts.to ?? 900, start + dur); const g = ctx.createGain(); const peak = opts.gain ?? 0.28; g.gain.setValueAtTime(0.0001, start); g.gain.exponentialRampToValueAtTime(peak, start + 0.008); g.gain.exponentialRampToValueAtTime(0.0001, start + dur); src.connect(bp); bp.connect(g); g.connect(this.master); src.start(start); src.stop(start + dur + 0.02); } 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": // Draw-card swish + a soft low tap as it lands on the felt. this.swish(t, 0.13, { gain: 0.3, from: 3200, to: 800 }); this.tone(150, t + 0.08, 0.06, { type: "sine", gain: 0.12, to: 90 }); break; case "deal": // A flurry of card-draw swishes (dealing). for (let i = 0; i < 4; i++) this.swish(t + i * 0.1, 0.1, { gain: 0.22, from: 3400, to: 1000 }); 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; } } // Two selectable loops: // • santoor — calm Persian-flavored (Dastgah-ish) legato loop with fifth harmony. // • playful — bouncy major-pentatonic staccato loop (UNO-like). private TRACKS: Record< MusicTrack, { notes: number[]; gap: number; type: OscillatorType; attack: number; dur: number; peak: number; fifth: boolean; shimmer?: boolean; // bright metallic overtones — santoor pluck character bassHz?: number; // soft sustained tonic drone underneath } > = { santoor: { // Dastgah-e Shur on D — a calm phrase that rises then settles, looping // seamlessly. Plucked (fast attack, long decay) with metallic shimmer. notes: [ 293.66, 349.23, 392.0, 440.0, 392.0, 349.23, 311.13, 293.66, 349.23, 392.0, 466.16, 440.0, 392.0, 349.23, 311.13, 293.66, ], gap: 470, type: "triangle", attack: 0.004, dur: 1.15, peak: 0.4, fifth: false, shimmer: true, bassHz: 73.42, // D2 drone }, playful: { notes: [523.25, 659.25, 784, 659.25, 587.33, 698.46, 880, 698.46, 587.33, 523.25], gap: 360, type: "triangle", attack: 0.02, dur: 0.34, peak: 0.4, fifth: false, }, }; /** Start the ambient loop for the current track (plays through musicGain so * the in-game mute / music volume apply). Idempotent + needs an unlocked ctx. */ startMusic() { if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return; const tick = () => { if (!this.ctx || !this.musicGain) return; const cfg = this.TRACKS[this.musicTrack]; const now = this.ctx.currentTime; const idx = this.step % cfg.notes.length; const freq = cfg.notes[idx]; this.step++; const voice = (f: number, scale = 1, type = cfg.type, attack = cfg.attack, dur = cfg.dur) => { const osc = this.ctx!.createOscillator(); const g = this.ctx!.createGain(); osc.type = type; osc.frequency.value = f; g.gain.setValueAtTime(0.0001, now); g.gain.linearRampToValueAtTime(cfg.peak * scale, now + attack); g.gain.exponentialRampToValueAtTime(0.0001, now + dur); osc.connect(g); g.connect(this.musicGain!); osc.start(now); osc.stop(now + dur + 0.05); }; voice(freq); if (cfg.fifth) voice(freq * 1.5, 0.45); // soft fifth above if (cfg.shimmer) { // Metallic santoor overtones: a brighter octave + a faint third partial, // both with quick decay so the pluck shimmers then fades. voice(freq * 2, 0.16, "sine", 0.003, cfg.dur * 0.55); voice(freq * 3, 0.06, "sine", 0.003, cfg.dur * 0.4); } // Soft sustained tonic drone re-struck once per phrase half. if (cfg.bassHz && idx % (cfg.notes.length / 2) === 0) { const span = (cfg.gap / 1000) * (cfg.notes.length / 2); voice(cfg.bassHz, 0.5, "sine", 0.25, span * 0.95); } }; tick(); this.musicTimer = setInterval(tick, this.TRACKS[this.musicTrack].gap); } stopMusic() { if (this.musicTimer) { clearInterval(this.musicTimer); this.musicTimer = null; } } } export const sound = new SoundManager();