astara / src /audio.js
system's picture
system HF Staff
cbe9c42: Every paper is read aloud, by whoever wrote it
7b6d5a9 verified
Raw History Blame Contribute Delete
26.9 kB
// Sound, and there are no sound files.
//
// Same answer as the art: generate it. These are placeholders in exactly the way
// gen_placeholders.js draws placeholders — made to be thrown away when somebody
// records the real thing, and made so that swapping them is a change here and
// nowhere else.
//
// Every sound is a plain function of (ctx, out, at) so it can be rendered into an
// OfflineAudioContext and MEASURED. A sound you cannot measure is a sound you are
// guessing about, and this project does not guess about the picture either.
//
// THE THING THAT BREAKS AUDIO ON THE WEB: a browser starts the audio clock
// suspended and will not start it until the user has touched something. Sounds
// fired before that are silently dropped — no error, no warning, nothing. So the
// context is created lazily and resumed on the first real input, and `ready`
// says whether anything can actually be heard yet.
import { AUDIO } from './tuning.js';
import { loudness, peak } from './loudness.js';
/** One second of white noise, built once. Every rough sound is filtered noise. */
function noiseBuffer(ctx) {
const n = Math.floor(ctx.sampleRate);
const buf = ctx.createBuffer(1, n, ctx.sampleRate);
const d = buf.getChannelData(0);
// A fixed sequence, not Math.random, so two runs of the same test agree.
let s = 0x2f6e2b1;
for (let i = 0; i < n; i++) {
s ^= s << 13; s ^= s >>> 17; s ^= s << 5; s >>>= 0;
d[i] = (s / 4294967296) * 2 - 1;
}
return buf;
}
function burst(ctx, out, at, buffer, { freq, q, gain, attack, decay, sweepTo }) {
const src = ctx.createBufferSource();
src.buffer = buffer;
// Start somewhere random-ish in the noise so repeats do not sound identical.
const off = (at * 977) % 0.8;
const bp = ctx.createBiquadFilter();
bp.type = 'bandpass';
bp.frequency.setValueAtTime(freq, at);
if (sweepTo) bp.frequency.exponentialRampToValueAtTime(sweepTo, at + attack + decay);
bp.Q.value = q;
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + attack + decay);
src.connect(bp).connect(g).connect(out);
src.start(at, off, attack + decay + 0.02);
src.stop(at + attack + decay + 0.02);
return attack + decay;
}
function thump(ctx, out, at, { from, to, gain, decay }) {
const o = ctx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(from, at);
o.frequency.exponentialRampToValueAtTime(to, at + decay);
const g = ctx.createGain();
g.gain.setValueAtTime(0.0001, at);
g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), at + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, at + decay);
o.connect(g).connect(out);
o.start(at);
o.stop(at + decay + 0.02);
return decay;
}
// --- the sounds themselves -------------------------------------------------
// Each returns roughly how long it lasts, which is what the tests assert on.
/** Boot on dirt. A click of grit over a little weight, and no ring. */
export function footstep(ctx, out, at, noise, v = 1) {
const jitter = 0.86 + ((at * 7919) % 1) * 0.28; // no two steps identical
const a = burst(ctx, out, at, noise, {
freq: 1500 * jitter, q: 0.9, gain: 0.5 * v, attack: 0.004, decay: 0.085,
});
thump(ctx, out, at, { from: 150, to: 60, gain: 0.32 * v, decay: 0.07 });
return a;
}
/** The swing. Air, not metal — he carries a short blade, not a greatsword. */
export function swing(ctx, out, at, noise, v = 1) {
return burst(ctx, out, at, noise, {
freq: 600, sweepTo: 2600, q: 1.0, gain: 1.5 * v, attack: 0.085, decay: 0.13,
});
}
/** It connects. A dull stop, not a clang: this is a body, not a shield. */
export function hit(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 900, sweepTo: 240, q: 1.1, gain: 0.5 * v, attack: 0.003, decay: 0.1,
});
return thump(ctx, out, at, { from: 190, to: 48, gain: 0.7 * v, decay: 0.16 });
}
/** Somebody goes down. Lower and longer than a hit, and it is the last word. */
export function bodyFall(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 420, sweepTo: 130, q: 0.8, gain: 0.42 * v, attack: 0.006, decay: 0.22,
});
return thump(ctx, out, at, { from: 110, to: 35, gain: 0.85 * v, decay: 0.34 });
}
/**
* A PAGE TURNS. The quietest thing in the game, and it has to be, because a
* turn happens three times a second while he reads and everything else here
* happens once.
*
* All noise and no body: paper has no pitch. Short and bright, gone before it
* can be listened to — the ear reads it as an event and never as a tone.
*/
export function pageTurn(ctx, out, at, noise, v = 1) {
return burst(ctx, out, at, noise, {
freq: 2400, sweepTo: 5200, q: 0.7, gain: 0.30 * v, attack: 0.004, decay: 0.055,
});
}
/**
* HE WRITES ON THE MAP. A pen, not a chime.
*
* This is the only sound in the game that acknowledges a CLAIM, and it must
* acknowledge without judging: nothing here goes up for right or down for
* wrong, because the map never tells him whether he is. One short dry scratch,
* the same whichever house he files under.
*/
export function mark(ctx, out, at, noise, v = 1) {
return burst(ctx, out, at, noise, {
freq: 1500, sweepTo: 900, q: 1.4, gain: 0.42 * v, attack: 0.006, decay: 0.09,
});
}
/**
* THUNDER, and it arrives after the light because it does.
*
* Far off, not overhead. A crack would be an alarm and this is not an alarm —
* it is the sky noticing. So: a long low rumble made of filtered noise with a
* slow attack, over a body of very low sine that gives it weight on a speaker
* that cannot reproduce the rumble at all.
*
* The flash carries this beat on its own, which is the whole reason it is a
* flash. A phone with the ringer off still sees the sky go white.
*/
// --- one sound per kind of drained ------------------------------------------
//
// THEY ARE BODY SOUNDS AND NOT TOOL SOUNDS, and that is not a style
// preference — it is the level's one real trick, protected.
//
// The loops are LAST MOMENTS, not jobs: "a blacksmith swings a hammer for
// thirty years; on the morning she came he was hauling water because his
// brother had a fever." The forge's letter says anvil and the forge's fragment
// is deliberately on a HAULER, so a swinging shape is bait. Give a swinger a
// hammer-on-anvil clang and every player in the world walks to the clang, the
// subversion dies, and the letters stop being worth reading — which is the
// thing this whole build exists to fix.
//
// So: effort, cloth, drag, breath. Air moving and a body straining. Nothing
// that names a trade, because nobody out there has one any more.
//
// FIRED ONCE PER CYCLE, off soundFrames in the manifest, the same deal the
// prince's boots and the drained's dragged foot already get: the art says when.
/** A swing: air moving, and the grunt at the bottom of the arc. */
export function drainedSwing(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 850, q: 0.7, gain: 0.30 * v, attack: 0.05, decay: 0.20, sweepTo: 260,
});
thump(ctx, out, at + 0.14, { from: 150, to: 74, gain: 0.16 * v, decay: 0.16 });
return 0.34;
}
/** A haul: something heavy dragged, and the body braced under it. */
export function drainedHaul(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 270, q: 0.45, gain: 0.26 * v, attack: 0.18, decay: 0.46, sweepTo: 130,
});
thump(ctx, out, at + 0.05, { from: 96, to: 52, gain: 0.20 * v, decay: 0.38 });
return 0.64;
}
/** Bearing: the quietest of the three. Cloth shifting against a chest. */
export function drainedBear(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 1500, q: 0.6, gain: 0.20 * v, attack: 0.10, decay: 0.30, sweepTo: 820,
});
return 0.40;
}
/**
* One that is coming for you, and until now it made no sound at all.
*
* Everything else in this game you choose to meet. This is the one thing that
* chose you, and it was silent — which on a torch-lit screen means it arrives
* from outside the circle with no warning whatever.
*/
export function drainedRush(ctx, out, at, noise, v = 1) {
// THE LOUDEST OF THE FIVE, and it started as the quietest by a factor of
// three. Measured through bandpass filters rather than guessed at: it came
// out at 5.4 against the haul's 32.8, and almost all of its energy is above
// 200Hz where a laptop or a phone can actually reproduce it — so on the
// speaker most people will use, the one thing in this game that chose YOU
// was the quietest thing in it.
burst(ctx, out, at, noise, {
freq: 640, q: 1.2, gain: 0.78 * v, attack: 0.03, decay: 0.13, sweepTo: 380,
});
burst(ctx, out, at + 0.15, noise, {
freq: 500, q: 1.0, gain: 0.54 * v, attack: 0.04, decay: 0.16, sweepTo: 300,
});
return 0.34;
}
/** The gatekeeper winding up: a shaft dragged round, and braced. */
export function postThrust(ctx, out, at, noise, v = 1) {
burst(ctx, out, at, noise, {
freq: 380, q: 0.9, gain: 0.30 * v, attack: 0.06, decay: 0.18, sweepTo: 1150,
});
thump(ctx, out, at + 0.10, { from: 120, to: 60, gain: 0.18 * v, decay: 0.20 });
return 0.32;
}
export function thunder(ctx, out, at, noise, v = 1) {
// The body: a slow sine slide, felt more than heard.
thump(ctx, out, at, { from: 62, to: 24, gain: 0.5 * v, decay: 1.5 });
// Two rolls of filtered noise, the second quieter and longer, so it reads as
// a rumble folding over itself rather than one gated hiss.
burst(ctx, out, at, noise, {
freq: 190, q: 0.55, gain: 0.34 * v, attack: 0.22, decay: 1.25, sweepTo: 70,
});
burst(ctx, out, at + 0.33, noise, {
freq: 120, q: 0.4, gain: 0.2 * v, attack: 0.3, decay: 1.5, sweepTo: 48,
});
return 1.9;
}
export const SOUNDS = { footstep, swing, hit, bodyFall, pageTurn, mark, thunder,
drainedSwing, drainedHaul, drainedBear, drainedRush, postThrust };
/**
* A real recording, sliced out of one take.
*
* The prince's is thirteen footfalls about 0.33s apart. He runs at 0.35s a
* step, so looping the clip drifts out of time within a single stride and it
* only gets worse the longer you hold the key. Slicing it and firing one slice
* per footfall stays in time at ANY speed, and rotating through them means no
* two consecutive steps are the same sample.
*
* THE SAME MACHINERY NOW CARRIES THE DRAINED, which is why this is keyed by
* name rather than hard-wired to feet. Their take is nine dry drags about 0.92s
* apart with no thump in it at all — 82% of its energy sits above 3kHz and
* there is nothing at all below 80Hz. It is a foot being pulled across dirt
* rather than one being put down, which is what they do.
*/
async function loadSlices(ctx, def) {
const res = await fetch(def.file);
if (!res.ok) throw new Error(`${def.file}: ${res.status}`);
const buf = await ctx.decodeAudioData(await res.arrayBuffer());
// Each step is [start, gain]: the loud foot and the quiet one need different
// lifts, and a bare list of starts cannot say that.
const steps = def.steps.map((v) => (Array.isArray(v) ? v : [v, 1]));
return { buf, steps, len: def.length ?? 0.25, gain: def.gain ?? 1 };
}
/**
* The live one. Nothing exists until the first sound is asked for, and nothing
* is audible until the browser has seen the player touch something.
*/
export function createAudio() {
let ctx = null, master = null, noise = null;
let enabled = true;
// Recordings by name. `wanted` is what the manifest asked for, `takes` is
// what has actually decoded — a context needs a gesture, so those are never
// the same thing at boot and one of them may never arrive at all.
const wanted = new Map();
const takes = new Map();
const turn = new Map();
// A bed, rather than a hit: one source that starts once and runs for the
// whole session with its level moved from outside. Everything else in this
// file is a one-shot, because everything else in this game happens once.
const loops = new Map();
// THE VOICES: every paper read aloud by whoever wrote it. Whole recordings,
// not slices, and only ever one at a time -- `speaking` is the one sounding.
// `voicesWanted` is what the scene asked for, `voices` what has decoded and
// been levelled, `voicesFailed` what never will.
const voicesWanted = new Map();
const voices = new Map();
const voicesFailed = new Set();
let speaking = null;
/**
* Fetch, decode and LEVEL one voice. The takes came in 14 dB apart, so each
* is measured the way broadcast measures loudness and brought to
* VOICE_LUFS -- short of a gain that would push its sharpest peak past
* -1 dBFS. A file recorded again later is levelled the same way.
*/
function fetchVoice(c, name) {
const def = voicesWanted.get(name);
if (!def || def.loading || voices.has(name) || voicesFailed.has(name)) return;
def.loading = true;
fetch(def.file)
.then((res) => {
if (!res.ok) throw new Error(`${def.file}: ${res.status}`);
return res.arrayBuffer();
})
.then((bytes) => c.decodeAudioData(bytes))
.then((buf) => {
const data = buf.getChannelData(0);
const lufs = loudness(data, buf.sampleRate);
const top = peak(data);
const gain = Math.min(10 ** ((AUDIO.VOICE_LUFS - lufs) / 20), top > 0 ? 0.89 / top : 1);
voices.set(name, { buf, gain, lufs });
})
.catch((e) => {
console.warn(`voice ${name} not loaded:`, e.message);
voicesFailed.add(name);
});
}
/** Fade the voice out over a moment rather than cutting it: he looked away. */
function stopVoice() {
const now = speaking;
speaking = null;
if (!now || !ctx) return;
const t = ctx.currentTime;
now.g.gain.cancelScheduledValues(t);
now.g.gain.setValueAtTime(now.g.gain.value, t);
now.g.gain.linearRampToValueAtTime(0, t + 0.12);
try { now.src.stop(t + 0.14); } catch { /* already over */ }
}
function ensure() {
if (ctx) return ctx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
// THE iOS SILENT SWITCH, which is the one that looks like nothing is wrong.
//
// Safari routes Web Audio to the "ambient" audio session by default, and
// ambient is muted by the physical ringer switch. The context runs, the
// samples are produced, `ready` says true, and not one sound comes out of
// the phone. Nothing logs and nothing fails.
//
// Asking for the "playback" session is what says this is content the player
// came for rather than a page making noise at them. Guarded twice: the API
// is iOS 16.4 and up, and assigning an unsupported value throws.
try {
if (navigator.audioSession) navigator.audioSession.type = 'playback';
} catch { /* older Safari, or a browser without sessions at all */ }
ctx = new AC();
master = ctx.createGain();
master.gain.value = AUDIO.MASTER;
master.connect(ctx.destination);
noise = noiseBuffer(ctx);
// Decoding needs a context, and a context needs a gesture, so a recording
// can only be fetched once the player has touched something.
for (const [name, def] of wanted) {
if (takes.has(name)) continue;
loadSlices(ctx, def)
.then((t) => { takes.set(name, t); })
.catch((e) => {
console.warn(`${name} recording not loaded, using the generated one:`, e.message);
wanted.delete(name);
});
}
// Decoding does not need the clock running, only a context, so the voices
// are ready before the first press -- which is what lets that press start
// his orders being read at once.
for (const name of voicesWanted.keys()) fetchVoice(ctx, name);
return ctx;
}
/**
* One slice of a named recording. False if it is not ready yet.
*
* `pan` is -1 left to 1 right. Only the drained's drag uses it: it is the
* one sound in the game that comes from somewhere he cannot see.
*/
function playSlice(name, vol, pan = 0) {
const take = takes.get(name);
if (!take) return false;
const at = ctx.currentTime;
const n = turn.get(name) ?? 0;
const [start, lift] = take.steps[n % take.steps.length];
const src = ctx.createBufferSource();
src.buffer = take.buf;
const g = ctx.createGain();
const level = take.gain * lift * vol;
g.gain.setValueAtTime(level, at);
// Short fade at the tail. The slice is already shorter than the gap between
// fires, so this is only to stop the cut being audible as a click.
g.gain.setValueAtTime(level, at + take.len - 0.04);
g.gain.exponentialRampToValueAtTime(0.0001, at + take.len);
src.connect(g);
if (pan && ctx.createStereoPanner) {
const p = ctx.createStereoPanner();
p.pan.value = Math.max(-1, Math.min(1, pan));
g.connect(p).connect(master);
} else {
g.connect(master);
}
src.start(at, start, take.len);
src.stop(at + take.len + 0.02);
turn.set(name, n + 1);
return true;
}
/**
* Start a recording looping, or move the level of one already running.
*
* THE VILLAGE HAS PEOPLE STANDING IN IT and it has been silent. Thirty-four
* of them in the trees and eleven in the street, sixty years after she came,
* and the only sound was the prince's own boots. A bed is the one thing in
* this file that is not an event, and it is here because the place is not an
* event either.
*
* Level is set from OUTSIDE, per frame, so the scene can fade it in as he
* walks into the bowl. Zero is silence and the source keeps running, which
* costs nothing and means it never has to be restarted mid-fade.
*/
function setLoop(name, level) {
if (!enabled) { if (loops.has(name)) loops.get(name).gain.gain.value = 0; return; }
const c = ensure();
if (!c || c.state !== 'running') return;
let L = loops.get(name);
if (!L) {
const take = takes.get(name);
if (!take) return; // not decoded yet; try again next frame
const src = c.createBufferSource();
src.buffer = take.buf;
src.loop = true;
const g = c.createGain();
g.gain.value = 0;
src.connect(g).connect(master);
src.start();
L = { src, gain: g, base: take.gain };
loops.set(name, L);
}
// Eased rather than snapped, or walking in and out of the bowl clicks. And
// down under a voice: the words are the evidence, the room is the room.
const duck = speaking ? AUDIO.VOICE_DUCK : 1;
L.gain.gain.setTargetAtTime(Math.max(0, level) * L.base * duck, c.currentTime, 0.25);
}
function play(fn, v) {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
fn(c, master, c.currentTime, noise, v);
}
return {
/** Call from a real input handler. Before this, everything is silent. */
resume() {
const c = ensure();
if (c && c.state === 'suspended') c.resume();
},
get ready() { return !!ctx && ctx.state === 'running'; },
/** For the readout. A phone has no console, so the state has to be visible. */
get state() { return ctx ? ctx.state : 'not started'; },
get session() {
try { return navigator.audioSession?.type ?? 'none'; } catch { return 'none'; }
},
get enabled() { return enabled; },
setEnabled(v) {
enabled = v;
if (!v) stopVoice();
if (master) master.gain.value = v ? AUDIO.MASTER : 0;
},
/** Tell it where a recording lives. Loaded on the first gesture. */
useRecording(name, def) { if (def && def.file && def.steps) wanted.set(name, def); },
get recordingsLoaded() { return [...takes.keys()]; },
/** Tell it which recording reads a paper aloud. Fetched with the context. */
useVoice(name, def) {
if (!def || !def.file) return;
voicesWanted.set(name, { file: def.file });
if (ctx) fetchVoice(ctx, name);
},
/**
* Read a paper aloud, from the top, stopping whichever voice was speaking.
* False while it cannot be heard yet -- no press so far, or not decoded --
* and the scene asks again next frame.
*/
voice(name) {
if (!enabled) return false;
const c = ensure();
if (!c || c.state !== 'running') return false;
const v = voices.get(name);
if (!v) { fetchVoice(c, name); return false; }
stopVoice();
const src = c.createBufferSource();
src.buffer = v.buf;
const g = c.createGain();
g.gain.value = v.gain * AUDIO.VOICE;
src.connect(g).connect(master);
const now = { name, src, g };
src.onended = () => { if (speaking === now) speaking = null; };
src.start();
speaking = now;
return true;
},
stopVoice,
/**
* Whether this voice could ever be heard: sound is on, the browser has it,
* and the file has not failed. NOT whether it can be heard yet -- before
* the first press it cannot, and that is the case this exists to tell apart.
*/
canSpeak(name) {
const AC = window.AudioContext || window.webkitAudioContext;
return enabled && !!AC && voicesWanted.has(name) && !voicesFailed.has(name);
},
get speaking() { return speaking ? speaking.name : null; },
/** What each voice measured as, and the gain that levelled it. For the readout. */
get voiceLevels() {
return Object.fromEntries([...voices].map(([k, v]) => [k, { lufs: +v.lufs.toFixed(1), gain: +v.gain.toFixed(2) }]));
},
footstep: (v = AUDIO.FOOT) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
// The generated one is the fallback, not the plan: it covers the moment
// before the file has decoded, and the case where it never does.
if (!playSlice('footsteps', v)) footstep(c, master, c.currentTime, noise, v);
},
/**
* One dragged foot. NO GENERATED FALLBACK, and that is on purpose: the
* synthesised footstep has a thump in it, and a thump is the one thing
* these do not have. Silence is closer to right than the wrong sound.
*/
drainedStep: (level = 1, pan = 0) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
playSlice('drainedWalk', AUDIO.DRAINED_FOOT * level, pan);
},
swing: () => play(swing, AUDIO.SWING),
hit: () => play(hit, AUDIO.HIT),
bodyFall: () => play(bodyFall, AUDIO.FALL),
pageTurn: () => play(pageTurn, AUDIO.PAGE),
mark: () => play(mark, AUDIO.MARK),
/**
* The real clap if it has decoded, the generated rumble if it has not.
*
* Same fallback shape the footsteps have, and for the same reason: the
* recording cannot be fetched until the player has touched something, and
* the sky can go off before that has happened.
*/
/**
* One per kind, picked by the clip the body is showing.
*
* The SCENE does not know what a swing sounds like and this does not know
* what a swing looks like; the clip's name is the whole contract, so a new
* loop is a new entry here and nothing else.
*/
drainedVoice: (kind, level = 1, pan = 0) => {
const v = AUDIO.DRAINED_VOICE * level;
const pick = { loop_swing: ['drainedSwing', drainedSwing],
loop_haul: ['drainedHaul', drainedHaul],
loop_bear: ['drainedBear', drainedBear],
drained_rush: ['drainedRush', drainedRush],
drained_thrust: ['postThrust', postThrust] }[kind];
if (!pick || !enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
// The recording if it has decoded, the generated one if it has not --
// the same fallback the boots and the thunder have, and for the same
// reason: a recording cannot be fetched before the first gesture.
if (!playSlice(pick[0], v, pan)) pick[1](c, master, c.currentTime, noise, v);
},
/**
* A key off its hook. Its own sound, because it is its own act.
*
* It used to share mark() with pinning a page and with putting a key INTO
* the gate -- three different things making one blip, which is how
* somebody collected four keys without registering that they had.
*/
keyTake: (v = AUDIO.MARK) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
if (!playSlice('keyTake', v)) mark(c, master, c.currentTime, noise, v);
},
/**
* THE TORCH, FOR AS LONG AS IT IS BURNING.
*
* It used to be one gas-flare fired on the raise, and a torch does not
* make one noise when you lift it -- it crackles the whole time it is lit.
* So it is a loop on the same machinery as the village bed, and the level
* is the raise itself: it comes up as the arm comes up, and goes out with
* it. No separate fade to keep in step with anything.
*/
torchFire: (level, v = AUDIO.TORCH) => setLoop('torchFire', Math.max(0, level) * v),
/**
* The instant a shape notices him, which is the one beat this whole game
* is built around and which made no sound at all.
*
* "One hundred percent of the combat in this level is caused by looking."
* You could hold a light on somebody for eight hundred milliseconds, watch
* them come awake, and hear nothing until they were already on you.
*/
/**
* The gasp when the light first lands on it -- the step before the wake.
* The recording once it has decoded; until then a generated intake, a band
* of noise swelling and rising in pitch. Quieter than the wake by design:
* this is the warning, not the cost.
*/
drainedStir: (pan = 0, v = AUDIO.STIR) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
if (playSlice('gasp', v, pan)) return;
let out = master;
if (pan && c.createStereoPanner) {
out = c.createStereoPanner();
out.pan.value = Math.max(-1, Math.min(1, pan));
out.connect(master);
}
burst(c, out, c.currentTime, noise, { freq: 650, q: 0.9, gain: v * 0.5, attack: 0.3, decay: 0.3, sweepTo: 1500 });
},
drainedWake: (level = 1, pan = 0) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
playSlice('wake', AUDIO.WAKE * level, pan);
},
/** The village, under everything. 0..1, set every frame by the scene. */
villageBed: (level) => setLoop('villageBed', level * AUDIO.VILLAGE),
thunder: (v = AUDIO.THUNDER) => {
if (!enabled) return;
const c = ensure();
if (!c || c.state !== 'running') return;
if (!playSlice('thunder', v)) thunder(c, master, c.currentTime, noise, v);
},
};
}