"use client"; /** * Spinza procedural sound engine. Every sound is synthesised with WebAudio — * no sample files — so each game gets an original sonic identity from its * `ambience` preset. Volumes come from the player's saved settings. */ type Ambience = string; interface Levels { master: number; music: number; effects: number; enabled: boolean; } const PRESETS: Record = { neon: { root: 55, wave: "sawtooth", detune: 7, filter: 700, lfo: 0.11, shimmer: true }, space: { root: 41.2, wave: "sine", detune: 3, filter: 500, lfo: 0.05, shimmer: true }, royal: { root: 65.4, wave: "triangle", detune: 5, filter: 900, lfo: 0.08, shimmer: false }, heist: { root: 49, wave: "square", detune: 4, filter: 450, lfo: 0.13, shimmer: false }, ice: { root: 73.4, wave: "sine", detune: 6, filter: 1200, lfo: 0.07, shimmer: true }, fire: { root: 43.65, wave: "sawtooth", detune: 9, filter: 600, lfo: 0.16, shimmer: false }, quantum: { root: 58.3, wave: "square", detune: 12, filter: 800, lfo: 0.21, shimmer: true }, city: { root: 51.9, wave: "sawtooth", detune: 6, filter: 750, lfo: 0.12, shimmer: true }, desert: { root: 46.2, wave: "triangle", detune: 4, filter: 650, lfo: 0.06, shimmer: false }, arcade: { root: 82.4, wave: "square", detune: 8, filter: 1400, lfo: 0.25, shimmer: false }, mine: { root: 38.9, wave: "sawtooth", detune: 5, filter: 400, lfo: 0.09, shimmer: false }, race: { root: 61.7, wave: "sawtooth", detune: 10, filter: 900, lfo: 0.18, shimmer: false }, dragon: { root: 36.7, wave: "triangle", detune: 7, filter: 550, lfo: 0.1, shimmer: false }, ocean: { root: 43.65, wave: "sine", detune: 4, filter: 480, lfo: 0.05, shimmer: true }, lunar: { root: 55, wave: "triangle", detune: 3, filter: 700, lfo: 0.06, shimmer: true }, jungle: { root: 49, wave: "triangle", detune: 6, filter: 850, lfo: 0.14, shimmer: false }, void: { root: 32.7, wave: "sine", detune: 2, filter: 380, lfo: 0.04, shimmer: true }, reactor: { root: 58.3, wave: "square", detune: 9, filter: 620, lfo: 0.19, shimmer: false }, obsidian: { root: 41.2, wave: "sine", detune: 1, filter: 420, lfo: 0.03, shimmer: false }, spinza: { root: 55, wave: "triangle", detune: 8, filter: 900, lfo: 0.1, shimmer: true }, }; export class SoundEngine { private ctx: AudioContext | null = null; private master!: GainNode; private musicBus!: GainNode; private fxBus!: GainNode; private ambienceNodes: AudioNode[] = []; private levels: Levels = { master: 0.8, music: 0.6, effects: 0.8, enabled: true }; private ambience: Ambience = "spinza"; private started = false; setLevels(l: Partial) { this.levels = { ...this.levels, ...l }; if (this.ctx) { this.master.gain.setTargetAtTime(this.levels.enabled ? this.levels.master : 0, this.ctx.currentTime, 0.05); this.musicBus.gain.setTargetAtTime(this.levels.music * 0.35, this.ctx.currentTime, 0.1); this.fxBus.gain.setTargetAtTime(this.levels.effects, this.ctx.currentTime, 0.05); } } setAmbience(a: Ambience) { this.ambience = a; if (this.started) this.startAmbience(); } /** Must be called from a user gesture. */ unlock() { if (this.ctx) { if (this.ctx.state === "suspended") void this.ctx.resume(); return; } 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 = this.levels.enabled ? this.levels.master : 0; const comp = this.ctx.createDynamicsCompressor(); comp.threshold.value = -18; comp.ratio.value = 4; this.master.connect(comp).connect(this.ctx.destination); this.musicBus = this.ctx.createGain(); this.musicBus.gain.value = this.levels.music * 0.35; this.musicBus.connect(this.master); this.fxBus = this.ctx.createGain(); this.fxBus.gain.value = this.levels.effects; this.fxBus.connect(this.master); this.started = true; this.startAmbience(); } destroy() { this.stopAmbience(); void this.ctx?.close(); this.ctx = null; this.started = false; } /* ------------------------------------------------------------ ambience */ private stopAmbience() { for (const n of this.ambienceNodes) { try { (n as OscillatorNode).stop?.(); } catch { /* noop */ } n.disconnect(); } this.ambienceNodes = []; } private startAmbience() { if (!this.ctx) return; this.stopAmbience(); const p = PRESETS[this.ambience] ?? PRESETS.spinza; const ctx = this.ctx; const filter = ctx.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.value = p.filter; filter.Q.value = 0.8; const lfo = ctx.createOscillator(); lfo.frequency.value = p.lfo; const lfoGain = ctx.createGain(); lfoGain.gain.value = p.filter * 0.45; lfo.connect(lfoGain).connect(filter.frequency); lfo.start(); const pad = ctx.createGain(); pad.gain.value = 0; pad.gain.setTargetAtTime(1, ctx.currentTime, 2.5); filter.connect(pad).connect(this.musicBus); const voices = [p.root, p.root * 1.5, p.root * 2, p.root * 2.9966]; voices.forEach((f, i) => { const o = ctx.createOscillator(); o.type = i === 3 ? "sine" : p.wave; o.frequency.value = f; o.detune.value = (i % 2 === 0 ? 1 : -1) * p.detune; const g = ctx.createGain(); g.gain.value = i === 0 ? 0.35 : i === 3 ? 0.08 : 0.18; o.connect(g).connect(filter); o.start(); this.ambienceNodes.push(o, g); }); if (p.shimmer) { const s = ctx.createOscillator(); s.type = "sine"; s.frequency.value = p.root * 8; const sg = ctx.createGain(); sg.gain.value = 0.02; const trem = ctx.createOscillator(); trem.frequency.value = 0.3; const tg = ctx.createGain(); tg.gain.value = 0.02; trem.connect(tg).connect(sg.gain); trem.start(); s.connect(sg).connect(filter); s.start(); this.ambienceNodes.push(s, sg, trem, tg); } this.ambienceNodes.push(filter, lfo, lfoGain, pad); } /* -------------------------------------------------------------- effects */ private blip(freq: number, dur: number, type: OscillatorType = "sine", gain = 0.5, slide = 0) { if (!this.ctx) return; const ctx = this.ctx; const o = ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(freq, ctx.currentTime); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), ctx.currentTime + dur); const g = ctx.createGain(); g.gain.setValueAtTime(0, ctx.currentTime); g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.005); g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur); o.connect(g).connect(this.fxBus); o.start(); o.stop(ctx.currentTime + dur + 0.02); } private noise(dur: number, gain = 0.3, filterHz = 1200) { if (!this.ctx) return; const ctx = this.ctx; const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate); const d = buf.getChannelData(0); for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length); // noise texture only, never game logic const src = ctx.createBufferSource(); src.buffer = buf; const f = ctx.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = filterHz; const g = ctx.createGain(); g.gain.value = gain; src.connect(f).connect(g).connect(this.fxBus); src.start(); } click() { this.blip(1800, 0.05, "square", 0.12); } spinStart() { this.noise(0.35, 0.2, 900); this.blip(220, 0.25, "triangle", 0.2, 180); } reelStop(index: number) { this.blip(160 + index * 18, 0.09, "triangle", 0.35, -60); this.noise(0.06, 0.15, 2200); } tick() { this.blip(2400, 0.02, "square", 0.05); } win(level: number) { // level 0..4 — arpeggio grows with size. const base = [523.25, 659.25, 783.99, 1046.5, 1318.5]; const notes = base.slice(0, 2 + Math.min(3, level)); notes.forEach((f, i) => setTimeout(() => this.blip(f, 0.35, "triangle", 0.3), i * 70)); } bigWin() { [392, 523.25, 659.25, 783.99, 1046.5, 1318.5, 1567.98].forEach((f, i) => setTimeout(() => this.blip(f, 0.6, "sawtooth", 0.22), i * 110)); setTimeout(() => this.noise(0.8, 0.25, 3000), 300); } cascade() { this.blip(880, 0.12, "sine", 0.25, 440); this.noise(0.12, 0.12, 1600); } coin() { this.blip(1567.98, 0.16, "triangle", 0.3, 400); } bonus() { [261.63, 329.63, 392, 523.25].forEach((f, i) => setTimeout(() => this.blip(f, 0.5, "triangle", 0.35), i * 120)); this.noise(0.6, 0.2, 800); } jackpot() { for (let i = 0; i < 12; i++) setTimeout(() => this.blip(523.25 * Math.pow(1.0595, (i * 5) % 24), 0.4, "square", 0.2), i * 90); setTimeout(() => this.noise(1.2, 0.3, 2500), 200); } error() { this.blip(180, 0.25, "square", 0.2, -60); } } let engine: SoundEngine | null = null; export function getSound(): SoundEngine { if (!engine) engine = new SoundEngine(); return engine; }