@bufinance/audio-cues
v0.2.0
Published
BUFI's synthesised UI sound: 21 cues as data (layered tone + noise, exponential envelopes, derived reverb tails), one lazily created AudioContext, and a gate that keeps the host's audio toggle absolute. Pure core, a DOM delegation layer (one document list
Downloads
442
Readme
@bufinance/audio-cues
BUFI's UI sound, synthesised. 21 cues are numbers, not files: layered tone and filtered noise with exponential envelopes, and a short feedback-delay tail on the cues that announce something. Nothing is fetched, the first play is sample-accurate, and overlapping plays layer instead of cutting each other off.
Canonical source: this directory. The table, engine and delegation were
lifted from desk-v1 (apps/app/src/utils/audio-cues.ts, audio-volume.ts,
cue-delegation.ts, context/UiSoundBridge.tsx) on 2026-09-22 with every
number intact. Hosts consume the published package; they do not keep a copy.
Entry points
| import | what | React |
| --- | --- | --- |
| @bufinance/audio-cues | CUES, cue types, CUE_BUS_GAIN, DEFAULT_TRIM, resolveEffectiveVolume, createCuePlayer | no |
| @bufinance/audio-cues/delegation | resolveCue, resolveScrollCue, isDragTarget, installCueDelegation | no |
| @bufinance/audio-cues/react | CueGateProvider, useCueFx, useCue, useThrottledCue, useCueLoop, <UiSoundBridge> | "use client" |
The root entry does no DOM or audio work at import — it is safe in a server
component, a route handler or a worker. The AudioContext is created by the
first cue that passes the gate, and there is one per page however many entries
you import.
The gate
Every play asks the host, at play time:
import { createCuePlayer } from '@bufinance/audio-cues';
const play = createCuePlayer({
gate: () => ({ enabled: store.enabled, master: store.volume /* trim?: 0.38 */ }),
});
play('press');A cue plays only if all of these hold, in this order: enabled; there is a
window; navigator.userActivation.hasBeenActive is not false; a context and
the cue exist; CUE_BUS_GAIN * resolveEffectiveVolume(local, trim, master) is
above 0. A suspended context is resumed and the unlocking click still sounds.
trim defaults to DEFAULT_TRIM (0.38), the level CUE_BUS_GAIN (17) was
calibrated against. Do not raise per-layer peaks to make a cue louder: they are
the relative mix.
React
import { CueGateProvider, UiSoundBridge, useCue } from '@bufinance/audio-cues/react';
// State form: toggle + trim re-render, the master rides a ref read at play time.
<CueGateProvider value={{ enabled, trim: 0.38, masterRef: volumeRef }}>
<UiSoundBridge>{children}</UiSoundBridge>
</CueGateProvider>
// Or a getter, read on every play — the shape for an external store:
<CueGateProvider value={() => { const s = useAudioStore.getState(); return { enabled: s.enabled, master: s.volume }; }}>Without a CueGateProvider every hook is silent.
Delegation
<UiSoundBridge> (or installCueDelegation(document, play)) listens once, in
the capture phase, for pointerdown, keydown (Enter/Space, no repeats),
wheel (80ms floor, direction picks tick/scroll) and slider drags (a tick
every 60ms). The cue comes from the control's role: tab → toggle;
checkbox/radio/switch/menu item/option → tick; link → page; anything
pressable → press. Text inputs, disabled controls and sliders stay silent.
data-soundlessmutes an element and its whole subtree.data-sound="<cue>"on a control or an ancestor overrides the cue. A name that is not a cue falls back to the role, never to silence.
A <div onClick> with no role is silent here for the same reason a screen
reader cannot see it: give it the role.
Build and test
bun install
bun run build # tsup, one output file per source file (see tsup.config.ts)
bun run typecheck
bun run test # pure suite without a DOM, then the DOM suite on happy-dom
npm pack --dry-run