THE FOLEY: the kitchen narrates itself, and the bench radio only knows one station

Continuous beds join the one-shot synths: a bed is a looping noise voice a
scene FEEDS every frame, and it starves to silence on its own when the
feeding stops — no scene has to remember to turn sound off. The pan's
sizzle follows butter and heat (burnt butter dulls the voice, 5600Hz down
to 2400); the grill's follows the heat actually UNDER the meat, so the
cool side goes quiet; the pot rumbles up with the water and throws
bubbles — 0.15/s cold, 3/s in the shiver, 13/s at a rolling boil, each
one a little upward chirp because that is what a collapsing bubble does.
The sound IS the thermometer, same doctrine as the words.

And R, anywhere: the bench radio. 84 BPM, Am-F-C-G through a bandpass
shaped like a three-inch speaker, tape wow on every note, a hash-picked
pentatonic melody that never changes — the same tune every service, the
way a real kitchen radio only knows one station. Hiss included.

Verified: radio toggles with the word, pan/grill/poach days run clean
through the live bed paths with the context awake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-20 17:36:13 +10:00
parent 322aeec16d
commit c76b62dfa9
6 changed files with 159 additions and 0 deletions

View File

@ -163,6 +163,131 @@ export class Audio {
trem.stop(t + dur + 0.05); trem.stop(t + dur + 0.05);
} }
// ---- Continuous foley beds -------------------------------------------
// A bed is a looping noise voice a scene FEEDS every frame: sizzle follows
// the sear, the pot follows the water. Stop feeding it and it starves to
// silence on its own — no scene ever has to remember to turn sound off.
private beds = new Map<string, { src: AudioBufferSourceNode; filt: BiquadFilterNode; g: GainNode; fed: number }>();
/** Feed a bed: level 0..~0.2, filter centre `freq`, playback `rate`. */
bed(id: string, level: number, freq: number, q = 1, rate = 1): void {
const ctx = this.ctx;
if (!ctx || !this.master || !this.noise) return;
let b = this.beds.get(id);
if (!b) {
const src = ctx.createBufferSource();
src.buffer = this.noise;
src.loop = true;
const filt = ctx.createBiquadFilter();
filt.type = 'bandpass';
const g = ctx.createGain();
g.gain.value = 0;
src.connect(filt).connect(g).connect(this.master);
src.start();
b = { src, filt, g, fed: 0 };
this.beds.set(id, b);
}
const t = this.now();
b.fed = t;
b.filt.frequency.setTargetAtTime(freq, t, 0.1);
b.filt.Q.value = q;
b.src.playbackRate.setTargetAtTime(rate, t, 0.1);
b.g.gain.setTargetAtTime(Math.min(0.25, Math.max(0, level)), t, 0.08);
// Starvation: if nothing feeds this bed for a beat, it fades itself out.
window.setTimeout(() => {
if (b && this.ctx && this.now() - b.fed > 0.25) b.g.gain.setTargetAtTime(0, this.now(), 0.15);
}, 350);
}
/** One water bubble — bubbles chirp UP as they collapse. Hotter = bigger. */
blip(hot = 0.5): void {
const ctx = this.ctx;
if (!ctx || !this.master) return;
const t = this.now();
const o = ctx.createOscillator();
o.type = 'sine';
const f = 240 + Math.random() * 260 + hot * 240;
o.frequency.setValueAtTime(f, t);
o.frequency.exponentialRampToValueAtTime(f * 1.8, t + 0.045);
const g = ctx.createGain();
g.gain.setValueAtTime(0.03 + hot * 0.05, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.055);
o.connect(g).connect(this.master);
o.start(t);
o.stop(t + 0.1);
}
// ---- The bench radio --------------------------------------------------
// Synthesised lo-fi through a "speaker" bandpass with a slow pitch wow —
// an old radio on the shelf, not a soundtrack. R turns it on and off.
private radioOn = false;
private radioT = 0;
private radioStep = 0;
private radioTimer: number | null = null;
/** Toggle the radio. Returns the new state. */
radio(): boolean {
this.resume();
if (!this.ctx) return false;
this.radioOn = !this.radioOn;
if (this.radioOn) {
this.radioT = this.now() + 0.1;
this.radioStep = 0;
this.radioTick();
} else if (this.radioTimer !== null) {
window.clearTimeout(this.radioTimer);
this.radioTimer = null;
}
return this.radioOn;
}
private radioTick(): void {
const ctx = this.ctx;
if (!this.radioOn || !ctx) return;
// static between the songs — the speaker never quite shuts up
this.bed('radio-hiss', 0.008, 4600, 0.4, 0.7);
const eighth = 60 / 84 / 2; // 84 BPM
const roots = [220, 174.61, 130.81, 196]; // Am — F — C — G, the lo-fi four
while (this.radioT < ctx.currentTime + 0.6) {
const step = this.radioStep++;
const root = roots[Math.floor(step / 8) % 4];
const t = this.radioT;
if (step % 8 === 0) this.radioVoice(root / 2, t, eighth * 7, 'sine', 0.085); // bass holds the bar
if (step % 4 === 0) for (const iv of [1, 1.1892, 1.4983]) this.radioVoice(root * iv, t, eighth * 3.4, 'triangle', 0.016); // m7 pad
// Melody: a deterministic hash decides note-or-rest — the same tune
// every service, the way a real kitchen radio only knows one station.
const h = Math.sin((step + 1) * 12.9898) * 43758.5453;
const r = h - Math.floor(h);
if (r < 0.45) {
const penta = [1, 1.125, 1.25, 1.5, 1.6667, 2];
this.radioVoice(root * 2 * penta[Math.floor(r * 13) % penta.length], t, eighth * (r < 0.15 ? 1.8 : 0.9), 'sine', 0.032);
}
this.radioT += eighth;
}
this.radioTimer = window.setTimeout(() => this.radioTick(), 250);
}
private radioVoice(freq: number, t: number, dur: number, type: OscillatorType, vol: number): void {
const ctx = this.ctx!;
const o = ctx.createOscillator();
o.type = type;
o.frequency.value = freq;
o.detune.setValueAtTime(-9, t);
o.detune.linearRampToValueAtTime(9, t + dur); // tape wow
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(vol, t + 0.02);
g.gain.setValueAtTime(vol, t + dur * 0.6);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass'; // the three-inch speaker
bp.frequency.value = 900;
bp.Q.value = 0.45;
o.connect(g).connect(bp).connect(this.master!);
o.start(t);
o.stop(t + dur + 0.05);
}
/** The judge's stamp. */ /** The judge's stamp. */
stamp(): void { stamp(): void {
const ctx = this.ctx; const ctx = this.ctx;

View File

@ -1,5 +1,6 @@
import * as THREE from 'three'; import * as THREE from 'three';
import { App, makeEnvironment, makeLights } from './core/app'; import { App, makeEnvironment, makeLights } from './core/app';
import { audio } from './core/audio';
import { Game } from './game/game'; import { Game } from './game/game';
import './style.css'; import './style.css';
@ -22,4 +23,21 @@ if (import.meta.env.DEV) {
void import('./dev-grate').then((m) => m.installGrateHarness(app)); void import('./dev-grate').then((m) => m.installGrateHarness(app));
} }
// The bench radio: R, anywhere. An old three-inch speaker that only knows one
// station, because every real kitchen has exactly that.
window.addEventListener('keydown', (e) => {
if (e.code !== 'KeyR' || e.repeat) return;
const on = audio.radio();
let w = document.getElementById('radio-word');
if (!w) {
w = document.createElement('div');
w.id = 'radio-word';
w.style.cssText = 'position:fixed;top:14px;right:16px;font:12px monospace;color:#d8c8a8;opacity:0;transition:opacity .4s;pointer-events:none;z-index:40';
document.body.appendChild(w);
}
w.textContent = on ? '♪ the radio crackles on' : 'the radio clicks off';
w.style.opacity = '1';
window.setTimeout(() => { w.style.opacity = '0'; }, 1800);
});
app.start(); app.start();

View File

@ -203,6 +203,10 @@ export class GrillView implements View {
} }
grillStep(s, dt); grillStep(s, dt);
// Open-air sizzle: the sum of heat actually UNDER the meat, so a piece
// parked on the cool side goes quiet and one over fresh coals roars.
const under = s.foods.reduce((a, f) => a + s.zone.sample(f.u, f.v), 0) / Math.max(1, s.foods.length);
audio.bed('sizzle', Math.min(1, under / 1.7) * 0.12, 4200, 0.7, 0.8 + Math.min(1, under / 1.7) * 0.3);
this.syncBed(); this.syncBed();
this.syncFood(); this.syncFood();
this.updateHud(); this.updateHud();

