# Spatializer · zvuk

zvuk v1.14.1 · MIT · https://zvuk.schmooky.dev/concepts/spatializer/

Stereo pan or full 3D positional audio. PannerNode wrapped, HRTF on by default.

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`](/api/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 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.

Use your own sound

sfx bus · L | Ridle

spatializer.pan0.00

L

R

Drag with mouse, touch, or stylus — pointer events + pointer capture handle all three. The Voice is held in a ref and panned via `voice.spatializer.setPan(next)`.

Unlock & start

## 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](/concepts/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](/concepts/voice/) is what gets spatialized.
-   [Bus](/concepts/bus/) is what a spatializer routes into.
