# @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.js` asserts
  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 `world` vocabulary 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: true` AND is listed in `SUSTAINED`.** 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 `play` on 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.

```sh
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.

```sh
pnpm check:world-audio    # the world's policy: footfall phase, engine loops, seams, culling
```

## Hearing it without a browser

```sh
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

```sh
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:theme
```


---
Source: shared/packages/sound/README.md
Canonical: https://docs.leumas.tech/p/packages/sound
