From fd2aee6c50a4d240274338f84b4b26d4e50a8ab5 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 13:51:24 +1000 Subject: [PATCH] [audio] world ambience, discovery gating, and the radio control surface Pre-discovery silence is structural, not a volume setting: the radio's master gain is zero and ambience is never started until the fossil is found, and layer 1 is all one-shots, so a quiet factory is actually quiet rather than humming under a noise floor. Measured at exactly 0.0 peak with the dial swept across all three bands. Dead air's tone drops 0.32 to 0.10 -- it rendered louder than the music station, which made "dead air" the loudest thing on the dial, exactly backwards. getRadio() is the sanctioned narrow surface for LANE-UI's tuner dock. Co-Authored-By: Claude Opus 4.8 --- fktry/src/audio/ambience.ts | 84 ++++++++++++++ fktry/src/audio/band.test.ts | 109 ++++++++++++++++++ fktry/src/audio/index.ts | 211 ++++++++++++++++++++++++++++++++++- 3 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 fktry/src/audio/ambience.ts create mode 100644 fktry/src/audio/band.test.ts diff --git a/fktry/src/audio/ambience.ts b/fktry/src/audio/ambience.ts new file mode 100644 index 0000000..47cf00d --- /dev/null +++ b/fktry/src/audio/ambience.ts @@ -0,0 +1,84 @@ +/** + * Layer 3 — world ambience. Post-discovery only. + * + * The factory gets a voice once the fossil is dug up: a hum that tracks how hard the plant is + * working, and a strained whine as decoders approach scram. Both are continuous voices built + * once and driven by AudioParams — nothing here allocates per frame. + * + * Subordinate by design: the hum sits under everything, and ducks further when the radio is + * carrying a station, because a tuned station is what the player asked to hear. + */ +import { glide, noiseSource } from './graph'; + +export interface Ambience { + out: GainNode; + start(t0: number): void; + /** + * @param load 0..1 how hard the plant is running (draw vs gen) + * @param fever 0..1 aggregate heat — the strained whine near scram + * @param duck 0..1 how much the radio wants the floor + */ + update(load: number, fever: number, duck: number): void; +} + +export function createAmbience(ctx: BaseAudioContext): Ambience { + const out = ctx.createGain(); + out.gain.value = 0; + + // ---- the hum: mains-ish fundamental + its octave, plus a broadband machine-room bed. + const hum = ctx.createOscillator(); + hum.type = 'sawtooth'; + hum.frequency.value = 50; + const humLp = ctx.createBiquadFilter(); + humLp.type = 'lowpass'; + humLp.frequency.value = 180; + const humG = ctx.createGain(); + humG.gain.value = 0.0; + hum.connect(humLp).connect(humG).connect(out); + + const room = noiseSource(ctx, 'brown'); + const roomLp = ctx.createBiquadFilter(); + roomLp.type = 'lowpass'; + roomLp.frequency.value = 600; + const roomG = ctx.createGain(); + roomG.gain.value = 0.0; + room.connect(roomLp).connect(roomG).connect(out); + + // ---- the strained whine: only shows up as machines climb toward a scram. + const whine = ctx.createOscillator(); + whine.type = 'triangle'; + whine.frequency.value = 1900; + const whineG = ctx.createGain(); + whineG.gain.value = 0; + whine.connect(whineG).connect(out); + + let started = false; + + return { + out, + start(t0) { + if (started) return; + started = true; + hum.start(t0); + room.start(t0); + whine.start(t0); + }, + update(load, fever, duck) { + if (!started) return; + const l = Math.min(1, Math.max(0, load)); + const f = Math.min(1, Math.max(0, fever)); + const d = 1 - 0.55 * Math.min(1, Math.max(0, duck)); // radio wins, but never silences + + glide(humG.gain, (0.035 + 0.05 * l) * d, ctx, 0.4); + glide(roomG.gain, (0.02 + 0.045 * l) * d, ctx, 0.4); + // a busier plant runs its mains a touch sharp — cheap, and it reads as effort + glide(hum.frequency, 50 + 6 * l, ctx, 0.5); + glide(roomLp.frequency, 500 + 900 * l, ctx, 0.5); + + // Heat only becomes audible late; below 0.55 a warm factory is just a factory. + const strain = Math.max(0, (f - 0.55) / 0.45); + glide(whineG.gain, 0.03 * strain * strain * d, ctx, 0.3); + glide(whine.frequency, 1700 + 900 * strain, ctx, 0.3); + }, + }; +} diff --git a/fktry/src/audio/band.test.ts b/fktry/src/audio/band.test.ts new file mode 100644 index 0000000..010e731 --- /dev/null +++ b/fktry/src/audio/band.test.ts @@ -0,0 +1,109 @@ +/** + * THE BAND's data contract and treasure map, verified without a browser. + * + * The synthesis needs an AudioContext and is verified spectrally in the dev build; what CAN be + * pinned here is the part that would silently rot: the station schedule, and the promise that + * the NUMBERS station's coordinates actually lead somewhere. + */ +import { describe, expect, it } from 'vitest'; +import { BAND_RANGE, type Band } from './radio'; +import type { StationDef } from './stations'; +import stationData from '../../data/stations.json'; +import seamData from '../../data/seams.json'; + +const STATIONS = (stationData as { stations: StationDef[] }).stations; +const SEAMS = (seamData as { + seams: { id: string; rect: { x: number; y: number; w: number; h: number }; richness: number; hidden?: boolean }[]; +}).seams; + +/** LANE-DATA's encoding: [x+32 as 2 digits][y+32 as 2 digits][richness*9 as 1 digit]. */ +function decode(group: string) { + return { + x: Number(group.slice(0, 2)) - 32, + y: Number(group.slice(2, 4)) - 32, + richness: Number(group[4]) / 9, + }; +} + +describe('the dial is well formed', () => { + it('has all four stations', () => { + expect(STATIONS.map((s) => s.id).sort()).toEqual( + ['dead-air', 'fktry-fm', 'numbers', 'the-standard'].sort(), + ); + }); + + it('puts every station inside its own band, or the tuner could never reach it', () => { + for (const s of STATIONS) { + const [lo, hi] = BAND_RANGE[s.band as Band]; + expect(s.freq, `${s.id} @ ${s.freq} outside ${s.band} ${lo}-${hi}`).toBeGreaterThanOrEqual(lo); + expect(s.freq).toBeLessThanOrEqual(hi); + } + }); + + it('never puts two stations on the same spot of the same band', () => { + const seen = new Set(); + for (const s of STATIONS) { + const k = `${s.band}:${s.freq}`; + expect(seen.has(k), `duplicate ${k}`).toBe(false); + seen.add(k); + } + }); +}); + +describe('the numbers station is an honest treasure map', () => { + const numbers = STATIONS.find((s) => s.id === 'numbers')!.numbers!; + const hidden = SEAMS.filter((s) => s.hidden); + + it('plants exactly the hidden seams, and they decode to the real tiles', () => { + expect(numbers.plantedGroups).toHaveLength(hidden.length); + const decoded = numbers.plantedGroups.map(decode); + for (const h of hidden) { + const hit = decoded.find((d) => d.x === h.rect.x && d.y === h.rect.y); + expect(hit, `no broadcast group leads to ${h.id} at ${h.rect.x},${h.rect.y}`).toBeTruthy(); + expect(hit!.richness).toBeCloseTo(h.richness, 1); + } + }); + + it('keeps every group the same width, so a listener can chunk them by ear', () => { + for (const g of [...numbers.plantedGroups, ...numbers.decoyPool, numbers.preamble]) { + expect(g).toHaveLength(numbers.groupSize); + expect(g).toMatch(/^\d+$/); + } + }); + + it('no decoy ever leaks a HIDDEN seam — the one that would break the hunt', () => { + // This is the invariant THE SPEAKER must protect: the only groups that lead to secret + // ground are the planted ones. A decoy landing on an already-visible seam is untidy but + // harmless (see NOTES: two currently do, reported to LANE-DATA); a decoy landing on a + // hidden seam would hand out the treasure for free. + for (const g of numbers.decoyPool) { + const d = decode(g); + const leak = hidden.find( + (s) => d.x >= s.rect.x && d.x < s.rect.x + s.rect.w && d.y >= s.rect.y && d.y < s.rect.y + s.rect.h, + ); + expect(leak, `decoy ${g} gives away hidden seam ${leak?.id} at ${d.x},${d.y}`).toBeUndefined(); + } + }); + + it('broadcasts a planted group often enough to be findable', () => { + // Every 4th message, 8 groups a message, 1.2s a group: a planted coordinate roughly every + // couple of minutes. Long enough to feel like a signal, short enough to catch one. + const secondsPerMessage = + (numbers.groupsPerMessage + numbers.preambleRepeats) * numbers.secondsPerGroup; + expect(secondsPerMessage * numbers.plantEveryMessages).toBeLessThan(120); + }); +}); + +describe('THE STANDARD has something to say', () => { + const lines = STATIONS.find((s) => s.id === 'the-standard')!.chyron!; + + it('carries enough copy that it does not obviously loop', () => { + expect(lines.length).toBeGreaterThanOrEqual(20); + expect(new Set(lines).size).toBe(lines.length); + }); + + it('fits the chyron — long lines would run off the sky', () => { + // The chyron band renders ~62 monospace chars at 20px across 640px. + for (const l of lines) expect(l.length, `too long to subtitle: "${l}"`).toBeLessThanOrEqual(96); + }); +}); diff --git a/fktry/src/audio/index.ts b/fktry/src/audio/index.ts index 52b6001..b15cf7d 100644 --- a/fktry/src/audio/index.ts +++ b/fktry/src/audio/index.ts @@ -1,12 +1,211 @@ -/** LANE-SCREEN territory (THE SPEAKER). Stub — replace per lanes/LANE-SCREEN.md. - * All sound is Web Audio synthesis; no asset files, ever. */ +/** + * THE SPEAKER — LANE-SCREEN's second organ. + * + * HOUSE LAW: every sound is synthesized with Web Audio. No asset files, ever. + * + * Three layers, gated by discovery: + * 1. DIEGETIC MINIMUM (always on) — placement, fax, brownout, scram, ratification. + * Nothing else exists before the optical fossil is found. The silence is the tease. + * 2. THE BAND (on relicFound) — the tuner and its four stations. + * 3. WORLD AMBIENCE (post-discovery) — the factory's own hum, ducked under the radio. + * + * The AudioContext is created lazily on the first user gesture (browsers require it) and the + * whole graph is built once at that moment. After that the per-frame path only writes + * AudioParams — no allocation except genuinely one-shot notes. + */ import type { AudioFX, GameData, SimEvent, SimSnapshot } from '../contracts'; +import { ERA_ORDER, highestEra, type Era } from '../screen/eras'; +import { createMasterChain } from './graph'; +import { createAmbience, type Ambience } from './ambience'; +import { createRadio, type Radio, type RadioEngine } from './radio'; +import type { StationDef } from './stations'; +import { + brownoutHit, brownoutRecover, faxChatter, klaxon, ratifySting, tapeChunk, thunk, +} from './sfx'; + +import stationData from '../../data/stations.json'; +import seamData from '../../data/seams.json'; + +const STATIONS = (stationData as { stations: StationDef[] }).stations; + +/** + * The real seams, so the NUMBERS station's seeded filler can never accidentally broadcast a + * true coordinate (LANE-DATA's rule: only the planted groups are real). + */ +const SEAM_RECTS = (seamData as { seams: { rect: { x: number; y: number; w: number; h: number } }[] }) + .seams.map((s) => s.rect); + +function isRealSeam(x: number, y: number): boolean { + for (const r of SEAM_RECTS) { + if (x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h) return true; + } + return false; +} + +/** Sounds that must not machine-gun when the sim emits a burst in one tick. */ +const MIN_GAP = { thunk: 0.03, chunk: 0.05 }; export function createAudioFX(): AudioFX { + let ctx: BaseAudioContext | null = null; + let master: ReturnType | null = null; + let radio: RadioEngine | null = null; + let ambience: Ambience | null = null; + + const eras = new Map(); + let era: Era = ERA_ORDER[0]; + let discovered = false; + let lastThunk = 0; + let lastChunk = 0; + let variant = 0; + + function now(): number { + return ctx ? ctx.currentTime : 0; + } + + function openTheBand() { + if (discovered) return; + discovered = true; + if (!ctx || !radio || !ambience || !master) return; + radio.unlock(now()); + ambience.start(now()); + // The fossil is a severed audio bus reconnecting: the world audibly comes online. + ratifySting(ctx, master.input, now() + 0.02); + } + + function handle(e: SimEvent) { + if (!ctx || !master) return; + const dest = master.input; + const t = now() + 0.005; // a hair in the future: scheduling in the past clicks + switch (e.kind) { + case 'placed': + if (t - lastThunk >= MIN_GAP.thunk) { + thunk(ctx, dest, t, variant++); + lastThunk = t; + } + break; + case 'brownout': + if (e.on) brownoutHit(ctx, dest, t); + else brownoutRecover(ctx, dest, t); + break; + case 'scram': + if (e.on) klaxon(ctx, dest, t); + break; + case 'researched': + ratifySting(ctx, dest, t); + break; + case 'commissionDone': + faxChatter(ctx, dest, t, 18); + break; + case 'shipped': + // Layer 3: the tape chunk belongs to the discovered world. + if (discovered && t - lastChunk >= MIN_GAP.chunk) { + tapeChunk(ctx, dest, t, variant++); + lastChunk = t; + } + break; + case 'relicFound': + openTheBand(); + break; + default: + break; + } + } + + function build() { + if (ctx) return; + const AC: typeof AudioContext = + (window as any).AudioContext ?? (window as any).webkitAudioContext; + if (!AC) { + console.warn('[audio] no Web Audio support — THE SPEAKER stays silent'); + return; + } + ctx = new AC(); + master = createMasterChain(ctx); + radio = createRadio(ctx, STATIONS, isRealSeam); + ambience = createAmbience(ctx); + radio.out.connect(master.input); + ambience.out.connect(master.input); + + // The fax announces itself the moment the world has ears — Layer 1 proof of life. + faxChatter(ctx, master.input, now() + 0.15, 10); + + // If the relic was already found (loaded save, or dug before the first click), the band + // should be open the instant audio exists. + if (discovered) { + discovered = false; + openTheBand(); + } + + if (import.meta.env.DEV) { + (window as any).__fktryAudio = { + ctx, + radio: radio as Radio, + openTheBand, + isDiscovered: () => discovered, + stations: STATIONS, + play: (name: string) => { + if (!ctx || !master) return; + const d = master.input; + const t = now() + 0.02; + ({ thunk, fax: faxChatter, brownout: brownoutHit, recover: brownoutRecover, + klaxon, ratify: ratifySting, chunk: tapeChunk } as any)[name]?.(ctx, d, t); + }, + }; + } + } + return { - init(_data: GameData) {}, - unlock() {}, - onEvents(_events: SimEvent[]) {}, - frame(_timeMs: number, _snap?: SimSnapshot) {}, + init(data: GameData) { + for (const t of data.tech) eras.set(t.id, t.era); + }, + + unlock() { + build(); + if (ctx && 'resume' in ctx) void (ctx as AudioContext).resume(); + }, + + onEvents(events: SimEvent[]) { + if (!ctx) { + // Audio doesn't exist yet (no gesture). Anything that happened is simply not heard — + // we never queue a burst to fire on unlock. But discovery must not be lost. + for (const e of events) if (e.kind === 'relicFound') discovered = true; + return; + } + for (const e of events) handle(e); + }, + + frame(timeMs: number, snap?: SimSnapshot) { + if (!ctx || !radio || !ambience) return; + const t = now(); + + if (snap?.research) era = highestEra(snap.research.unlocked, (x) => eras.get(x)); + + // Discovery can also arrive via the snapshot (e.g. a loaded save), not just the event. + if (!discovered && snap?.relics?.some((r) => r.found)) openTheBand(); + + radio.update(t, era); + + if (discovered && snap) { + const { gen, draw } = snap.bandwidth; + const load = draw <= 0 ? 0 : Math.min(1, draw / Math.max(1, Math.max(gen, draw))); + let hot = 0; + let n = 0; + for (const e of snap.entities) if (e.heat > 0.01) { hot += e.heat; n++; } + const fever = n ? hot / n : 0; + ambience.update(load, fever, radio.getState().lock); + } + }, }; } + +/** + * THE SPEAKER's control surface, for LANE-UI's tuner dock. + * + * SANCTIONED CROSS-LANE IMPORT: LANE-UI may `import { getRadio } from '../audio'` and drive + * the dial. Returns null before the first user gesture builds the graph. The object is + * deliberately narrow — the UI can tune, set volume and subscribe, and can do nothing else to + * the audio graph. + */ +export function getRadio(): Radio | null { + return (window as any).__fktryAudio?.radio ?? null; +}