Six Sines UI
Repo: baconpaul/six-sines. Paths are relative to the repo root.
Framework: JUCE, through sst-jucegui.
Companion skill: six-sines-dsp — read its §3 (the node model) and §9 (patch/patchMain)
before working here, because the UI mirrors both.
cmake --build $B --target six-sines_standalone --parallel
Build the standalone, not six-sines_all — the latter overwrites installed plugin bundles.
1. The editor binds patchMain, it does not own a copy
struct SixSinesEditor : jcmp::WindowPanel, ScreenHolder<SixSinesEditor>
{
Patch &patchMainRef; // bound to Synth::patchMain
SixSinesEditor(Patch &patchMain, Synth::audioToMainQueue_t &, ...);
This is the whole model. patchMainRef is the UI's data — there is no separate UI copy
to keep in sync. A widget reads a Param straight out of patchMainRef.paramMap, and
writing one writes patchMain directly and then tells the audio thread.
That is the opposite of a "dumb terminal" design and it is deliberate: the main thread
owns patchMain, the audio thread owns patch, and the CLAP adapter reads only
patchMain. Nothing has to wait for an echo before displaying.
Editor size is fixed: edWidth / edHeight in six-sines-editor.h. Read them; the layout
has been reproportioned before.
Idle
An idleTimer drives SixSinesEditor::idle(), which does two things:
- Checks
uiForceRebuild. An out-of-band write topatchMain— hoststateLoad, preset load, an inactiveparamsFlush— bumps that atomic.patchMainRefalready holds the new values, so the editor callsrebuildFromPatchMain()to refresh every widget. - Drains
audioToMain.UPDATE_PARAMis the host-automation echo and goes throughsetAndSendParamValue(id, value, /*notifyAudio*/ false)— writing the model and refreshing the widget without dirtying the patch or bouncing the value back. The rest is telemetry: VU, voice count, CPU, sample rate, MTS pointer.
editorActive gates the audio thread's telemetry pushes, so a closed editor costs nothing.
2. Data bindings
patch-data-bindings.h. Two adapters implement the sst-jucegui data interfaces over a
parameter id:
struct PatchContinuous : jdat::Continuous { SixSinesEditor &editor; uint32_t pid; Param *p; ... };
struct PatchDiscrete : jdat::Discrete { ... };
Both resolve pid through patchMainRef.paramMap in their constructor and assert and
terminate if the id is not there — a parameter you forgot to push in Patch::params()
fails loudly at startup rather than silently doing nothing.
setValueFromGUI does four things in order: write p->value, mark the patch dirty, push
SET_PARAM onto mainToAudio, request a params flush so the host sees it.
Display strings come from p->meta (ParamMetaData — see the sst-param-metadata skill).
PatchContinuous::setTemposyncPowerPartner lets a rate control render as a beat fraction
when its temposync toggle is on; the partner is another jdat::Discrete.
onPullFromMin / onPullFromDef fire when a control leaves its minimum or default — used
for "turning this up should also switch that on" behaviour.
3. createComponent
Never wire a control by hand. createComponent builds the widget and its binding together
and attaches everything:
createComponent(editor, *this, patch.someNode.someParam, myWidget, myWidgetData, args...);
It sets up:
- the binding (
PatchContinuousorPatchDiscrete) andsetSource onBeginEdit/onEndEditpushingBEGIN_EDIT/END_EDITto the audio thread, which is what gives the host proper automation gestures- tooltip show/update/hide on edit, idle hover and popup
- the right-click popup menu for continuous controls
setClapParamId, so the host can map the widget to its parameter- registration in
editor.componentByID[id], used byrebuildFromPatchMain panelSelectGestureFor, so touching the control also selects its panel
createRescaledComponent is the same thing with a rescaler in between — e.g.
PatchContinuous::cubic_t, a cubic throw for controls that need fine resolution near zero.
To suppress tooltips for a widget type, specialize by widget only:
template <> constexpr bool suppressTooltipByWidget<MyWidget>() { return true; }
That covers every binding of that widget type. JogUpDownButton is already specialized —
it shows its value in the label.
4. The component templates mirror the DSP mixins
The DSP side composes nodes from EnvelopeSupport, LFOSupport and ModulationSupport.
The UI composes panels from the matching three:
template <typename Comp, typename PatchPart> struct DAHDSRComponents; // dahdsr-components.h
template <typename Comp, typename Patch> struct LFOComponents; // lfo-components.h
template <typename Comp, typename Patch> struct ModulationComponents; // modulation-components.h
So a sub-panel is:
struct SourceSubPanel : juce::Component, HasEditor,
DAHDSRComponents<SourceSubPanel, Patch::SourceNode>,
ModulationComponents<SourceSubPanel, Patch::SourceNode>,
LFOComponents<SourceSubPanel, Patch::SourceNode>,
SupportsClipboard
A node that gains a mixin on the DSP side should gain the matching template here. You
get the whole envelope, LFO or mod-slot control set for free, including the step-sequencer
editor (LFOComponents::StepEditor).
5. Panels and sub-panels
Two tiers. The always-visible panels across the body — SourcePanel, MatrixPanel,
MainPanel, MixerPanel, MacroPanel — show the headline control for each node. A single
singlePanel at the bottom hosts exactly one sub-panel at a time: SourceSubPanel,
MatrixSubPanel, SelfSubPanel, MixerSubPanel, MainSubPanel, MainPanSubPanel,
FineTuneSubPanel, PlayModeSubPanel, MacroSubPanel, SettingsPanel.
Clicking a control in a top panel selects the corresponding sub-panel — that is what
panelSelectGestureFor and each panel's beginEdit(args...) do. Sub-panels carry an
index and a setSelectedIndex(size_t), since one sub-panel serves all six operators or
all fifteen matrix cells. hideAllSubPanels() and doSinglePanelHamburger() handle
switching.
ls src/ui/ is the index. Also there and easy to miss: segmented-ratio-editor (the ratio
control's own widget), waveform-display, spectrum-analyzer, knob-highlight,
ui-constants.h / ui-defaults.h, six-sines-skin.h.
Layout is SixSinesEditor::resized() driven by ui-constants.h. Read it rather than a
diagram.
6. Clipboard
clipboard.h. Panels implement SupportsClipboard and get copy/paste/reset for an
envelope, an LFO, a modulation block, or a whole node, typed by ClipboardType. The
templates take any node type, so a new node gets this by declaring the interface —
copyEnvelopeTo, pasteEnvelopeFrom and friends.
Paste goes through the editor so it produces proper parameter edits, not raw writes.
7. Presets and theming
src/presets/ holds PresetManager and UIThemeManager; the editor owns one of each plus
a PresetDataBinding behind the preset jog button.
Preset identity is subtler than it looks — a user preset can share a name with a factory
one, and jogging must stay in the right list and survive a session save. That behaviour is
pinned by tests/preset_jog.cpp; read it before touching preset selection.
Theming: SixSinesSkin, applyTheme, setThemeFromPreference, plus a live colour editor
in a DocumentWindow (openColorEditor, refreshColorEditorFromSkin,
commitSessionColorMap). factoryThemeSentinel marks a built-in theme in the stored
preference.
8. DAW extra state
State that belongs to the plugin instance rather than the patch — zoom factor, theme
choice, MPE settings, smoothing times — lives in DAW extra state.
scheduleDawExtraStatePush, applyDawExtraState and dawExtraStateRefreshListeners
manage it, and pushAudioDawState sends the audio-relevant subset
(SET_AUDIO_DAW_STATE) to the engine.
Register a dawExtraStateRefreshListener if a widget needs to re-read after a session
load.
9. Adding a control
- Add the parameter in
src/synth/patch.hwith a permanent id and a version tag — seesix-sines-dsp§4. - Declare a
std::unique_ptr<Widget>and a matchingstd::unique_ptr<PatchContinuous>(orPatchDiscrete) as members of the panel. createComponent(editor, *this, patch.node.param, widget, widgetData, args...)in the constructor.- Position it in
resized(). - If the parameter affects modulation routing, add its id to
modRoutingParamIdssorecomputeMacroUsage()runs on automation echo.
Everything else — tooltips, begin/end edit, clap id, rebuild registration — comes from
createComponent.
10. Sharp edges
- A missing parameter id terminates at construction. If the editor dies on launch after
you added a control, you did not push the parameter in
Patch::params(). UPDATE_PARAMmust not dirty the patch. It is the host's own value coming back;setAndSendParamValue(..., notifyAudio=false)exists for exactly this.- Do not read
Synth::patchfrom the UI. OnlypatchMainRef. rebuildFromPatchMainonly reaches widgets incomponentByID— hand-built controls that skippedcreateComponentwill not refresh after a preset load.- Sub-panels are shared across indices; anything cached in one must be reset in
setSelectedIndex.