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

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 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
Source BufferSource Voice gain jitter, fade BUS input FX chain output Master headroom Speakers ctx.destination

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 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 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 });

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