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).
// 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
setTimeouton hidden tabs to roughly1 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
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
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
// 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.
// 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 case | Recommendation |
|---|---|
| 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
tickSource. Spinning a second requestAnimationFrame
loop in parallel buys you nothing and burns frames.
rAF does not fix tab-blur lag.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.