Loading sounds
The patterns for getting audio off the network and into the engine — single, bulk, with progress, with cancellation.
Single sound
await engine.loadSound('coin', '/sfx/coin.webm', { bus: 'sfx' }); Codec ladder
Pass an array; the engine picks the first URL the browser can decode. See the asset-formats guide for the encoding pipeline.
await engine.loadSound('coin', [
'/sfx/coin.webm',
'/sfx/coin.m4a',
], { bus: 'sfx' }); Bulk load with Promise.all
The Decoder is concurrent-safe; firing N requests in parallel
is the right move. Network is almost always the bottleneck, not decoding.
const manifest = [
{ name: 'coin', urls: ['/sfx/coin.webm', '/sfx/coin.m4a'], bus: 'sfx' },
{ name: 'win', urls: ['/sfx/win.webm', '/sfx/win.m4a'], bus: 'sfx' },
{ name: 'reel', urls: ['/sfx/reel.webm', '/sfx/reel.m4a'], bus: 'sfx' },
{ name: 'bg-music', urls: ['/music/bg.webm', '/music/bg.m4a'], bus: 'music' },
];
await Promise.all(
manifest.map(({ name, urls, bus }) =>
engine.loadSound(name, urls, { bus }),
),
); Progress reporting
The DIY pattern with Promise.all works for small batches.
let loaded = 0;
const total = manifest.length;
await Promise.all(
manifest.map(async (entry) => {
await engine.loadSound(entry.name, entry.urls, { bus: entry.bus });
loaded++;
onProgress(loaded / total); // 0..1
}),
);
First-class preloader: engine.preload
For loading screens that ship a fixed manifest, engine.preload
handles the boilerplate: per-item progress, a concurrency cap so the rest
of the page's network isn't starved, and aggregated failure reporting that
doesn't short-circuit on the first broken asset. Items match the
loadSound shape one-for-one.
// engine.preload(items) — bulk-load with progress + concurrency cap.
await engine.preload(
[
{ name: 'coin', url: ['/sfx/coin.webm', '/sfx/coin.m4a'], options: { bus: 'sfx' } },
{ name: 'win', url: ['/sfx/win.webm', '/sfx/win.m4a'], options: { bus: 'sfx' } },
{ name: 'reel', url: ['/sfx/reel.webm', '/sfx/reel.m4a'], options: { bus: 'sfx' } },
// ... 100 more items
],
{
concurrency: 4, // default — caps in-flight fetches
onProgress: ({ name, status, completed, total }) => {
bar.value = completed / total;
if (status === 'failed') log.warn(`${name} failed`);
},
},
);
// On failure: PreloadError with .failures: { name, cause }[]
// — the rest of the batch still completes; broken assets don't short-circuit
// the loading screen. Cancelling a preload
Pass an AbortSignal in options. The same
convention as a single loadSound call — pending items aren't
started, in-flight fetches receive the abort.
// Cancellable preload — same shape as loadSound's signal.
const ac = new AbortController();
button.addEventListener('click', () => ac.abort());
try {
await engine.preload(items, { signal: ac.signal });
} catch (e) {
if ((e as Error).name === 'AbortError') console.log('user cancelled');
else throw e;
} Cancellable single loads
Pass an AbortSignal. The fetch is cancelled mid-flight; in-progress
decodes complete (Web Audio's decodeAudioData is not abortable).
const ac = new AbortController();
try {
await Promise.all(
manifest.map((m) => engine.loadSound(m.name, m.urls, { bus: m.bus, signal: ac.signal })),
);
} catch (e) {
if ((e as Error).name === 'AbortError') console.log('cancelled');
else throw e;
}
// On route change:
ac.abort(); Long media — stream, don't decode
For tracks longer than ~30 seconds, use loadStream instead of
loadSound. The asset is read off disk progressively through an
internal HTMLAudioElement + MediaElementAudioSource,
so RAM stays flat and iOS Safari stops choking.
// Long media — use loadStream so a 4-minute mix doesn't decode into RAM.
const intro = engine.loadStream('intro', '/music/intro.m4a', { bus: 'music' });
await intro.play({ loop: true, volume: 0.6 });
intro.fade({ to: 0, duration: 1.5 }); Sprites — bundle short SFX
For UI variants and cascade SFX that share a session, a single buffer with
named regions is faster to load and lower-overhead per voice than N
separate loadSound calls.
// Sprite — share one buffer across N regions.
await engine.loadSprite('reel', '/sfx/reel.webm', {
tick: { start: 0, duration: 0.06 },
stop: { start: 0.1, duration: 0.18 },
'win-sting': { start: 0.5, duration: 1.2 },
}, { bus: 'sfx' }); Variants — randomise stacked SFX
Slot games play coin/win/reel-stop variants on every interaction. A
pure random picker can play the same one twice in a row, which sounds
robotic. engine.loadVariants bundles N alternates and
picks one per play() using a configurable strategy:
'random', 'no-repeat' (default — never the
same as the previous pick), or 'shuffle-bag' (Tetris-style
cycle through every variant before reshuffling).
// Variants — N alternate one-shots; spam picks one each play() so
// stacked SFX don't sound robotic.
await engine.loadVariants(
'coin',
[
['/sfx/coin-1.webm', '/sfx/coin-1.m4a'],
['/sfx/coin-2.webm', '/sfx/coin-2.m4a'],
['/sfx/coin-3.webm', '/sfx/coin-3.m4a'],
],
{ bus: 'sfx', strategy: 'no-repeat' }, // 'random' | 'no-repeat' | 'shuffle-bag'
);
engine.variants('coin').play();
engine.variants('coin').play({ pitch: { jitter: 0.04 } }); Unloading sounds
removeSound(name) drops the registry entry. The buffer
stays in the internal LRU cache so a re-load is free. Use
unloadSound(name) (default evictBuffer: true)
when the user is unlikely to revisit the asset — the canonical
hot-swap-themes use case.
// Drop a sound from the registry AND evict its buffer from the LRU.
engine.unloadSound('coin'); // default: evictBuffer: true
engine.unloadSound('coin', { evictBuffer: false }); // registry only
// Active voices keep playing until they end naturally — only future
// play() calls are affected. Use this for hot-swappable themes
// (kiosks, slot lobbies). Loudness normalization on load
// Normalize on load — uniform perceived loudness without re-mastering.
await engine.loadSound('crowd', '/sfx/crowd.webm', {
bus: 'sfx',
normalize: true,
}); Typed banks via npx zvuk gen
Keep your sound names in a manifest, run the CLI, get a typed loader back.
engine.sound("coin") autocompletes against the literal union.
// CLI: zvuk gen bank.json → bank.gen.ts (typed loader).
import { loadBank } from './bank.gen';
await loadBank(engine); Cache & memory
Decoded buffers are cached by URL inside the engine. Loading the same URL twice is free; loading 130+ unique URLs evicts the least-recently-used entries (configurable via the internal Decoder limit).
Pitfalls
loadSound calls
touch() to construct the context lazily — but on iOS Safari,
decodeAudioData can hang on a suspended context. Always
await unlock() first, ideally on the same gesture that
triggers the load.