// 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); }, }; }