Spatializer
Stereo pan or full 3D positional audio. PannerNode wrapped, HRTF on by default.
A footstep behind you and a footstep in front of you are the same sample. What tells them apart is where the engine puts it, and the browser will do that for you if you hand it a position.
A Spatializer sits between one
voice and its bus, as either a StereoPannerNode for a flat
left-right pan or a PannerNode in HRTF mode for a position in
3D. Pass spatializer to play() for a fixed
placement. For a source that moves, hold the object 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 stores the node on the returned
voice, so there is nothing to re-attach and no second reference to keep
in sync.
// 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
Four values, available both as construction options and as live setters.
refDistance is the radius inside which nothing attenuates.
maxDistance anchors the far end of the rolloff curve.
rolloffFactor scales how hard distance bites.
distanceModel picks the curve shape, one of
'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"
One knob from 0 to 1, driving an internal lowpass whose cutoff sweeps logarithmically from 22050 Hz down to about 500 Hz, plus a gain dip reaching -6 dB at full amount. That is roughly what a wall does to a sound. Bind it to a Parameter and one handle moves the whole room.
// 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 on it.