clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

M13: ambient audio — procedural mood pads + rolling voice + event cues (gesture-gated, pure mapping layer rule-tested)

Dami committed fbc47ac
commit
fbc47ac64b37aed2c5ed3c2b029678688c4dbf6f
parent
f989dddab091ea6dc27c097d54efe8f9964c4989
author
Dami <dami.etoile@gmail.com>
4 files changed, +476 -1
Asrc/client/audio.ts334++++++++++++++++++++++++++++++
Msrc/client/endless.ts55+++++-
Mstatic/index.html2+
Atests/audio_test.ts86++++++++
A src/client/audio.ts +334 -0
@@ -0,0 +1,334 @@
1+/**2+ * Ambient audio (M13) — fully procedural WebAudio; there are no audio3+ * assets in the kit. Three layers:4+ *5+ * - PAD: a mood-tinted drone chord (few detuned sines through a lowpass)6+ * that crossfades when the atmosphere zone changes — the ear hears the7+ * climate turn over with the sky.8+ * - ROLL: a filtered-noise bed whose gain/brightness follow marble speed9+ * — the marble's voice; silent at rest, a bright rush at full tilt.10+ * - CUES: one-shot envelope sweeps for events (boost entry, launch,11+ * landing, fall/respawn, milestone) — rule-derived, no RNG.12+ *13+ * The mapping layer (padForMood / rollParams / cueFor) is PURE and rule-14+ * tested headlessly; the WebAudio layer is a thin realization. Autoplay15+ * policy: everything is muted until the user toggles audio on (the M key16+ * or the HUD button) — the toggle gesture is what may create/resume the17+ * AudioContext.18+ */19+20+// ---------------------------------------------------------------------------21+// Pure mapping layer22+// ---------------------------------------------------------------------------23+24+export interface PadSpec {25+ /** Chord partials, Hz, ascending — the mood's drone. */26+ freqs: readonly number[];27+ /** Pad lowpass cutoff, Hz (dark moods sit low). */28+ cutoff: number;29+ /** Pad layer gain, 0..1 (scaled by the master). */30+ gain: number;31+ /** Air/wind noise lowpass cutoff, Hz. */32+ airCutoff: number;33+ /** Air layer gain, 0..1. */34+ airGain: number;35+}36+37+/** Mood → drone spec. Chords stay low and open (screensaver-grade: felt,38+ * not listened to). Every atmosphere mood has a row — a missing row is a39+ * test failure, not a silent default. */40+const PADS: Record<string, PadSpec> = {41+ // Low minor-ish drone, dark and calm.42+ midnight: {43+ freqs: [73.42, 110.0, 174.61, 220.0],44+ cutoff: 420,45+ gain: 0.5,46+ airCutoff: 500,47+ airGain: 0.14,48+ },49+ // Warm low third + crackle-bright air.50+ ember: {51+ freqs: [82.41, 103.83, 164.81],52+ cutoff: 380,53+ gain: 0.55,54+ airCutoff: 1400,55+ airGain: 0.2,56+ },57+ // Suspended shimmer, brighter cutoff.58+ violet: {59+ freqs: [87.31, 130.81, 196.0, 261.63],60+ cutoff: 700,61+ gain: 0.45,62+ airCutoff: 900,63+ airGain: 0.16,64+ },65+ // A deep fifth, nearly subsonic — pressure more than pitch.66+ abyss: {67+ freqs: [55.0, 82.41, 110.0],68+ cutoff: 260,69+ gain: 0.6,70+ airCutoff: 350,71+ airGain: 0.1,72+ },73+ // Airy add9, the air layer leads (wind over snow).74+ snowdrift: {75+ freqs: [98.0, 146.83, 220.0, 293.66],76+ cutoff: 850,77+ gain: 0.4,78+ airCutoff: 2200,79+ airGain: 0.26,80+ },81+ // Warm major sixth, golden and settled.82+ goldhour: {83+ freqs: [98.0, 123.47, 164.81, 196.0],84+ cutoff: 560,85+ gain: 0.5,86+ airCutoff: 750,87+ airGain: 0.15,88+ },89+};90+91+export function padForMood(mood: string): PadSpec | null {92+ return PADS[mood] ?? null;93+}94+95+export interface RollSpec {96+ /** Roll-noise gain, 0..1 — silent at rest. */97+ gain: number;98+ /** Bandpass center, Hz — brighter with speed. */99+ cutoff: number;100+}101+102+/** Marble speed (world u/s) → rolling-noise voice. Monotone in speed,103+ * bounded: gain 0→0.4 saturating ~8 u/s, cutoff 240→2200 Hz. */104+export function rollParams(speed: number): RollSpec {105+ const s = Math.max(0, speed);106+ const t = 1 - Math.exp(-s / 3.2);107+ return {108+ gain: 0.4 * t,109+ cutoff: 240 + 1960 * t,110+ };111+}112+113+export type CueKind =114+ | "boost"115+ | "launch"116+ | "land"117+ | "fall"118+ | "milestone"119+ | "checkpoint";120+121+export interface CueSpec {122+ /** Sweep start/end frequency, Hz. */123+ freq0: number;124+ freq1: number;125+ /** Envelope length, seconds. */126+ dur: number;127+ /** Peak gain, 0..0.5 (cues sit above the bed, below discomfort). */128+ gain: number;129+ type: "sine" | "triangle";130+}131+132+const CUES: Record<CueKind, CueSpec> = {133+ // Rising surge into a boost/gravity zone.134+ boost: { freq0: 220, freq1: 560, dur: 0.35, gain: 0.18, type: "triangle" },135+ // Lip launch — a quick upward zip.136+ launch: { freq0: 330, freq1: 990, dur: 0.25, gain: 0.2, type: "triangle" },137+ // Caught — a soft settling thump.138+ land: { freq0: 260, freq1: 130, dur: 0.22, gain: 0.22, type: "sine" },139+ // Falling off the line — a long descend into the respawn.140+ fall: { freq0: 520, freq1: 90, dur: 0.7, gain: 0.24, type: "sine" },141+ // Distance milestone — a bright little bell rise.142+ milestone: { freq0: 660, freq1: 880, dur: 0.5, gain: 0.2, type: "sine" },143+ // Checkpoint/segment crossing tick (subtle).144+ checkpoint: { freq0: 440, freq1: 440, dur: 0.08, gain: 0.08, type: "sine" },145+};146+147+export function cueFor(kind: CueKind): CueSpec {148+ return CUES[kind];149+}150+151+// ---------------------------------------------------------------------------152+// WebAudio realization (browser only — nothing below is imported by tests)153+// ---------------------------------------------------------------------------154+155+const MASTER_GAIN = 0.5;156+/** Pad/mood crossfade, seconds (matches the 4s sky crossfade). */157+const PAD_FADE = 4;158+159+export class AmbientAudio {160+ private ctx: AudioContext | null = null;161+ private master: GainNode | null = null;162+ private padNodes: { osc: OscillatorNode[]; gain: GainNode } | null = null;163+ private airNodes: { gain: GainNode; filter: BiquadFilterNode } | null = null;164+ private rollNodes: { gain: GainNode; filter: BiquadFilterNode } | null =165+ null;166+ private mood = "midnight";167+ enabled = false;168+169+ /** Toggle from a user gesture — the only place the context is made. */170+ toggle(): boolean {171+ this.enabled = !this.enabled;172+ if (this.enabled) {173+ this.ensure();174+ this.ctx?.resume();175+ this.master?.gain.setTargetAtTime(MASTER_GAIN, this.now(), 0.4);176+ } else {177+ this.master?.gain.setTargetAtTime(0, this.now(), 0.15);178+ }179+ return this.enabled;180+ }181+182+ /** Atmosphere zone turned over — crossfade the pad + air to the mood. */183+ setMood(mood: string): void {184+ this.mood = mood;185+ if (!this.ctx) return;186+ const spec = padForMood(mood);187+ if (!spec) return;188+ this.buildPad(spec);189+ }190+191+ /** Per-frame: the marble's rolling voice follows its speed. */192+ setSpeed(speed: number): void {193+ if (!this.ctx || !this.rollNodes || !this.enabled) return;194+ const { gain, cutoff } = rollParams(speed);195+ this.rollNodes.gain.gain.setTargetAtTime(gain, this.now(), 0.12);196+ this.rollNodes.filter.frequency.setTargetAtTime(cutoff, this.now(), 0.12);197+ }198+199+ /** One-shot event cue. */200+ cue(kind: CueKind): void {201+ if (!this.ctx || !this.master || !this.enabled) return;202+ const spec = cueFor(kind);203+ const t = this.now();204+ const osc = this.ctx.createOscillator();205+ osc.type = spec.type;206+ osc.frequency.setValueAtTime(spec.freq0, t);207+ osc.frequency.exponentialRampToValueAtTime(208+ Math.max(30, spec.freq1),209+ t + spec.dur,210+ );211+ const g = this.ctx.createGain();212+ g.gain.setValueAtTime(0, t);213+ g.gain.linearRampToValueAtTime(spec.gain, t + 0.02);214+ g.gain.exponentialRampToValueAtTime(0.001, t + spec.dur);215+ osc.connect(g).connect(this.master);216+ osc.start(t);217+ osc.stop(t + spec.dur + 0.05);218+ osc.onended = () => {219+ osc.disconnect();220+ g.disconnect();221+ };222+ }223+224+ dispose(): void {225+ this.ctx?.close();226+ this.ctx = null;227+ this.master = null;228+ this.padNodes = null;229+ this.airNodes = null;230+ this.rollNodes = null;231+ }232+233+ private now(): number {234+ return this.ctx?.currentTime ?? 0;235+ }236+237+ private ensure(): void {238+ if (this.ctx) return;239+ const Ctor = (globalThis as { AudioContext?: typeof AudioContext })240+ .AudioContext;241+ if (!Ctor) return;242+ this.ctx = new Ctor();243+ this.master = this.ctx.createGain();244+ this.master.gain.value = 0;245+ this.master.connect(this.ctx.destination);246+247+ // Shared looping noise buffer (2s of white noise).248+ const noise = this.ctx.createBuffer(249+ 1,250+ this.ctx.sampleRate * 2,251+ this.ctx.sampleRate,252+ );253+ const data = noise.getChannelData(0);254+ for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;255+256+ // AIR: lowpassed noise, mood-tinted.257+ {258+ const src = this.ctx.createBufferSource();259+ src.buffer = noise;260+ src.loop = true;261+ const filter = this.ctx.createBiquadFilter();262+ filter.type = "lowpass";263+ filter.frequency.value = 600;264+ const gain = this.ctx.createGain();265+ gain.gain.value = 0;266+ src.connect(filter).connect(gain).connect(this.master);267+ src.start();268+ this.airNodes = { gain, filter };269+ }270+ // ROLL: bandpassed noise following speed.271+ {272+ const src = this.ctx.createBufferSource();273+ src.buffer = noise;274+ src.loop = true;275+ const filter = this.ctx.createBiquadFilter();276+ filter.type = "bandpass";277+ filter.frequency.value = 400;278+ filter.Q.value = 0.8;279+ const gain = this.ctx.createGain();280+ gain.gain.value = 0;281+ src.connect(filter).connect(gain).connect(this.master);282+ src.start();283+ this.rollNodes = { gain, filter };284+ }285+ this.buildPad(padForMood(this.mood) ?? PADS.midnight);286+ }287+288+ private buildPad(spec: PadSpec): void {289+ if (!this.ctx || !this.master) return;290+ const t = this.now();291+ // Fade the old pad out and schedule its teardown.292+ if (this.padNodes) {293+ const old = this.padNodes;294+ old.gain.gain.setTargetAtTime(0, t, PAD_FADE / 4);295+ setTimeout(() => {296+ for (const o of old.osc) {297+ try {298+ o.stop();299+ o.disconnect();300+ } catch {301+ // already stopped302+ }303+ }304+ old.gain.disconnect();305+ }, PAD_FADE * 1000);306+ }307+ const filter = this.ctx.createBiquadFilter();308+ filter.type = "lowpass";309+ filter.frequency.value = spec.cutoff;310+ const gain = this.ctx.createGain();311+ gain.gain.value = 0;312+ gain.gain.setTargetAtTime(spec.gain, t, PAD_FADE / 4);313+ filter.connect(gain).connect(this.master);314+ const oscs = spec.freqs.map((f, i) => {315+ const osc = this.ctx!.createOscillator();316+ osc.type = "sine";317+ // Slight detune spread keeps the drone alive without an LFO.318+ osc.frequency.value = f * (1 + (i % 2 === 0 ? 0.0012 : -0.0012));319+ osc.connect(filter);320+ osc.start();321+ return osc;322+ });323+ this.padNodes = { osc: oscs, gain };324+ // Air layer follows the mood too.325+ if (this.airNodes) {326+ this.airNodes.filter.frequency.setTargetAtTime(327+ spec.airCutoff,328+ t,329+ PAD_FADE / 4,330+ );331+ this.airNodes.gain.gain.setTargetAtTime(spec.airGain, t, PAD_FADE / 4);332+ }333+ }334+}
M src/client/endless.ts +54 -1
@@ -49,6 +49,7 @@ import { genVersion } from "../gen/version.ts";
4949 import { drawPipeColor } from "./pipeColor.ts";5050 import { PeerRing } from "./peers.ts";5151 import { installPhotoKey } from "./photo.ts";52+import { AmbientAudio } from "./audio.ts";5253 import { milestoneFor, MilestoneToast } from "./hud.ts";5354 import { installCua } from "./cua.ts";5455
@@ -129,6 +130,29 @@ export function bootEndless(
129130 130131 let session: Session | null = null;131132 133+ // Ambient audio (M13): procedural mood pad + rolling voice + event134+ // cues. Muted until the user's toggle gesture (autoplay policy); the135+ // M key or the HUD button.136+ const audio = new AmbientAudio();137+ const audioBtn = document.getElementById(138+ "audio-toggle",139+ ) as HTMLButtonElement | null;140+ const setAudioLabel = () => {141+ if (audioBtn) audioBtn.textContent = audio.enabled ? "audio: on" : "audio: off";142+ };143+ const toggleAudio = () => {144+ audio.toggle();145+ if (audio.enabled && session) audio.setMood(session.atmosphere.name);146+ setAudioLabel();147+ };148+ if (audioBtn) {149+ audioBtn.hidden = false;150+ audioBtn.addEventListener("click", toggleAudio);151+ }152+ addEventListener("keydown", (e) => {153+ if (e.key === "m" || e.key === "M") toggleAudio();154+ });155+132156 // P = live photo export (user rule: never freeze the run).133157 const photo = installPhotoKey(134158 ctx,
@@ -152,7 +176,9 @@ export function bootEndless(
152176 ui,153177 stars,154178 physics,179+ audio,155180 );181+ if (audio.enabled) audio.setMood(session.atmosphere.name);156182 } catch (err) {157183 console.error(err);158184 if (ui.statsEl) {
@@ -210,6 +236,7 @@ export function bootEndless(
210236 peerRing: session.peerRing.stats(session.marbleS),211237 milestone: session.toast.last,212238 photos: photo.count,239+ audio: audio.enabled,213240 // Instancing gate: island pieces share draw calls now.214241 drawCalls: ctx.renderer.info.render.calls,215242 }
@@ -282,6 +309,9 @@ class Session {
282309 private respawnsHere = 0;283310 private lastRespawnSeg = -1;284311 private zone = 0;312+ /** Audio cue edge detectors (launch/land/boost transitions). */313+ private wasFlight = false;314+ private wasSurge = false;285315 private focus: { center: THREE.Vector3; radius: number } | null = null;286316 287317 private readonly point = new THREE.Vector3();
@@ -299,6 +329,7 @@ class Session {
299329 private readonly ui: UiRefs,300330 stars: THREE.Points,301331 physics: PhysicsModule | null = null,332+ private readonly audio: AmbientAudio = new AmbientAudio(),302333 ) {303334 this.seed = seed;304335 if (physics) this.engine = new PhysicsEngine(physics);
@@ -609,17 +640,21 @@ class Session {
609640 `✦ ${milestone.toLocaleString("en-US")} TRAVELLED · ` +610641 `${this.islandsSeen} ISLANDS ✦`,611642 );643+ this.audio.cue("milestone");612644 }613645 614646 const next = this.segmentAt(this.marbleS);615647 if (next && next.index !== this.currentIndex) {616648 this.currentIndex = next.index;649+ this.audio.cue("checkpoint");617650 // Atmosphere zones: every ZONE_SEGMENTS segments the void's climate618651 // crossfades to the next drawn mood+weather combo.619652 const zone = Math.floor(this.currentIndex / ZONE_SEGMENTS);620653 if (zone !== this.zone) {621654 this.zone = zone;622- this.atmosphere.blendTo(drawZone(this.seed, zone));655+ const za = drawZone(this.seed, zone);656+ this.atmosphere.blendTo(za);657+ this.audio.setMood(za.mood.name);623658 }624659 }625660 this.ensure();
@@ -683,6 +718,20 @@ class Session {
683718 this.marbleObj.position.set(st.pos[0], st.pos[1], st.pos[2]);684719 this.pivot.quaternion.set(st.quat[0], st.quat[1], st.quat[2], st.quat[3]);685720 this.speed = Math.hypot(st.vel[0], st.vel[1], st.vel[2]);721+722+ // Event cues off the station modifier transitions (rule-derived).723+ {724+ const rail2 = this.rails.get(this.nearest.seg);725+ const mod = rail2?.mods[this.nearest.idx] ?? null;726+ const flight = !!mod?.flight;727+ if (flight && !this.wasFlight) this.audio.cue("launch");728+ if (!flight && this.wasFlight) this.audio.cue("land");729+ this.wasFlight = flight;730+ const surge = (mod?.speed !== undefined && mod.speed > 1) ||731+ mod?.tilt !== undefined;732+ if (surge && !this.wasSurge) this.audio.cue("boost");733+ this.wasSurge = surge;734+ }686735 const seg = this.built.get(this.nearest.seg);687736 const rail = this.rails.get(this.nearest.seg);688737 if (seg && rail) {
@@ -751,6 +800,7 @@ class Session {
751800 * guaranteed even over broken geometry. */752801 private respawn(): void {753802 this.deaths++;803+ this.audio.cue("fall");754804 this.stallClock = 0;755805 this.lastProgressS = 0;756806 let target = this.currentIndex;
@@ -796,6 +846,9 @@ class Session {
796846 }797847 798848 private finishFrame(dt: number): void {849+ // The marble's rolling voice follows its speed.850+ this.audio.setSpeed(this.speed);851+799852 for (const built of this.built.values()) {800853 for (const sp of built.spinners) {801854 sp.obj.rotateOnAxis(sp.axis, sp.radPerSec * dt);
M static/index.html +2 -0
A tests/audio_test.ts +86 -0
@@ -0,0 +1,86 @@
1+/**2+ * Ambient audio (M13) — rule tests over the PURE mapping layer (the3+ * WebAudio realization is browser-only; nothing here touches an4+ * AudioContext). USER VALIDATION STYLE: rules, not time.5+ */6+import { assert } from "@std/assert";7+import {8+ type CueKind,9+ cueFor,10+ padForMood,11+ rollParams,12+} from "../src/client/audio.ts";13+import { MOODS } from "../src/client/atmosphere.ts";14+15+Deno.test("audio pads: every atmosphere mood has a bounded, ascending drone", () => {16+ for (const mood of MOODS) {17+ const pad = padForMood(mood.name);18+ assert(pad !== null, `mood "${mood.name}" has no pad row`);19+ assert(pad.freqs.length >= 2, `${mood.name}: drone needs ≥2 partials`);20+ for (let i = 0; i < pad.freqs.length; i++) {21+ assert(22+ pad.freqs[i] >= 30 && pad.freqs[i] <= 2000,23+ `${mood.name}: partial ${i} out of band (${pad.freqs[i]} Hz)`,24+ );25+ if (i > 0) {26+ assert(27+ pad.freqs[i] > pad.freqs[i - 1],28+ `${mood.name}: partials not ascending`,29+ );30+ }31+ }32+ assert(pad.gain > 0 && pad.gain <= 1, `${mood.name}: pad gain`);33+ assert(pad.airGain >= 0 && pad.airGain <= 1, `${mood.name}: air gain`);34+ assert(35+ pad.cutoff >= 100 && pad.cutoff <= 4000 &&36+ pad.airCutoff >= 100 && pad.airCutoff <= 8000,37+ `${mood.name}: cutoffs out of range`,38+ );39+ }40+ // Unknown moods are an explicit null, never a silent default.41+ assert(padForMood("no-such-mood") === null);42+});43+44+Deno.test("audio roll: the marble's voice is monotone in speed and bounded", () => {45+ let prevGain = -1;46+ let prevCutoff = -1;47+ for (let s = 0; s <= 12; s += 0.25) {48+ const r = rollParams(s);49+ assert(r.gain >= prevGain, `gain not monotone at ${s}`);50+ assert(r.cutoff >= prevCutoff, `cutoff not monotone at ${s}`);51+ assert(r.gain >= 0 && r.gain <= 0.5, `gain out of bounds at ${s}`);52+ assert(53+ r.cutoff >= 200 && r.cutoff <= 2400,54+ `cutoff out of bounds at ${s}`,55+ );56+ prevGain = r.gain;57+ prevCutoff = r.cutoff;58+ }59+ // Silent at rest — the bed carries the quiet, not the roll.60+ assert(rollParams(0).gain === 0);61+});62+63+Deno.test("audio cues: every event kind is a short, bounded envelope", () => {64+ const kinds: CueKind[] = [65+ "boost",66+ "launch",67+ "land",68+ "fall",69+ "milestone",70+ "checkpoint",71+ ];72+ for (const k of kinds) {73+ const c = cueFor(k);74+ assert(c.dur >= 0.05 && c.dur <= 2, `${k}: duration ${c.dur}`);75+ assert(c.gain > 0 && c.gain <= 0.5, `${k}: gain ${c.gain}`);76+ assert(77+ c.freq0 >= 60 && c.freq0 <= 4000 && c.freq1 >= 60 && c.freq1 <= 4000,78+ `${k}: sweep out of band`,79+ );80+ }81+ // Directional grammar: rises announce energy in, falls announce loss.82+ assert(cueFor("boost").freq1 > cueFor("boost").freq0);83+ assert(cueFor("launch").freq1 > cueFor("launch").freq0);84+ assert(cueFor("land").freq1 < cueFor("land").freq0);85+ assert(cueFor("fall").freq1 < cueFor("fall").freq0);86+});