# @inklu/audio

A synthesized sound library for interfaces. No audio files, no network requests, no dependencies — every sound is generated on the fly with the Web Audio API and run through a mastered bus so nothing ever clips.

- **29 sounds** across interaction, navigation, feedback, action and motion
- **Zero assets, zero dependencies** — nothing to host, nothing to load, works offline
- **Declarative** — mark up your HTML with `data-sound` and call `observe()` once
- Framework-agnostic. React, Vue, Svelte, vanilla JS, or a plain `<script>` tag.

## Install

```bash
npm install @inklu/audio
```

## Quick start

The `engine` singleton coordinates the AudioContext, master bus, rate limiting and rendering.

```ts
import { engine } from "@inklu/audio";

engine.play("tap");
engine.play("success");
```

### iOS / Safari note

Call `engine.prime()` inside your first user-gesture handler to unlock the `AudioContext`. It resumes a suspended context and plays a silent buffer, which avoids the delay or dropped first sound Safari can introduce when a context is created and played through in the same interaction.

```ts
document.addEventListener("pointerdown", () => engine.prime(), { once: true });
```

`observe()` calls this automatically on the first click on a bound element, so you don't need it if you're using the attribute API. (Hover and focus aren't user gestures, so they can't unlock audio.)

## Declarative HTML attributes

Mark up your HTML, call `observe()` once, and you're done. No refs, no `onClick` handlers, no rewiring when elements are added later — one delegated listener per trigger handles everything, including elements that don't exist yet.

```html
<button data-sound="tap">Click me</button>
<button data-sound-click="success" data-sound-hover="hover">Save</button>
<a data-sound="turn:forward">Next</a>
```

```ts
import { observe } from "@inklu/audio";

observe();
```

| Attribute                 | Fires on                                                        |
| ------------------------- | --------------------------------------------------------------- |
| `data-sound="name"`       | click                                                           |
| `data-sound-click="name"` | click (explicit form)                                           |
| `data-sound-hover="name"` | a mouse pointer entering the element (not touch or pen)         |
| `data-sound-focus="name"` | focus moving into the element from outside it                   |
| `data-sound-disabled`     | Excludes this element and its children from every binding above |

A click plays one sound: the nearest element with `data-sound` or `data-sound-click`, so a bound button inside a bound list item plays only the button's sound. On one element, `data-sound-click` takes precedence over `data-sound`, and an empty `data-sound-click=""` silences the click.

Hover and focus fire once per entry: moving between an element's own children doesn't replay the sound, and a tap on a touch screen plays only the click sound, not a hover as well.

Sounds that take an argument accept it after a colon: `data-sound="turn:forward"` or `data-sound="turn:back"`.

You can also control the library from the root element — handy if you toggle these from outside your JS bundle:

```html
<html data-sound-theme="default" data-sound-volume="80" data-sound-muted="false"></html>
```

`data-sound-volume` accepts either scale: `0.8` and `80` both mean 80%. For `data-sound-muted`, presence means muted — only `"false"` or `"0"` turn sound back on.

These are read once on `observe()` and stay reactive via a `MutationObserver`, so changing them later takes effect immediately.

`observe()` returns a cleanup function:

```ts
const stop = observe();
stop(); // SPA teardown, test afterEach, etc.
```

Calls are reference-counted per root, so observing twice — the drop-in script plus your own call, or a component that remounts — binds the listeners once. They're removed when every returned cleanup has run.

## Zero-build drop-in script

No bundler, no imports — serve `dist/auto.global.js` and add a `<script>` tag:

```html
<script src="/auto.global.js"></script>
<button data-sound="tap">Click me</button>
```

This calls `observe()` as soon as the page is ready and exposes the engine globally:

```html
<script>
	window.InkluAudio.play("success");
</script>
```

## Sounds

```
Interaction   tap  click  key-press  hover
State         toggle-on  toggle-off  checkbox  select  deselect
Navigation    tab-switch  expand  collapse  page-enter  page-exit  turn
Feedback      success  error  warning  notification  info  deny
Actions       copy  send  delete  erase  undo
Motion        pop  swoosh  slide
```

