Guide

Runtime timing

How zvuk schedules JS callbacks, why it doesn't run a parallel rAF, and when to inject your own ticker.

The two clocks

zvuk has two distinct timing concerns:

  • Audio time — what the Web Audio thread does. Sample- accurate, runs independently of the main thread, unaffected by tab blur. Anything stamped onto an AudioParam (setValueAtTime, linearRampToValueAtTime, source.start(when)) is dispatched here. zvuk's fades, ramps, crossfades, and click-free stops all schedule directly on the audio thread.
  • JS dispatch time — when the main thread fires user callbacks (engine.scheduleAt(t, fn), region-end callbacks, end-of-fade resolutions). This depends on a JavaScript timer, which is what this guide is about.

Default: setTimeout, explicitly

Without a tickSource, zvuk dispatches scheduled callbacks via setTimeout. We do not spin up an internal requestAnimationFrame loop — running a parallel rAF in a library that's likely embedded in a host that already has one is a waste, and rAF doesn't actually fix the visibility problem (more on that below).

ts ts
// No tickSource — scheduler dispatches via setTimeout (default).
const engine = createEngine({ buses: { sfx: {} } });
engine.scheduleAt(engine.now + 1, () => playStinger());

The trade-off you're accepting with the default:

  • Browsers throttle setTimeout on hidden tabs to roughly 1 Hz. Callbacks scheduled to fire while the tab is hidden will land late.
  • Your audio doesn't glitch. Audio playback runs on the Web Audio thread, which keeps going regardless of the JS event loop. As long as you stamp ramps and source starts directly with audio time, they fire sample-accurately. The only thing that lags is the JS-side confirmation callback.
  • When the tab regains focus, the scheduler sweeps every overdue task on its next tick. No tasks are lost.

Inject a host ticker for frame-aligned dispatch

If your host (Pixi, GSAP, a custom render loop) is already running an rAF on every frame, hand it to zvuk. The scheduler subscribes lazily — only while there are pending tasks — so your 60 Hz loop isn't waking the scheduler 60 times a second to do nothing.

Pixi v8

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

const app = new Application();
await app.init({ /* ... */ });

const tickSource: TickSource = {
  subscribe(handler) {
    app.ticker.add(handler);
    return () => app.ticker.remove(handler);
  },
};

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

GSAP

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

const tickSource: TickSource = {
  subscribe(handler) {
    gsap.ticker.add(handler);
    return () => gsap.ticker.remove(handler);
  },
};

const engine = createEngine({ tickSource });

Standalone requestAnimationFrame

ts ts
// If you have no host loop and want frame-aligned dispatch anyway,
// you can roll your own rAF subscriber. Be aware: rAF pauses entirely
// when the tab is hidden — setTimeout (the default) at least throttles
// to ~1 Hz, so it still fires occasionally.
const tickSource: TickSource = {
  subscribe(handler) {
    let id: number;
    const loop = () => {
      handler();
      id = requestAnimationFrame(loop);
    };
    id = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(id);
  },
};

A TickSource is just any object that exposes a subscribe(handler) → unsubscribe shape. Anything you can add a callback to and remove it from is a valid source.

Pause on tab hide

By default, zvuk listens for visibilitychange and suspends the AudioContext while the tab is hidden, then resumes automatically on return. This is also the iOS Safari reliability workaround for suspension-on-blur. While suspended, audio time stops advancing — so scheduled callbacks pause right along with the audio they were paired with.

ts ts
// Default — suspend on tab hide, resume on return. Recommended for
// most apps; also part of the iOS Safari reliability story.
createEngine({ buses: { sfx: {} } });

// Music players that should keep playing across tab switches:
createEngine({ buses: { music: {} }, autoPauseOnHidden: false });

Set autoPauseOnHidden: false if you're building a background music player and want playback to continue across tab switches.

Picking a strategy

Use caseRecommendation
Game with Pixi / Phaser / a render loop Inject the host's ticker. Audio dispatch lines up with frames.
Slot machine, casino, casual game Same — inject the host ticker for crisp wheel/reel sync.
Music player, background-audio app Default setTimeout. Set autoPauseOnHidden: false.
Embedded widget on a page (single SFX, simple UI) Default setTimeout. Don't add the rAF complexity unless you have a reason.
You need sample-accurate sample triggers Stamp AudioParam values directly with the audioTime you scheduled against (close over it — the callback isn't passed it). The ticker only affects when your JS confirmation runs, not when the audio fires.

Pitfalls

Don't run zvuk's scheduler from a second rAF.
If you already have a render loop, inject that as the tickSource. Spinning a second requestAnimationFrame loop in parallel buys you nothing and burns frames.
rAF does not fix tab-blur lag.
Hidden tabs throttle setTimeout to ~1 Hz; they pause requestAnimationFrame entirely (0 Hz). For audio that must be sample-accurate through blur, schedule on AudioParam directly — that's the only path that runs on the audio thread independent of the main thread.

Related

  • Voice — fade / stop / cues fire on these timers.
  • EnginescheduleAt API.