SpectrumWorx — effects and DSP
Repo: surge-synthesizer/SpectrumWorx. Paths are relative to the repo root.
$B is your CMake configure directory.
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:
- 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. - 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.
- 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)andsetLast(last)take an inclusive last and storelast + 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 issetup.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:
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.cppfails 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 anengagelambda 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/andsrc/core/(barcore/host_interop/) is inside theengine-links-no-jucectest, which checks the compile command rather than the includes. Anything wantingjuce::Filebelongs insw-ioor above. - No allocation in
process(). Storage comes fromChannelState::resize()or the stack viaLE_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+infandpow(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
forcePositivewhenever 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 awhile (data)that advances it let the optimiser hoist the pointer, so every bin got bin zero's value — a 160× error. Read throughdataeach 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.