Files
HokmPlay/src/lib/sound.ts
T

260 lines
8.7 KiB
TypeScript
Raw Normal View History

// 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<typeof setInterval> | 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));
}
2026-06-07 00:21:27 +03:30
/** 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":
2026-06-07 00:21:27 +03:30
// 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":
2026-06-07 00:21:27 +03:30
// A flurry of card-draw swishes (dealing).
for (let i = 0; i < 4; i++)
2026-06-07 00:21:27 +03:30
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 }
> = {
santoor: {
notes: [293.66, 311.13, 392, 440, 466.16, 392, 311.13, 293.66],
gap: 900, type: "sine", attack: 0.3, dur: 1.6, peak: 0.5, fifth: true,
},
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,
},
};
startMusic() {
if (!this.musicEnabled || this.musicTimer || !this.ctx || !this.musicGain) return;
const playNote = () => {
if (!this.ctx || !this.musicGain) return;
const cfg = this.TRACKS[this.musicTrack];
const freq = cfg.notes[this.step % cfg.notes.length];
this.step++;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
const t = this.ctx.currentTime;
osc.type = cfg.type;
osc.frequency.value = freq;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(cfg.peak, t + cfg.attack);
g.gain.exponentialRampToValueAtTime(0.0001, t + cfg.dur);
osc.connect(g);
g.connect(this.musicGain);
osc.start(t);
osc.stop(t + cfg.dur + 0.1);
// soft fifth harmony (santoor) every other note
if (cfg.fifth && 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, this.TRACKS[this.musicTrack].gap);
}
stopMusic() {
if (this.musicTimer) {
clearInterval(this.musicTimer);
this.musicTimer = null;
}
}
}
export const sound = new SoundManager();