[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 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 {
|
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 {
|
return {
|
||||||
init(_data: GameData) {},
|
init(data: GameData) {
|
||||||
unlock() {},
|
for (const t of data.tech) eras.set(t.id, t.era);
|
||||||
onEvents(_events: SimEvent[]) {},
|
},
|
||||||
frame(_timeMs: number, _snap?: SimSnapshot) {},
|
|
||||||
|
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