Skip to content

Effects

Effects are pure data. An fx.* builder returns an EffectInstance ({ effectId, params }); the runtime maps that to the actual audio nodes. Times are in beats so chains stay tempo-relative, and wet is 0..1 everywhere (0 = dry only).

import { fx } from "@barline/core";

Effects appear in three places: a track’s static effects chain, a bus’s chain, and a per-play .through() route.

BUILTIN_EFFECT_IDS lists every built-in. Defaults shown:

BuilderKey params (defaults)
fx.distortiondrive 0.4, wet 1
fx.bitcrusherbits 4, wet 1
fx.filterfrequency 2000, type "lowpass" | "highpass" | "bandpass", q 1
fx.delaytime 0.75 (beats), feedback 0.35, wet 0.3
fx.pingpongtime 0.75 (beats), feedback 0.35, wet 0.3
fx.reverbdecay 1.5 (seconds), wet 0.3
fx.chorusfrequency 1.5, depth 0.5, wet 0.4
fx.compressorthreshold -18, ratio 4, attack 0.005, release 0.1
fx.limiterthreshold -1
fx.eq3low 0, mid 0, high 0 (dB)
fx.widenerwidth 0.7
fx.saturatordrive 0.5, character 0, wet 1 — AudioWorklet, bandlimited 4× oversampled
fx.maximizerthreshold -1, lookahead 3 (ms), release 80 (ms) — 4× true-peak detector
fx.laddercutoff 1200, resonance 0.4, drive 1 — FAUST Moog VCF
fx.tapedrive 0.5, warmth 0.4, wet 1 — FAUST tape colour
fx.phaserrate 0.5, depth 0.7, feedback 0.25, wet 1 — FAUST 4-stage
fx.channeltrim/phase, HP/LP, mono-bass, four-band EQ, gate, linked compressor
fx.drumBustransient 0.2, body 0.25, drive 0.35, mix 0.8 — drum finisher
fx.motionmode 0, rate 0.35, depth 0.65, mix 0.45 — flanger/ensemble/ring/reverse/brake family
fx.spacemode 0, decay 2.5, size 1.5, duck 0, predelay 0, wet 0.3 — production reverb family
fx.halldecay 3 (seconds), wet 0.25 — FAUST algorithmic reverb, deterministic offline
fx.platedecay 0.5 (tank feedback), wet 0.25 — FAUST plate reverb, deterministic offline
fx.convolverdecay 2 (seconds), wet 0.3 — Web Audio convolution, synthesized IR, deterministic offline
fx.diodecutoff 800, resonance 0.5, drive 1 — 303 diode-ladder filter
fx.gluethreshold -18, ratio 2, attack 0.01, release 0.2, makeup 0 — bus/glue compressor
fx.tilttilt 0 (−1 weight … +1 air) — single-knob tilt EQ
fx.masterlowCut 24, glue 0.2, punch 0.5, width 1, ceiling -1 — finishing/monitor strip
fx.resonatorpitch 110, material 0.45, modes 6, blend 0.45 — modal resonator
fx.sidechainsource (required), amount 0.7, release 0.15

The saturator/maximizer hand-written worklets and every generated FAUST device gracefully fall back to the nearest Tone.js node, with a one-time warning, when their artifacts aren’t available. Playback never breaks. Device ids, ranges, macros, CPU class, latency, tails, fallbacks, and artifact paths come from one generated manifest.

Declared once in song.track({ effects: [...] }), applied in series, instrument → effects → mixer:

const kick = song.track({
name: "kick",
output: { kind: "synth", synth: { id: "kick" } },
effects: [
fx.distortion({ drive: 0.35, wet: 0.6 }), // hard-techno kicks are not clean
fx.eq3({ low: 2, mid: -1, high: 1 }),
fx.limiter({ threshold: -2 }),
],
});

A bus is a shared space — instead of a reverb per track, tracks tap their post-fader signal into a bus, and the bus runs one chain into the master. Declare buses with song.bus(name, { effects, volume }), and route into them with each track’s sends: { busName: level } (a linear 0..1 send level).

song.bus("space", {
effects: [fx.reverb({ decay: 6, wet: 1 }), fx.eq3({ low: -10 })],
volume: -6,
});
song.bus("echo", {
effects: [
fx.pingpong({ time: 0.75, feedback: 0.45, wet: 1 }), // dotted-8th, in beats
fx.filter({ frequency: 6500, type: "lowpass", q: 0.7 }),
],
volume: -8,
});
const clap = song.track({
name: "clap",
output: { kind: "synth", synth: { id: "clap" } },
sends: { space: 0.4, echo: 0.1 }, // big room, slight repeat
});

A return should be all-wet (wet: 1) — the dry path already went straight to the master. The bus strips show up in the mixer.

fx.sidechain({ source, amount, release }) ducks this chain’s gain in time with another track’s signal. The runtime envelope-follows the source track’s pre-fader signal and ducks by 1 - amount × envelope. Source binding is lazy, so declaration order doesn’t matter; a missing source warns once and passes through dry.

const rumble = song.track({
name: "rumble",
output: { kind: "synth", synth: { id: "bass" } },
effects: [
fx.filter({ frequency: 700, type: "lowpass", q: 1.2 }),
fx.sidechain({ source: "kick", amount: 0.8, release: 0.12 }), // the pump
fx.compressor({ threshold: -20, ratio: 5 }),
],
});

play().through(...effects) gives that one assignment a dedicated instrument instance routed through its own effect chain, so the effects color only that pattern’s events while track automation, the static chain, the fader, and the sends still apply. Other play() calls on the same track keep the shared instrument. Identical effect lists reuse a cached chain across bars (tails survive), and it renders correctly in offline WAV export too.

MIDI and external-instrument tracks share one hardware destination/return, so they cannot create an isolated per-play audio path. On those tracks .through() is ignored with a warning; put the effect on the track, or print the external return and process the resulting audio clip.

song.section("drop1", bars(16), () => {
stab
.play(stabsA)
.gain(0.5)
.through(fx.delay({ time: 1.5, feedback: 0.3, wet: 0.25 })); // delay colors only the stabs
});
song.section("breakdown", bars(16), () => {
stab
.play(hymn)
.gain(0.7)
.through(fx.reverb({ decay: 8, wet: 0.5 })); // a wash that only lives here
});

song.setMasterEffects([...]) sets the master-bus chain (read-only in the Devices panel — the master chain is code-owned by design). If a song never calls it, the runtime installs a tuned default (glue compressor → tilt EQ → maximizer). song.setMasterEffects([]) opts out of the default explicitly.