Back to Mini Samurai
SOURCE / PINNED RELEASE

Made of little things.

Mini Samurai

Release
a7beb798adbc…
Author-recorded commit
4f97be1515b8…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-3b28f0efc5b

Archive hash verified: 401ec9347ab8f8cd…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/game/sound.ts
/*
 * Synthesized Web Audio sound effects. No audio files: everything is built from
 * oscillators and a cached noise buffer, routed through a master gain and a
 * light compressor. Every sfx* call is a silent no-op until unlockAudio() has
 * run from a user gesture, or while sound is disabled.
 */

let audio: AudioContext | null = null;
let master: GainNode | null = null;
let noiseBuf: AudioBuffer | null = null;
let enabled = true;

const MASTER_VOLUME = 0.5;

export function setSoundEnabled(v: boolean): void {
  enabled = v;
  if (master && audio) {
    master.gain.setTargetAtTime(v ? MASTER_VOLUME : 0, audio.currentTime, 0.02);
  }
}

/** Creates or resumes the audio context. Call from a user gesture (click, key, touch). */
export function unlockAudio(): void {
  if (!audio) {
    const Ctor: typeof AudioContext | undefined =
      typeof window !== 'undefined'
        ? window.AudioContext ??
          (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
        : undefined;
    if (!Ctor) return;
    try {
      audio = new Ctor();
    } catch {
      audio = null;
      return;
    }
    const comp = audio.createDynamicsCompressor();
    comp.threshold.value = -18;
    comp.knee.value = 12;
    comp.ratio.value = 4;
    comp.attack.value = 0.003;
    comp.release.value = 0.2;
    comp.connect(audio.destination);
    master = audio.createGain();
    master.gain.value = enabled ? MASTER_VOLUME : 0;
    master.connect(comp);

    const len = Math.floor(audio.sampleRate * 1.0);
    noiseBuf = audio.createBuffer(1, len, audio.sampleRate);
    const data = noiseBuf.getChannelData(0);
    for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
  }
  if (audio.state === 'suspended') {
    void audio.resume().catch(() => undefined);
  }
}

/** Running context or null when sound should not play. */
function ready(): AudioContext | null {
  if (!enabled || !audio || !master || !noiseBuf) return null;
  if (audio.state !== 'running') return null;
  return audio;
}

const rand = (a: number, b: number): number => a + Math.random() * (b - a);

// ---------------------------------------------------------------------------
// Building blocks
// ---------------------------------------------------------------------------

/** Percussive envelope on a fresh gain node feeding `dest`. */
function envGain(
  ac: AudioContext,
  t: number,
  attack: number,
  peak: number,
  decay: number,
  dest: AudioNode,
): GainNode {
  const g = ac.createGain();
  g.gain.setValueAtTime(0.0001, t);
  g.gain.linearRampToValueAtTime(Math.max(0.0002, peak), t + attack);
  g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
  g.connect(dest);
  return g;
}

interface ToneOpts {
  type?: OscillatorType;
  freq: number;
  freqEnd?: number;
  /** Seconds for the pitch glide (defaults to the whole sound). */
  glide?: number;
  attack?: number;
  decay: number;
  peak: number;
  delay?: number;
  dest?: AudioNode;
}

function tone(ac: AudioContext, o: ToneOpts): void {
  const t = ac.currentTime + (o.delay ?? 0);
  const attack = o.attack ?? 0.004;
  const osc = ac.createOscillator();
  osc.type = o.type ?? 'sine';
  osc.frequency.setValueAtTime(o.freq, t);
  if (o.freqEnd !== undefined) {
    osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.freqEnd), t + (o.glide ?? attack + o.decay));
  }
  const g = envGain(ac, t, attack, o.peak, o.decay, o.dest ?? master!);
  osc.connect(g);
  osc.start(t);
  osc.stop(t + attack + o.decay + 0.05);
}

interface NoiseOpts {
  filter: BiquadFilterType;
  freq: number;
  freqEnd?: number;
  q?: number;
  attack?: number;
  decay: number;
  peak: number;
  delay?: number;
  dest?: AudioNode;
}

