# Loading sounds · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/guides/loading/

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

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](/guides/asset-formats/) 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 a loading screen with a fixed manifest, `engine.preload` handles the boilerplate. It reports per-item progress, caps concurrency so the rest of the page's network isn't starved, and aggregates failures rather than short-circuiting 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 are never started and 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'` is uniform. `'no-repeat'`, the default, never picks the same one twice running. `'shuffle-bag'` cycles through every variant before reshuffling, the way Tetris deals pieces.

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

Four takes of one dice roll, below. Set the strategy to `'random'` and mash the button: the same take comes up twice in a row often enough to sound like a bug, and the history marks each repeat in red. `'no-repeat'` makes that impossible. `engine.variants(name).lastPick` is where the history comes from, and it is also how you drive a subtitle or a telemetry event off whichever take actually fired.

sfx busidle

randomno-repeatshuffle-bag

Uniform pick. Repeats happen, and they sound like a bug.

Roll the dice

last 14 picks

nothing yet

Unlock & load takes

## 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)`, which evicts by default, when the user is unlikely to revisit the asset. Hot-swapping themes is the canonical 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.
