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.
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.
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 dashboard below runs three buses, six samples in
.webm/.m4a pairs, and a voice counter that
updates as voices start and end.
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 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).
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.
// 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.
// 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.
// 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.
// 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.