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.
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.
API surface
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.
Recipes
Build at module load, unlock on click
import { createEngine } from '@schmooky/zvuk';
const engine = createEngine({
buses: {
music: { level: 0.8 },
sfx: { level: 1.0 },
voice: { level: 1.0 },
},
master: { headroom: -3 },
}); 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
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.
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.
// 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.
// 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.
// 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.
// 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
// React example
useEffect(() => () => { void engine.close(); }, []);
// Vue example
onBeforeUnmount(() => { void engine.close(); });
// Plain SPA
router.beforeEach(async () => { await engine.close(); }); Pitfalls
createEngine is cheap; only
unlock() needs a user gesture.
suspended. Sounds will be silently
dropped. Always await engine.unlock() first.
close() is terminal. Construct a fresh one if you need audio again.