Shape sound with filters and bus-level processing.
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
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.
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.