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 */1920// ---------------------------------------------------------------------------21// Pure mapping layer22// ---------------------------------------------------------------------------2324export 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}3637/** 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. */40const 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};9091export function padForMood(mood: string): PadSpec | null {92 return PADS[mood] ?? null;93}9495export 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}101102/** 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. */104export 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}112113export type CueKind =114 | "boost"115 | "launch"116 | "land"117 | "fall"118 | "milestone"119 | "checkpoint";120121export 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}131132const 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};146147export function cueFor(kind: CueKind): CueSpec {148 return CUES[kind];149}150151// ---------------------------------------------------------------------------152// WebAudio realization (browser only — nothing below is imported by tests)153// ---------------------------------------------------------------------------154155const MASTER_GAIN = 0.5;156/** Pad/mood crossfade, seconds (matches the 4s sky crossfade). */157const PAD_FADE = 4;158159export 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 = null;165 private mood = "midnight";166 enabled = false;167168 /** Toggle from a user gesture — the only place the context is made. */169 toggle(): boolean {170 this.enabled = !this.enabled;171 if (this.enabled) {172 this.ensure();173 this.ctx?.resume();174 this.master?.gain.setTargetAtTime(MASTER_GAIN, this.now(), 0.4);175 } else {176 this.master?.gain.setTargetAtTime(0, this.now(), 0.15);177 }178 return this.enabled;179 }180181 /** Atmosphere zone turned over — crossfade the pad + air to the mood. */182 setMood(mood: string): void {183 this.mood = mood;184 if (!this.ctx) return;185 const spec = padForMood(mood);186 if (!spec) return;187 this.buildPad(spec);188 }189190 /** Per-frame: the marble's rolling voice follows its speed. */191 setSpeed(speed: number): void {192 if (!this.ctx || !this.rollNodes || !this.enabled) return;193 const { gain, cutoff } = rollParams(speed);194 this.rollNodes.gain.gain.setTargetAtTime(gain, this.now(), 0.12);195 this.rollNodes.filter.frequency.setTargetAtTime(cutoff, this.now(), 0.12);196 }197198 /** One-shot event cue. */199 cue(kind: CueKind): void {200 if (!this.ctx || !this.master || !this.enabled) return;201 const spec = cueFor(kind);202 const t = this.now();203 const osc = this.ctx.createOscillator();204 osc.type = spec.type;205 osc.frequency.setValueAtTime(spec.freq0, t);206 osc.frequency.exponentialRampToValueAtTime(207 Math.max(30, spec.freq1),208 t + spec.dur,209 );210 const g = this.ctx.createGain();211 g.gain.setValueAtTime(0, t);212 g.gain.linearRampToValueAtTime(spec.gain, t + 0.02);213 g.gain.exponentialRampToValueAtTime(0.001, t + spec.dur);214 osc.connect(g).connect(this.master);215 osc.start(t);216 osc.stop(t + spec.dur + 0.05);217 osc.onended = () => {218 osc.disconnect();219 g.disconnect();220 };221 }222223 dispose(): void {224 this.ctx?.close();225 this.ctx = null;226 this.master = null;227 this.padNodes = null;228 this.airNodes = null;229 this.rollNodes = null;230 }231232 private now(): number {233 return this.ctx?.currentTime ?? 0;234 }235236 private ensure(): void {237 if (this.ctx) return;238 const Ctor = (globalThis as { AudioContext?: typeof AudioContext })239 .AudioContext;240 if (!Ctor) return;241 this.ctx = new Ctor();242 this.master = this.ctx.createGain();243 this.master.gain.value = 0;244 this.master.connect(this.ctx.destination);245246 // Shared looping noise buffer (2s of white noise).247 const noise = this.ctx.createBuffer(248 1,249 this.ctx.sampleRate * 2,250 this.ctx.sampleRate,251 );252 const data = noise.getChannelData(0);253 for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;254255 // AIR: lowpassed noise, mood-tinted.256 {257 const src = this.ctx.createBufferSource();258 src.buffer = noise;259 src.loop = true;260 const filter = this.ctx.createBiquadFilter();261 filter.type = "lowpass";262 filter.frequency.value = 600;263 const gain = this.ctx.createGain();264 gain.gain.value = 0;265 src.connect(filter).connect(gain).connect(this.master);266 src.start();267 this.airNodes = { gain, filter };268 }269 // ROLL: bandpassed noise following speed.270 {271 const src = this.ctx.createBufferSource();272 src.buffer = noise;273 src.loop = true;274 const filter = this.ctx.createBiquadFilter();275 filter.type = "bandpass";276 filter.frequency.value = 400;277 filter.Q.value = 0.8;278 const gain = this.ctx.createGain();279 gain.gain.value = 0;280 src.connect(filter).connect(gain).connect(this.master);281 src.start();282 this.rollNodes = { gain, filter };283 }284 this.buildPad(padForMood(this.mood) ?? PADS.midnight);285 }286287 private buildPad(spec: PadSpec): void {288 if (!this.ctx || !this.master) return;289 const t = this.now();290 // Fade the old pad out and schedule its teardown.291 if (this.padNodes) {292 const old = this.padNodes;293 old.gain.gain.setTargetAtTime(0, t, PAD_FADE / 4);294 setTimeout(() => {295 for (const o of old.osc) {296 try {297 o.stop();298 o.disconnect();299 } catch {300 // already stopped301 }302 }303 old.gain.disconnect();304 }, PAD_FADE * 1000);305 }306 const filter = this.ctx.createBiquadFilter();307 filter.type = "lowpass";308 filter.frequency.value = spec.cutoff;309 const gain = this.ctx.createGain();310 gain.gain.value = 0;311 gain.gain.setTargetAtTime(spec.gain, t, PAD_FADE / 4);312 filter.connect(gain).connect(this.master);313 const oscs = spec.freqs.map((f, i) => {314 const osc = this.ctx!.createOscillator();315 osc.type = "sine";316 // Slight detune spread keeps the drone alive without an LFO.317 osc.frequency.value = f * (1 + (i % 2 === 0 ? 0.0012 : -0.0012));318 osc.connect(filter);319 osc.start();320 return osc;321 });322 this.padNodes = { osc: oscs, gain };323 // Air layer follows the mood too.324 if (this.airNodes) {325 this.airNodes.filter.frequency.setTargetAtTime(326 spec.airCutoff,327 t,328 PAD_FADE / 4,329 );330 this.airNodes.gain.gain.setTargetAtTime(spec.airGain, t, PAD_FADE / 4);331 }332 }333}