@leumas/sound
The Leumas UI sound layer — a synthesised sound bank, a non-spatial mixer and ONE delegated DOM listener set, so every button, tab, toggle and window in the ecosystem sounds without a single...
@leumas/sound
The faint layer under the interface. A button you press answers you, a window that opens says so, and a quest that completes rings — at about a seventh of full scale, on a bank of sounds that ships as zero bytes.
Two interface packs over one 24-cue vocabulary: os, the macOS register (short, soft, warm), and hud, the game register (brighter, more travel in the bends). A surface asks for click; the pack decides what a click sounds like there.
And a third, world — 27 voices for the 3D world, on a vocabulary of its own that shares not one cue with the other two. Footsteps, engines, water, weather, contact. It plays through the world's own spatial mixer, never through the interface one.
Why there are no audio files
Every voice is rendered from a parameter table into an AudioBuffer on the first user gesture — a band-limited noise burst plus one or two sines, each under its own exponential decay, under a shared raised-cosine attack. For UI transients that is not a compromise:
- Quality. A macOS tick is a soft-attack filtered click with a quiet inharmonic partial. A
recording gets no closer for this material.
- Consistency. One tuning table means the two packs are provably the same family.
- Performance. No network, no decode, nothing on any product's bundle. The whole bank is a couple
of milliseconds of arithmetic, once.
The model is @leumas/game-3d's synth.js, generalised — that file now imports renderVoice from here and keeps only its own table. Its header makes the same argument for gunshots.
Layout
| Path | Owns |
|---|---|
src/engine/synth.js | renderVoice + renderPack — the sample renderer, and the four fields a UI tick needs that a gunshot does not |
src/engine/packs.js | the sound design: OS_PACK, HUD_PACK, WORLD_PACK. Loaded LAZILY — see bank.js |
src/engine/cues.js | both vocabularies, their categories, and SUSTAINED — the synchronous half play() gates on |
src/engine/bank.js | the one lazy boundary: the renderer and the tables, off every boot path |
src/engine/mixer.js | the pool, the latch, and the rules that keep this pleasant rather than irritating |
src/engine/backend.js | createWebAudioBackend (one context per page) · createSilentBackend (for tests) |
src/install.js | installSfx() — ONE delegated listener set for the whole document |
src/prefs.js | the local mirror and the flat sound.* profile keys |
src/react/SoundProvider.jsx | SoundProvider · useSound · useSoundPack · SfxHost |
src/react/SoundSettingsCard.jsx | the audition card — every voice, a play button, live sliders |
src/sound.css | the card's paint, tokens only |
Entries
| Subpath | For |
|---|---|
. | the headless engine — no React, no DOM, safe on a guarded bundle path |
./react | SoundProvider · SoundBoundary · useSound · useSoundPack · SfxHost |
./settings | the audition card. Lazy-load it; it renders 50 voices |
./install | installSfx() and emitSfx(), for a surface with no React |
./prefs | readLocalSound() for a main.jsx that needs an answer before any provider exists |
./sound.css | imported by ./settings itself; no product needs to import it |
Rules
- The engine half imports nothing but
@leumas/game-kit/rng. That is a subpath onto a file with
no imports at all; the game-kit barrel reaches rapier through ./physics and must never appear here. check:deck walks every static and dynamic edge from the Operator Deck and fails on new weight, and this package sits on that path.
- No React under
src/engine/. It is imported by a Node test and by the game. - Adding an interface cue means adding it to BOTH interface packs.
test/mixer.test.jsasserts
it, because a cue present in one and missing from the other is a sound that vanishes when a person switches packs, with nothing to attribute it to.
- The
worldvocabulary is separate and does NOT fall back.voiceFor('world', 'click')is
null on purpose: the two sets share no cue, so a fallback would answer a mistyped world cue with whatever interface voice happened to share its name. world is also not offered in the settings picker (UI_PACK_IDS) — choosing it as an interface register would silence every button in the app while looking perfectly configured.
- A looping voice carries
sustain: trueAND is listed inSUSTAINED. The renderer overshoots
and folds the overrun back over the head so the buffer joins to itself; playing a one-shot on loop: true, or a sustained voice as a one-shot, is a click at a perfectly regular interval. check:world-audio measures the seam against each voice's own RMS.
- Sounds are added by data attribute, not by editing components.
data-sfx="<cue>"overrides the
cue an element plays; data-sfx="off" silences it and everything inside it; data-sfx-hover on a container makes everything pressable inside it hover too. All three are found with closest(). Reach for an explicit useSound().play() only where there is no DOM event at all — a route change, a toast, a window opening — or emitSfx() where there is no React either.
- A hover must be dwelt on for 60ms. Sweeping a pointer across a grid of icons on the way
somewhere else touches a dozen of them in a fifth of a second; without the dwell that is a dozen sounds for a gesture that was about none of them, and the mixer's 120ms rate limit only thins it to a machine gun at a lower rate. test/install.test.js pins the sweep silent.
- Never call
playon scroll, on pointer movement or on a keystroke. No rate limit makes those
bearable, and one of them is the fastest way to make somebody disable the whole layer.
- CSS lives under
.lms-sfx, tokens only. No literal near-black or near-white;check:theme.
The desktop
@leumas/os sounds at its store dispatch, not at its components and not at its actions object — Alt-Tab, Escape and the tiling shortcut all dispatch directly from the keyboard handler, and so do the action-mapper and the agent. One table (os/os.events.js) therefore covers every path there is.
Two things that table has to know, and neither is obvious:
- A dispatch is not an event. Escape dispatches three closes unconditionally; the reducer ignores
the ones that were not open. Unguarded, pressing Escape at an empty desktop made three sounds.
- Another tab's desktop is not yours. The OS syncs over a BroadcastChannel and replays remote
actions through the same dispatch, so a window opened in another tab would have been audible here.
The desktop carries data-sfx-hover on its root, which is what gives it hovers at all: it paints its own vocabulary (desktop-icon, taskbar-btn, start-menu-item, context-item…) and none of it is in the kit's curated list. It also wraps itself in SoundBoundary, so it is heard standalone and does not open a second mixer when embedded in Studio.
pnpm --filter @leumas/os test # the cue table, including everything that must stay SILENT
The 3D world
The world does not use the mixer this package's provider owns. It uses @leumas/game-3d/audio's spatial one — a voice pool with distance culling and stealing, which shipped finished and silent for want of a bank — with WORLD_PACK rendered into it. The binding lives in the world's own lane, at @leumas/features/src/nav-views/world/audio.js, because the numbers in it ("a landing above 11 units per second is the heavy one") are facts about that world's movement constants, not about audio.
What this package owes it: the bank, sustain rendering for the five loops, and one AudioContext — useSound().context() is handed to three so it does not open a second with its own gesture latch.
pnpm check:world-audio # the world's policy: footfall phase, engine loops, seams, culling
Hearing it without a browser
pnpm sfx:preview # sfx-preview.wav — all three packs, in order
pnpm sfx:preview --pack world --out D:/tmp/a.wav
Sustained voices are written out four times over, because the only question worth asking of a loop is whether it clicks at the join, and one repetition cannot answer it.
Renders through the same renderPack the browser uses, so there is no second implementation to drift. Reviewing a sound design as a diff of numbers is not reviewing it.
Tuning
pnpm dev, then Dashboard → Profile → Sound (or Studio → Settings). Every voice has a play button and a Tune drawer; drag, listen, and Copy row puts the line on the clipboard in exactly the shape packs.js expects. Paste it back into the table and commit — that is the whole loop, and it is the only honest way to decide whether a sound is satisfying.
Verify
pnpm check:sound # the policy, against a fake clock and a silent backend
node ops/infra/scripts/check-boot.mjs # this package is ~3.7KB gzip eager; keep it there
pnpm --filter @leumas/game-3d test # the shared renderer did not change the game's bank
pnpm check:deck # the bundle guard this package is designed around
pnpm check:themeWhat it exports
40 exports: 22 components, 2 hooks, 16 helpers. Import from @leumas/sound.
ALL_CATEGORIESCATEGORIESCUESDEFAULT_CATEGORIESDEFAULT_PACKDEFAULTSDEFAULTSHUD_PACKKEYSKEYSOS_PACKPACK_IDSPACKSSfxHostSoundBoundarySoundCardSoundProviderSUSTAINEDUI_PACK_IDSWORLD_CATEGORIESWORLD_CUESWORLD_PACKuseSounduseSoundPackcreateMixercreateSilentBackendcreateWebAudioBackendemitSfxemitSfxinstallSfxloadPacksmergeProfileSoundmergeProfileSoundreadLocalSoundreadLocalSoundrenderPackrenderVoicevoiceForwriteLocalSoundwriteLocalSound