The Master FX rack
The Master Control panel's middle section is the FX rack — a chain of effect slots applied to the summed master bus. Each slot is a Faust DSP worklet you can author inline, parameterise live, enable / disable, and reorder. Two slot kinds:
- Insert — in the serial chain. Audio goes through every enabled insert in order. Use for EQ, compression, saturation — anything where the wet signal IS the new dry.
- Send — a parallel bus. Each mixer track taps in at its own send level (see § 1 Sends). Use for reverb, delay, modulated tape echoes — anything you'd want partially wet, per-source.
The rack ships with three built-in convenience slots (reverb / delay / filter) so you have something to fiddle with on first open. They're not magic — they're regular slots, removable + replaceable like anything you add yourself.
The slot lifecycle
A slot moves through three states:
- Added — you've called
master.fx.slot.add(or the UI's Add Slot button). The slot row appears with its label + params.available: falsewhile the worklet is still compiling. - Available — the Faust source has compiled successfully and the AudioWorklet is live.
available: true; the slot is now in the audio path. UI lights the slot green. - Removed —
master.fx.slot.remove. Worklet disposed, chain rewires, the slot vanishes from the panel.
The available flag matters because master.fx.slot.add returns immediately but the libfaust compile is asynchronous (Web Worker round-trip). If you add a slot then immediately fire master.fx.slot.param.set, the param call queues until the worklet binds — graceful but worth noting.
Authoring a custom slot
A slot's source is a tiny self-contained Faust program. Canonical shape — stereo in, stereo out (paired ports):
import("stdfaust.lib");
process(in_l, in_r) =
(in_l : <your processing>), (in_r : <your processing>) ;
Use any libfaust function (fi.lowpass, re.zita_rev1, ef.cubicnl, etc.). Every hslider("<label>", ...) you declare becomes a callable param via master.fx.slot.param.set. Since params are shared across both channels, define the per-channel processing once as a named function and apply it to in_l and in_r.
Example — a resonant lowpass with cutoff + resonance:
import("stdfaust.lib");
cutoff = hslider("cutoff", 1000, 50, 12000, 1) : si.smoo;
q = hslider("q", 1.0, 0.1, 20, 0.01) : si.smoo;
lp(x) = x : fi.resonlp(cutoff, q, 1.0);
process(in_l, in_r) = lp(in_l), lp(in_r);
After adding this as a slot, master.fx.slot.param.set { slot_id, param_name: "cutoff", value: 2000 } sets cutoff to 2000 Hz live. The si.smoo on input avoids zipper noise from MCP-driven param changes.
MCP surface
actions:
- action_id: master.fx.slots.list
input: {}
save_as: slots
label: "▶ 1. Snapshot current slots"
- action_id: master.fx.slot.add
input:
kind: insert
label: "Saturator"
source: |
import("stdfaust.lib");
drive = hslider("drive", 1.0, 1.0, 20.0, 0.1) : si.smoo;
sat(x) = x * drive : ef.cubicnl(0.3, 0.0);
process(in_l, in_r) = sat(in_l), sat(in_r);
save_as: sat
label: "▶ 2. Add a saturator insert"
- action_id: master.fx.slot.param.set
input:
slot_id: "{{sat.id}}"
param_name: drive
value: 4.0
label: "▶ 3. Crank drive to 4"
- action_id: master.fx.slot.enabled.set
input:
slot_id: "{{sat.id}}"
enabled: false
label: "▶ 4. Bypass it"
- action_id: master.fx.slot.remove
input:
slot_id: "{{sat.id}}"
label: "■ 5. Remove it"
The actions
| Action | Purpose |
|---|---|
master.fx.slots.list | Every slot, in routing order: { id, label, kind, enabled, params, source, available }. |
master.fx.slot.add { kind, label, source, params? } | Compile + insert. Sends append to the parallel bus; inserts append to the serial chain end. |
master.fx.slot.remove { slot_id } | Drop the slot, rewire the chain. Built-in slots can also be removed. |
master.fx.slot.enabled.set { slot_id, enabled } | Bypass / unbypass. Sends ramp their return gain; inserts get bypassed via a chain rebuild. |
master.fx.slot.param.set { slot_id, param_name, value } | Set one param. param_name is the literal Faust slider label. Special name returnLevel rides the JS-side return gain on sends. |
Send slots in depth
A send slot looks identical to an insert source-wise (same stereo process(in_l, in_r) shape) but its routing is different:
- Inserts:
master_dry → insert₁ → insert₂ → … → output. The whole bus goes through. Disabling routes around. - Sends:
master_dry → outputAND(track₁ send₁ + track₂ send₁ + …) → send₁ worklet → returnLevel → output. Tracks contribute parallel taps; the send worklet runs once on the sum; the wet returns atreturnLeveland sums to the output.
Two controls on a send slot:
- The Faust params you declared (
hsliderlabels). returnLevel— the JS-side gain on the send's return path. Set via the samemaster.fx.slot.param.setaction withparam_name: "returnLevel". Ramps smoothly; no recompile.
V1 caveat (see § 1): per-track send taps are wired at the track side but the engine-side tap isn't fully live yet. Insert slots are the load-bearing path today; send slots compile and own their bus.
Reordering inserts
The chain order matters for inserts (a saturator before EQ sounds different to EQ before saturator). V1 doesn't expose a reorder action — the order is the order of add calls. To reorder, remove and re-add in the desired sequence. The next iteration of the rack UI will expose drag-to-reorder.
Common moves
- "Bypass the whole FX chain to A/B with the dry mix" — iterate
master.fx.slot.enabled.set { enabled: false }over every slot; revert withenabled: trueper slot. (A single "bypass all" action is on the TODO list.) - "Audition a built-in reverb" — the rack ships with a
reverbslot pre-added;master.fx.slot.enabled.set { slot_id: "reverb", enabled: true }and mixer-strip send levels do the rest. - "Try a Hub-installed FX DSP as a master insert" — install the FX-shaped
.builder(it lands in the project), then use the panel's "+ ADD FX" picker to drop it into the rack. (Scripted equivalent:builder.exportthe rendered Faust source, feed it intomaster.fx.slot.add.)
Next section: master volume, the meter, softclip, and clip recovery.