View File

@ -191,6 +191,11 @@ export class PanView implements View {
this.wasDown = inp.down; this.wasDown = inp.down;
panStep(s, dt); panStep(s, dt);
// The sizzle IS the thermometer: butter alone whispers, food on hot metal
// roars, and burnt butter dulls the whole voice down.
const heat01 = Math.min(1, s.src.output / 9);
const act = s.food ? 1 : s.hasButter && s.src.output > 1.5 ? 0.45 : 0;
audio.bed('sizzle', act * heat01 * 0.13, 5600 - s.butter * 3200, 0.8, 0.85 + heat01 * 0.3);
if (s.zapped) audio.clatter(1, 2); if (s.zapped) audio.clatter(1, 2);
this.syncVisuals(dt); this.syncVisuals(dt);
this.updateHud(); this.updateHud();

View File

@ -239,6 +239,12 @@ export class PoachView implements View {
if (s.lifted) drainStep(s, dt); if (s.lifted) drainStep(s, dt);
poachStep(s, dt); poachStep(s, dt);
// The pot narrates its own temperature: a low rumble that thickens as the
// water climbs, and bubbles that go from shy blips to a rolling argument.
const t01 = Math.min(1, s.src.output / 10);
audio.bed('water', 0.02 + t01 * 0.05, 260 + t01 * 480, 0.6, 0.45 + t01 * 0.2);
const bps = s.src.output > BOIL_AT ? 13 : s.src.output >= SHIVER_LO ? 3 : s.src.output > 4 ? 1.2 : 0.15;
if (Math.random() < bps * dt) audio.blip(t01);
this.syncVisuals(dt); this.syncVisuals(dt);
this.updateHud(); this.updateHud();

View File

@ -56,5 +56,6 @@ export class Title {
el('div', undefined, keys, 'DRAG — fish the right tool from the drawer'); el('div', undefined, keys, 'DRAG — fish the right tool from the drawer');
el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife · dip when empty'); el('div', undefined, keys, 'DRAG — spread · WHEEL — tilt the knife · dip when empty');
el('div', undefined, keys, 'ENTER — serve it to him'); el('div', undefined, keys, 'ENTER — serve it to him');
el('div', undefined, keys, 'R — the radio (he pretends not to like it)');
} }
} }