SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
9.1 KB · 245 lines typescript
Raw Blame History
1"use client";23/**4 * Spinza procedural sound engine. Every sound is synthesised with WebAudio —5 * no sample files — so each game gets an original sonic identity from its6 * `ambience` preset. Volumes come from the player's saved settings.7 */89type Ambience = string;1011interface Levels {12  master: number;13  music: number;14  effects: number;15  enabled: boolean;16}1718const PRESETS: Record<string, { root: number; wave: OscillatorType; detune: number; filter: number; lfo: number; shimmer: boolean }> = {19  neon: { root: 55, wave: "sawtooth", detune: 7, filter: 700, lfo: 0.11, shimmer: true },20  space: { root: 41.2, wave: "sine", detune: 3, filter: 500, lfo: 0.05, shimmer: true },21  royal: { root: 65.4, wave: "triangle", detune: 5, filter: 900, lfo: 0.08, shimmer: false },22  heist: { root: 49, wave: "square", detune: 4, filter: 450, lfo: 0.13, shimmer: false },23  ice: { root: 73.4, wave: "sine", detune: 6, filter: 1200, lfo: 0.07, shimmer: true },24  fire: { root: 43.65, wave: "sawtooth", detune: 9, filter: 600, lfo: 0.16, shimmer: false },25  quantum: { root: 58.3, wave: "square", detune: 12, filter: 800, lfo: 0.21, shimmer: true },26  city: { root: 51.9, wave: "sawtooth", detune: 6, filter: 750, lfo: 0.12, shimmer: true },27  desert: { root: 46.2, wave: "triangle", detune: 4, filter: 650, lfo: 0.06, shimmer: false },28  arcade: { root: 82.4, wave: "square", detune: 8, filter: 1400, lfo: 0.25, shimmer: false },29  mine: { root: 38.9, wave: "sawtooth", detune: 5, filter: 400, lfo: 0.09, shimmer: false },30  race: { root: 61.7, wave: "sawtooth", detune: 10, filter: 900, lfo: 0.18, shimmer: false },31  dragon: { root: 36.7, wave: "triangle", detune: 7, filter: 550, lfo: 0.1, shimmer: false },32  ocean: { root: 43.65, wave: "sine", detune: 4, filter: 480, lfo: 0.05, shimmer: true },33  lunar: { root: 55, wave: "triangle", detune: 3, filter: 700, lfo: 0.06, shimmer: true },34  jungle: { root: 49, wave: "triangle", detune: 6, filter: 850, lfo: 0.14, shimmer: false },35  void: { root: 32.7, wave: "sine", detune: 2, filter: 380, lfo: 0.04, shimmer: true },36  reactor: { root: 58.3, wave: "square", detune: 9, filter: 620, lfo: 0.19, shimmer: false },37  obsidian: { root: 41.2, wave: "sine", detune: 1, filter: 420, lfo: 0.03, shimmer: false },38  spinza: { root: 55, wave: "triangle", detune: 8, filter: 900, lfo: 0.1, shimmer: true },39};4041export class SoundEngine {42  private ctx: AudioContext | null = null;43  private master!: GainNode;44  private musicBus!: GainNode;45  private fxBus!: GainNode;46  private ambienceNodes: AudioNode[] = [];47  private levels: Levels = { master: 0.8, music: 0.6, effects: 0.8, enabled: true };48  private ambience: Ambience = "spinza";49  private started = false;5051  setLevels(l: Partial<Levels>) {52    this.levels = { ...this.levels, ...l };53    if (this.ctx) {54      this.master.gain.setTargetAtTime(this.levels.enabled ? this.levels.master : 0, this.ctx.currentTime, 0.05);55      this.musicBus.gain.setTargetAtTime(this.levels.music * 0.35, this.ctx.currentTime, 0.1);56      this.fxBus.gain.setTargetAtTime(this.levels.effects, this.ctx.currentTime, 0.05);57    }58  }5960  setAmbience(a: Ambience) {61    this.ambience = a;62    if (this.started) this.startAmbience();63  }6465  /** Must be called from a user gesture. */66  unlock() {67    if (this.ctx) {68      if (this.ctx.state === "suspended") void this.ctx.resume();69      return;70    }71    const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;72    if (!AC) return;73    this.ctx = new AC();74    this.master = this.ctx.createGain();75    this.master.gain.value = this.levels.enabled ? this.levels.master : 0;76    const comp = this.ctx.createDynamicsCompressor();77    comp.threshold.value = -18;78    comp.ratio.value = 4;79    this.master.connect(comp).connect(this.ctx.destination);80    this.musicBus = this.ctx.createGain();81    this.musicBus.gain.value = this.levels.music * 0.35;82    this.musicBus.connect(this.master);83    this.fxBus = this.ctx.createGain();84    this.fxBus.gain.value = this.levels.effects;85    this.fxBus.connect(this.master);86    this.started = true;87    this.startAmbience();88  }8990  destroy() {91    this.stopAmbience();92    void this.ctx?.close();93    this.ctx = null;94    this.started = false;95  }9697  /* ------------------------------------------------------------ ambience */9899  private stopAmbience() {100    for (const n of this.ambienceNodes) {101      try {102        (n as OscillatorNode).stop?.();103      } catch {104        /* noop */105      }106      n.disconnect();107    }108    this.ambienceNodes = [];109  }110111  private startAmbience() {112    if (!this.ctx) return;113    this.stopAmbience();114    const p = PRESETS[this.ambience] ?? PRESETS.spinza;115    const ctx = this.ctx;116    const filter = ctx.createBiquadFilter();117    filter.type = "lowpass";118    filter.frequency.value = p.filter;119    filter.Q.value = 0.8;120    const lfo = ctx.createOscillator();121    lfo.frequency.value = p.lfo;122    const lfoGain = ctx.createGain();123    lfoGain.gain.value = p.filter * 0.45;124    lfo.connect(lfoGain).connect(filter.frequency);125    lfo.start();126    const pad = ctx.createGain();127    pad.gain.value = 0;128    pad.gain.setTargetAtTime(1, ctx.currentTime, 2.5);129    filter.connect(pad).connect(this.musicBus);130    const voices = [p.root, p.root * 1.5, p.root * 2, p.root * 2.9966];131    voices.forEach((f, i) => {132      const o = ctx.createOscillator();133      o.type = i === 3 ? "sine" : p.wave;134      o.frequency.value = f;135      o.detune.value = (i % 2 === 0 ? 1 : -1) * p.detune;136      const g = ctx.createGain();137      g.gain.value = i === 0 ? 0.35 : i === 3 ? 0.08 : 0.18;138      o.connect(g).connect(filter);139      o.start();140      this.ambienceNodes.push(o, g);141    });142    if (p.shimmer) {143      const s = ctx.createOscillator();144      s.type = "sine";145      s.frequency.value = p.root * 8;146      const sg = ctx.createGain();147      sg.gain.value = 0.02;148      const trem = ctx.createOscillator();149      trem.frequency.value = 0.3;150      const tg = ctx.createGain();151      tg.gain.value = 0.02;152      trem.connect(tg).connect(sg.gain);153      trem.start();154      s.connect(sg).connect(filter);155      s.start();156      this.ambienceNodes.push(s, sg, trem, tg);157    }158    this.ambienceNodes.push(filter, lfo, lfoGain, pad);159  }160161  /* -------------------------------------------------------------- effects */162163  private blip(freq: number, dur: number, type: OscillatorType = "sine", gain = 0.5, slide = 0) {164    if (!this.ctx) return;165    const ctx = this.ctx;166    const o = ctx.createOscillator();167    o.type = type;168    o.frequency.setValueAtTime(freq, ctx.currentTime);169    if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(20, freq + slide), ctx.currentTime + dur);170    const g = ctx.createGain();171    g.gain.setValueAtTime(0, ctx.currentTime);172    g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.005);173    g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + dur);174    o.connect(g).connect(this.fxBus);175    o.start();176    o.stop(ctx.currentTime + dur + 0.02);177  }178179  private noise(dur: number, gain = 0.3, filterHz = 1200) {180    if (!this.ctx) return;181    const ctx = this.ctx;182    const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * dur), ctx.sampleRate);183    const d = buf.getChannelData(0);184    for (let i = 0; i < d.length; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / d.length); // noise texture only, never game logic185    const src = ctx.createBufferSource();186    src.buffer = buf;187    const f = ctx.createBiquadFilter();188    f.type = "bandpass";189    f.frequency.value = filterHz;190    const g = ctx.createGain();191    g.gain.value = gain;192    src.connect(f).connect(g).connect(this.fxBus);193    src.start();194  }195196  click() {197    this.blip(1800, 0.05, "square", 0.12);198  }199  spinStart() {200    this.noise(0.35, 0.2, 900);201    this.blip(220, 0.25, "triangle", 0.2, 180);202  }203  reelStop(index: number) {204    this.blip(160 + index * 18, 0.09, "triangle", 0.35, -60);205    this.noise(0.06, 0.15, 2200);206  }207  tick() {208    this.blip(2400, 0.02, "square", 0.05);209  }210  win(level: number) {211    // level 0..4 — arpeggio grows with size.212    const base = [523.25, 659.25, 783.99, 1046.5, 1318.5];213    const notes = base.slice(0, 2 + Math.min(3, level));214    notes.forEach((f, i) => setTimeout(() => this.blip(f, 0.35, "triangle", 0.3), i * 70));215  }216  bigWin() {217    [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));218    setTimeout(() => this.noise(0.8, 0.25, 3000), 300);219  }220  cascade() {221    this.blip(880, 0.12, "sine", 0.25, 440);222    this.noise(0.12, 0.12, 1600);223  }224  coin() {225    this.blip(1567.98, 0.16, "triangle", 0.3, 400);226  }227  bonus() {228    [261.63, 329.63, 392, 523.25].forEach((f, i) => setTimeout(() => this.blip(f, 0.5, "triangle", 0.35), i * 120));229    this.noise(0.6, 0.2, 800);230  }231  jackpot() {232    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);233    setTimeout(() => this.noise(1.2, 0.3, 2500), 200);234  }235  error() {236    this.blip(180, 0.25, "square", 0.2, -60);237  }238}239240let engine: SoundEngine | null = null;241export function getSound(): SoundEngine {242  if (!engine) engine = new SoundEngine();243  return engine;244}245