Spatializer
Stereo pan or full 3D positional audio. PannerNode wrapped, HRTF on by default.
TL;DR
Pass spatializer in play() to insert a
StereoPannerNode (2D) or PannerNode (3D HRTF)
between the voice and its bus. For positions that change over time —
e.g. a moving NPC — construct a Spatializer manually so
you can hold its reference and update it per frame.
Mental model
API surface
interface SpatialOptions {
pan?: number; // [-1, 1] — 2D
position?: [number, number, number]; // x, y, z — 3D
}
class Spatializer {
setPan(pan: number): void; // 2D only
setPosition(x: number, y: number, z: number): void; // 3D only
connectInto(dest: AudioNode): AudioNode;
dispose(): void;
} Live demo
Drag the puck to pan a looping sound. Best with headphones.
Recipes
2D pan from play()
engine.sound('footstep').play({
spatializer: { pan: -0.6 }, // [-1, 1] stereo
}); 3D position from play()
engine.sound('footstep').play({
spatializer: { position: [x, y, z] }, // world-space coords
}); Dynamic position — moving source
import { Spatializer } from '@schmooky/zvuk';
const sp = new Spatializer(engine.context, { position: [0, 0, 0] });
// connectInto() wires the spatializer to the bus and RETURNS the node your
// source should feed into — route your source there, or you'll get silence.
const input = sp.connectInto(engine.bus('sfx').input);
mySourceNode.connect(input);
sp.setPosition(playerX, playerY, playerZ); // each frame
// when done:
sp.dispose(); Live binding from the spawned Voice
Spawning with spatializer now stores the node on the
returned voice. No more re-attaching, no separate ref to manage.
// The spawned voice exposes its Spatializer for live steering.
const v = engine.sound('engine').play({
loop: true,
spatializer: { position: [0, 0, 0] },
});
requestAnimationFrame(function tick() {
if (v.spatializer) v.spatializer.setPosition(player.x, 0, player.z);
requestAnimationFrame(tick);
});
// 2D — same shape with setPan.
const swarm = engine.sound('bee').play({ spatializer: { pan: 0 } });
swarm.spatializer?.setPan(-0.6); 3D distance config
Previously hard-coded; now exposed both as construction options and
live setters. refDistance is the no-attenuation radius;
maxDistance anchors the rolloff curve;
rolloffFactor scales how aggressively distance bites;
distanceModel picks the curve shape
('inverse', 'linear', or 'exponential').
// 3D config exposed: tune the distance-attenuation curve to your scene.
engine.sound('engine').play({
spatializer: {
position: [10, 0, 0],
refDistance: 5, // full volume within 5 units of the listener
maxDistance: 250, // 'linear' model reaches zero here
rolloffFactor: 1.5, // steeper than the natural rolloff
distanceModel: 'inverse', // 'linear' | 'inverse' | 'exponential'
},
});
// Live setters for moving sources / dynamic environments:
v.spatializer?.setRefDistance(8);
v.spatializer?.setMaxDistance(500);
v.spatializer?.setRolloffFactor(2);
v.spatializer?.setDistanceModel('linear'); Occlusion — "behind a wall"
A single 0..1 knob driving an internal lowpass filter (cutoff sweeps
log-style from 22050 Hz to ~500 Hz) plus a small gain dip
(up to -6 dB at amount = 1). The standard shape of a sound that's
obstructed — bind it to a Parameter and you get a single
handle for whole-room occlusion changes.
// Occlusion — single 0..1 knob driving an internal lowpass + small gain dip.
// 0 = clear; 1 = behind a wall (cutoff sweeps to ~500 Hz, level drops -6 dB).
engine.sound('boss-roar').play({
spatializer: { position: [50, 0, 0], occlusion: 0 },
});
// Drive it from a Parameter so a single knob can occlude every relevant voice.
const occlusion = engine.parameter('boss-occlusion', 0);
v.spatializer && occlusion.bindTo((amount) => v.spatializer!.setOcclusion(amount));
occlusion.set(0.7); // boss is now mostly muffled
// Combine with distance for a "behind a wall, far away" feel — bind the
// same parameter to both setOcclusion() and a position update. Pitfalls
setPosition it.