function noise(ac: AudioContext, o: NoiseOpts): void {
  const t = ac.currentTime + (o.delay ?? 0);
  const attack = o.attack ?? 0.002;
  const dur = attack + o.decay;
  const src = ac.createBufferSource();
  src.buffer = noiseBuf;
  src.loop = true;
  const f = ac.createBiquadFilter();
  f.type = o.filter;
  f.Q.value = o.q ?? 0.8;
  f.frequency.setValueAtTime(o.freq, t);
  if (o.freqEnd !== undefined) {
    f.frequency.exponentialRampToValueAtTime(Math.max(20, o.freqEnd), t + dur);
  }
  const g = envGain(ac, t, attack, o.peak, o.decay, o.dest ?? master!);
  src.connect(f);
  f.connect(g);
  src.start(t, Math.random() * 0.5);
  src.stop(t + dur + 0.05);
}

/** Set of inharmonic sine partials (bells, blades, gongs). */
function partials(
  ac: AudioContext,
  freqs: number[],
  decays: number[],
  peak: number,
  delay = 0,
  attack = 0.002,
): void {
  freqs.forEach((f, i) => {
    tone(ac, {
      freq: f,
      decay: decays[i % decays.length],
      peak: peak / (1 + i * 0.35),
      attack,
      delay,
    });
  });
}

// ---------------------------------------------------------------------------
// Combat
// ---------------------------------------------------------------------------

let lastSwish = 0;

/** Blade whoosh. strength 0..1 scales volume and pitch. */
export function sfxSwish(strength: number): void {
  const ac = ready();
  if (!ac) return;
  const now = ac.currentTime;
  if (now - lastSwish < 0.025) return;
  lastSwish = now;
  const s = Math.min(1, Math.max(0, strength));
  const dur = 0.13 + 0.07 * s;
  const lo = 380 * (1 + s * 0.6) * rand(0.9, 1.1);
  const hi = 2000 * (1 + s * 0.5) * rand(0.9, 1.1);
  const ctxNow = ac.currentTime;
  const src = ac.createBufferSource();
  src.buffer = noiseBuf;
  const f = ac.createBiquadFilter();
  f.type = 'bandpass';
  f.Q.value = 1.6;
  f.frequency.setValueAtTime(lo, ctxNow);
  f.frequency.exponentialRampToValueAtTime(hi, ctxNow + dur * 0.55);
  f.frequency.exponentialRampToValueAtTime(lo * 0.8, ctxNow + dur);
  const g = ac.createGain();
  const peak = 0.12 + 0.28 * s;
  g.gain.setValueAtTime(0.0001, ctxNow);
  g.gain.linearRampToValueAtTime(peak, ctxNow + dur * 0.45);
  g.gain.exponentialRampToValueAtTime(0.0001, ctxNow + dur);
  src.connect(f);
  f.connect(g);
  g.connect(master!);
  src.start(ctxNow, Math.random() * 0.5);
  src.stop(ctxNow + dur + 0.05);
}

/** Meaty impact: low thud plus a short noise crack. */
export function sfxHit(heavy: boolean): void {
  const ac = ready();
  if (!ac) return;
  const p = rand(0.92, 1.08);
  tone(ac, {
    freq: (heavy ? 130 : 170) * p,
    freqEnd: (heavy ? 42 : 60) * p,
    decay: heavy ? 0.26 : 0.16,
    peak: heavy ? 0.85 : 0.6,
  });
  tone(ac, { type: 'triangle', freq: 320 * p, freqEnd: 110, decay: 0.07, peak: 0.25 });
  noise(ac, { filter: 'lowpass', freq: heavy ? 2200 : 3200, freqEnd: 500, decay: heavy ? 0.09 : 0.06, peak: heavy ? 0.5 : 0.38 });
}

