Spaces:
Running
Running
Download src/audio.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 26.9 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/audio.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/audio.js
-
curl -L -o audio.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/audio.js
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); | |
| }, | |
| }; | |
| } | |