Back to Pursuit PIT
SOURCE / PINNED RELEASE

Made of little things.

Pursuit PIT

Release
565f4981bdc3…
Author-recorded commit
197afdbed44b…
License
LICENSE
Author’s source reference
nostr://npub12lm3rxsva8kfempyjkxupt09e02n3fezurkyadyqfejdc5e0r2kscqty6r/wss%3A%2F%2Fgit.napplet.soy%2F/n-951414e7fd7

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

src/game/music.ts
// The getaway car's stereo: three stations of music made on the fly with Web Audio (the
// napplet can't fetch audio files). Each station loops a four-chord progression with
// drums, bass and a lead or pad, varying its riffs every few bars. V cycles the stations.

interface Station {
  freq: string; // shown on the dash radio, e.g. 'FM 101.9'
  name: string;
  bpm: number;
  chords: number[][]; // semitones from A2 for each bar's chord (root first)
  play: (m: Music, step: number, t: number, chord: number[], bar: number) => void;
}

const A2 = 110;
const hz = (semi: number) => A2 * 2 ** (semi / 12);

export class Music {
  private ctx: AudioContext | null = null;
  private out: AudioNode | null = null;
  private noise: AudioBuffer | null = null;
  private bus: GainNode | null = null;
  private timer: number | null = null;
  private next = 0; // audio time of the next 16th note
  private step = 0;
  /** -1 = off, else the index of the station playing. */
  station = -1;

  constructor(private connect: () => { ctx: AudioContext; out: AudioNode } | null) {}

  get label(): string {
    return this.station < 0 ? 'RADIO OFF' : `${STATIONS[this.station].freq} ${STATIONS[this.station].name}`;
  }

  get playing(): boolean {
    return this.station >= 0;
  }

  /** Next station, or off after the last one. Returns what the dash should show. */
  cycle(): string {
    this.tune(this.station + 1 >= STATIONS.length ? -1 : this.station + 1);
    return this.label;
  }

  off(): void {
    this.tune(-1);
  }

  private tune(station: number): void {
    this.station = station;
    if (this.bus && this.ctx) {
      // The old station fades out under a burst of static.
      const old = this.bus;
      old.gain.setTargetAtTime(0, this.ctx.currentTime, 0.04);
      setTimeout(() => old.disconnect(), 400);
      this.bus = null;
    }
    if (this.timer !== null) {
      clearInterval(this.timer);
      this.timer = null;
    }
    if (station < 0) return;
    const link = this.connect();
    if (!link) return;
    this.ctx = link.ctx;
    this.out = link.out;
    if (!this.noise) {
      this.noise = this.ctx.createBuffer(1, this.ctx.sampleRate, this.ctx.sampleRate);
      const d = this.noise.getChannelData(0);
      for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
    }
    this.static(0.25);
    this.bus = this.ctx.createGain();
    this.bus.gain.value = 0.85;
    this.bus.connect(this.out);
    this.next = this.ctx.currentTime + 0.3;
    this.step = 0;
    this.timer = window.setInterval(() => this.schedule(), 25);
  }

  /** Schedule the notes due in the next 120 ms. */
  private schedule(): void {
    const ctx = this.ctx;
    if (!ctx || this.station < 0) return;
    const st = STATIONS[this.station];
    const sixteenth = 60 / st.bpm / 4;
    while (this.next < ctx.currentTime + 0.12) {
      const bar = Math.floor(this.step / 16);
      st.play(this, this.step % 16, this.next, st.chords[bar % st.chords.length], bar);
      this.next += sixteenth;
      this.step++;
    }
  }

  // ---- instruments (all write into this.bus)

  private env(t: number, peak: number, attack: number, decay: number): GainNode {
    const g = this.ctx!.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.exponentialRampToValueAtTime(peak, t + attack);
    g.gain.exponentialRampToValueAtTime(0.0001, t + attack + decay);
    g.connect(this.bus!);
    return g;
  }