`turn` takes a direction: `turn:forward` (default) or `turn:back`.

## Volume & muting

```ts
engine.setVolume(0.5); // 0→1
engine.getVolume(); // 0.5

engine.setEnabled(false); // every sound becomes a silent no-op
engine.isEnabled(); // false
```

Volume ramps rather than jumps, so dragging a slider doesn't produce zipper noise. Every sound is also throttled per-name at a 45 ms minimum interval, so rapid-fire triggers (fast typing, scroll feedback) never pile up. No configuration needed.

The package ships no UI — these two calls are the whole surface a volume control needs.

### Reduced motion

By default the engine stays silent while the user has `prefers-reduced-motion: reduce` set — interface sound is motion feedback, and people who ask for less motion usually want less of it. The preference is tracked live, so changing the OS setting takes effect immediately. Opt out if your sounds carry information the user needs:

```ts
engine.configure({ respectReducedMotion: false });
```

`engine.configure()` sets any of `volume`, `enabled`, `theme`, `defaultThrottleMs` and `respectReducedMotion` in one call, and doesn't depend on `this`, so it's safe to destructure. Non-finite numbers (`NaN`, `Infinity`) passed to `setVolume`, `configure`, or as `pitch`/`force` are ignored rather than reaching the audio graph.

## Custom sounds

The engine is a registry. Add your own with `engine.registerSound`:

```ts
engine.registerSound("level-up", {
	layers: [
		{
			frequency: { start: 400, end: 1200 },
			envelope: { decay: 0.5, release: 0.1 },
			fm: { ratio: 2.0, depth: 300 },
			gain: 0.6,
		},
	],
});

engine.play("level-up");
```

Keep decays short — 10–20 ms for a discrete event is the difference between a tick and a beep — and reserve pitch sweeps for sounds that describe motion.

### Contact and body

Most sounds in the default palette are one struck event built from two layers, and custom sounds that match will sit alongside them. This is `tap`, written out:

```ts
engine.registerSound("stamp", {
	layers: [
		// The contact: a 1.5 ms burst of band-passed white noise. Carries the brightness.
		{
			noise: "white",
			filter: { type: "bandpass", frequency: 2500, resonance: 1.2 },
			envelope: { decay: 0.0015 },
			gain: 0.028,
		},
		// The body: a short sine that drops a quarter-octave into its pitch. Carries the note.
		{
			role: "body",
			frequency: { start: 889, end: 740 },
			filter: { type: "lowpass", frequency: 4440, resonance: 0.8 },
			fm: { ratio: 2, depth: 266 },
			envelope: { decay: 0.004, release: 0.0012 },
			gain: 0.047,
		},
	],
});
```

A layer on its own is a tone, and the ear hears a beep. The pair is what reads as something physically happening. Keep bodies in the 700 Hz–3 kHz register: lower reads as muffled on laptop speakers, which roll off below about 300 Hz. And keep the sounds you trigger most the shortest (3–8 ms), so they read as touches rather than notes.

### Layer roles

Themes reshape a sound by scaling each layer's **role** independently — `transient`, `body`, `sub` or `tone`. The role is inferred from the layer's shape and you can usually leave it alone, but a low-pitched _lead_ voice is the case worth knowing about:

```ts
engine.registerSound("bass-drop", {
	layers: [
		// Without the explicit role this reads as a `sub` layer, and a theme that
		// strips sub weight would silence the whole sound.
		{ frequency: { start: 220, end: 90 }, role: "tone", gain: 0.5 },
	],
});
```

### Inspecting a built-in sound

`engine.getSound()` resolves a name to the definition that would actually be rendered, applying the active theme's override and invoking the factory for sounds that take an argument. It returns a copy, so editing the result never changes the registered sound:

```ts
const tap = engine.getSound("tap");
engine.registerSound("tap-quiet", {
	...tap!,
	layers: tap!.layers.map((l) => ({ ...l, gain: (l.gain ?? 0.5) * 0.5 })),
});

engine.getSound("turn", { arg: "back" });
```

