# Engine · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/concepts/engine/

The root object. Owns the AudioContext, the bus graph, the scheduler, and every loaded sound.

Concept

# Engine

The root object. Owns the AudioContext, the bus graph, the scheduler, and every loaded sound.

Constructing an `AudioContext` at module load used to be normal. Then browsers made autoplay a permission, and now the context you built before the user touched anything starts suspended, stays suspended, and swallows every sound you play through it.

The engine is the root object and the only one you construct directly. Buses, sounds, voices, parameters and snapshots are all reached through it. It holds the `AudioContext`, the bus graph, the scheduler, and every loaded asset. The context is created on first use rather than in the constructor, so `createEngine()` is safe to call before any user interaction. See [`Engine`](/api/Engine/) for the full surface.

## How do I unlock audio on iOS Safari?

Call `await engine.unlock()` from inside a real user gesture: a click, a tap, a keydown. It is idempotent, and repeated calls return the same in-flight promise, so wiring it to several handlers is fine. Visibility and focus changes suspend and resume the context on their own afterwards.

## State machine

Six states, one terminal transition. `suspended` covers a tab that went to the background. `interrupted` is the iOS state for a phone call or Siri taking the audio session, which `resume()` cannot recover from on its own.

Engine lifecycle. unlock() is idempotent and returns the same in-flight promise.

## Signal flow

Each `play()` call attaches a voice to its bus's input node. Voices feed through the bus FX chain if there is one, out of the bus output, into master, and on to `ctx.destination`. Everything else in the library is a variation on this graph.

signal flow

## API surface

The Engine interface ts

```
interface Engine {
  readonly state: 'cold' | 'unlocking' | 'live' | 'interrupted' | 'closed';
  readonly now: number;
  readonly context: AudioContext;

  unlock(): Promise<void>;
  close(): Promise<void>;

  loadSound(name: string, url: string | readonly string[], options?: LoadSoundOptions): Promise<Sound>;
  loadSprite(name: string, url, regions: SpriteMap, options?): Promise<Sprite>;
  loadStream(name: string, url: string | readonly string[], options?): StreamSound;
  hasSound(name: string): boolean;
  hasSprite(name: string): boolean;
  hasStream(name: string): boolean;

  sound(name: string): Sound;            // throws SoundNotFoundError ("did you mean…")
  sprite(name: string): Sprite;
  stream(name: string): StreamSound;
  bus(name: string): Bus;                // throws BusNotFoundError ("did you mean…")

  crossfade(from: string, to: string, opts?: CrossfadeOptions): Voice;
  scheduleAt(audioTime: number, fn: () => void): () => void;

  parameter(name: string, initial?: number): Parameter;
  captureSnapshot(name: string): Snapshot;

  activeVoices(): readonly Voice[];
  onStateChange(fn: (s: EngineState) => void): () => void;
}
```

## Live demo

The dashboard below runs three buses, six samples in `.webm`/`.m4a` pairs, and a voice counter that updates as voices start and end.

engine.state = cold

0 voicesPop out

music

mute

level: 0.80

sfx

mute

level: 1.00

ui

mute

level: 0.70

chimemusicbellsmusicdice-rollsfxdice-shakesfxgemuiheartui

Each sample is shipped as `.webm` (Opus) +`.m4a` (AAC). The engine picks whichever your browser supports.

Unlock audio & load samples

## Recipes

### Build at module load, unlock on click

ts ts

```
import { createEngine } from '@schmooky/zvuk';

const engine = createEngine({
  buses: {
    music: { level: 0.8 },
    sfx:   { level: 1.0 },
    voice: { level: 1.0 },
  },
  master: { headroom: -3 },
});
```

ts ts

```
await engine.unlock();          // call from a user gesture
engine.state;                    // 'cold' | 'unlocking' | 'live' | 'interrupted' | 'closed'

await engine.loadSound('coin', '/sfx/coin.webm', { bus: 'sfx' });
engine.sound('coin').play();

await engine.close();            // terminal — construct a new engine if needed
```

### Watch state transitions

ts ts

