Spatial audio in ExoJS is 2D: a single shared listener and any number of sound sources, each with a world-space position. The engine maps the 2D coordinates to the Web Audio API’s 3D panner behind the scenes — you work in the same pixel coordinates your sprites and containers use, and the audio system pans and attenuates accordingly.
The listener
The AudioListener is a singleton at app.audio.listener. Set its position directly or point it at a target that updates every frame:
// Static listener — audio doesn't moveapp.audio.listener.position.set(400, 300);// Dynamic listener — audio follows a scene nodeapp.audio.listener.target = this.player;// Dynamic listener — audio follows a plain object (useful with views)this.camera = { x: 0, y: 0 };app.audio.listener.target = this.camera;
When target is set, the listener reads the target’s x and y each frame automatically. Use target for cameras, player characters, or any moving viewpoint. Set position directly when the listener is fixed.
Valid target types are:
A SceneNode (Sprite, Container, etc.) — reads from its global transform
A View — reads from its center
A plain { x, y } object — reads the properties directly
null — stops auto-tracking
Sound sources
A Sound descriptor carries a position. When set, it seeds the spatial position of every voice played from that sound:
The audio pans left when the source is left of the listener, right when to the right. Volume attenuates with distance according to the configured distance model.
To move a source while it plays, drive the live Voice — spatial voices are Spatializable, exposing position and follow():
const voice = this.app.audio.play(pickup);// Move the live source each frame...voice.position = { x: item.x, y: item.y };// ...or have it track a scene node automatically.voice.follow(this.enemy); // reads the node's global position every framevoice.follow(null); // stop tracking, fall back to voice.position
Setting a voice’s position to null makes it non-spatial again — it plays at full volume in both channels regardless of listener position.
Distance models
Three attenuation curves control how volume drops with distance:
Model
Behavior
'linear'
Full volume at refDistance, linear falloff to silence at maxDistance. Simplest to reason about.
'inverse'
Full volume at refDistance, inverse-proportional falloff beyond. Natural-sounding drop-off.
'exponential'
Full volume at refDistance, exponential falloff beyond. Sharpest near-field drop-off.
Configure per-sound via constructor options or live setters:
import { Sound } from '@codexo/exojs';const ambient = new Sound(audioBuffer, { distanceModel: 'exponential', refDistance: 100, // pixels — full volume within this radius maxDistance: 800, // pixels — silence beyond this (linear only) rolloffFactor: 1.5, // steepness multiplier (all models)});ambient.position = { x: 0, y: 0 };this.app.audio.play(ambient, { loop: true });
refDistance (default 50) is the radius around the listener where the sound plays at full volume. maxDistance (default 1000) only applies to the linear model. rolloffFactor (default 1) scales the steepness — higher values make the sound drop off faster.
All four attenuation properties are live setters on the Sound descriptor; they apply to voices played afterwards:
The most common pattern is to set the listener’s target to the camera or player once in init, then assign a position to a spatial sound right before each one-shot play:
Spatial updates happen automatically — each frame the AudioManager updates the listener from its target and ticks every spatial Voice, writing the resolved position to the Web Audio PannerNode. You do not need to call any per-frame update on spatial sources: assign a position (on the descriptor before play, or on the live voice after), or call voice.follow(node), and the engine handles the rest.
Browser constraints
The Web Audio PannerNode maps to 3D space. ExoJS sets Z=0 for both listener and sources and uses forward=-Z with up=+Y, which produces correct left/right panning for 2D scenes. The elevation axis (up/down) is unused — all audio is co-planar with the listener.
ExoJS uses the panner’s equalpower model for 2D spatialisation. If you need deterministic non-spatial left/right balance, keep sounds non-spatial (position = null) and use bus-level pan instead.
import { Application, Color, Graphics, Scene, Sound, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';
const app = new Application({
canvas: {
width: 1280,
height: 720,
mount: document.body,
sizingMode: 'fit',
},
clearColor: Color.black,
loader: {
basePath: 'assets/',
},
});
type FalloffModel = 'linear' | 'inverse' | 'exponential';
interface FalloffModelDef {
model: FalloffModel;
color: Color;
}
// Horizontal placement (0..1 of canvas width) for each source; absolute pixel
// positions are resolved against the canvas in init().
const MODELS: Array<FalloffModelDef & { tx: number }> = [
{ model: 'linear', tx: 0.25, color: new Color(255, 140, 140) },
{ model: 'inverse', tx: 0.5, color: new Color(140, 200, 255) },
{ model: 'exponential', tx: 0.75, color: new Color(200, 255, 140) },
];
const REF_DISTANCE = 60;
const MAX_DISTANCE = 460;
const ROLLOFF = 1;
function attenuation(model: FalloffModel, d: number): number {
if (d <= REF_DISTANCE) return 1;
if (model === 'linear') {
return Math.max(0, 1 - ROLLOFF * ((d - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE)));
}
if (model === 'inverse') {
return REF_DISTANCE / (REF_DISTANCE + ROLLOFF * (d - REF_DISTANCE));
}
return Math.pow(d / REF_DISTANCE, -ROLLOFF);
}
interface FalloffSource extends FalloffModelDef {
x: number;
y: number;
}
class FalloffCurvesScene extends Scene {
private listener!: { x: number; y: number };
private sources!: FalloffSource[];
private sounds!: Sound[];
private graphics!: Graphics;
private labels!: Text[];
private tapPrompt!: Text;
// Canvas-relative plot geometry computed in init().
private plot = { x: 0, y: 0, w: 0, h: 0 };
private hud!: ReturnType<typeof mountControls>;
override async load(loader): Promise<void> {
await loader.load(Sound, { source: 'audio/impact-light.ogg' });
}
override init(loader): void {
const { width, height } = this.app.canvas;
// Sources spread across the lower half; the listener starts centred.
const sourceY = height * 0.72;
this.sources = MODELS.map(({ model, color, tx }) => ({ model, color, x: width * tx, y: sourceY }));
this.plot = { x: width * 0.06, y: height * 0.16, w: width * 0.88, h: height * 0.18 };
this.listener = { x: width / 2, y: height / 2 };
app.audio.listener.target = this.listener;
this.sounds = this.sources.map(({ model, x, y }) => {
const sound = new Sound(loader.get(Sound, 'source').audioBuffer, {
distanceModel: model,
refDistance: REF_DISTANCE,
maxDistance: MAX_DISTANCE,
rolloffFactor: ROLLOFF,
});
sound.position = { x, y };
return sound;
});
this.graphics = new Graphics();
this.labels = this.sources.map(({ model, x, y }) => {
const label = new Text(model, { fillColor: Color.white, fontSize: 16, align: 'center' });
label.setAnchor(0.5, 0).setPosition(x, y + 30);
return label;
});
// Shown while the browser still blocks audio (`app.audio.locked`); the
// first click or keypress unlocks it and the queued loops start.
this.tapPrompt = new Text('Click or press any key to start audio', { fillColor: Color.white, fontSize: 22, align: 'center' })
.setAnchor(0.5, 0.5)
.setPosition(width / 2, height - 24);
this.hud = mountControls({
title: 'Falloff Curves',
controls: [{ keys: 'Move', action: 'relocate the listener' }],
status: 'Click or press any key to start…',
hint: 'Each source uses a different distance model — move the listener to compare attenuation.',
});
this.app.input.onPointerMove.add(pointer => {
this.listener.x = pointer.x;
this.listener.y = pointer.y;
});
// Core defers playback until the AudioContext unlocks on the first
// gesture, then starts automatically — just call play().
for (const sound of this.sounds) this.app.audio.play(sound, { loop: true, volume: 0.5 });
this.hud.setStatus('Move the pointer to relocate the listener');
}
override draw(context): void {
context.backend.clear();
this.graphics.clear();
// Falloff-curve plots in the upper canvas area.
const { x: plotX, y: plotY, w: plotW, h: plotH } = this.plot;
this.graphics.fillColor = new Color(40, 40, 50);
this.graphics.drawRectangle(plotX, plotY, plotW, plotH);
for (const { model, color } of this.sources) {
this.graphics.fillColor = color;
for (let i = 0; i < plotW; i += 2) {
const d = (i / plotW) * MAX_DISTANCE * 1.2;
const v = attenuation(model, d);
this.graphics.drawRectangle(plotX + i, plotY + plotH - v * plotH, 2, 2);
}
}
// Listener marker.
this.graphics.fillColor = new Color(120, 255, 160);
this.graphics.drawCircle(this.listener.x, this.listener.y, 10);
// Source markers + live attenuation readouts.
for (let i = 0; i < this.sources.length; i++) {
const { x, y, color, model } = this.sources[i];
const dx = x - this.listener.x;
const dy = y - this.listener.y;
const d = Math.sqrt(dx * dx + dy * dy);
const v = attenuation(model, d);
this.graphics.fillColor = color;
this.graphics.drawCircle(x, y, 18);
this.graphics.fillColor = new Color(255, 255, 255, Math.floor(v * 255));
this.graphics.drawCircle(x, y, 6);
this.labels[i].text = `${model}\nvol ${v.toFixed(2)}`;
}
context.render(this.graphics);
for (const label of this.labels) context.render(label);
if (this.app.audio.locked) {
context.render(this.tapPrompt);
}
}
}
app.start(new FalloffCurvesScene());
Three sound sources with different distance models, with live attenuation readouts as you move the listener.
Where to go next
The next chapter, Audio effects, covers the audio filter system — how to shape sound with compressors, EQs, reverb, delay, and worklet-based effects like pitch shifting and ducking.