## Themes

A theme reshapes every sound in the palette at once, so one set of definitions can become a different instrument without being rewritten. Three ship:

| Theme        | Character                                                                                                                                                                                                                                                                                                          |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `default`    | Crisp and clean. The palette exactly as authored: short sine strikes with a white-noise click, 700 Hz–3 kHz. Every multiplier neutral, no saturation. **Default.**                                                                                                                                                 |
| `soft`       | Felt mallets on wood. Every built-in sound is re-authored with a felt (pink-noise) contact, a rounded onset, a wooden overtone near the 4th harmonic, and longer, roomier tails, at the same loudness as the default. Custom sounds play as pure sines, rounder, 25% longer and roomier.                           |
| `mechanical` | Switches, latches and ratchets. Every built-in sound is re-authored from noise as struck parts: a contact impulse, then a narrow band ringing with an inharmonic overtone. Custom sounds play through square waves with halved pitch sweeps. Master saturation at `0.3`, reverb cut to 15% and extra humanization. |

```ts
engine.setTheme("mechanical");
engine.getAvailableThemes(); // ["default", "soft", "mechanical", …registered]
```

A theme assembled only from multipliers is an EQ on a single identity, and several such themes read as the same palette at different settings. Tone colour is an _authoring_ decision and belongs in the palette; a theme earns its place by changing what the sounds are made of. `soft` and `mechanical` do both. `soft` re-authors every built-in sound as felt on wood and plays custom sounds as pure sines; `mechanical` re-authors every built-in sound as noise-only parts, and custom sounds take `waveformOverride: "square"`, which lifts the 3rd, 5th and 7th harmonics by 9.5, 14 and 17 dB — a different material, not a different EQ.

No theme can change what a sound _means_: themes scale and reshape layers, but never flip which way a gesture moves. `mechanical`'s re-authored sounds keep each gesture's direction in which part rings first and last (a ratchet climbing or falling, a lever leaving one stop for the other), and its pitch sweeps on custom sounds are halved rather than flattened, so `expand`/`collapse`, `turn:forward`/`back` and `send`/`undo` stay distinguishable by direction. Sub layers are exempt from `waveformOverride` — they are unfiltered sines, and a square there would put a loud third harmonic into the body's register with nothing to remove it.

### Make your own feel

`createTheme` builds a theme from a few controls that each change one thing you can hear. Start from any built-in, move only what you want different, and register the result:

```ts
import { createTheme, engine } from "@inklu/audio";

engine.registerTheme("deep", createTheme({ depth: 0.6, bump: 0.5, space: 0.3 }));
engine.registerTheme("felt-small", createTheme({ base: "soft", depth: -0.5, length: -0.4 }));
engine.setTheme("deep");
```

| Control      | Range  | Low end → high end                                                                       |
| ------------ | ------ | ---------------------------------------------------------------------------------------- |
| `depth`      | -1 … 1 | Lighter → deeper. Every pitch and band moves together, 4 semitones up to 5 down.         |
| `bump`       | 0 … 1  | None → a short low thump under each struck onset, 300–440 Hz so laptop speakers play it. |
| `brightness` | -1 … 1 | Darker → brighter. Filters move ±0.6 octave; clicks get quieter or louder.               |
| `length`     | -1 … 1 | Shorter → longer tails (0.7× to 1.7×).                                                   |
| `space`      | 0 … 1  | Dry → roomy. Every sound gets some room, and the room gets bigger.                       |
| `softness`   | -1 … 1 | Harder → softer. Clicks get louder, or onsets round off into felt.                       |
| `grit`       | 0 … 1  | Clean → driven. Saturation and denser overtones.                                         |

`0` (or leaving a control out) always means "as the base is authored", and `base` defaults to `"default"`. Values outside a range are clamped. Every control is loudness-compensated per base, so moving it changes the character, not the volume; meanings survive at every setting (on still rises, off still falls). One caveat: at full depth, especially with `bump`, the lowest sounds lose 2–4 dB on small laptop speakers, which roll off below ~350 Hz.

