/** * 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; }; 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: () => '', }; }