Guide

Loading sounds

The patterns for getting audio off the network and into the engine — single, bulk, with progress, with cancellation.

Single sound

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

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

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

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

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

ts ts
// 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).

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

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

ts ts
// 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).

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

ts ts
// 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

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

ts ts
// 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

Don't load before unlock.
The decoder needs a live AudioContext. 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.