glytch/fktry/src/audio/stations.ts
type-two 02fc7d5094 [audio] THE BAND: a tuner, four stations, and the sky as subtitles
Gaussian lock so stations swim up out of the noise instead of snapping, and
band-specific static because that is what makes a band feel like a place: AM
brown crackle, FM white hiss, SW a wandering bandpass plus a heterodyne whose
pitch IS the beat frequency, so it swoops down to nothing as you zero in.

THE STANDARD is deliberately just short of intelligible -- you can hear it is
speech and not what it says. THE SCREEN tells you what it says. That gap is the
trick, and subtitleBus is the wire that carries it.

NUMBERS speaks LANE-DATA's planted coordinates; generated filler is rejected if
it would decode onto real ground, so the only true coordinates on the band are
the deliberate ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 13:51:24 +10:00

489 lines
17 KiB
TypeScript

/**
* THE BAND — the four things living on the dial (round 5 order 6).
*
* Schema is LANE-DATA's (`data/stations.json`); this file is pure synthesis. Every station is
* a continuous voice built once at unlock, whose OUTPUT GAIN is driven by the tuner's lock
* strength. Stations self-schedule with a lookahead so nothing allocates per frame except
* genuinely one-shot notes.
*
* Each station also decides what THE SCREEN says: when a talking station is locked it writes
* the subtitle bus, and the sky captions it.
*/
import type { Era } from '../screen/eras';
import { envGain, hash01, noiseSource } from './graph';
export interface StationDef {
id: string;
band: 'AM' | 'FM' | 'SW';
freq: number;
name: string;
format?: string;
flavor?: string;
music?: {
bpm: number; scale: string; steps: number; mutateEveryBars: number;
seed: number; voices: string[]; eraFlavor?: Record<string, string>;
};
chyron?: string[];
numbers?: {
preamble: string; preambleRepeats: number; groupSize: number; groupsPerMessage: number;
secondsPerGroup: number; plantedGroups: string[]; plantEveryMessages: number;
decoyPool: string[];
};
ghost?: { everyMinutes: number; reversed: boolean; line: string };
}
export interface LiveStation {
def: StationDef;
/** station audio lands here; the tuner drives this node's gain from the lock strength */
out: GainNode;
start(t0: number): void;
/** called every frame. `lock` 0..1 — only a locked station schedules and subtitles. */
update(now: number, lock: number, era: Era): void;
/** the line the sky should caption right now, or '' */
subtitle(): string;
}
const LOOKAHEAD = 0.25; // seconds of scheduling runway
/** Mulberry32 — small, fast, and seedable, so a run's radio is always the same radio. */
function rng(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ─────────────────────────────────────────────────────────────── 88.1 FKTRY FM
const PENTA_MINOR = [0, 3, 5, 7, 10]; // the scale DATA asked for
/**
* The factory's own transmitter: a sequencer nobody is operating.
* Square lead + triangle bass on a seeded 16-step pattern that mutates every 32 bars, so it
* genuinely never repeats a bar it liked. The era colours the transmission, not the notes —
* same music, aging equipment.
*/
export function fmMusic(ctx: BaseAudioContext, def: StationDef): LiveStation {
const m = def.music!;
const out = ctx.createGain();
out.gain.value = 0;
const voiceBus = ctx.createGain();
voiceBus.gain.value = 0.5;
// Wow/flutter lives on a delay line: reel-era tape doesn't hold pitch.
const warble = ctx.createDelay(0.05);
warble.delayTime.value = 0.004;
const wowLfo = ctx.createOscillator();
wowLfo.frequency.value = 0.7;
const wowDepth = ctx.createGain();
wowDepth.gain.value = 0; // era sets this
wowLfo.connect(wowDepth).connect(warble.delayTime);
voiceBus.connect(warble).connect(out);
// Era beds: surface crackle (reel) and the 15.7kHz line whine (broadcast).
const crackle = noiseSource(ctx, 'brown');
const crackleF = ctx.createBiquadFilter();
crackleF.type = 'highpass';
crackleF.frequency.value = 1800;
const crackleG = ctx.createGain();
crackleG.gain.value = 0;
crackle.connect(crackleF).connect(crackleG).connect(out);
const lineWhine = ctx.createOscillator();
lineWhine.type = 'sine';
lineWhine.frequency.value = 15734; // NTSC horizontal line rate, the sound of a warm CRT
const whineG = ctx.createGain();
whineG.gain.value = 0;
lineWhine.connect(whineG).connect(out);
const spb = 60 / m.bpm / 4; // 16th notes
let step = 0;
let bars = 0;
let nextTime = 0;
let started = false;
let pattern: number[] = [];
let mutations = 0;
const rand = rng(m.seed);
function mutate() {
pattern = Array.from({ length: m.steps }, () => {
const r = rand();
if (r < 0.34) return -1; // rest — space is what stops it sounding like a demo
return PENTA_MINOR[Math.floor(rand() * PENTA_MINOR.length)] + (rand() < 0.25 ? 12 : 0);
});
mutations++;
}
mutate();
let eraNow: Era = 'stream';
function note(semi: number, t: number, isDownbeat: boolean) {
const root = 110; // A2
const f = root * Math.pow(2, semi / 12);
// fortress: wavelet-smooth, no transients. Everything else keeps its edge.
const attack = eraNow === 'fortress' ? 0.09 : 0.006;
const decay = eraNow === 'fortress' ? 0.5 : 0.16;
const lead = ctx.createOscillator();
lead.type = 'square';
lead.frequency.value = f * 2;
const lg = envGain(ctx, t, eraNow === 'stream' ? 0.3 : 0.22, attack, decay);
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = eraNow === 'disc' ? 6000 : eraNow === 'reel' ? 2200 : 4000;
lead.connect(lp).connect(lg).connect(voiceBus);
lead.start(t);
lead.stop(t + attack + decay + 0.02);
if (isDownbeat) {
const bass = ctx.createOscillator();
bass.type = 'triangle';
bass.frequency.value = f / 2;
const bg = envGain(ctx, t, 0.34, attack, decay * 1.8);
bass.connect(bg).connect(voiceBus);
bass.start(t);
bass.stop(t + attack + decay * 1.8 + 0.02);
}
}
return {
def,
out,
start(t0) {
wowLfo.start(t0);
crackle.start(t0);
lineWhine.start(t0);
nextTime = t0;
started = true;
},
update(now, lock, era) {
if (era !== eraNow) {
eraNow = era;
// The transmission ages even though the tune doesn't.
wowDepth.gain.value = era === 'reel' ? 0.0022 : 0;
crackleG.gain.value = era === 'reel' ? 0.055 : 0;
whineG.gain.value = era === 'broadcast' ? 0.012 : 0;
}
if (!started || lock <= 0.02) {
// Keep the clock rolling while off-station so the music is a continuous broadcast
// you tune INTO, not a track that starts when you arrive.
while (nextTime < now) { nextTime += spb; step = (step + 1) % m.steps; }
return;
}
while (nextTime < now + LOOKAHEAD) {
const semi = pattern[step];
if (semi >= 0) note(semi, nextTime, step % 4 === 0);
nextTime += spb;
step++;
if (step >= m.steps) {
step = 0;
bars++;
if (bars % m.mutateEveryBars === 0) mutate();
}
}
},
subtitle: () => '',
};
}
// ────────────────────────────────────────────────────────── AM 640 THE STANDARD
/**
* The Correction, with a transmitter.
*
* A ring-modulated mumble: a low carrier multiplied by a formant-swept tone, gated into
* syllables. It is deliberately just short of intelligible — you can hear that it is speech
* and not what it says. THE SCREEN tells you what it says. That gap is the whole trick.
*/
export function amPropaganda(ctx: BaseAudioContext, def: StationDef): LiveStation {
const lines = def.chyron ?? [];
const out = ctx.createGain();
out.gain.value = 0;
// carrier * modulator = ring mod. The modulator is what makes it sound institutional.
const carrier = ctx.createOscillator();
carrier.type = 'sawtooth';
carrier.frequency.value = 118;
const ring = ctx.createGain();
ring.gain.value = 0; // driven by the modulator; 0 = silence between syllables
const modulator = ctx.createOscillator();
modulator.type = 'sine';
modulator.frequency.value = 61;
const modDepth = ctx.createGain();
modDepth.gain.value = 0.5;
modulator.connect(modDepth).connect(ring.gain);
// two formants: a mouth shape that never quite resolves into a vowel
const f1 = ctx.createBiquadFilter();
f1.type = 'bandpass';
f1.frequency.value = 520;
f1.Q.value = 4;
const f2 = ctx.createBiquadFilter();
f2.type = 'bandpass';
f2.frequency.value = 1180;
f2.Q.value = 6;
const syllable = ctx.createGain();
syllable.gain.value = 0;
carrier.connect(ring);
ring.connect(f1).connect(syllable);
ring.connect(f2).connect(syllable);
// AM band means AM bandwidth: nothing above ~4.5k survives the transmitter.
const band = ctx.createBiquadFilter();
band.type = 'lowpass';
band.frequency.value = 4500;
syllable.connect(band).connect(out);
let lineIdx = 0;
let nextSyllable = 0;
let syllablesLeft = 0;
let lineEndsAt = 0;
let started = false;
const rand = rng(6400);
function beginLine(t: number) {
lineIdx = (lineIdx + 1) % Math.max(1, lines.length);
const words = (lines[lineIdx] ?? '').split(/\s+/).length;
syllablesLeft = Math.max(6, Math.round(words * 1.6));
lineEndsAt = t + syllablesLeft * 0.17 + 1.4; // + the pause it leaves for effect
nextSyllable = t + 0.25;
}
return {
def,
out,
start(t0) {
carrier.start(t0);
modulator.start(t0);
started = true;
beginLine(t0);
},
update(now, lock) {
if (!started) return;
if (now > lineEndsAt) beginLine(now);
if (lock <= 0.02) return;
while (nextSyllable < now + LOOKAHEAD && syllablesLeft > 0) {
const t = nextSyllable;
const dur = 0.075 + rand() * 0.075;
// syllable envelope
syllable.gain.setValueAtTime(0.0001, t);
syllable.gain.exponentialRampToValueAtTime(0.5, t + 0.02);
syllable.gain.exponentialRampToValueAtTime(0.0001, t + dur);
// formant motion per syllable — the mouth moves, the meaning doesn't arrive
f1.frequency.setValueAtTime(380 + rand() * 320, t);
f2.frequency.setValueAtTime(950 + rand() * 700, t);
carrier.frequency.setValueAtTime(104 + rand() * 28, t);
nextSyllable = t + dur + 0.06 + rand() * 0.05;
syllablesLeft--;
}
},
// The flagship: the sky captions the radio.
subtitle: () => lines[lineIdx] ?? '',
};
}
// ─────────────────────────────────────────────────────────── SW 6955 NUMBERS
/**
* The unlisted station. Cold formant digits, no affect, no acknowledgement of a listener.
* Some groups are coordinates (LANE-DATA plants the real ones); the rest are decoys and
* seeded filler. The chyron shows the digits, so a player with a pencil can map the ground.
*/
export function swNumbers(
ctx: BaseAudioContext,
def: StationDef,
isRealSeam: (x: number, y: number) => boolean,
): LiveStation {
const n = def.numbers!;
const out = ctx.createGain();
out.gain.value = 0;
// A formant voice: buzz source + three resonances. Vowel-ish, sexless, entirely uninterested.
const buzz = ctx.createOscillator();
buzz.type = 'sawtooth';
buzz.frequency.value = 196;
const voiceGain = ctx.createGain();
voiceGain.gain.value = 0;
const fs = [720, 1240, 2600].map((f) => {
const b = ctx.createBiquadFilter();
b.type = 'bandpass';
b.frequency.value = f;
b.Q.value = 8;
return b;
});
const sum = ctx.createGain();
buzz.connect(voiceGain);
for (const f of fs) voiceGain.connect(f).connect(sum);
// SW bandwidth is narrow and awful
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.7;
sum.connect(band).connect(out);
const rand = rng(69550);
/** Vowel formants per digit so each numeral has a consistent, learnable shape. */
const DIGIT_FORMANTS: [number, number, number][] = [
[520, 1000, 2400], [300, 2200, 2900], [600, 1700, 2500], [500, 1750, 2450],
[640, 1200, 2300], [430, 2100, 2800], [560, 900, 2350], [400, 1900, 2600],
[700, 1150, 2400], [350, 2300, 3000],
];
function decodes(group: string): { x: number; y: number } | null {
if (group.length !== 5) return null;
const x = Number(group.slice(0, 2)) - 32;
const y = Number(group.slice(2, 4)) - 32;
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
}
/** Seeded filler that never accidentally gives away real ground. */
function fillerGroup(): string {
for (let tries = 0; tries < 24; tries++) {
const g = Array.from({ length: n.groupSize }, () => Math.floor(rand() * 10)).join('');
const d = decodes(g);
if (d && isRealSeam(d.x, d.y)) continue; // never leak a coordinate by accident
return g;
}
return '00000';
}
let messages = 0;
let queue: string[] = [];
let nextAt = 0;
let started = false;
let showing = '';
function buildMessage() {
const groups: string[] = [];
for (let i = 0; i < n.preambleRepeats; i++) groups.push(n.preamble);
const planted = messages % n.plantEveryMessages === 0;
for (let i = 0; i < n.groupsPerMessage; i++) {
if (planted && i === Math.floor(n.groupsPerMessage / 2)) {
groups.push(n.plantedGroups[(messages / n.plantEveryMessages) % n.plantedGroups.length]);
} else if (rand() < 0.35) {
groups.push(n.decoyPool[Math.floor(rand() * n.decoyPool.length)]);
} else {
groups.push(fillerGroup());
}
}
messages++;
queue = groups;
}
function speakGroup(group: string, t0: number) {
const per = n.secondsPerGroup / (n.groupSize + 1);
for (let i = 0; i < group.length; i++) {
const d = Number(group[i]);
const t = t0 + i * per;
const [a, b, c] = DIGIT_FORMANTS[d] ?? DIGIT_FORMANTS[0];
fs[0].frequency.setValueAtTime(a, t);
fs[1].frequency.setValueAtTime(b, t);
fs[2].frequency.setValueAtTime(c, t);
buzz.frequency.setValueAtTime(190 + d * 2, t); // barely any melody: no affect
voiceGain.gain.setValueAtTime(0.0001, t);
voiceGain.gain.exponentialRampToValueAtTime(0.34, t + 0.03);
voiceGain.gain.setValueAtTime(0.34, t + per * 0.6);
voiceGain.gain.exponentialRampToValueAtTime(0.0001, t + per * 0.92);
}
}
return {
def,
out,
start(t0) {
buzz.start(t0);
started = true;
nextAt = t0 + 0.5;
buildMessage();
},
update(now, lock) {
if (!started) return;
while (nextAt < now + LOOKAHEAD) {
if (!queue.length) buildMessage();
const g = queue.shift()!;
showing = g;
if (lock > 0.02) speakGroup(g, nextAt);
nextAt += n.secondsPerGroup;
}
},
// Digits on the sky — the treasure map, for anyone bothering to write them down.
subtitle: () => (showing ? showing.split('').join(' ') : ''),
};
}
// ──────────────────────────────────────────────────────────── 108.0 DEAD AIR
/**
* The top of the dial. Room tone from a room that should not have any, breathing at the pace
* of a projector, and once in a very long while a chime played backwards.
*/
export function deadAir(ctx: BaseAudioContext, def: StationDef): LiveStation {
const out = ctx.createGain();
out.gain.value = 0;
const tone = noiseSource(ctx, 'brown');
const lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 420;
const toneG = ctx.createGain();
// Deliberately well under a station: this is a room you strain to hear, not a channel.
// (Measured at 0.32 it rendered LOUDER than FKTRY FM, which made "dead air" the loudest
// thing on the dial — exactly backwards.)
toneG.gain.value = 0.1;
tone.connect(lp).connect(toneG).connect(out);
// the breathing: a slow swell at roughly a projector's pace
const breath = ctx.createOscillator();
breath.type = 'sine';
breath.frequency.value = 0.21;
const breathDepth = ctx.createGain();
breathDepth.gain.value = 0.05;
breath.connect(breathDepth).connect(toneG.gain);
let started = false;
let nextGhost = 0;
const ghostEvery = (def.ghost?.everyMinutes ?? 10) * 60;
/** A reversed chime: swell up out of nothing, then stop dead. Wrong-way-round on purpose. */
function ghost(t0: number) {
const dur = 2.2;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.28, t0 + dur); // the swell
g.gain.setValueAtTime(0.0001, t0 + dur + 0.01); // the cut
g.connect(out);
[523.25, 783.99, 1046.5].forEach((f, i) => {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.value = f * (1 + i * 0.001);
o.connect(g);
o.start(t0);
o.stop(t0 + dur + 0.05);
});
}
return {
def,
out,
start(t0) {
tone.start(t0);
breath.start(t0);
started = true;
// seeded, so the ghost always arrives at the same moment in a given run
nextGhost = t0 + ghostEvery * (0.35 + hash01(1080) * 0.4);
},
update(now, lock) {
if (!started || lock <= 0.02) return;
if (now >= nextGhost) {
ghost(now + 0.05);
nextGhost = now + ghostEvery;
}
},
subtitle: () => '',
};
}