Guide

GuideAudioSpatial audio

Spatial audio

Place listener and sources in space for directional sound behavior.

Intermediate~10 min read

What you'll learn

  • place a listener and sources in space
  • tune directional falloff
  • lift a source off the world plane with elevation
  • muffle an obstructed source with occlusion
  • feed one shared reverb from many voices, and gate it on a zone

Before you start

Spatial audio

Spatial audio in ExoJS is 2D by default: a single shared listener and any number of sound sources, each with a world-space position. The engine maps the 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. A third axis is available when you want it, as an elevation you set explicitly.

The listener

The AudioListener lives at app.audio.listener — one per Application, not one per process. Set its position directly or point it at a target that updates every frame:

import { type Application, SceneNode } from '@codexo/exojs';

declare const application: Application;
declare const player: SceneNode;

// Static listener — audio doesn't move
application.audio.listener.position.set(400, 300);

// Dynamic listener — audio follows a scene node
application.audio.listener.target = player;

// Dynamic listener — audio follows a plain object (useful with views)
const camera = { x: 0, y: 0 };
application.audio.listener.target = 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

Sound itself carries no spatial state — position and distance model live on the Voice that app.audio.play() returns. Seed the initial position via PlayOptions:

import { type Application, Sound } from '@codexo/exojs';

declare const application: Application;
declare const pickup: Sound;

const voice = application.audio.play(pickup, { position: { x: 320, y: 180 } });

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():

import { SceneNode, type Spatializable, type Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;
declare const item: { x: number; y: number };
declare const enemy: SceneNode;

// Move the live source each frame...
voice.position = { x: item.x, y: item.y };

// ...or have it track a scene node automatically.
voice.follow(enemy);   // reads the node's global position every frame
voice.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-voice via PlayOptions at play time, or live on the returned Voice:

examples/guides/spatial-audio/distance-model.ts
const ambient = new Sound(audioBuffer);
const voice = this.app.audio.play(ambient, {
  loop: true,
  position: { x: 0, y: 0 },
  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)
});

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 also live setters on the Voice — reassign them to change the model or falloff of a sound that’s already playing:

import type { Spatializable, Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;

voice.distanceModel = 'inverse';
voice.refDistance = 80;
voice.rolloffFactor = 2;

Panning model: equalpower vs HRTF

The PannerNode behind every spatial voice supports two panning algorithms. 'equalpower' (the default) is a cheap constant-power pan — it works identically on any speaker setup and is what you want for the vast majority of sources. 'HRTF' filters each source through a head-related transfer function, which sounds convincingly directional — behind you, above-ish, close — but only through headphones, and it costs meaningfully more CPU per voice than 'equalpower'. Reserve it for a handful of sources that most benefit from it (a stalking enemy, a narrated whisper), not for every voice in a busy scene.

Switch the app-wide default once:

import type { Application } from '@codexo/exojs';

declare const application: Application;

application.audio.spatial.panningModel = 'HRTF';

Or override it per source, leaving the app-wide default untouched for everything else:

import { type Application, SceneNode, Sound } from '@codexo/exojs';

declare const application: Application;
declare const footsteps: Sound;
declare const npc: SceneNode;

const voice = application.audio.play(footsteps, {
    position: { x: npc.x, y: npc.y },
    panningModel: 'HRTF',
});

A voice’s panningModel is also a live setter — set it back to null to drop the override and re-inherit app.audio.spatial.panningModel:

import type { Spatializable, Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;

voice.panningModel = null;

Directional emitters (cone)

A spatial voice can face a direction and attenuate sound outside a cone in front of it — useful for directional sources like a turret’s alarm, a megaphone, or a flashlight-mounted speaker. orientation is in degrees and follows the same convention as SceneNode.rotation: 0° is local +X (“east”), and positive values rotate clockwise.

orientation alone has no audible effect — a source is omnidirectional until you narrow coneInnerAngle/coneOuterAngle below the default 360°. Inside coneInnerAngle the source plays at full volume; between coneInnerAngle and coneOuterAngle it fades toward coneOuterGain; beyond coneOuterAngle it plays at coneOuterGain.

import { type Application, SceneNode, Sound } from '@codexo/exojs';

declare const application: Application;
declare const alarmLoop: Sound;
declare const turret: SceneNode;

const alarm = application.audio.play(alarmLoop, {
    loop: true,
    position: { x: turret.x, y: turret.y },
    orientation: turret.rotation,
    coneInnerAngle: 30,
    coneOuterAngle: 90,
    coneOuterGain: 0.1,
});

Doppler shift

Doppler pitch-shifting is off by default (app.audio.spatial.dopplerFactor is 0) — enabling it costs nothing extra when unused, since the engine skips the calculation entirely while the factor is zero. Turn it on app-wide with:

import type { Application } from '@codexo/exojs';

declare const application: Application;

application.audio.spatial.dopplerFactor = 1;

1 is a physically-scaled shift (relative to the tunable app.audio.spatial.speedOfSound); many games deliberately exaggerate beyond 1 for a punchier effect on fast-moving sources like vehicles or projectiles.

The effect needs a velocity to work with. Set it explicitly on a voice:

import type { Spatializable, Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;

voice.velocity = { x: 120, y: 0 };

Or omit it entirely and let the engine derive it automatically each frame from the position deltas of a followed node:

import { SceneNode, type Spatializable, type Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;
declare const car: SceneNode;

voice.follow(car); // no explicit velocity — derived from car's frame-to-frame position

The same explicit-or-derived rule applies symmetrically to the listener: set app.audio.listener.velocity directly, or leave it alone while app.audio.listener.target is set and it derives from the target’s own movement.

Practical use

The most common pattern is to set the listener’s target to the camera or player once in init, then pass a position in PlayOptions right before each one-shot play:

examples/guides/spatial-audio/positional-scene.ts
init() {
  this.pickupSfx = this.loader.get('audio/pickup.wav');
  this.pickupSfx.volume = 0.6;

  this.app.audio.listener.target = this.player;
}

update(delta: Seconds) {
  // ... game logic ...

  if (this.pickupCollected) {
    this.app.audio.play(this.pickupSfx, { position: { x: this.item.x, y: this.item.y } });
  }
}

For ambient sounds that should follow the listener’s general area but not overlap, set a large refDistance:

import { type Application, Sound } from '@codexo/exojs';

declare const application: Application;
declare const waterfall: Sound;

const voice = application.audio.play(waterfall, {
    loop: true,
    position: { x: 600, y: 200 },
    distanceModel: 'inverse',
    refDistance: 300,
});

Sounds per frame

Spatial updates happen automatically — each frame the AudioSystem updates the listener from its target and ticks every spatial Voice, writing the resolved position (relative to that application’s listener) to the Web Audio PannerNode. You do not need to call any per-frame update on spatial sources: pass position in PlayOptions at play time, set it live on the returned Voice, or call voice.follow(node), and the engine handles the rest.

Elevation: the third axis

The scene graph has no third axis, so height is something you state rather than something the engine can read off a node. Every source and the listener carry an elevation in the same world units as x and y, 0 by default:

import { type Application, Sound } from '@codexo/exojs';

declare const application: Application;
declare const bell: Sound;

application.audio.listener.elevation = 0;

// Two equivalent ways to put a source 200 units up.
const voice = application.audio.play(bell, { position: { x: 400, y: 300 }, elevation: 200 });

voice.position = { x: 400, y: 300, z: 200 };

position stays two-dimensional when you read it — that is the world plane, and it is the part follow(node) can fill in. A point you pass without a z leaves the current height alone, so following a node never drops a source back onto the plane behind your back.

Elevation contributes to distance attenuation, to panning, and to Doppler: voice.elevationVelocity (and listener.elevationVelocity) is the vertical component, so a source rising straight away from the listener genuinely pitches down.

Occlusion

voice.occlusion is how obstructed the path to the listener is, from 0 (clear, the default) to 1 (fully obstructed). It muffles the source with a lowpass and attenuates it:

import type { Spatializable, Voice } from '@codexo/exojs';

declare const voice: Voice & Spatializable;
declare const wallsBetweenSourceAndListener: number;

voice.occlusion = Math.min(wallsBetweenSourceAndListener * 0.5, 1);

You supply the estimate — the engine does not trace geometry, because what counts as an obstruction is a game’s decision (a wall, a closed door, a crowd). Write it as often as you like: both the cutoff and the gain are ramped, not stepped, so a per-frame value never clicks.

Tune the endpoints on app.audio.spatial: occlusionCutoff (default 400 Hz) is where a fully occluded voice’s lowpass lands, and occlusionAttenuation (default 0.25) is how far its gain drops. The sweep between clear and occluded is logarithmic, matching how pitch is heard.

A voice whose occlusion never leaves 0 builds no filter at all, so this costs nothing when unused.

Sends: one effect for many voices

An insert effect replaces a signal, so it cannot express “keep playing dry, and also feed a shared reverb”. A send can:

import { AudioBus, type Voice } from '@codexo/exojs';

declare const voice: Voice;
declare const myConvolver: import('@codexo/exojs').AudioEffect;

const reverb = new AudioBus('reverb');

reverb.addEffect(myConvolver);

const send = voice.addSend(reverb, 0.4); // 40 % of the voice also reaches the reverb

send.level = 0.8; // ramped, not stepped

The voice keeps playing into its own bus unchanged; a copy of the same signal additionally reaches the send’s bus. Sends are owned by the voice and torn down with it, so you only remove one (voice.removeSend(send)) to change routing. PlayOptions.sends opens them at play time.

Reverb zones

An AudioZone is a region of the world that contributes a send while the listener is inside it. The zone owns geometry and a level and nothing else — it never routes audio, holds effects or touches a voice:

import { AudioBus, AudioZone, type Application, Rectangle } from '@codexo/exojs';

declare const application: Application;
declare const myConvolver: import('@codexo/exojs').AudioEffect;

const caveBus = new AudioBus('cave-reverb');

caveBus.addEffect(myConvolver);

application.audio.zones.add(
    new AudioZone({
        shape: new Rectangle(0, 0, 800, 600),
        bus: caveBus,
        send: 0.6,
        falloff: 120,
    }),
);

app.audio.zones samples every zone’s weight at the listener each frame and maintains one send per voice per active zone. That is deliberate: reverb is a property of the environment a scene is heard from, not of each individual source.

  • shape is a Rectangle or a { x, y, radius } circle.
  • falloff is the distance outside the shape over which the send ramps to zero — 0 gives a hard edge.
  • height bounds the zone vertically; the default is a column of infinite height.
  • Overlapping zones each contribute their own send.

Crossing a boundary is a level ramp on the existing send, not a teardown and rebuild, so walking in and out of a cave crossfades. The zone layer is inert until a zone is added, and the bus stays yours — two zones may legitimately name the same one, and destroying it is your call.

Browser constraints

The Web Audio PannerNode maps to 3D space. ExoJS uses forward=-Z with up=+Y, which produces correct left/right panning for 2D scenes, and writes the source’s elevation relative to the listener’s as the panner’s Z. A scene that never sets an elevation is therefore exactly co-planar, as before.

AudioContext.listener, though, is a property of the process-wide AudioContext: there is exactly one, shared by every Application. ExoJS therefore pins it at the origin and pans each voice by its offset from its own application’s app.audio.listener — so two applications in one page each keep their own viewpoint instead of overwriting one another every frame. Distance, attenuation and the distance model are unaffected; the only observable difference is that app.audio.spatial.teleportThreshold is measured on the source-to-listener offset, so warping the listener snaps every spatial voice rather than snapping one 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.

Examples

Listener and SourcePointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import type { RenderingContext, Spatializable, Voice } from '@codexo/exojs';
import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, Scene, Sound, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

// Spatial parameters tuned to the canvas so attenuation is visible across the
// wide 1280px canvas. These mirror the Web Audio `linear` model (see
// DistanceModel in src/audio/Sound.ts) so the on-screen readout matches what
// you hear.
const REF_DISTANCE = 50;
const MAX_DISTANCE = 560;
const ROLLOFF = 1;
const SOURCE_RADIUS = 24;

function linearAttenuation(distance: number): number {
  if (distance <= REF_DISTANCE) return 1;
  const t = (distance - REF_DISTANCE) / (MAX_DISTANCE - REF_DISTANCE);
  return Math.max(0, 1 - ROLLOFF * t);
}

class ListenerAndSourceScene extends Scene {
  private sound!: Sound;
  private voice: (Voice & Spatializable) | null = null;
  private readonly source = { x: 0, y: 0 };
  private dragging = false;
  private listener!: { x: number; y: number };
  private graphics!: Graphics;
  private label!: Text;
  private tapPrompt!: Text;
  private hud!: ReturnType<typeof mountControls>;

  override async load(): Promise<void> {
    const app = this.app;
    const { width, height } = app;

    // A continuous music loop, not a one-shot: spatialization is only
    // audible while there is sustained signal to pan/attenuate. The derived
    // Sound below reads .audioBuffer synchronously, so await load() instead
    // of the deferred get() (whose placeholder audioBuffer is null until fill).
    const source = await this.loader.load(Asset.type('sound', 'audio/demo-loop-main.ogg'));
    this.sound = new Sound(source.audioBuffer);
    this.listener = { x: width / 2, y: height / 2 };
    app.audio.listener.target = this.listener;

    this.graphics = new Graphics();
    this.label = new Text('', { fillColor: Color.white, fontSize: 17 });
    this.label.setPosition(20, 20);

    // Shown while the browser still blocks audio (`app.audio.locked`); the
    // first click or keypress unlocks it and the loop starts.
    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 - 48);

    this.hud = mountControls({
      title: 'Listener and Source',
      controls: [{ keys: 'Drag', action: 'move the red source around the listener' }],
      status: 'Click or press any key to start…',
      hint: 'The green dot is the listener. Drag the red source — volume falls off with distance.',
    });

    this.source.x = width / 2 + 220;
    this.source.y = height / 2;

    app.input.onPointerDown.add(pointer => {
      const dx = pointer.x - this.source.x;
      const dy = pointer.y - this.source.y;
      // Generous grab radius so the source is easy to pick up.
      if (dx * dx + dy * dy < SOURCE_RADIUS * SOURCE_RADIUS * 4) this.dragging = true;
    });
    app.input.onPointerMove.add(pointer => {
      if (!this.dragging) return;
      this.source.x = pointer.x;
      this.source.y = pointer.y;
      if (this.voice) this.voice.position = this.source;
    });
    app.input.onPointerUp.add(() => {
      this.dragging = false;
    });

    // A Sound played while audio is still locked is a no-op: a suspended
    // AudioContext's clock stands still, so nothing can be scheduled
    // honestly. Start the loop from the unlock gesture instead. Subscribing
    // is safe even if audio unlocked earlier - onUnlock replays.
    // play() returns the narrow Voice interface; Sound voices are spatializable.
    app.audio.onUnlock.add(() => {
      this.voice = app.audio.play(this.sound, {
        loop: true,
        volume: 1,
        position: this.source,
        distanceModel: 'linear',
        refDistance: REF_DISTANCE,
        maxDistance: MAX_DISTANCE,
        rolloffFactor: ROLLOFF,
      }) as Voice & Spatializable;
      this.hud.setStatus('Drag the red source to move it');
    });
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    const source = this.source;
    const dx = source.x - this.listener.x;
    const dy = source.y - this.listener.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const volume = linearAttenuation(dist);
    // Horizontal offset maps to stereo pan (left of listener = left ear).
    const pan = Math.max(-1, Math.min(1, dx / MAX_DISTANCE));
    const panText = pan < -0.05 ? `L ${Math.abs(pan).toFixed(2)}` : pan > 0.05 ? `R ${pan.toFixed(2)}` : 'center';
    this.label.text = `distance: ${dist.toFixed(0)} px   volume: ${(volume * 100).toFixed(0)}%   pan: ${panText}`;

    this.graphics.clear();

    // Reference + max distance rings around the listener.
    this.graphics.fillColor = new Color(50, 60, 60);
    this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE);
    this.graphics.fillColor = new Color(0, 0, 0);
    this.graphics.drawCircle(this.listener.x, this.listener.y, MAX_DISTANCE - 2);

    // Listener.
    this.graphics.fillColor = new Color(120, 255, 160);
    this.graphics.drawCircle(this.listener.x, this.listener.y, 14);

    // Source - brightness tracks attenuation so volume reads visually too.
    const glow = Math.floor(80 + volume * 175);
    this.graphics.fillColor = new Color(glow, Math.floor(80 + volume * 60), Math.floor(80 + volume * 60));
    this.graphics.drawCircle(source.x, source.y, SOURCE_RADIUS);

    context.render(this.graphics);
    context.render(this.label);

    if (app.audio.locked) {
      context.render(this.tapPrompt);
    }
  }
}

const app = new Application({
  scenes: { ListenerAndSourceScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(ListenerAndSourceScene);

A draggable sound source and a fixed listener — drag the source to hear pan and attenuation change in real time.

Falloff CurvesPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Sound, Text } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

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: (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(): Promise<void> {
    const app = this.app;
    const { width, height } = app;

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

    // Each derived Sound below reads .audioBuffer synchronously, so the
    // shared source must be fully decoded first - await load() instead of
    // the deferred get() (whose placeholder audioBuffer is null until fill).
    const source = await this.loader.load(Asset.type('sound', 'audio/impact-light.ogg'));
    this.sounds = this.sources.map(() => new Sound(source.audioBuffer));

    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 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.',
    });

    app.input.onPointerMove.add(pointer => {
      this.listener.x = pointer.x;
      this.listener.y = pointer.y;
    });

    // A Sound played while audio is still locked is a no-op: a suspended
    // AudioContext's clock stands still, so nothing can be scheduled
    // honestly. Start the loops from the unlock gesture instead.
    // Subscribing is safe even if audio unlocked earlier - onUnlock replays.
    app.audio.onUnlock.add(() => {
      for (let i = 0; i < this.sounds.length; i++) {
        const { model, x, y } = this.sources[i];
        app.audio.play(this.sounds[i], {
          loop: true,
          volume: 0.5,
          position: { x, y },
          distanceModel: model,
          refDistance: REF_DISTANCE,
          maxDistance: MAX_DISTANCE,
          rolloffFactor: ROLLOFF,
        });
      }
      this.hud.setStatus('Move the pointer to relocate the listener');
    });
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    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 (app.audio.locked) {
      context.render(this.tapPrompt);
    }
  }
}

const app = new Application({
  scenes: { FalloffCurvesScene },
  canvas: {
    width: 1280,
    height: 720,
    mount: document.body,
    sizing: new FixedResolutionCanvasSizing(),
  },
  clearColor: Color.black,
  loader: {
    basePath: 'assets/',
  },
});

await app.start(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.