  private noiseHit(t: number, type: BiquadFilterType, freq: number, peak: number, decay: number): void {
    const src = this.ctx!.createBufferSource();
    src.buffer = this.noise;
    const f = this.ctx!.createBiquadFilter();
    f.type = type;
    f.frequency.value = freq;
    src.connect(f).connect(this.env(t, peak, 0.002, decay));
    src.start(t, Math.random() * 0.5);
    src.stop(t + decay + 0.05);
  }

  private static(seconds: number): void {
    const ctx = this.ctx!;
    const src = ctx.createBufferSource();
    src.buffer = this.noise;
    const f = ctx.createBiquadFilter();
    f.type = 'bandpass';
    f.frequency.value = 2500;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.12, ctx.currentTime);
    g.gain.linearRampToValueAtTime(0, ctx.currentTime + seconds);
    src.connect(f).connect(g).connect(this.out!);
    src.start();
    src.stop(ctx.currentTime + seconds + 0.05);
  }

  kick(t: number, peak = 0.9): void {
    const o = this.ctx!.createOscillator();
    o.frequency.setValueAtTime(150, t);
    o.frequency.exponentialRampToValueAtTime(42, t + 0.12);
    o.connect(this.env(t, peak, 0.002, 0.28));
    o.start(t);
    o.stop(t + 0.35);
  }

  snare(t: number, peak = 0.45): void {
    this.noiseHit(t, 'bandpass', 1900, peak, 0.16);
    const o = this.ctx!.createOscillator();
    o.type = 'triangle';
    o.frequency.value = 190;
    o.connect(this.env(t, peak * 0.5, 0.002, 0.08));
    o.start(t);
    o.stop(t + 0.12);
  }

  hat(t: number, open = false, peak = 0.14): void {
    this.noiseHit(t, 'highpass', 7500, peak, open ? 0.18 : 0.035);
  }

  /** A filtered synth note: bass, lead or pluck depending on wave and cutoff. */
  note(t: number, semi: number, dur: number, wave: OscillatorType, cutoff: number, peak: number, detune = 0): void {
    const ctx = this.ctx!;
    const f = ctx.createBiquadFilter();
    f.type = 'lowpass';
    f.frequency.setValueAtTime(cutoff * 2, t);
    f.frequency.exponentialRampToValueAtTime(cutoff, t + dur * 0.6);
    f.connect(this.env(t, peak, 0.005, dur));
    for (const d of detune ? [-detune, detune] : [0]) {
      const o = ctx.createOscillator();
      o.type = wave;
      o.frequency.value = hz(semi);
      o.detune.value = d;
      o.connect(f);
      o.start(t);
      o.stop(t + dur + 0.05);
    }
  }

  /** Distorted power chord (root and fifth) for the rock station. */
  power(t: number, semi: number, dur: number): void {
    const ctx = this.ctx!;
    const shaper = ctx.createWaveShaper();
    const curve = new Float32Array(512);
    for (let i = 0; i < curve.length; i++) curve[i] = Math.tanh(((i / 511) * 2 - 1) * 5);
    shaper.curve = curve;
    const f = ctx.createBiquadFilter();
    f.type = 'lowpass';
    f.frequency.value = 2600;
    shaper.connect(f).connect(this.env(t, 0.16, 0.004, dur));
    for (const s of [semi, semi + 7, semi + 12]) {
      const o = ctx.createOscillator();
      o.type = 'sawtooth';
      o.frequency.value = hz(s);
      o.connect(shaper);
      o.start(t);
      o.stop(t + dur + 0.05);
    }
  }

  /** Soft, slow pad of the chord's notes. */
  pad(t: number, chord: number[], dur: number): void {
    const ctx = this.ctx!;
    const f = ctx.createBiquadFilter();
    f.type = 'lowpass';
    f.frequency.value = 1300;
    const g = ctx.createGain();
    g.gain.setValueAtTime(0.0001, t);
    g.gain.linearRampToValueAtTime(0.06, t + dur * 0.3);
    g.gain.linearRampToValueAtTime(0.0001, t + dur);
    f.connect(g).connect(this.bus!);
    for (const s of chord) {
      for (const d of [-9, 9]) {
        const o = ctx.createOscillator();
        o.type = 'sawtooth';
        o.frequency.value = hz(s + 12);
        o.detune.value = d;
        o.connect(f);
        o.start(t);
        o.stop(t + dur + 0.05);
      }
    }
  }
}

