Concept

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

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 now stores the node on the returned voice. No more re-attaching, no separate ref to manage.

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

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').

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"

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.

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 a noticeable downgrade. Pan it manually if you want a side-bias.
Don't construct a Spatializer per frame.
HRTF nodes are cheap-ish but not free. Hold one per emitter and setPosition it.

Related

  • Voice — the spatialized voice.
  • Bus — what spatializers route into.