[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:
parent
02fc7d5094
commit
fd2aee6c50
84
fktry/src/audio/ambience.ts
Normal file
84
fktry/src/audio/ambience.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
109
fktry/src/audio/band.test.ts
Normal file
109
fktry/src/audio/band.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user