Concept

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

2D — pan: -1 ↔ 1 L R -1 0 +1 voice @ 0.43 3D — position: [x, y, z] · listener at origin listener A B C
2D pan is cheap (StereoPannerNode). 3D uses PannerNode in HRTF mode, one node per voice.

API surface

Spatializer + SpatialOptions ts
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()

ts ts
engine.sound('footstep').play({
  spatializer: { pan: -0.6 },              // [-1, 1] stereo
});

3D position from play()

ts ts
engine.sound('footstep').play({
  spatializer: { position: [x, y, z] },    // world-space coords
});

Dynamic position — moving source

ts ts
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.

ts ts
// 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'.

ts ts
// 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.

ts ts
// 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

Don't 3D-spatialize music.
Music wants to feel wide, not located. HRTF on a stereo music bus is an audible downgrade. Pan it manually if you want a side bias.
Don't construct a Spatializer per frame.
HRTF nodes are cheap but not free. Hold one per emitter and call setPosition on it.

Related

  • Voice is what gets spatialized.
  • Bus is what a spatializer routes into.