function clangCore(ac: AudioContext, base: number[], decayScale: number, peak: number, delay = 0): void {
  const p = rand(0.94, 1.06);
  const freqs = base.map((f) => f * p * rand(0.99, 1.01));
  const decays = freqs.map((_, i) => (0.32 - i * 0.05) * decayScale);
  partials(ac, freqs, decays, peak, delay);
  noise(ac, { filter: 'highpass', freq: 3000, decay: 0.025, peak: 0.35, delay });
}

/** Metal-on-metal block. */
export function sfxClang(): void {
  const ac = ready();
  if (!ac) return;
  clangCore(ac, [1200, 1870, 2650, 3300], 1, 0.16);
}

/** Bright, longer ringing clang for a perfect parry. */
export function sfxParry(): void {
  const ac = ready();
  if (!ac) return;
  clangCore(ac, [1480, 2290, 3170, 4120, 5350], 2.6, 0.17);
  tone(ac, { freq: 2960 * rand(0.98, 1.02), decay: 0.9, peak: 0.05, attack: 0.01 });
}

/** Clang followed by a spinning whirr as the weapon flies away. */
export function sfxDisarm(): void {
  const ac = ready();
  if (!ac) return;
  clangCore(ac, [1250, 1930, 2720, 3410], 1.4, 0.17);
  const t = ac.currentTime + 0.06;
  const dur = 0.6;
  const src = ac.createBufferSource();
  src.buffer = noiseBuf;
  const f = ac.createBiquadFilter();
  f.type = 'bandpass';
  f.Q.value = 3;
  f.frequency.setValueAtTime(1300, t);
  f.frequency.exponentialRampToValueAtTime(600, t + dur);
  // Amplitude chopped by an LFO whose rate slows as the spin decays.
  const chop = ac.createGain();
  chop.gain.value = 0.5;
  const lfo = ac.createOscillator();
  lfo.type = 'triangle';
  lfo.frequency.setValueAtTime(22, t);
  lfo.frequency.exponentialRampToValueAtTime(7, t + dur);
  const lfoDepth = ac.createGain();
  lfoDepth.gain.value = 0.5;
  lfo.connect(lfoDepth);
  lfoDepth.connect(chop.gain);
  const env = envGain(ac, t, 0.03, 0.3, dur, master!);
  src.connect(f);
  f.connect(chop);
  chop.connect(env);
  src.start(t, Math.random() * 0.3);
  lfo.start(t);
  src.stop(t + dur + 0.1);
  lfo.stop(t + dur + 0.1);
}

/** Cracking snap for an improvised weapon breaking. */
export function sfxBreak(): void {
  const ac = ready();
  if (!ac) return;
  tone(ac, { type: 'square', freq: 900 * rand(0.9, 1.1), freqEnd: 180, decay: 0.035, peak: 0.12 });
  noise(ac, { filter: 'highpass', freq: 1800, decay: 0.03, peak: 0.45 });
  for (let i = 0; i < 4; i++) {
    noise(ac, {
      filter: 'bandpass',
      freq: rand(1500, 4500),
      q: 2,
      decay: rand(0.015, 0.04),
      peak: rand(0.15, 0.3),
      delay: 0.02 + i * rand(0.02, 0.04),
    });
  }
  tone(ac, { type: 'triangle', freq: 260, freqEnd: 120, decay: 0.08, peak: 0.2, delay: 0.01 });
}

/** One taiko drum hit. */
export function sfxTaiko(): void {
  const ac = ready();
  if (!ac) return;
  taiko(ac, 0, 1);
}

function taiko(ac: AudioContext, delay: number, gain: number): void {
  const p = rand(0.96, 1.04);
  tone(ac, { freq: 150 * p, freqEnd: 58 * p, glide: 0.18, decay: 0.42, peak: 0.85 * gain, delay });
  tone(ac, { type: 'triangle', freq: 95 * p, freqEnd: 70, decay: 0.2, peak: 0.3 * gain, delay });
  noise(ac, { filter: 'lowpass', freq: 1300, freqEnd: 300, decay: 0.07, peak: 0.35 * gain, delay });
}

