From c76b62dfa9cd7b5137bb246576017ba4a8a8a163 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 20 Jul 2026 17:36:13 +1000 Subject: [PATCH] THE FOLEY: the kitchen narrates itself, and the bench radio only knows one station MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/core/audio.ts | 125 ++++++++++++++++++++++++++++++++++++++++++++ src/main.ts | 18 +++++++ src/scenes/grill.ts | 4 ++ src/scenes/pan.ts | 5 ++ src/scenes/poach.ts | 6 +++ src/ui/title.ts | 1 + 6 files changed, 159 insertions(+) diff --git a/src/core/audio.ts b/src/core/audio.ts index cf44748..a7e72ff 100644 --- a/src/core/audio.ts +++ b/src/core/audio.ts @@ -163,6 +163,131 @@ export class Audio { 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(); + + /** 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. */ stamp(): void { const ctx = this.ctx; diff --git a/src/main.ts b/src/main.ts index cf49970..5d44fe4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import * as THREE from 'three'; import { App, makeEnvironment, makeLights } from './core/app'; +import { audio } from './core/audio'; import { Game } from './game/game'; import './style.css'; @@ -22,4 +23,21 @@ if (import.meta.env.DEV) { 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(); diff --git a/src/scenes/grill.ts b/src/scenes/grill.ts index 3e38c38..75f9099 100644 --- a/src/scenes/grill.ts +++ b/src/scenes/grill.ts @@ -203,6 +203,10 @@ export class GrillView implements View { } 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.syncFood(); this.updateHud(); diff --git a/src/scenes/pan.ts b/src/scenes/pan.ts index 4130591..0fbbed4 100644 --- a/src/scenes/pan.ts +++ b/src/scenes/pan.ts @@ -191,6 +191,11 @@ export class PanView implements View { this.wasDown = inp.down; 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); this.syncVisuals(dt); this.updateHud(); diff --git a/src/scenes/poach.ts b/src/scenes/poach.ts index c7d7dde..c46f35e 100644 --- a/src/scenes/poach.ts +++ b/src/scenes/poach.ts @@ -239,6 +239,12 @@ export class PoachView implements View { if (s.lifted) drainStep(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.updateHud(); diff --git a/src/ui/title.ts b/src/ui/title.ts index 7e724a9..48f7c6f 100644 --- a/src/ui/title.ts +++ b/src/ui/title.ts @@ -56,5 +56,6 @@ export class Title { 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, 'ENTER — serve it to him'); + el('div', undefined, keys, 'R — the radio (he pretends not to like it)'); } }