# Spectrumworx Dsp

> Reference for SpectrumWorx spectral effects and DSP (src/le/spectrumworx/effects/, src/le/math/). Covers the effect contract — the two classes, setup/process, ChannelData flavours, the working range, ChannelState, side chain and MIDI — the six-file registration, the test obligations a new effect inherits, and the traps. Routes to doc/tech/effect_contract.md, which is the authority.

- Skill: `baconpaul/spectrumworx-dsp` (Agent Skill)
- Install (CLI): `npx skillmds@latest add baconpaul/spectrumworx-dsp`
- Raw SKILL.md: https://api.skillmd.com/api/skills/baconpaul/spectrumworx-dsp/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: baconpaul (https://skillmd.com/u/baconpaul)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/baconpaul/spectrumworx-dsp

---


# SpectrumWorx — effects and DSP

**Repo:** `surge-synthesizer/SpectrumWorx`. Paths are relative to the repo root.
`$B` is your CMake configure directory.

```bash
cmake --build $B --target sw-dsp-tests && $B/sw-dsp-tests
$B/sw-dsp-tests "[goldens]"
ctest --test-dir $B
```

Companion skills: `spectrumworx-engine` (who may call what, parameters, streaming),
`spectrumworx-ui` (`src/gui/`).

---

## 1. `doc/tech/effect_contract.md` is the authority

It is long, current, and written for exactly the person about to write or revive an
effect. **Read it rather than reasoning from this page.** Its three parts:

1. **The contract** — the two classes, `setup`/`process`, parameters, the working range,
   `Engine::Setup`, `ChannelState`, the side channel, MIDI, the six-file registration, the
   tests a new effect joins, and what the layering forbids.
2. **The inner DSP** — what a frame is, the phase vocoder domain, and four effects taken
   apart (Bandstop, Shifter, Blender, Freqverb), then the idioms collected and four traps
   with receipts.
3. **The inventory** — the shipped effects by group, the ones in the tree and in no build,
   and `_unfinished/` triaged by what each would cost.

The *normative* statement is the comment block at
`src/le/spectrumworx/effects/effects.hpp` — the document is that comment with the
machinery attached and the tree measured against it.

This skill is the orientation and the checklist. Counts and inventories live in the
document, dated; do not restate them from memory.

## 2. What an effect is

Three files in one folder — `<folder>` snake_case, `<module>` camelCase:

```
src/le/spectrumworx/effects/<folder>/
    <module>.hpp        base class     — parameters, title, description
    <module>Impl.hpp    implementation — setup(), process(), ChannelState
    <module>Impl.cpp    definitions    — title[], description[], the DSP
```

The split is not cosmetic. **Everything a host, a preset or the GUI needs lives in the
base header, and nothing there says how the effect works.** The parameter table, the menus
and the preset keys are built from the base headers alone.

Base class: `title[]` (the menu entry **and the preset key** — must be unique),
`description[]`, and optionally a `LE_DEFINE_PARAMETERS(...)` list.

Impl: derive from `EffectImpl<Base>`, or `NoParametersEffectImpl<Base>` when there are
none. Default constructible; `setup(IndexRange const &, Engine::Setup const &)`;
`process([ChannelState &,] <a ChannelData flavour>, Engine::Setup const &) const`; neither
may throw.

**`setup()` runs once per block on the audio thread, `process()` once per channel per
frame and is `const`.** Anything derived from a parameter belongs in `setup()`; anything
that must remember across frames belongs in `ChannelState`.

## 3. The signature *is* the declaration

Two facts about an effect are declared by nothing but its function signatures, and this is
deliberate:

**Which data flavour it wants** — overload resolution on `process()` picks from
`ModuleDSP::ChannelDataProxy`'s conversion operators. There is no flag and no
registration.

| Declare | You get |
|---|---|
| `Engine::ChannelData_AmPh` | main channel, amplitude + phase — the engine's native form, free |
| `Engine::ChannelData_ReIm` | main channel, rectangular — costs a conversion if the previous module left AmPh |
| `Engine::MainSideChannelData_AmPh` / `_ReIm` | `.main()` and `.side()` |
| `Engine::ChannelData_AmPh2ReIm` / `_ReIm2AmPh` | read one domain, write the other — for effects that sum voices rather than replace bins |