/** Dramatic KO: low boom plus a taiko hit. */
export function sfxKO(): void {
  const ac = ready();
  if (!ac) return;
  tone(ac, { freq: 75, freqEnd: 28, glide: 1.0, decay: 1.2, peak: 0.9, attack: 0.01 });
  noise(ac, { filter: 'lowpass', freq: 500, freqEnd: 80, decay: 0.7, peak: 0.45, attack: 0.01 });
  taiko(ac, 0, 1);
  taiko(ac, 0.22, 0.7);
}

/** Round-start gong: low inharmonic partials with a long decay. */
export function sfxGong(): void {
  const ac = ready();
  if (!ac) return;
  const base = 98 * rand(0.98, 1.02);
  const ratios = [1, 1.006, 1.52, 2.07, 2.76, 3.49, 4.23];
  const decays = [2.5, 2.4, 2.1, 1.7, 1.3, 0.9, 0.7];
  partials(
    ac,
    ratios.map((r) => base * r),
    decays,
    0.3,
    0,
    0.012,
  );
  noise(ac, { filter: 'lowpass', freq: 700, freqEnd: 150, decay: 0.25, peak: 0.35 });
  tone(ac, { freq: 60, freqEnd: 45, decay: 0.5, peak: 0.35, attack: 0.01 });
}

// ---------------------------------------------------------------------------
// Movement
// ---------------------------------------------------------------------------

let lastStep = 0;

/** Tiny soft footstep tap. */
export function sfxStep(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const now = ac.currentTime;
  if (now - lastStep < 0.06) return;
  lastStep = now;
  const v = Math.min(1, Math.max(0, volume));
  if (v <= 0.01) return;
  noise(ac, { filter: 'lowpass', freq: rand(700, 1100), decay: 0.03, peak: 0.16 * v });
  tone(ac, { freq: rand(170, 200), freqEnd: 90, decay: 0.04, peak: 0.1 * v });
}

export function sfxJump(): void {
  const ac = ready();
  if (!ac) return;
  tone(ac, { freq: 280 * rand(0.95, 1.05), freqEnd: 560, decay: 0.1, peak: 0.1, attack: 0.01 });
  noise(ac, { filter: 'bandpass', freq: 800, freqEnd: 1800, q: 1.2, decay: 0.08, peak: 0.12, attack: 0.01 });
}

export function sfxLand(heavy: boolean): void {
  const ac = ready();
  if (!ac) return;
  tone(ac, { freq: heavy ? 110 : 150, freqEnd: heavy ? 45 : 70, decay: heavy ? 0.16 : 0.09, peak: heavy ? 0.5 : 0.28 });
  noise(ac, { filter: 'lowpass', freq: heavy ? 700 : 1000, decay: heavy ? 0.1 : 0.05, peak: heavy ? 0.35 : 0.2 });
}

/** Soft steam chuff of the toy train; volume 0..1 (by distance). */
export function sfxChuff(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const v = Math.min(1, Math.max(0, volume));
  if (v <= 0.02) return;
  noise(ac, { filter: 'bandpass', freq: rand(900, 1200), freqEnd: 450, q: 0.9, attack: 0.012, decay: 0.15, peak: 0.1 * v });
  tone(ac, { freq: 95, freqEnd: 70, decay: 0.08, peak: 0.05 * v });
}

/** Two-note toy-train whistle; volume 0..1 (by distance). */
export function sfxWhistle(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const v = Math.min(1, Math.max(0, volume));
  if (v <= 0.02) return;
  for (const delay of [0, 0.42]) {
    const len = delay ? 0.55 : 0.32;
    tone(ac, { type: 'triangle', freq: 880, decay: len, peak: 0.07 * v, attack: 0.04, delay });
    tone(ac, { type: 'triangle', freq: 1109, decay: len, peak: 0.06 * v, attack: 0.04, delay });
    noise(ac, { filter: 'bandpass', freq: 2200, q: 2, decay: len, peak: 0.025 * v, attack: 0.04, delay });
  }
}

