# 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.

> 🟦 **Master-FX rack vs per-track FX**: this section is the **global** rack on the summed master bus. Each MixerTrack ALSO has its own insert chain (§ 1 *Per-track FX*, `mixer.tracks.fx.*`) for channel-strip processing. Same Faust-slot mechanism, different scope.

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:

1. **Added** — you've called `master.fx.slot.add` (or the UI's *Add Slot* button). The slot row appears with its label + params. `available: false` while the worklet is still compiling.
2. **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.
3. **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):

```faust
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`.

> 🟦 **MONO LEGACY**: an old-style `process(in)` source still compiles — the engine downmixes the stereo bus into the single input — but the slot strip flags it ("mono on input") and it collapses the stereo image of everything running through it. Author the stereo signature. A Builder FX-shaped graph (one with an `input` node) already emits `process(in_l, in_r)` and drops straight into a slot, no folding needed. Validate unfamiliar sources via the `.dsp` editor's **Compile** button before mounting (see **FaustWave — Builder** § 2).

Example — a resonant lowpass with cutoff + resonance:

```faust
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

```yaml
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"
```

> 🟦 **HANDS-FREE PATH**: step 1 reads the current rack so you can see what's there; step 2 inserts a cubic-saturator (`ef.cubicnl` from libfaust) at the end of the chain; step 3 drives it harder live; step 4 bypasses it (audio routes around the slot without dropping the worklet); step 5 removes it entirely. Hit **Reset** between runs to clear saved chain state.

### 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 → output` AND `(track₁ send₁ + track₂ send₁ + …) → send₁ worklet → returnLevel → output`. Tracks contribute parallel taps; the send worklet runs once on the sum; the wet returns at `returnLevel` and sums to the output.

Two controls on a send slot:

- The Faust params you declared (`hslider` labels).
- `returnLevel` — the JS-side gain on the send's return path. Set via the same `master.fx.slot.param.set` action with `param_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 with `enabled: true` per slot. (A single "bypass all" action is on the TODO list.)
- **"Audition a built-in reverb"** — the rack ships with a `reverb` slot 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.export` the rendered Faust source, feed it into `master.fx.slot.add`.)

Next section: master volume, the meter, softclip, and clip recovery.
