From 02fc7d50941cbece0a172b0de29296bd35837fc7 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 13:51:24 +1000 Subject: [PATCH] [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 --- fktry/src/audio/radio.ts | 248 +++++++++++++++++ fktry/src/audio/stations.ts | 488 +++++++++++++++++++++++++++++++++ fktry/src/audio/subtitleBus.ts | 23 ++ 3 files changed, 759 insertions(+) create mode 100644 fktry/src/audio/radio.ts create mode 100644 fktry/src/audio/stations.ts create mode 100644 fktry/src/audio/subtitleBus.ts diff --git a/fktry/src/audio/radio.ts b/fktry/src/audio/radio.ts new file mode 100644 index 0000000..b83edfa --- /dev/null +++ b/fktry/src/audio/radio.ts @@ -0,0 +1,248 @@ +/** + * The tuner (round 5 order 6). + * + * A real dial: you sweep through noise and stations swim up out of it. Lock is a gaussian on + * the distance between the dial and each carrier, so stations fade in and out rather than + * snapping — hunting for a station is the point, not an obstacle. + * + * Inter-station noise is BAND-APPROPRIATE, because that's what makes each band feel like a + * different physical place: + * AM — brown crackle. Heavy, atmospheric, full of distant weather. + * FM — white hiss. Bright, flat, modern, boring in a clean way. + * SW — warble and heterodyne whistles that beat against nearby carriers. Alive and hostile. + */ +import type { Era } from '../screen/eras'; +import { glide, noiseSource } from './graph'; +import { setSubtitle } from './subtitleBus'; +import { + amPropaganda, deadAir, fmMusic, swNumbers, type LiveStation, type StationDef, +} from './stations'; + +export type Band = 'AM' | 'FM' | 'SW'; + +/** Dial travel per band, in that band's own units (MHz for FM, kHz for AM/SW). */ +export const BAND_RANGE: Record = { + AM: [520, 1710], + FM: [87.5, 108.0], + SW: [5900, 7400], +}; + +/** + * How wide a station's capture is, in band units. Tuned so a station occupies a comfortable + * fraction of the dial: wide enough to find by hand, narrow enough that finding it feels like + * something. SW is deliberately the fiddliest. + */ +const LOCK_WIDTH: Record = { AM: 9, FM: 0.32, SW: 6 }; + +export interface RadioState { + band: Band; + frequency: number; + volume: number; + muted: boolean; + /** id of the station currently locked, or null when you're between stations */ + locked: string | null; + /** 0..1 strength of that lock */ + lock: number; + unlocked: boolean; +} + +/** The narrow control surface LANE-UI's tuner dock drives. */ +export interface Radio { + setBand(b: Band): void; + setFrequency(f: number): void; + setVolume(v: number): void; + setMuted(m: boolean): void; + getState(): RadioState; + subscribe(fn: (s: RadioState) => void): () => void; + /** every station on the dial, for the UI to draw tick marks */ + listStations(): { id: string; name: string; band: Band; freq: number }[]; +} + +export interface RadioEngine extends Radio { + /** open THE BAND — called once the optical fossil is found */ + unlock(t0: number): void; + isUnlocked(): boolean; + update(now: number, era: Era): void; + /** master output for the whole radio, so ambience can duck under it */ + out: GainNode; +} + +const gaussian = (d: number, w: number) => Math.exp(-(d * d) / (2 * w * w)); + +export function createRadio( + ctx: BaseAudioContext, + defs: StationDef[], + isRealSeam: (x: number, y: number) => boolean, +): RadioEngine { + const out = ctx.createGain(); + out.gain.value = 0; // silent until the fossil is dug up + + const stations: LiveStation[] = defs.map((d) => { + if (d.music) return fmMusic(ctx, d); + if (d.chyron) return amPropaganda(ctx, d); + if (d.numbers) return swNumbers(ctx, d, isRealSeam); + return deadAir(ctx, d); + }); + for (const s of stations) s.out.connect(out); + + // ---- the three inter-station noise beds. All three exist; only the current band is audible. + const hiss = noiseSource(ctx, 'white'); // FM + const hissF = ctx.createBiquadFilter(); + hissF.type = 'highpass'; + hissF.frequency.value = 1200; + const hissG = ctx.createGain(); + hissG.gain.value = 0; + hiss.connect(hissF).connect(hissG).connect(out); + + const crackle = noiseSource(ctx, 'brown'); // AM + const crackleF = ctx.createBiquadFilter(); + crackleF.type = 'lowpass'; + crackleF.frequency.value = 3200; + const crackleG = ctx.createGain(); + crackleG.gain.value = 0; + crackle.connect(crackleF).connect(crackleG).connect(out); + + const warble = noiseSource(ctx, 'white'); // SW bed + const warbleF = ctx.createBiquadFilter(); + warbleF.type = 'bandpass'; + warbleF.frequency.value = 1500; + warbleF.Q.value = 1.2; + const warbleG = ctx.createGain(); + warbleG.gain.value = 0; + // the bandpass centre wanders — that slow swimming quality of a shortwave band at night + const warbleLfo = ctx.createOscillator(); + warbleLfo.frequency.value = 0.13; + const warbleLfoDepth = ctx.createGain(); + warbleLfoDepth.gain.value = 600; + warbleLfo.connect(warbleLfoDepth).connect(warbleF.frequency); + warble.connect(warbleF).connect(warbleG).connect(out); + + // Heterodyne: the whistle you get when your dial is NEAR a carrier but not on it. Pitch is + // the beat frequency, so it swoops down to nothing as you zero in — the sound of hunting. + const het = ctx.createOscillator(); + het.type = 'sine'; + het.frequency.value = 1000; + const hetG = ctx.createGain(); + hetG.gain.value = 0; + het.connect(hetG).connect(out); + + let band: Band = 'FM'; + let frequency = 88.1; + let volume = 0.7; + let muted = false; + let unlocked = false; + let started = false; + let locked: string | null = null; + let lockStrength = 0; + const subs = new Set<(s: RadioState) => void>(); + + function state(): RadioState { + return { band, frequency, volume, muted, locked, lock: +lockStrength.toFixed(3), unlocked }; + } + function publish() { + const s = state(); + for (const fn of subs) fn(s); + } + + function applyMaster() { + glide(out.gain, unlocked && !muted ? volume : 0, ctx, 0.08); + } + + return { + out, + + unlock(t0: number) { + if (unlocked) return; + unlocked = true; + if (!started) { + started = true; + for (const s of stations) s.start(t0); + hiss.start(t0); + crackle.start(t0); + warble.start(t0); + warbleLfo.start(t0); + het.start(t0); + } + applyMaster(); + publish(); + }, + isUnlocked: () => unlocked, + + setBand(b) { + if (b === band) return; + band = b; + // land somewhere sensible in the new band rather than an out-of-range number + const [lo, hi] = BAND_RANGE[b]; + frequency = Math.min(hi, Math.max(lo, frequency)); + const near = stations.find((s) => s.def.band === b); + if (near && (frequency < lo || frequency > hi)) frequency = near.def.freq; + publish(); + }, + setFrequency(f) { + const [lo, hi] = BAND_RANGE[band]; + frequency = Math.min(hi, Math.max(lo, f)); + publish(); + }, + setVolume(v) { + volume = Math.min(1, Math.max(0, v)); + applyMaster(); + publish(); + }, + setMuted(m) { + muted = m; + applyMaster(); + publish(); + }, + getState: state, + subscribe(fn) { + subs.add(fn); + fn(state()); + return () => subs.delete(fn); + }, + listStations: () => + stations.map((s) => ({ id: s.def.id, name: s.def.name, band: s.def.band, freq: s.def.freq })), + + update(now, era) { + if (!unlocked) return; + + const w = LOCK_WIDTH[band]; + let best: LiveStation | null = null; + let bestLock = 0; + let nearestDist = Infinity; + + for (const s of stations) { + const onBand = s.def.band === band; + const d = Math.abs(s.def.freq - frequency); + const lock = onBand ? gaussian(d, w) : 0; + if (onBand && d < nearestDist) nearestDist = d; + // Below the audible floor a station is genuinely off — no CPU spent scheduling it. + glide(s.out.gain, lock < 0.02 ? 0 : lock, ctx, 0.06); + s.update(now, lock, era); + if (lock > bestLock) { bestLock = lock; best = s; } + } + + const changed = (best?.def.id ?? null) !== locked; + locked = best && bestLock > 0.02 ? best.def.id : null; + lockStrength = bestLock; + + // Static fills whatever the stations leave behind. + const staticAmt = 1 - Math.min(1, bestLock); + glide(hissG.gain, band === 'FM' ? 0.05 * staticAmt : 0, ctx, 0.1); + glide(crackleG.gain, band === 'AM' ? 0.11 * staticAmt : 0, ctx, 0.1); + glide(warbleG.gain, band === 'SW' ? 0.08 * staticAmt : 0, ctx, 0.1); + + // Heterodyne only on SW, only while close-but-not-locked, pitch = how far off you are. + if (band === 'SW' && nearestDist < w * 3 && bestLock < 0.85) { + const beat = Math.min(2400, 40 + (nearestDist / (w * 3)) * 2400); + glide(het.frequency, beat, ctx, 0.08); + glide(hetG.gain, 0.05 * (1 - bestLock), ctx, 0.1); + } else { + glide(hetG.gain, 0, ctx, 0.15); + } + + // The sky captions whatever is currently talking. + setSubtitle(best && bestLock > 0.35 ? best.subtitle() : ''); + if (changed) publish(); + }, + }; +} diff --git a/fktry/src/audio/stations.ts b/fktry/src/audio/stations.ts new file mode 100644 index 0000000..928b395 --- /dev/null +++ b/fktry/src/audio/stations.ts @@ -0,0 +1,488 @@ +/** + * 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: () => '', + }; +} diff --git a/fktry/src/audio/subtitleBus.ts b/fktry/src/audio/subtitleBus.ts new file mode 100644 index 0000000..74f7530 --- /dev/null +++ b/fktry/src/audio/subtitleBus.ts @@ -0,0 +1,23 @@ +/** + * The subtitle bus — THE SPEAKER captions THE SCREEN. + * + * The flagship cross-organ trick (round 5 order 6): when the radio is tuned to a talking + * station, the sky's chyron subtitles the audio. Both organs are LANE-SCREEN, so this is a + * sanctioned in-lane channel: THE SPEAKER (src/audio) writes, THE SCREEN (src/screen) reads. + * A module-level singleton keeps it out of the contract — main.ts never has to marshal it. + * + * Deliberately trivial and dependency-free so both sides can import it without coupling to + * each other's guts. + */ + +let current = ''; + +/** Set the line the sky should be showing (''/empty clears it). Called by the tuned station. */ +export function setSubtitle(line: string): void { + current = line ?? ''; +} + +/** Read the current caption. Called by THE SCREEN each frame when building its chyron. */ +export function getSubtitle(): string { + return current; +}