/** A repeatable choice for bar `bar` (riffs vary every few bars but come round again). */
const pickFor = (bar: number, salt: number, n: number) => Math.abs(Math.floor(Math.sin(bar * 12.9898 + salt * 78.233) * 43758.5453)) % n;

const STATIONS: Station[] = [
  {
    freq: 'FM 101.9', name: 'KRSH ROCK', bpm: 138,
    chords: [[7, 14, 19], [3, 10, 15], [5, 12, 17], [0, 7, 12]], // E5 C5 D5 A5
    play(m, step, t, chord, bar) {
      const beat = 60 / 138 / 4;
      if (step % 8 === 0) m.kick(t);
      if (step === 10 && pickFor(bar, 1, 2)) m.kick(t, 0.7);
      if (step % 8 === 4) m.snare(t, 0.5);
      if (step % 2 === 0) m.hat(t, step === 14);
      m.note(t, chord[0] - 12, beat * 0.9, 'sawtooth', 500, 0.22); // driving eighths... in sixteenths
      if (step % 8 === 0) m.power(t, chord[0], beat * (pickFor(bar, 2, 2) ? 7.5 : 3.5));
      if (step === 4 || step === 12) m.power(t, chord[0], beat * 3);
    },
  },
  {
    freq: 'FM 88.5', name: 'WAVE SYNTH', bpm: 108,
    chords: [[0, 3, 7], [8, 12, 15], [3, 7, 10], [10, 14, 17]], // Am F C G
    play(m, step, t, chord, bar) {
      const beat = 60 / 108 / 4;
      if (step % 4 === 0) m.kick(t, 0.8);
      if (step === 4 || step === 12) m.snare(t, 0.4);
      if (step % 2 === 1) m.hat(t, false, 0.08);
      m.note(t, chord[0] - 12 + (step % 2 ? 12 : 0), beat * 0.8, 'sawtooth', 700, 0.16); // octave bass
      if (step === 0) m.pad(t, chord, beat * 16);
      // Arpeggio up and down the chord, a different shape every other bar.
      const shapes = [[0, 1, 2, 1], [0, 2, 1, 2], [2, 1, 0, 1]];
      const shape = shapes[pickFor(Math.floor(bar / 2), 3, shapes.length)];
      if (step % 2 === 0) m.note(t, chord[shape[(step / 2) % 4]] + 24, beat * 1.6, 'square', 2400, 0.05);
    },
  },
  {
    freq: 'FM 94.7', name: 'KFNK FUNK', bpm: 112,
    chords: [[5, 9, 12, 15], [5, 9, 12, 15], [10, 14, 17, 20], [12, 16, 19, 22]], // D9-ish, G9, A9
    play(m, step, t, chord, bar) {
      const beat = 60 / 112 / 4;
      if (step === 0 || step === 7 || step === 10) m.kick(t, 0.8);
      if (step === 4 || step === 12) m.snare(t, 0.45);
      if (step === 15) m.snare(t, 0.12); // ghost note
      m.hat(t, step === 6, step % 2 ? 0.05 : 0.11);
      // Syncopated slap bass: a pattern of root, octave and fifth hits.
      const riffs = [[0, 3, 6, 8, 11, 14], [0, 2, 7, 10, 12, 15], [0, 3, 5, 8, 10, 13]];
      const riff = riffs[pickFor(bar, 4, riffs.length)];
      const at = riff.indexOf(step);
      if (at >= 0) m.note(t, chord[0] - 12 + [0, 12, 7, 0, 12, 10][at], beat * 1.2, 'triangle', 900, 0.32);
      // Clipped chord stabs on the offbeats.
      if (step === 2 || step === 9 || step === 13) for (const s of chord) m.note(t, s + 12, beat * 0.6, 'square', 1800, 0.035);
    },
  },
];