**Whether it reads the side chain** — taking a `MainSideChannelData` *is* the declaration.
There is no second place to say so and therefore no second place to be wrong. A
`static bool usesSideChannel` existed until it was measured and found to name seven
effects where the engine's behaviour said fifteen; it was deleted rather than corrected.

**Whether it consumes MIDI** — take a third `setup()` parameter,
`Engine::MIDINoteStatus const &`. Same reasoning. It goes to `setup()` and never to
`process()`, because `process()` is `const` and runs per channel, so an edge taken there
would be seen by the first channel only.

`data.side()` and the note status are both **read only**: every module in the chain is
handed the same one.

## 4. The working range

`IndexRange` is a half-open `[begin, end)` of bin indices computed per block from the two
base frequency parameters. Every `ChannelData` flavour arrives already clipped to it, so
an effect that only touches `data.amps()` and `data.phases()` honours it by construction.

> **It is half-open and its setter is not.** `begin()`/`end()`/`size()` are half-open;
> `first()`/`last()` are inclusive; `setNewRange(begin, last)` and `setLast(last)` take an
> **inclusive** last and store `last + 1`.

You need the range explicitly in three cases: bin arithmetic in `setup()` (clamp Hz→bin
conversions, the user can move the range under you); effects that deliberately work
*outside* it (Bandpass is the one documented inversion); and **anything keeping a per-bin
history, which must keep it for the whole spectrum** or it goes incoherent the moment the
range moves — `data.copySkippedRanges(...)` carries the untouched bins across.

The phase-vocoder domain markers ignore the range on purpose and use `data.full()`: a
domain transition has to be whole-spectrum or the two halves disagree about what "phase"
means.

## 5. `Engine::Setup` and `ChannelState`

`Setup` is the frame's geometry, read-only, passed to both functions. The two to know:

- **`maximumAmplitude()`** — amplitudes are *not* normalised to 1, so a dB threshold is
  `setup.maximumAmplitude() * Math::dB2NormalisedLinear(dB)`.
- **`stepTime()` / `stepsPerSecond()`** — how a time constant becomes a per-frame
  constant. An effect that counts frames without dividing by these changes speed when the
  user changes the overlap factor.

`ChannelState` is the only mutable thing, one per channel, and no channel sees another's.
Four tiers: none (cached scalars as Impl members, written by `setup()`);
`StaticChannelState` (per-channel scalars, mandatory `reset()`);
`ModuloCounterChannelState` (fire every N frames);
`DynamicChannelState_<Self>` (members sized out of the engine's storage block — declare
them, return them from `members()` via `std::tie`, and `requiredStorage()`/`resize()`/
`reset()` are generated).

**An effect that draws random numbers holds a `Math::Rng` in its `ChannelState` and
nowhere else.** There is no global generator; the old file-scope state was a race *and*
made three effects depend on the host's block size.

## 6. Registering it — six files

Full detail in `effect_contract.md` §1.10. The two that are load-bearing:

**`effects/configuration/effectsList.hpp`** — one `x( folder, module, Class )` line, and
bump `LE_SW_NUMBER_OF_EFFECTS`. **The order is ABI**: presets resolve by name but
automation addresses a slot's content by index. **Append. Never insert, reorder or
remove.** The table is bracketed by `clang-format off/on` because a CMake regex parses it
one `x(...)` per line — reflowing it once silently dropped seventeen tests *without
failing*; the configure step now fatals if the parse count disagrees.

**`gui/editor/moduleMenuLayout.cpp`** — put the effect in a menu group, by its **streaming
name**. Not optional: an effect in no group **terminates the plugin** on the first menu,
because a menu missing an entry is an effect no user can reach and nothing else would
notice.

The other four: `allEffects.hpp` and `allEffectImpls.hpp` (alphabetical includes),
`src/dsp.cmake` (an explicit list — **a file not named there is not compiled and nothing
tells you**, which is exactly how four effects rotted), and the count assertions in
`tests/effects/effectsListTests.cpp`.

