Guide

Asset resolution

Adopt buffers from Pixi assetpack, IndexedDB, manifests, or any asset system — without coupling zvuk to it.

Why a resolver?

Most apps already have an asset system: Pixi's Assets.cache, a manifest-driven loader, an IndexedDB persistence layer, a build-time inliner. Forcing zvuk to also fetch and decode the same audio file means double the download, double the RAM, and weird race conditions on the loading screen.

resolveAsset is the seam: a single function that maps an asset name to a buffer (or URL) drawn from whatever system you already run. zvuk consults it before any fetch — so you can build a Pixi adapter, an IndexedDB cache, or a manifest layer without zvuk depending on any of them.

API shape

ts ts
import { createEngine, type AssetResolver } from '@schmooky/zvuk';

const resolveAsset: AssetResolver = ({ name, url, signal }) => {
  // Return one of:
  //   AudioBuffer    — used as-is, no decode
  //   ArrayBuffer    — decoded via the engine's AudioContext
  //   string         — treated as a URL, fetched + decoded normally
  //   undefined/null — explicit miss; falls through to the URL list
};

const engine = createEngine({
  buses: { sfx: {}, music: {} },
  resolveAsset,
});

Returning undefined or null falls through to the URL list passed to loadSound. That means you can seamlessly mix cached and uncached sounds without branching at the call site — the resolver returns what it has, the URL fetch handles the rest.

Recipe — Pixi v8 + assetpack

The integration zvuk is mainly designed around. Bundle audio with your Pixi assetpack manifest, load it as part of the normal preload flow, and have zvuk pull buffers straight out of the Pixi cache.

ts ts
import { Application, Assets } from 'pixi.js';
import { createEngine, type AssetResolver } from '@schmooky/zvuk';

// In your assetpack manifest (assetpack.config.ts), make sure the audio
// pipeline is configured so .webm/.m4a files end up as ArrayBuffer / Blob
// in Pixi's cache. Then load them as part of your normal Pixi bundle:
const app = new Application();
await app.init({ /* ... */ });

await Assets.init({ manifest: '/manifest.json' });
await Assets.loadBundle('game-audio');   // populates Assets.cache

// Pixi v8 Assets.cache returns whatever the pipeline produced. For an
// audio file with no special loader, that's typically an ArrayBuffer or
// a Blob. Adapt to ArrayBuffer here:
const resolveAsset: AssetResolver = async ({ name }) => {
  const asset = Assets.cache.get(name);
  if (!asset) return undefined;                 // miss → URL fetch

  if (asset instanceof ArrayBuffer) return asset;
  if (asset instanceof Blob) return await asset.arrayBuffer();
  if (typeof asset === 'string') return asset;  // URL string in the cache
  return undefined;
};

const engine = createEngine({
  buses: { sfx: {}, music: {} },
  resolveAsset,
});

// loadSound now consults the Pixi cache first; if nothing is there, it
// falls through to the URL fetch — handy for audio added at runtime.
await engine.loadSound('coin', '/sfx/coin.webm', { bus: 'sfx' });
await engine.loadSound('win',  '/sfx/win.webm',  { bus: 'sfx' });

A real example app is shipped separately with slotplate. Your existing Pixi loading-screen progress bar continues to drive the audio download too — there's no second progress to wire up.

Recipe — IndexedDB persistent cache

For apps that load the same audio set repeatedly (slot machines, casino games, kiosk experiences), persisting decoded bytes in IndexedDB cuts cold-start time on returning users. The resolver fetches the first time, then hydrates from the DB on subsequent loads.

ts ts
import { createEngine, type AssetResolver } from '@schmooky/zvuk';

// Persistent offline cache: store decoded buffers in IndexedDB the first
// time you fetch them, hydrate from the DB on subsequent loads. Slot
// machines and casino apps that ship a fixed audio set get a noticeable
// cold-start improvement.

const DB_NAME = 'zvuk-audio';
const STORE   = 'buffers';

async function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE);
    req.onsuccess = () => resolve(req.result);
    req.onerror   = () => reject(req.error);
  });
}

async function dbGet(name: string): Promise<ArrayBuffer | undefined> {
  const db = await openDb();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readonly').objectStore(STORE).get(name);
    tx.onsuccess = () => resolve(tx.result);
    tx.onerror   = () => reject(tx.error);
  });
}

async function dbPut(name: string, bytes: ArrayBuffer): Promise<void> {
  const db = await openDb();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite').objectStore(STORE).put(bytes, name);
    tx.onsuccess = () => resolve();
    tx.onerror   = () => reject(tx.error);
  });
}

const resolveAsset: AssetResolver = async ({ name, url, signal }) => {
  const cached = await dbGet(name);
  if (cached) return cached;

  // Miss — fetch ourselves so we can persist before handing to zvuk.
  const single = typeof url === 'string' ? url : url[0];
  if (!single) return undefined;
  const res = await fetch(single, { signal });
  if (!res.ok) return undefined;          // let zvuk's loader try the ladder
  const bytes = await res.arrayBuffer();
  await dbPut(name, bytes);
  return bytes;
};

const engine = createEngine({ buses: { sfx: {} }, resolveAsset });

Recipe — in-memory cache

If you want full control over eviction or you're injecting buffers from a service worker / build-time inliner, a plain Map is enough.

ts ts
import { createEngine, type AssetResolver } from '@schmooky/zvuk';

// Tiny manual cache — useful when you want full control over eviction or
// you're hydrating from a service worker / build-time inlined buffer.
const cache = new Map<string, AudioBuffer>();

export function preload(name: string, buffer: AudioBuffer): void {
  cache.set(name, buffer);
}

const resolveAsset: AssetResolver = ({ name }) => cache.get(name);

const engine = createEngine({ buses: { sfx: {} }, resolveAsset });

Recipe — manifest-driven URLs

Ship one JSON that maps logical names to versioned (hash-busted) URLs, and let the resolver pick the right URL given a name. Call sites stay terse — they don't need to know the actual file path.

ts ts
import { createEngine, type AssetResolver } from '@schmooky/zvuk';

// Manifest-driven loading: ship a single JSON that maps logical names to
// versioned URLs (cache-busting hashes), so engine.loadSound('coin') just
// works without the call site knowing the URL.
type Manifest = Record<string, string[]>;
const manifest: Manifest = await fetch('/audio.manifest.json').then(r => r.json());

const resolveAsset: AssetResolver = ({ name, url }) => {
  const urls = manifest[name];
  if (urls && urls.length > 0) {
    // Return the manifest URL list as-is (zvuk runs it through the codec
    // ladder and falls back across entries on its own). Returning the
    // first one is also fine — codec preferences win either way.
    return urls[0];
  }
  // Manifest miss — fall through to whatever URL the call passed in.
  return undefined;
};

const engine = createEngine({ buses: { sfx: {} }, resolveAsset });

// Call sites can be terse — the URL is just a fallback / type stub:
await engine.loadSound('coin', '');

Pitfalls

Resolver miss is not the same as a load failure.
Returning undefined/null falls through to the URL list. If the URL list also fails, then the load throws — a miss is just "I don't have this, try the network." If you actually want to fail fast when the asset isn't in your cache, return a string URL that you know doesn't exist, or wrap loadSound in a pre-check.
Streams use a different path.
resolveAsset applies to loadSound and loadSprite. loadStream is HTMLAudioElement- backed and doesn't decode buffers, so it doesn't run through the resolver. If your manifest covers streams too, point them at URLs and let loadStream consume those directly.