Back to Squad Table
SOURCE / PINNED RELEASE

Made of little things.

Squad Table

Release
d36201f8ed4f…
Author-recorded commit
7b9bb681d6ba…
License
LICENSE
Author’s source reference
nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d

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

src/sound.ts
// Small Web Audio synth for battle sounds: rifle cracks, MG bursts and
// explosions from filtered noise. No audio files. Muted until enabled and only
// starts after a user gesture has allowed audio.

let ctx: AudioContext | null = null;
let noise: AudioBuffer | null = null;
let enabled = true;

export function setSoundEnabled(on: boolean): void {
  enabled = on;
}

export function soundEnabled(): boolean {
  return enabled;
}

function audio(): AudioContext | null {
  if (!enabled) return null;
  try {
    ctx ??= new AudioContext();
    if (ctx.state === 'suspended') void ctx.resume();
    if (!noise) {
      noise = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
      const data = noise.getChannelData(0);
      for (let i = 0; i < data.length; i += 1) data[i] = Math.random() * 2 - 1;
    }
    return ctx;
  } catch {
    return null;
  }
}

/** One gunshot: a band-passed noise crack with a fast decay. */
function crack(a: AudioContext, at: number, pitch: number, volume: number, length = 0.12): void {
  const src = a.createBufferSource();
  src.buffer = noise;
  const band = a.createBiquadFilter();
  band.type = 'bandpass';
  band.frequency.value = pitch;
  band.Q.value = 0.8;
  const gain = a.createGain();
  gain.gain.setValueAtTime(volume, at);
  gain.gain.exponentialRampToValueAtTime(0.001, at + length);
  src.connect(band).connect(gain).connect(a.destination);
  src.start(at, Math.random() * 0.5, length + 0.05);
}

/** Rifle volley or MG burst; more firepower means more shots. */
export function gunfire(shots: number, mg: boolean): void {
  const a = audio();
  if (!a) return;
  const t = a.currentTime + 0.02;
  const n = Math.max(1, Math.min(14, shots));
  for (let i = 0; i < n; i += 1) {
    const at = mg ? t + i * 0.07 : t + i * 0.11 + Math.random() * 0.06;
    crack(a, at, mg ? 1400 + Math.random() * 300 : 1900 + Math.random() * 600, mg ? 0.35 : 0.3);
  }
}

/** A deep thump with a long rumble. */
export function explosion(delay = 0): void {
  const a = audio();
  if (!a) return;
  const at = a.currentTime + delay;
  const src = a.createBufferSource();
  src.buffer = noise;
  const low = a.createBiquadFilter();
  low.type = 'lowpass';
  low.frequency.setValueAtTime(900, at);
  low.frequency.exponentialRampToValueAtTime(80, at + 0.9);
  const gain = a.createGain();
  gain.gain.setValueAtTime(0.9, at);
  gain.gain.exponentialRampToValueAtTime(0.001, at + 1.1);
  src.connect(low).connect(gain).connect(a.destination);
  src.start(at, 0, 1.2);
}

/** Short rising chime for a successful rally. */
export function chime(): void {
  const a = audio();
  if (!a) return;
  const at = a.currentTime;
  for (const [i, f] of [523, 659, 784].entries()) {
    const osc = a.createOscillator();
    osc.type = 'triangle';
    osc.frequency.value = f;
    const gain = a.createGain();
    gain.gain.setValueAtTime(0.0001, at + i * 0.08);
    gain.gain.exponentialRampToValueAtTime(0.12, at + i * 0.08 + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.0001, at + i * 0.08 + 0.4);
    osc.connect(gain).connect(a.destination);
    osc.start(at + i * 0.08);
    osc.stop(at + i * 0.08 + 0.45);
  }
}