Guide

Guide Audio Audio effects

Audio effects

Shape sound with filters and bus-level processing.

Intermediate ~3 min read

What you'll learn

  • shape sound with bus filters
  • apply reverb, delay, and ducking

Before you start

Audio effects

Every bus in ExoJS carries a filter chain — an ordered list of AudioFilter instances that process audio in series. Filters are applied at the bus level, which means adding a filter to app.audio.music affects every Music instance 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 { CompressorFilter, DelayFilter, ReverbFilter } from '@codexo/exojs';

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

app.audio.master.addFilter(compressor);
app.audio.master.addFilter(reverb);
app.audio.sound.addFilter(delay);

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

app.audio.master.removeFilter(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.

CompressorFilter

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:

const compressor = new CompressorFilter({
    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
});

app.audio.master.addFilter(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:

update(delta) {
    const gr = this.compressor.reduction;
    this.meterBar.height = Math.abs(gr) * 10; // scale to pixels
}

ReverbFilter and DelayFilter

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

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

DelayFilter creates an echo with configurable feedback:

const delay = new DelayFilter({
    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. Changing durationSeconds or decay on a reverb rebuilds the impulse response — prefer to keep these stable during playback.

DuckingFilter

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 { AudioBus, DuckingFilter } from '@codexo/exojs';

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

const ducker = new DuckingFilter({
    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
});

app.audio.music.addFilter(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 { ChorusFilter, EqualizerFilter, HighpassFilter, LowpassFilter } from '@codexo/exojs';

// Three-band equalizer — low shelf, peaking mid, high shelf
const eq = new EqualizerFilter({ 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 ChorusFilter({ rateHz: 1.5, depthMs: 5, wet: 0.5 });

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

Worklet-based effects

Three filters use AudioWorkletProcessor and load asynchronously. They extend WorkletFilter, 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 { GranularFilter, PitchShiftFilter, VocoderFilter } from '@codexo/exojs';

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

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

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

All three have live wet controls. PitchShiftFilter exposes pitch; GranularFilter exposes grainSize, density, spread, pitchMin, and pitchMax for real-time grain cloud manipulation. 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:

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

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

const wind = loader.get(Sound, 'wind');
wind.bus = ambientBus; // now routes through reverb + lowpass
wind.setLoop(true).play();

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

Compressor Pointer Audio Open in Playground View source
import { Application, Color, CompressorFilter, Graphics, Music, Scene, 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 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!: Music;
    private filter!: CompressorFilter;
    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(loader): Promise<void> {
        await loader.load(Music, { music: 'audio/demo-loop-main.ogg' });
    }

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

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

        this.music = loader.get(Music, 'music');
        this.filter = new CompressorFilter();
        app.audio.music.addFilter(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.',
        });

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

        // Core defers playback until the AudioContext unlocks on the first
        // gesture, then starts automatically — just call play().
        this.music.setLoop(true).setVolume(0.8).play();
        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): void {
        context.backend.clear();
        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 (this.app.audio.locked) {
            context.render(this.tapPrompt);
        }
    }
}

app.start(new CompressorScene());

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

Reverb and Delay Pointer Audio Open in Playground View source
import { Application, Color, DelayFilter, Graphics, ReverbFilter, Scene, Sound, Text } from '@codexo/exojs';
import { mountControlPanel, mountControls } from '@examples/runtime';

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

class ReverbAndDelayScene extends Scene {
    private sound!: Sound;
    private reverb!: ReverbFilter;
    private delay!: DelayFilter;
    private gfx!: Graphics;
    private prompt!: Text;
    private tapPrompt!: Text;
    private flash = 0;
    private triggers = 0;
    // Canvas-relative click-pad geometry computed in init().
    private pad = { x: 0, y: 0, w: 0, h: 0 };
    private hud!: ReturnType<typeof mountControls>;
    private panel!: ReturnType<typeof mountControlPanel>;

    override async load(loader): Promise<void> {
        await loader.load(Sound, { sfx: 'audio/impact-light.ogg' });
    }

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

        // A large click pad centred on the canvas.
        this.pad = { x: width / 2 - 240, y: height * 0.36, w: 480, h: 160 };

        this.sound = loader.get(Sound, 'sfx');

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

        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', action: 'trigger the impact sound' }],
            status: 'Click or press any key to start…',
            hint: 'Each click fires the dry impact through the reverb tail and delay echoes.',
        });

        // All four effect parameters are live: reverb wet + decay, delay wet,
        // delay time + feedback. Ranges mirror the filter clamps in src/audio/filters.
        this.panel = mountControlPanel({ title: 'Effect chain', corner: 'bottom-left' });
        this.panel.addSlider({
            label: 'Reverb wet',
            min: 0,
            max: 1,
            step: 0.01,
            value: this.reverb.wet,
            onChange: v => {
                this.reverb.wet = v;
            },
        });
        this.panel.addSlider({
            label: 'Reverb decay',
            min: 0.5,
            max: 10,
            step: 0.1,
            value: this.reverb.decay,
            onChange: v => {
                this.reverb.decay = v;
            },
        });
        this.panel.addSlider({
            label: 'Delay wet',
            min: 0,
            max: 1,
            step: 0.01,
            value: this.delay.wet,
            onChange: v => {
                this.delay.wet = v;
            },
        });
        this.panel.addSlider({
            label: 'Delay time (s)',
            min: 0.02,
            max: 0.82,
            step: 0.01,
            value: this.delay.delaySeconds,
            onChange: v => {
                this.delay.delaySeconds = v;
            },
        });
        this.panel.addSlider({
            label: 'Delay feedback',
            min: 0,
            max: 0.95,
            step: 0.01,
            value: this.delay.feedback,
            onChange: v => {
                this.delay.feedback = v;
            },
        });

        this.app.input.onPointerTap.add(() => {
            // 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.sound.play({ replace: true });
            this.flash = 1;
            this.triggers += 1;
            this.hud.setStatus(`Impacts triggered: ${this.triggers}`);
        });

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

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

    override draw(context): void {
        context.backend.clear();
        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.drawRectangle(this.pad.x, this.pad.y, this.pad.w, this.pad.h);

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

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

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