SpectrumWorx — the interface
Repo: surge-synthesizer/SpectrumWorx. Paths are relative to the repo root.
JUCE, above an engine that links none of it. $B is your CMake configure directory.
cmake --build $B --target sw-plugin-tests && $B/sw-plugin-tests
$B/sw-plugin-tests "[gui]"
cmake --build $B --target sw-show-ui # the offscreen render tool
ctest --test-dir $B
Companion skills: spectrumworx-engine (who owns what — read its §3 first),
spectrumworx-dsp (effects).
There is no doc/tech document for the interface, so this skill carries more of its own
weight than the other two. The documents that constrain it are threading_model.md
(§1, §5 and §7 are about the editor), parameter_system.md and undo-redo.md.
1. The rule the interface exists under
The engine holds no widget. In 2016 SW::Module owned its own editor region as a
member, so every module the factory allocated carried that effect's JUCE widget storage
inline. Now SpectrumWorxEditor owns the strips and each one holds an
IntrusivePtr<Module> — the reference runs from the interface to the engine and never
back.
That is not a convention; it is what makes sw-dsp link no JUCE, which is checked by the
engine-links-no-juce ctest. See spectrumworx-engine §2.
The interface reads three channels from the engine and writes one:
ToEngine ring |
commands out — ordered, all delivered |
ToUI ring |
base-value echoes in, plus Retire on a ring of its own |
ValueMailbox |
modulated values in, as const & — coalescing, painting only |
| flags | chain changed, timing changed — carry no payload, cannot be dropped |
SpectrumWorxCLAP owns all three, not the editor. paramsValue, paramsValueToText
and stateSave are main-thread calls that happen with the window shut, so the model has
to outlive the editor and exist when there has never been one. The editor is handed
references at construction.
2. EditorHost — the dependency inversion
sw-impl links sw-gui, so sw-gui naming SpectrumWorxCLAP would be a cycle. Instead
gui/editor/editorHost.hpp declares what the editor needs from whatever hosts it.
Most of what the old editor asked the plugin for was really the engine's and is reached
through core(). EditorHost carries only what is genuinely the host's — the side
channel's sample file, presets, and the persisted settings.
It is deliberately small. Every function added there is one the editor cannot be tested without a plugin behind it — which is exactly what the headless harness in §6 would lose.
3. The module rack is recomputed, not diffed
resyncModuleRack() drops strips whose module has gone, builds strips for modules that
have none, and places every one where the main thread's chain says. A recomputation
rather than a diff, because between a click and the engine applying it the rack is what
the user asked for and the engine's chain is what is playing.
Three things ask for it, and all three are needed:
- Whatever changed the main thread's chain says so — add, remove, preset load, each
calling
refreshModuleRackAsync(). - The engine's echo says so, through
chainChangedPending_, for changes originating on the audio thread — a host writing a slot selector insideprocess(). - That echo is acted on synchronously, from
drainEngineEvents()inonMainThread().
Point 3 has a consequence that governs everything below: a strip can be destroyed inside a host callback, between one paint and the next.
Which is why resyncModuleRack() opens by dismissing any open menu, and why
detachFrom() decides what to drop by asking each widget what it is pointing at
rather than asking the editor what is currently selected. The LFO display and the shared
module controls are children of the editor, each holding a raw ModuleUI *, and
deactivation deliberately leaves them alive while clearing the editor's records — so the
two questions have different answers exactly when a strip is being freed.
And point 1 is not redundant with point 2: a preset load fills the main thread's program
outright and only queues the engine's copy, so waiting for the echo would make the
picture depend on the host calling process() — which Logic does not do for an AudioUnit
on a track that is neither playing nor monitored. That was a live bug, and it is pinned by
a case that never calls process().
4. Widgets
gui/gui.hpp |
WidgetBase<Base>, PopupMenu, PaintedButton, DrawableText, the postMessage helpers, SkinLifetime |
gui/modules/moduleUI.hpp |
ModuleUI — one strip; ModuleKnob, ModuleLEDTextButton, TriggerButton |
gui/modules/moduleControl.hpp |
ModuleControl<ImplWidget> / ModuleControlBase — the parameter-bearing behaviour a strip control has |
gui/editor/spectrumWorxEditor.hpp |
the editor: main area, module menu, drop indicator, sample area, LFO display, settings tabs, undo button |
gui/editor/zoomedEditor.hpp |
the editor drawn larger than the skin it is laid out in |
gui/preset_browser/ |
the browser |
gui/painters/ |
every painted element — knobs, strips, buttons, frames, capsules, arrows, glyphs, waveform |
A module control is generic over the parameter it carries; the parameter's type selects
quantization and menu behaviour through traits, which is how one knob class serves every
effect's parameters. That is the interface half of the dynamic parameter system — see
parameter_system.md.
SkinLifetime owns JUCE's theme, not JUCE's lifetime. It builds the Theme and
installs it as the default LookAndFeel while at least one editor exists. It does not
count JUCE up or down — the shim's ScopedJuceInitialiser_GUI does that. Closing an
editor used to call shutdownJuce_GUI() against a counter JUCE's own initialiser never
saw, which with two instances is one tearing down the message loop the other runs on.
5. The skin is painted, and the palette is a single file
The artwork is drawn in code rather than loaded as bitmaps, so the palette has to live somewhere — and "a constant beside the drawing" is how this tree came to spell its accent blue four ways, none chosen and no two three parts in 255 apart.
gui/colourMap.hpp is the palette and nothing else may name a colour. Enforced by the
no-colours-outside-the-palette ctest, a source scan.
The fix for anything it reports is to add an enumerator for what the colour is for and
call getColour(). Deriving from an existing one is fine and does not match:
.withAlpha(), .brighter() and friends say what they do. A gradient fading a colour out
wants getColour(X).withAlpha(0.0f) rather than a transparent enumerator — a gradient
interpolates the channels as well as the alpha.
getColour() is a switch rather than a table so the answer can grow a condition without
every call site learning about it. That is what let the palettes multiply without touching
two hundred call sites: only one is written out, the classic recolours turn its hue — and
a colour the artwork left neutral has no hue to turn, which keeps the greys grey with no
list of exceptions. The dark set is the one that is not a recolour; it inverts the chassis
and so names what it changes.
gui/theme.hpp is the LookAndFeel and sits in the same layer, below everything else in
src/gui. gui/resources.hpp reads bitmaps and fonts out of the binary — in 2016
they were found on disk through an installer-written paths file, so a plugin that had been
copied rather than installed came up with no skin.
6. Testing the interface headlessly
tests/gui/editorHarness.hpp is one plugin's worth of everything a SpectrumWorxEditor
reaches into, with no host and no plugin format under it. Editors are constructed
directly rather than through the CLAP shim, because what these cases test is our own
bookkeeping; the shim's half is stood in for by a ScopedJuceInitialiser_GUI.
The GUI cases live in sw-plugin-tests (tests/CMakeLists.txt, an explicit list) and
cover knobs and their menus, sliders and drags, module hover / header / drag, module
control focus, the module menu, the LFO display, the preset browser's navigation and save
button, the side-chain selector, palettes, paths, preferences, overlay panels, discrete
parameters, and two instances at once.
tests/gui/twoInstanceTests.cpp is the one to know: closing one editor must leave the
other's MessageManager alive, selection must stay independent, and ejecting a module and
then its ghost must not fault.
sw-show-ui renders a module offscreen per effect — one
show-ui-renders-module-<Effect> ctest each, registered by parsing effectsList.hpp and
driven by SW_SHOW_UI_EFFECT. That parse is fragile by construction: reflowing the effect
table once dropped seventeen of these tests without failing, which is why the configure
step now fatals when the parse count disagrees with the effect count.
A menu is one of the things a headless editor cannot drive, so anything reachable only
through a popup needs its case built at the engine end instead — see spectrumworx-engine
§8 on asserting at the far end.
7. What the interface must not do
- Never touch engine state from a paint or a timer. The audio thread owns the engine while activated; the interface talks to it only through the rings.
- Never write a widget from the audio thread. The engine's LFO update runs there; reaching a component from it is rule 1 of the threading model. This is why one editor redraw had no caller for the life of the port — its CLAP equivalent ran on the wrong thread.
- A queued edit has to be asked for. Nothing drains the command queue but
process(),paramsFlush()anddeactivate(). A knob gets that for free from the automation notification; a bulk change such as a preset must callrequest_flushitself. - Both copies, or neither. An edit made in the interface must move the main thread's program and reach the engine. Writing only the strip's own object moves the display and the saved state and nothing anybody can hear — which is exactly what the LFO sync-mode buttons did until it was found.
- Name a colour and the build tells you. §5.
- Menus are dismissed before a strip, a chain or a program is replaced. §3.
LE_ASSERTcompiles to nothing underNDEBUG; anything a shipped build must not do needs a real check.