Everything else is derived and updates itself. Do not hand-edit `constants.hpp`,
`includedEffects.hpp`, `indexToEffectImplMapping.hpp`, `effectNames.cpp`, the factory
dispatch, the parameter table or the widgets. **A new effect adds nothing to
`effectNames.cpp`'s pin table** — that exists only for a title that moved after presets
had named it.

## 7. What a new effect is enrolled in, and what you must regenerate

Almost every test enumerates the effect count, so appending a row enrols you. Three
snapshots then oblige you to regenerate — read the diff before committing it:

```bash
SW_GOLDEN_UPDATE=1          $B/sw-dsp-tests "[goldens]"
SW_PARAMETER_TABLE_UPDATE=1 $B/sw-plugin-tests
SW_STREAMING_NAMES_UPDATE=1 $B/sw-dsp-tests
```

Two suites carry **hand-maintained name lists you can fall silently outside of**:

> **A side-chain effect with no row in `sideChainTests.cpp` fails bit-exactly.** The sweep
> partitions the set; an effect not listed is held to the *deaf* control — render with
> main == render with side — which an effect that reads the side chain cannot satisfy. The
> count check still passes, so the failure arrives as a mystery. Add the row, plus an
> `engage` lambda if your effect does not listen at its defaults.

`goldenTests.cpp`'s `amplifiesRounding` list is the effects held to a looser tolerance.
Its own note asks you to establish that a new entry is a decision boundary rather than a
bug before adding one — a third list was deleted once the divergence turned out to be
undefined behaviour rather than arithmetic.

**Two fixture cases render in Release only** (the hash column is a same-build contract), so
verify in both build directories.

**A note-consuming effect has no goldens** — the golden harness drives the engine directly
and has nowhere to put a note, so it would render silence at every configuration. Those
are skipped and pinned by a case that can press a key instead.

## 8. What the layering forbids

- **No JUCE and no host.** Everything under `src/le/` and `src/core/` (bar
  `core/host_interop/`) is inside the `engine-links-no-juce` ctest, which checks the
  compile *command* rather than the includes. Anything wanting `juce::File` belongs in
  `sw-io` or above.
- **No allocation in `process()`.** Storage comes from `ChannelState::resize()` or the
  stack via `LE_ALIGNED_SCOPED_STACK_BUFFER(name, type, count)`.
- **No exceptions.** The functions must be no-fail.
- **No locks, no atomics, no messaging.** An effect that wants to tell the UI something
  has no channel to do it on, and inventing one is a change to `threading_model.md`, not
  to the effect contract.

## 9. The traps, with receipts

All four are in `effect_contract.md` §2.6 with the issue numbers and the fixes. Know they
exist:

- **Exact zeros arrive.** A preceding Bandpass, Sharper or Denoiser leaves amplitudes at
  exactly 0. `pow(0, negative)` is `+inf` and `pow(0, 0)` is 1 — that combination rendered
  four factory presets as NaN.
- **Running sums drift.** A moving average over thousands of bins loses a term below its
  own ulp and then subtracts it back out, so a non-negative input comes back negative and
  stays that way. Pass `forcePositive` whenever the input is a magnitude.
- **A stride is not a count.** The one strided primitive forwards on Apple to a vDSP call
  wanting *elements*; passing the float count wrote past the end into the side channel's
  arena. An overrun inside one arena is the class a sanitizer cannot see — factory presets
  caught it.
- **A range picked before the loop does not follow it.** Taking a reference to
  `data.side().amps()` outside a `while (data)` that advances it let the optimiser hoist
  the pointer, so every bin got bin zero's value — a 160× error. Read through `data` each
  bin, or advance a cursor of your own.

## 10. Reviving an orphan

`effect_contract.md` §3.2 and §3.3 cover the effects in the tree and in no build: four
that are complete and simply unwired, and the `_unfinished/` folder triaged into delete /
port / spec-disagrees / skeleton / not-C++, each with what it would cost. §3.3 also
states the port tax that applies once per effect. Start there rather than opening folders
at random.