`FEEL_CONTROLS` lists each control's key, label, range and end words, in display order, for building a picker UI.

### Custom themes

For full control, register raw overrides with `engine.registerTheme`. Omitted multipliers fall back to neutral, so a theme states only what makes it different:

```ts
import { engine } from "@inklu/audio";

engine.registerTheme("sharp", { attackMultiplier: 0.4, durationMultiplier: 0.6 });
engine.setTheme("sharp");
```

Beyond the multipliers, a theme can set `frequencyMultiplier` (every frequency: pitches, cutoffs and noise bands, 0.25–4), `bump` (0–1), `room` (a reverb send for every sound, 0–0.5), `attackFloor` (a minimum attack in seconds, up to 0.05) and `soundsFrom` (a theme whose re-authored sounds it plays, e.g. `"soft"`). Values outside those ranges are clamped.

Registered names work everywhere a built-in does — `setTheme`, `configure({ theme })` and `data-sound-theme="sharp"`. Registering the active theme's name again updates it in place. `defineTheme(overrides)` returns the fully resolved profile if you want to inspect it.

Where a theme's multipliers can't get close enough, `engine.registerThemeSound(theme, name, def)` swaps in a different definition entirely for that theme.

## Design notes

**Reverb is per-sound, not a bus send.** A send on the master bus puts a tail behind everything, including `tap` and `hover`, which fire constantly. Only sounds that ring declare one:

```ts
engine.registerSound("chime", {
	reverb: { mix: 0.12 }, // omit entirely for a completely dry sound
	layers: [
		{
			frequency: 880,
			fm: { ratio: 2.76, depth: 300 },
			envelope: { decay: 0.4, release: 0.18 },
			gain: 0.16,
		},
	],
});
```

**Master signal chain.** Voices run into a shared bus, with a per-sound send into the shared reverb, then through a rumble filter, soft saturation (only in themes that use it), a fast limiter and finally the user's volume:

```
voices ─┬───────────────────────────────────────→ bus input
        └→ [per-sound send] → HPF → LPF → conv ──┘

bus input → rumble HPF → saturation → limiter → output gain → speakers
```

Every stage before the output gain is level-independent, so the volume slider changes loudness _only_ — the tone and the amount of saturation are identical at every setting.

- A single `AudioContext` and master bus are reused across every call. After 20 seconds of silence the context suspends to release the audio device, and resumes on the next `play()`.
- A `DynamicsCompressorNode` configured as a fast limiter (hard knee, 12:1 above -3 dB) catches layered and overlapping sounds without being audible on single quiet tones. Themes without saturation take the shaper out of the path entirely, so nothing is clamped before the limiter.
- A 28 Hz highpass removes the sub-audible energy that fast envelopes on low sweep layers put below the audible band, where it only ate headroom.
- Envelopes decay with `setTargetAtTime` — a true RC curve, the way a physical resonance actually falls away. An `exponentialRampToValueAtTime` is forced to arrive at its target at an exact time, and the steep final stretch that requires is what makes a short sound read as clipped rather than as ringing down. Sources are held open well past the envelope so the asymptotic tail is inaudible before teardown.
- A short synthesized impulse response (no bundled IR file) backs the reverb. Its length scales with the theme's `reverbMultiplier`, which also scales each sound's declared `mix`.
- Noise buffers are generated once per context from a fixed seed, and each layer reads its own fixed slice of them: no per-trigger allocation, and a sound comes out at the same level on every play. (A few milliseconds of noise through a narrow band varies by several dB from one random slice to the next.)
- Each trigger is micro-varied in pitch (±7 cents), gain (±6%) and timing. Pitch moves noise bands too, so noise-only sounds vary like pitched ones. Identical repetition is what makes synthesized clicks read as "a machine gun" rather than as ten key presses; themes control the amount via `humanize`.

## Cleanup

`engine.dispose()` closes the `AudioContext` and drops every cached buffer. The next `play()` rebuilds everything, so this is safe to call from an SPA teardown or a test's `afterEach`.

## License

MIT