/** The toaster popping: a springy boing and a little bell. */
export function sfxPop(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const v = Math.min(1, Math.max(0, volume));
  tone(ac, { type: 'triangle', freq: 180, freqEnd: 620, glide: 0.12, decay: 0.22, peak: 0.22 * v, attack: 0.005 });
  noise(ac, { filter: 'bandpass', freq: 2400, q: 1.5, decay: 0.05, peak: 0.12 * v });
  partials(ac, [1568, 3136, 4700], [0.6, 0.35, 0.2], 0.08 * v, 0.06);
}

/** One tick of the grandfather clock. */
export function sfxTick(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const v = Math.min(1, Math.max(0, volume));
  noise(ac, { filter: 'bandpass', freq: 3200, q: 4, decay: 0.03, peak: 0.16 * v });
  tone(ac, { freq: 900, freqEnd: 600, decay: 0.04, peak: 0.05 * v });
}

/** Tyres squealing in a drift. */
export function sfxSkid(volume: number): void {
  const ac = ready();
  if (!ac) return;
  const v = Math.min(1, Math.max(0, volume));
  if (v <= 0.03) return;
  noise(ac, { filter: 'bandpass', freq: rand(1800, 2400), q: 6, attack: 0.02, decay: 0.16, peak: 0.08 * v });
  tone(ac, { type: 'sawtooth', freq: rand(780, 860), decay: 0.14, peak: 0.02 * v, attack: 0.02 });
}

/** Hand slap on a ledge. */
export function sfxGrab(): void {
  const ac = ready();
  if (!ac) return;
  noise(ac, { filter: 'bandpass', freq: rand(1600, 2000), q: 1, decay: 0.04, peak: 0.38 });
  tone(ac, { freq: 230, freqEnd: 150, decay: 0.05, peak: 0.12 });
}

/** Scrambling up over a ledge. */
export function sfxClimb(): void {
  const ac = ready();
  if (!ac) return;
  noise(ac, { filter: 'bandpass', freq: 1100, q: 1.5, decay: 0.05, peak: 0.18, attack: 0.01 });
  noise(ac, { filter: 'bandpass', freq: 1400, q: 1.5, decay: 0.05, peak: 0.16, attack: 0.01, delay: 0.09 });
  tone(ac, { freq: 220, freqEnd: 330, decay: 0.12, peak: 0.06, attack: 0.02, delay: 0.05 });
}

/** Pleasant two-note pluck when picking up an item. */
export function sfxPickup(): void {
  const ac = ready();
  if (!ac) return;
  tone(ac, { type: 'triangle', freq: 659.3, decay: 0.22, peak: 0.2 });
  tone(ac, { freq: 1318.5, decay: 0.08, peak: 0.05 });
  tone(ac, { type: 'triangle', freq: 987.8, decay: 0.3, peak: 0.2, delay: 0.08 });
  tone(ac, { freq: 1975.5, decay: 0.1, peak: 0.05, delay: 0.08 });
}

// ---------------------------------------------------------------------------
// UI (hyoshigi-like wooden clicks)
// ---------------------------------------------------------------------------

function woodClick(ac: AudioContext, freq: number, peak: number, delay = 0): void {
  tone(ac, { type: 'triangle', freq, freqEnd: freq * 0.92, decay: 0.045, peak, attack: 0.001, delay });
  tone(ac, { freq: freq * 2.71, decay: 0.02, peak: peak * 0.35, attack: 0.001, delay });
  noise(ac, { filter: 'bandpass', freq: freq * 1.6, q: 5, decay: 0.015, peak: peak * 0.8, delay });
}

export function sfxUiMove(): void {
  const ac = ready();
  if (!ac) return;
  woodClick(ac, 1500, 0.1);
}

export function sfxUiSelect(): void {
  const ac = ready();
  if (!ac) return;
  woodClick(ac, 1250, 0.16);
  woodClick(ac, 1330, 0.18, 0.075);
}