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:
parent
322aeec16d
commit
c76b62dfa9
@ -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;
|
||||||
|
|||||||
18
src/main.ts
18
src/main.ts
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user