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.
// 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);
},
},
];
