Concept

Engine

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

TL;DR

The Engine is the only object you construct directly. Everything else (Bus, Sound, Voice, Parameter, Snapshot) is reached through it. Lifecycle is explicit — the underlying AudioContext is created on first use, never in the constructor — so createEngine() is safe to call before any user interaction.

State machine

Five states (including interrupted, used on iOS phone-call / Siri interruptions), one terminal transition. unlock() is the only thing you call manually; visibility/focus changes auto-suspend and auto-resume the context behind the scenes.

cold no ctx unlocking resume()… live audible closed terminal unlock() resolved close() close() at any time
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 bus FX (if any), out the bus's output, into the master, and onward to ctx.destination. Everything else is variation on this graph.

signal flow
Source BufferSource Voice gain jitter, fade BUS input FX chain output Master headroom Speakers ctx.destination

API surface

Engine — public 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 mixer dashboard below runs a real Engine with three buses, six pre-loaded samples in .webm/.m4a pairs, and a live voice counter.

engine.state = cold
0 voices

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 timing is within a tick (a few ms) — not sample-accurate. For sample-accurate playback, stamp Web Audio parameters (e.g. source.start(t)) with the audio time directly.

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

Use sprites for low-latency one-shots that share a buffer: cascades, UI variants, dialogue chunks. One fetch, one decode, region-bound voices.

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

For tracks > 30s, prefer loadStream over loadSound. It uses an internal HTMLAudioElement + MediaElementAudioSource, so the asset is read off disk progressively — and iOS Safari stops choking on it.

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 the perceived loudness stays flat across the swap. 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 });

"Did you mean…" lookups

Misspelled bus or sound names produce errors that include the closest declared name (Levenshtein) — so the typo doesn't waste an afternoon.

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 — the bus graph rooted at the engine.
  • Bus — routing, level, mute, fade.
  • Voice — what play() returns.