[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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-18 13:51:24 +10:00
parent 02fc7d5094
commit fd2aee6c50
3 changed files with 398 additions and 6 deletions

View File

@ -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);
},
};
}

View File

@ -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<string>();
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);
});
});

View File

@ -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<typeof createMasterChain> | null = null;
let radio: RadioEngine | null = null;
let ambience: Ambience | null = null;
const eras = new Map<string, Era>();
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;
}