Guide

GuideAudioAudio effects

Audio effects

Shape sound with filters and bus-level processing.

Intermediate~5 min read

What you'll learn

  • shape sound with bus and per-voice effects
  • apply reverb, delay, and ducking

Before you start

Audio effects

Every bus in ExoJS carries a filter chain — an ordered list of AudioEffect instances that process audio in series. Filters are applied at the bus level, which means adding a filter to app.audio.music affects every voice routing through the music bus. You can also create custom buses for isolated effect chains.

The filter chain

A bus’s filter chain is inputNode → [filter₁ → filter₂ → ...] → panNode → outputNode. Adding a filter appends it to the end of the chain. Order matters — a compressor before reverb sounds different from a compressor after reverb.

import type { Application } from '@codexo/exojs';
import { CompressorEffect, DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const compressor = new CompressorEffect({ threshold: -20, ratio: 8 });
const reverb = new ReverbEffect({ wet: 0.3 });
const delay = new DelayEffect({ delaySeconds: 0.25, wet: 0.2, feedback: 0.4 });

application.audio.master.addEffect(compressor);
application.audio.master.addEffect(reverb);
application.audio.sound.addEffect(delay);

Remove a filter to take it out of the chain without destroying it:

import type { Application } from '@codexo/exojs';
import { ReverbEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;
declare const reverb: ReverbEffect;

application.audio.master.removeEffect(reverb);

Filters are reusable — move one from bus to bus by removing it from the old bus and adding it to the new one. Destroy a filter with filter.destroy() when it’s no longer needed anywhere.

CompressorEffect

A dynamics compressor that clamps the dynamic range of audio passing through it. Useful for evening out volume, adding punch to SFX, and preventing clipping when many sounds play simultaneously:

import type { Application } from '@codexo/exojs';
import { CompressorEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const compressor = new CompressorEffect({
    threshold: -24,   // dBFS — signals above this get compressed
    ratio: 12,        // 1:1 = no compression, 20:1 = near-limiting
    attack: 0.003,    // seconds — how fast compression kicks in
    release: 0.25,    // seconds — how fast it lets go
    knee: 30,         // dB — softens the threshold transition
});

application.audio.master.addEffect(compressor);

All properties are live get/set. The compressor.reduction getter returns the current gain reduction in dB (always ≤ 0) — useful for live level metering:

examples/guides/audio-effects/compressor-meter.ts
update(delta: Seconds) {
  const gr = this.compressor.reduction;
  this.meterBar.height = Math.abs(gr) * 10; // scale to pixels
}

ReverbEffect and DelayEffect

ReverbEffect simulates acoustic space by convolving audio through a procedurally-generated impulse response:

import { ReverbEffect } from '@codexo/exojs-audio-fx';

const reverb = new ReverbEffect({
    durationSeconds: 2,   // IR length — longer = bigger space
    decay: 2,             // exponential decay factor
    wet: 0.4,             // 0 = dry only, 1 = wet only
});

DelayEffect creates an echo with configurable feedback:

import { DelayEffect } from '@codexo/exojs-audio-fx';

const delay = new DelayEffect({
    delaySeconds: 0.3,    // echo interval
    feedback: 0.45,       // 0 = one echo, 0.95 = long tail
    wet: 0.5,             // dry/wet mix
});

Both expose live wet, delaySeconds, feedback, decay, and durationSeconds properties.

DuckingEffect

A sidechain compressor — when the sidechain bus exceeds a threshold, the main signal is attenuated. Common use case: automatically lower music volume when voice-over plays:

import { type Application, AudioBus } from '@codexo/exojs';
import { DuckingEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const voiceBus = new AudioBus('voice-over', { parent: application.audio.master });
application.audio.registerBus(voiceBus);

const ducker = new DuckingEffect({
    sidechain: voiceBus,
    threshold: -30,   // dBFS — sidechain level that triggers ducking
    ratio: 6,         // gain reduction ratio
    attackMs: 25,     // how fast ducking engages
    releaseMs: 260,   // how fast audio recovers after sidechain drops
});

application.audio.music.addEffect(ducker);

threshold, ratio, attackMs, and releaseMs are live get/set. The filter.ready promise resolves when the worklet processor is loaded and the chain is fully wired.

Shaping and modulation

Several filters cover tone shaping and modulation effects:

import { HighpassFilter, LowpassFilter } from '@codexo/exojs';
import { ChorusEffect, EqualizerEffect } from '@codexo/exojs-audio-fx';

// Three-band equalizer — low shelf, peaking mid, high shelf
const eq = new EqualizerEffect({ low: 3, mid: -2, high: 4 });

// Frequency cuts — animate the frequency property for sweeps
const lp = new LowpassFilter({ frequency: 2000, resonance: 1 });
const hp = new HighpassFilter({ frequency: 200, resonance: 1 });

// Modulated delay for thickness and doubling (native nodes, instant startup)
const chorus = new ChorusEffect({ rateHz: 1.5, depthMs: 5, wet: 0.5 });

All parameters on these effects are live get/set. The EqualizerEffect API reference documents individual band frequencies; the ChorusEffect and LowpassFilter references cover constructor options in detail.

Distortion and modulation effects

Like ChorusEffect, the six filters below are built from native Web Audio nodes — they’re ready the instant you construct them, with no worklet loading or ready promise involved. All expose a live wet dry/wet mix; see each API reference for the full option list.

DistortionEffect

Soft-clip saturation through a tanh-based WaveShaperNode curve, followed by a tone-control lowpass on the wet path. drive (0..1) controls clip intensity and tone (0..1) sweeps the wet-path cutoff logarithmically from 100 Hz to 20 kHz:

import type { Application } from '@codexo/exojs';
import { DistortionEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const dist = new DistortionEffect({ drive: 0.6, tone: 0.7, wet: 0.8 });
application.audio.sound.addEffect(dist);

PhaserEffect

Sweeps a cascade of allpass filters with a sine LFO, producing moving notches in the spectrum. stages (an even number, 2..12) sets the notch count and feedback (0..0.9) sharpens the resonance:

import type { Application } from '@codexo/exojs';
import { PhaserEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const phaser = new PhaserEffect({ stages: 6, rateHz: 0.3, depth: 0.8, feedback: 0.4, wet: 0.6 });
application.audio.music.addEffect(phaser);

FlangerEffect

A short (0.5–20 ms) LFO-modulated delay with a feedback loop, producing the classic sweeping “jet-plane” comb-filter sound. Unlike ChorusEffect — a longer, feedback-free delay used for thickening — the feedback here reinforces phase cancellation:

import type { Application } from '@codexo/exojs';
import { FlangerEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const flanger = new FlangerEffect({ delayMs: 3, depthMs: 2, rateHz: 0.25, feedback: 0.5, wet: 0.5 });
application.audio.sound.addEffect(flanger);

TremoloEffect

Amplitude-modulates the signal with a sine LFO for classic volume pulsing. Set autoPan: true to have the same LFO drive stereo panning in sync with the pulse:

import type { Application } from '@codexo/exojs';
import { TremoloEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const tremolo = new TremoloEffect({ rateHz: 5, depth: 0.7, autoPan: true });
application.audio.music.addEffect(tremolo);

RingModulatorEffect

Multiplies the input by a carrier oscillator, producing sum/difference sidebands while suppressing the fundamental. Mid-range carrier frequencies (100–1000 Hz) give the classic robotic ring-mod timbre; sub-audio rates (< 20 Hz) sound like tremolo:

import type { Application } from '@codexo/exojs';
import { RingModulatorEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const ringMod = new RingModulatorEffect({ frequency: 220, waveform: 'sine', wet: 0.8 });
application.audio.sound.addEffect(ringMod);

AutoWahEffect

An envelope-driven wah: a rectifier and smoothing filter track input loudness and sweep a resonant bandpass filter upward from baseFrequency. sensitivity sets the maximum sweep in Hz and responseMs controls how snappy or legato the response feels:

import type { Application } from '@codexo/exojs';
import { AutoWahEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const wah = new AutoWahEffect({ baseFrequency: 300, sensitivity: 2500, q: 5, wet: 0.8 });
application.audio.sound.addEffect(wah);

Ping-pong delay, limiting, and convolution

PingPongDelayEffect

A stereo delay whose taps cross-feed between channels, bouncing echoes hard-left/hard-right. delayTime sets the per-tap interval in seconds and feedback (0..0.9) controls how long the bounce sustains:

import type { Application } from '@codexo/exojs';
import { PingPongDelayEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const pingPong = new PingPongDelayEffect({ delayTime: 0.3, feedback: 0.5, wet: 0.5 });
application.audio.music.addEffect(pingPong);

LimiterEffect

A brick-wall limiter — a DynamicsCompressorNode fixed at a high ratio and hard knee — meant as a safety net at the end of a chain to catch peaks before they clip:

import type { Application } from '@codexo/exojs';
import { LimiterEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;

const limiter = new LimiterEffect({ threshold: -3, release: 0.1 });
application.audio.master.addEffect(limiter);

ConvolutionEffect

Convolves the signal with a real impulse response instead of ReverbEffect’s procedurally generated one — useful for captured spaces, cabinet/speaker simulation, or telephone-style filtering. Pass a decoded AudioBuffer or a loaded Sound; call setImpulse() to swap the IR later.

An impulse response is just an audio file, so it loads like any other sound — there is no separate asset type for it. Load it with Asset.type('sound', 'ir/concert-hall.wav') (or as a catalog leaf) and hand the result straight to the effect:

import { type Application, Sound } from '@codexo/exojs';
import { ConvolutionEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;
declare const hall: Sound;

const convReverb = new ConvolutionEffect({ impulse: hall, wet: 0.6 });
application.audio.master.addEffect(convReverb);

Worklet-based effects

Four filters use AudioWorkletProcessor and load asynchronously. They extend WorkletEffect, which exposes a ready promise — the engine registers the worklet, you construct the filter and add it to a bus, and the chain rewires automatically once the processor is loaded.

import { BitCrusherEffect, GranularEffect, PitchShiftEffect, VocoderEffect } from '@codexo/exojs-audio-fx';

// Granular pitch shifting (0.25x to 4x)
const shifter = new PitchShiftEffect({ pitch: 1.2, wet: 1.0 });

// Cross-synthesis between a carrier signal and a modulator bus
const vocoder = new VocoderEffect({ modulator: modulatorBus, numBands: 14 });

// Slices input into short grains with random pitch and time offset
const granular = new GranularEffect({ grainSize: 0.05, density: 50, wet: 1.0 });

// Lo-fi bit-depth and sample-rate reduction
const crusher = new BitCrusherEffect({ bits: 4, frequencyReduction: 0.3, wet: 0.8 });

All four have live wet controls. PitchShiftEffect exposes pitch; GranularEffect exposes grainSize, density, spread, pitchMin, and pitchMax for real-time grain cloud manipulation; BitCrusherEffect exposes bits (1..16, quantization depth) and frequencyReduction (0..1, sample-and-hold rate). The API reference documents the full constructor options for each.

Custom buses

Bus-level filters affect everything on that bus. Create isolated chains for specific sound categories:

import { type Application, AudioBus, LowpassFilter, Sound } from '@codexo/exojs';
import { ReverbEffect } from '@codexo/exojs-audio-fx';

declare const application: Application;
declare const wind: Sound;

const ambientBus = new AudioBus('ambient', { parent: application.audio.master });
application.audio.registerBus(ambientBus);

ambientBus.addEffect(new ReverbEffect({ wet: 0.6, durationSeconds: 3 }));
ambientBus.addEffect(new LowpassFilter({ frequency: 800 }));

// Route this play onto the ambient bus — reverb + lowpass applied.
application.audio.play(wind, { bus: ambientBus, loop: true });

registerBus stores the bus by name, retrievable later via app.audio.getBus('ambient'). unregisterBus removes and destroys a custom bus. The three built-in buses (master, music, sound) cannot be unregistered.

Examples

CompressorPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Asset, AudioStream, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, Text } from '@codexo/exojs';
import { CompressorEffect } from '@codexo/exojs-audio-fx';
import { mountControls } from '@examples/runtime';

type CompressorParam = 'threshold' | 'ratio' | 'attack' | 'release';

interface SliderDef {
  key: CompressorParam;
  min: number;
  max: number;
}

const sliders: SliderDef[] = [
  { key: 'threshold', min: -60, max: 0 },
  { key: 'ratio', min: 1, max: 16 },
  { key: 'attack', min: 0.001, max: 0.2 },
  { key: 'release', min: 0.02, max: 0.8 },
];

class CompressorScene extends Scene {
  private music!: AudioStream;
  private filter!: CompressorEffect;
  private gfx!: Graphics;
  private labels!: Text[];
  private meterLabel!: Text;
  private tapPrompt!: Text;
  private drag = -1;
  // Canvas-relative bar layout computed in init().
  private barX = 0;
  private barW = 0;
  private labelX = 0;
  private rowY: number[] = [];
  private meterY = 0;
  private hud!: ReturnType<typeof mountControls>;

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

    // Wide horizontal bars centred on the 16:9 canvas; labels sit to the left.
    this.barW = width * 0.45;
    this.barX = width * 0.32;
    this.labelX = width * 0.1;
    this.rowY = sliders.map((_, i) => height * 0.26 + i * 90);
    this.meterY = this.rowY[this.rowY.length - 1] + 100;

    // AudioStream has no seamless adapter - await it explicitly.
    const music = await this.loader.load(Asset.type('music', 'audio/demo-loop-main.ogg'));
    this.music = music;
    this.filter = new CompressorEffect();
    app.audio.music.addEffect(this.filter);

    this.gfx = new Graphics();
    this.labels = sliders.map(() => new Text('', { fillColor: Color.white, fontSize: 16 }));
    this.meterLabel = new Text('', { fillColor: Color.white, fontSize: 16 });
    this.meterLabel.setPosition(this.labelX, this.meterY - 6);

    // Shown while the browser still blocks audio (`app.audio.locked`); the
    // first click or keypress unlocks it and the queued music 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: 'Compressor',
      controls: [{ keys: 'Drag', action: 'sweep a parameter bar' }],
      status: 'Click or press any key to start…',
      hint: 'The red bar shows live gain reduction — louder peaks pull it further right.',
    });

    app.input.onPointerDown.add(p => {
      this.drag = this.sliderAt(p.y);
      this.apply(p.x);
    });
    app.input.onPointerMove.add(p => {
      this.apply(p.x);
    });
    app.input.onPointerUp.add(() => {
      this.drag = -1;
    });

    // Core defers playback until the AudioContext unlocks on the first
    // gesture, then starts automatically.
    app.audio.play(this.music, { loop: true, volume: 0.8 });
    this.hud.setStatus('Compressing music bus…');
  }

  private sliderAt(y: number): number {
    for (let i = 0; i < sliders.length; i++) if (Math.abs(y - this.rowY[i]) <= 16) return i;
    return -1;
  }

  private apply(x: number): void {
    if (this.drag < 0) return;
    const def = sliders[this.drag];
    const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW));
    this.filter[def.key] = def.min + (def.max - def.min) * t;
  }

  private value(def: SliderDef): number {
    return this.filter[def.key];
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    this.gfx.clear();
    for (let i = 0; i < sliders.length; i++) {
      const def = sliders[i];
      const y = this.rowY[i];
      const val = this.value(def);
      const t = (val - def.min) / (def.max - def.min);
      this.gfx.fillColor = new Color(70, 70, 70);
      this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12);
      this.gfx.fillColor = new Color(120, 200, 255);
      this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12);
      this.labels[i].text = `${def.key}: ${val.toFixed(def.key === 'ratio' ? 2 : 3)}`;
      this.labels[i].setPosition(this.labelX, y - 12);
      context.render(this.labels[i]);
    }

    const reduction = this.filter.reduction;
    const meterT = Math.max(0, Math.min(1, -reduction / 24));
    this.gfx.fillColor = new Color(70, 70, 70);
    this.gfx.drawRectangle(this.barX, this.meterY, this.barW, 12);
    this.gfx.fillColor = new Color(255, 140, 140);
    this.gfx.drawRectangle(this.barX, this.meterY, this.barW * meterT, 12);
    this.meterLabel.text = `gain reduction: ${reduction.toFixed(1)} dB`;
    context.render(this.meterLabel);

    context.render(this.gfx);

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

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

await app.start(CompressorScene);

Interactive compressor with live gain-reduction meter — drag sliders to adjust threshold, ratio, attack, and release.

Reverb and DelayPointerAudioOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, Graphics, type RenderingContext, Scene, type Seconds, Sound, Text } from '@codexo/exojs';
import { DelayEffect, ReverbEffect } from '@codexo/exojs-audio-fx';
import { mountControls } from '@examples/runtime';

interface SliderDef {
  label: string;
  min: number;
  max: number;
  get(): number;
  set(value: number): void;
}

class ReverbAndDelayScene extends Scene {
  private sound!: Sound;
  private reverb!: ReverbEffect;
  private delay!: DelayEffect;
  private sliders: SliderDef[] = [];
  private labels: Text[] = [];
  private gfx!: Graphics;
  private prompt!: Text;
  private tapPrompt!: Text;
  private flash = 0;
  private triggers = 0;
  private drag = -1;
  // Canvas-relative layout computed in init().
  private pad = { x: 0, y: 0, w: 0, h: 0 };
  private barX = 0;
  private barW = 0;
  private labelX = 0;
  private rowY: number[] = [];
  private hud!: ReturnType<typeof mountControls>;

  override init(): void {
    const app = this.app;
    const { width, height } = app;

    this.pad = { x: width / 2 - 240, y: 36, w: 480, h: 100 };
    this.barW = width * 0.5;
    this.barX = width * 0.25;
    this.labelX = width * 0.06;
    this.rowY = Array.from({ length: 5 }, (_, i) => 210 + i * 78);

    // Path-only get() infers Sound from the .ogg extension - sidesteps a
    // compile-time overload ambiguity between Sound and the Json token form
    // when passing the Sound token explicitly.
    this.sound = this.loader.get('audio/impact-light.ogg');

    // Reverb (room tail) → Delay (echoes) chained on the sound bus.
    this.reverb = new ReverbEffect({ wet: 0.4, decay: 2 });
    this.delay = new DelayEffect({ wet: 0.35, delaySeconds: 0.25, feedback: 0.45 });
    app.audio.sound.addEffect(this.reverb);
    app.audio.sound.addEffect(this.delay);

    this.sliders = [
      { label: 'reverb wet', min: 0, max: 1, get: () => this.reverb.wet, set: v => (this.reverb.wet = v) },
      { label: 'reverb decay', min: 0.5, max: 10, get: () => this.reverb.decay, set: v => (this.reverb.decay = v) },
      { label: 'delay wet', min: 0, max: 1, get: () => this.delay.wet, set: v => (this.delay.wet = v) },
      { label: 'delay time (s)', min: 0.02, max: 0.82, get: () => this.delay.delaySeconds, set: v => (this.delay.delaySeconds = v) },
      { label: 'delay feedback', min: 0, max: 0.95, get: () => this.delay.feedback, set: v => (this.delay.feedback = v) },
    ];
    this.labels = this.sliders.map((_, i) => {
      const label = new Text('', { fillColor: Color.white, fontSize: 16 });
      label.setPosition(this.labelX, this.rowY[i]! - 12);
      return label;
    });

    this.gfx = new Graphics();
    this.prompt = new Text('', { fillColor: Color.white, fontSize: 22, align: 'center' })
      .setAnchor(0.5, 0.5)
      .setPosition(width / 2, this.pad.y + this.pad.h / 2);

    // Shown while the browser still blocks audio (`app.audio.locked`); the
    // first click or keypress unlocks it.
    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: 'Reverb and Delay',
      controls: [
        { keys: 'Click pad', action: 'trigger the impact sound' },
        { keys: 'Drag bar', action: 'sweep a parameter' },
      ],
      status: 'Click or press any key to start…',
    });

    this.root.addChild(this.gfx, ...this.labels, this.prompt, this.tapPrompt);

    app.input.onPointerDown.add(p => {
      if (p.x >= this.pad.x && p.x <= this.pad.x + this.pad.w && p.y >= this.pad.y && p.y <= this.pad.y + this.pad.h) {
        this.strike();
        return;
      }

      this.drag = this.sliderAt(p.y);
      this.apply(p.x);
    });
    app.input.onPointerMove.add(p => this.apply(p.x));
    app.input.onPointerUp.add(() => {
      this.drag = -1;
    });

    this.hud.setStatus('Click the pad to trigger the impact');
  }

  private sliderAt(y: number): number {
    for (let i = 0; i < this.rowY.length; i++) if (Math.abs(y - this.rowY[i]!) <= 16) return i;
    return -1;
  }

  private apply(x: number): void {
    if (this.drag < 0) return;

    const def = this.sliders[this.drag]!;
    const t = Math.max(0, Math.min(1, (x - this.barX) / this.barW));

    def.set(def.min + (def.max - def.min) * t);
  }

  private strike(): void {
    // The pointer gesture also unlocks the AudioContext; firing while still
    // locked would be silent, so wait until audio is ready.
    if (this.app.audio.locked) return;

    this.app.audio.play(this.sound);
    this.flash = 1;
    this.triggers += 1;
    this.hud.setStatus(`Impacts triggered: ${this.triggers}`);
  }

  override update(delta: Seconds): void {
    this.flash = Math.max(0, this.flash - delta * 2.2);
  }

  override draw(context: RenderingContext): void {
    const app = this.app;
    this.gfx.clear();

    // A big click pad that flashes on each trigger so the play action reads.
    const lit = Math.floor(60 + this.flash * 180);
    this.gfx.fillColor = new Color(lit, lit, Math.floor(60 + this.flash * 120));
    this.gfx.drawRoundedRectangle(this.pad.x, this.pad.y, this.pad.w, this.pad.h, 12);

    for (let i = 0; i < this.sliders.length; i++) {
      const def = this.sliders[i]!;
      const y = this.rowY[i]!;
      const value = def.get();
      const t = (value - def.min) / (def.max - def.min);

      this.gfx.fillColor = new Color(70, 70, 70);
      this.gfx.drawRectangle(this.barX, y - 6, this.barW, 12);
      this.gfx.fillColor = new Color(120, 200, 255);
      this.gfx.drawRectangle(this.barX, y - 6, this.barW * t, 12);
      this.labels[i]!.text = `${def.label}: ${value.toFixed(2)}`;
    }

    this.prompt.text = app.audio.locked ? 'Click or press a key to enable audio' : 'Click the pad to play impact';
    context.render(this.root);
  }
}

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

await app.start(ReverbAndDelayScene);

Reverb and delay filters on the SFX bus, with live wet/dry and delay-time sliders.

Where to go next

The next chapter, Beat detection, covers tempo tracking and beat analysis — how to sync game logic to the rhythm of your music.