```
const off = engine.onStateChange((s) => {
  if (s === 'live') console.log('audio is live; ctx time:', engine.now);
});
// later: off();
```

### Audio-clock scheduling

`scheduleAt` dispatches a callback against the audio clock. It runs on the main thread, so the drift is bounded by one tick, a few milliseconds. It is not sample-accurate. When you need that, stamp the Web Audio parameter itself with the audio time and let the audio thread do the work: `source.start(t)`, or `gain.linearRampToValueAtTime(v, t)`.

ts ts

```
const beat = engine.now + 0.25;          // 250 ms ahead in audio time
engine.scheduleAt(beat, () => engine.sound('downbeat').play());
```

### Audio sprites: one buffer, many regions

Sprites suit low-latency one-shots that can share a buffer. Cascades, UI variants, dialogue chunks. One fetch, one decode, and voices bound to a named region of the result.

ts ts

```
// One buffer, three regions — the cascade SFX share a single fetch + decode.
await engine.loadSprite('cascade', '/sfx/cascade.webm', {
  small:  { start: 0,    duration: 0.2 },
  medium: { start: 0.25, duration: 0.4 },
  big:    { start: 0.7,  duration: 0.6 },
}, { bus: 'sfx' });

engine.sprite('cascade').play('medium', { volume: { jitter: 0.05 } });
```

### Stream long media instead of decoding it

Past about thirty seconds, prefer `loadStream` over `loadSound`. Decoded audio costs four bytes per sample per channel, so three minutes of stereo at 48 kHz is roughly 70 MB of resident memory. A stream reads progressively through an `HTMLAudioElement`, which is also the path iOS Safari is happiest with.

ts ts

```
// Multi-minute music — don't decode a 4-min track into RAM.
const music = engine.loadStream('intro', '/music/intro.m4a', { bus: 'music' });
await music.play({ loop: true, volume: 0.6 });
await music.fade({ to: 0, duration: 1.5 });
music.stop();
```

### Crossfade music tracks in one call

Equal-power by default, so perceived loudness stays flat across the swap instead of dipping in the middle. Outgoing voices are matched by `sourceName`.

ts ts

```
// 1.5-second equal-power crossfade between two pre-loaded music tracks.
await engine.loadSound('intro', '/music/intro.webm', { bus: 'music', normalize: true });
await engine.loadSound('main',  '/music/main.webm',  { bus: 'music', normalize: true });

engine.sound('intro').play({ loop: true });
// Later — at the boss reveal.
engine.crossfade('intro', 'main', { duration: 1.5, loop: true });
```

bus outputidle

rainbirdsong

1500 ms · equal-power · streamed

Crossfade to rain

`engine.loadStream(...)` keeps both beds out of RAM; two `stream.fade({ curve: 'equal-power' })` legs sum to constant power, so the swap doesn't dip in the middle.

Unlock & start rain

### "Did you mean…" lookups

A misspelled bus or sound name produces an error carrying the closest declared name, by Levenshtein distance. It is a small thing that saves an afternoon roughly once per project.

ts ts

```
// Typo? The error tells you what you meant.
try {
  engine.bus('sxf');
} catch (e) {
  // BusNotFoundError: Bus "sxf"; did you mean "sfx" is not configured. ...
}
```

### Tear down on route change

ts ts

```
// React example
useEffect(() => () => { void engine.close(); }, []);

// Vue example
onBeforeUnmount(() => { void engine.close(); });

// Plain SPA
router.beforeEach(async () => { await engine.close(); });
```

## Pitfalls

Don't construct the engine inside a click handler.

Construct it at module load. `createEngine` is cheap; only `unlock()` needs a user gesture.

Don't call play() before unlock.

On iOS Safari the AudioContext starts `suspended`. Sounds will be silently dropped. Always `await engine.unlock()` first.

Don't reuse a closed engine.

`close()` is terminal. Construct a fresh one if you need audio again.

## Related

-   [Mixer](/concepts/mixer/) is the bus graph rooted at the engine.
-   [Bus](/concepts/bus/) covers routing, level, mute and fade.
-   [Voice](/concepts/voice/) is what `play()` returns.
