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; +}