Files
HokmPlay/src/lib/sound.ts
T
soroush.asadi 9901c5e6d4
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m0s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m18s
feat(audio,site): calm santoor default music + card-fan logo site redesign
- audio: default background music is now the santoor track (calm Persian),
  rebuilt as a real plucked-santoor loop — fast metallic attack, shimmer
  overtones, soft tonic drone, longer Dastgah-e-Shur phrase
- site: marketing logo is now the app's card-fan icon (Logo.tsx + icon.svg);
  hero features the big logo with gold halo, floating suit motifs, and
  polished section dividers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:48:59 +03:30

275 lines
9.6 KiB
TypeScript

// 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));
}
/** 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();