Six Sines DSP and Synth Engine
Repo: baconpaul/six-sines. Paths are relative to the repo root. C++20, CLAP-first.
$B is your CMake configure directory.
cmake --build $B --target six-sines_standalone --parallel # default for verifying a change
cmake --build $B --target six-sines-test && $B/six-sines-test
cmake --build $B --target six-sines-perf # timing harness, separate
Build the standalone, not six-sines_all, unless asked. six-sines_all overwrites the
installed AUv2/CLAP/VST3 bundles, which disrupts a DAW session running the plugin live.
The repo's own CLAUDE.md says this too.
Companion skill: six-sines-ui for src/ui/.
1. The shape of it
A 6-operator FM/PM synth. The CLAP plugin owns a Synth, which owns a fixed
std::array<Voice, maxVoices> and a voice manager. Each Voice holds 6 OpSource
oscillators, matrixSize (15) cross-operator nodes, 6 self-feedback nodes, 6 mixer nodes,
6 macro nodes and one output node.
Operators render in index order 0→5 every 8-sample block, so a lower-indexed operator is
always finished before anything reads it as a source. That ordering is why the matrix is
strictly lower-triangular: 15 connections, one for each src < tgt pair.
The engine runs oversampled (2.5× to 5× host rate, selectable) and downsamples through libsamplerate or a Lanczos resampler.
2. Constants
src/configuration.h — short, read it. blockSize 8, numOps 6, matrixSize 15,
numMacros 6, maxVoices 64, numModsPer 3, numSeqSteps 16.
It also defines the streamed enums for the output signal path (SaturationType,
LowpassMode, BitRateMode, BitDepthMode, HighpassMode), the oversampling choice
(SampleRateStrategy) and the resampler choice (ResamplerEngine). These values stream
— append, never reorder.
3. The node model
This is the organizing idea of the whole codebase, and the thing to understand first.
Every node exists twice: once as parameters in Patch (src/synth/patch.h) and once as
DSP state (src/dsp/). The DSP side binds const float & references to the patch side at
construction, so rendering never looks anything up.
Patch node (patch.h) |
DSP node | Role |
|---|---|---|
SourceNode |
OpSource (op_source.h) |
One oscillator |
SelfNode |
MatrixNodeSelf (matrix_node.h) |
Self-feedback for one operator |
MatrixNode |
MatrixNodeFrom |
One src→tgt connection |
MixerNode |
MixerNode |
Per-operator level and pan into the bus |
MacroNode |
MacroVoiceNode (macro_node.h) |
Per-voice macro with its own env and LFO |
OutputNode |
OutputNode |
Master level, pan, tuning, signal path |
FineTuneNode, MainPanNode |
same names | ModulationOnlyNode subclasses hung off output |
The three mixins
Patch nodes compose from DAHDSRMixin, LFOMixin and ModulationMixin. The DSP nodes
mirror that with three templates in src/dsp/node_support.h:
template <typename T> struct EnvelopeSupport; // DAHDSR
template <typename Parent, typename T, bool needsSmoothing> struct LFOSupport;
template <typename Bundle, typename Node> struct ModulationSupport;
So OpSource is:
struct alignas(16) OpSource : EnvelopeSupport<Patch::SourceNode>,
LFOSupport<OpSource, Patch::SourceNode, false>,
ModulationSupport<Patch::SourceNode, OpSource>
Adding a node type means writing both halves and picking the mixins. Adding a parameter
to an existing node means adding it in patch.h, binding a reference in the DSP node's
constructor, and using it in renderBlock().
4. Parameters, IDs and streaming versions
Every parameter is a Param with ParamMetaData (see the sst-param-metadata skill for
the builder methods). patch.h is ~2000 lines and is the source of truth for what exists.
Parameter IDs are permanent. Each node type declares idBase and idStride:
struct SourceNode : ... { static constexpr uint32_t idBase{1500}, idStride{250}; ... };
and every parameter takes .withID(id(n, idx)). Hosts store these IDs in automation lanes,
so changing one silently breaks every saved project. Add new parameters at unused
offsets within the node's stride; never renumber.
Every parameter also carries a version tag. Patch declares a ladder of them —
version_110, version_120a through version_120h, version_130a — each with a comment
naming the feature it introduced (step sequencer, extended source mode, super macros,
resonant sweeps, output signal-path stages, pink noise, LFO song-position run mode,
phase-map read-phase offset). A parameter added later is built with
floatMd(version_120d) rather than the default.
That tag is what lets an old patch load: a parameter whose version postdates the file gets
its default instead of a garbage read. When you add a parameter, add a version constant
for the feature and tag the parameter with it. Bump patchVersion alongside.
structure.cpp in the tests pins the version_110 parameter order — if it fails, you
moved something that streams.
5. Signal flow
Voice::renderBlock() in src/synth/voice.cpp, per 8-sample block:
- Advance expression lags. MPE bend / timbre / pressure and note-expression tuning and
pan all go through lags. On the first block after attack they snap; afterwards they
smooth.
firstBlockAfterAttackexists because the voice manager pushes initial MPE setter values after the voice is created but before its first render — smoothing from zero would glide every note in from the wrong pitch. - Refresh unison scalars from the smoothed mono values, so unison spread and pan track automation mid-note.
- Compute pitch: base key → MTS-ESP retuning (interpolated across the two straddled semitones when MPE is active, so bend follows the local scale slope) → pitch bend → portamento → note expression → fine tune and octave transpose.
- Macros: each
MacroVoiceNodeeither runs its own env/LFO (power on) or passes the mono macro value through. - Per operator 0→5: skip if inactive; zero inputs; set base frequency; apply every
MatrixNodeFromwhere this operator is the target; apply self-feedback; render the oscillator; render its mixer node. - Output node: accumulate the mixer outputs that route to main, apply env, LFO, level, velocity sensitivity, pan.
- Fade if the voice is releasing (
fadeOverBlocks= 32, linear, avoids clicks).
Inside an operator
OpSource holds per-block input arrays that the matrix nodes write into:
phaseInput[], feedbackLevel[], rmLevel[], fmAmount[] (in Hz). Phase is a uint32_t
accumulator advanced by dPhase; the waveform comes from SinTable by cubic Hermite
interpolation.
MatrixNodeFrom writes into exactly one of those four depending on its mode — phase
modulation, ring modulation, linear FM or exponential FM.
6. Extended source modes
SourceNode::ExtendedMode turns an operator into something other than a plain oscillator:
| Mode | What it does |
|---|---|
NONE |
Plain oscillator |
PHASE_REMAP |
CZ-style phase distortion: read wav(map(phase)) instead of wav(phase). Shapes in PhaseMapShape; implementations in dsp/remap_functions.h. phaseMapReadPhase rotates the read point, so you can read a cosine through the same map. |
RESONANT_SWEEP |
CZ-style resonant sweep — a windowed high-frequency sine. Windows in ResonantSweepWindow, implemented in dsp/resonant_window.h. ResonantSweepFrequencyDepth picks the multiplier. |
NOISE |
Noise injection via dsp/noise_helper.h. NoiseType is white / pink / tilt / chip LFSR; NoiseMode decides whether it adds to phase, adds to signal, mixes, or multiplies. |
Two things in NoiseHelper are load-bearing rather than cosmetic:
- The LFSR shift clock is capped at 20 kHz. Without the cap, modulating N high drives the shift frequency into the gigahertz and the per-sample shift loop hangs the audio thread. Do not remove it.
noisePhaseScale(0.33) attenuates noise inADD_TO_PHASE, because a full cycle of phase jitter is unusable.
Latch-at-attack
Mode enums are cached at note attack and do not change mid-note. OpSource::cacheEnums()
populates waveFormCachedAtAttack, extendedModeCachedAtAttack, noiseTypeCachedAtAttack
and friends in reset(); renderBlock() and the inner-loop dispatcher read those typed
members rather than rounding a float every block.
So a mode change takes effect on the next retrigger, not immediately. That is deliberate — it is both a performance win and what keeps the heavy per-mode state coherent. If you add a mode-shaped parameter, cache it the same way.
Feedback dispatch
hasActiveFeedback is set per block by MatrixNodeSelf::applyBlock. When it is false the
inner loop instantiates a no-feedback template that skips the feedback math and the
history shift, removing a per-sample backward dependency and letting the compiler reorder
much more aggressively. Keep that split intact when touching the inner loop; the perf
harness has a "no self-FB (SIMD baseline)" scenario to measure it.
7. Modulation
ModMatrixConfig (src/synth/mod_matrix.h) enumerates sources with streamed IDs laid
out in deliberately gapped ranges:
CHANNEL_AT 100, PITCH_BEND 101
MIDICC_0 200 (126 gap after)
MACRO_0 400, MACRO_MOD_0 410 (numMacros gap after each)
voiceLevel 5000 + { VELOCITY, RELEASE_VELOCITY, POLY_AT, GATED, RELEASED, UNISON_VAL,
KEYTRACK_FROM_60, MPE_*, RANDOM_*, INTERNAL_LFO, INTERNAL_ENV }
The gaps exist so a new source in a family does not collide. Add inside the family's gap; never renumber.
Each node has numModsPer (3) slots, each a (modsource, moddepth) parameter pair.
ModulationSupport resolves a source id to a live const float * at bindModulation()
time and caches the prior value for change detection.
8. Envelopes and LFOs
EnvelopeSupport wraps a DAHDSR from sst-basic-blocks with per-stage shape parameters
and a TriggerMode: NEW_GATE, NEW_VOICE, KEY_PRESS, PATCH_DEFAULT, ON_RELEASE.
Voice::retriggerAllEnvelopesForKeyPress() and ...ForReGate() respect each node's mode,
and restart from the current value so retriggering does not click.
LFOSupport carries two modulators: a SimpleLFO (Sine, Ramp, Saw, Triangle, Pulse,
Noise, S&H) and a StepLFO with numSeqSteps steps, selected by the Step shape. The
step sequencer has its own storage, transport and cycle mode.
LfoRunMode decides phase origin: VOICE_TRIGGER, SONGPOS (locked to host transport),
RANDOM_PHASE, RANDOM_PHASE_UNISON (random but coherent across a unison stack — see
tests/lfo_unison_random.cpp).
needsSmoothing on the LFOSupport template applies a one-pole lag for the discontinuous
shapes. OpSource instantiates it false; audio-rate phase modulation wants the steps.
9. Threading: patch and patchMain
Synth owns two patches:
Patch patch; // audio-thread working copy
Patch patchMain; // main-thread source of truth
The editor binds a Patch & to patchMain and never owns a copy. Every host-facing
main-thread call — stateSave, stateLoad, paramsValue, paramsInfo,
paramsValueToText, and paramsFlush when inactive — reads patchMain. The CLAP
adapter must never read patch. This is what removes the old stateSave quiesce and
spin-wait; see the patch-to-main skill for the invariants and the migration.
Two lock-free ring buffers connect them:
mainToAudio—SET_PARAM,SET_PARAM_WITHOUT_NOTIFYING,BEGIN_EDIT/END_EDIT,STOP_AUDIO/START_AUDIO,SEND_POST_LOAD,PANIC_STOP_VOICES,SET_AUDIO_DAW_STATE,REQUEST_NON_PATCH_STATE.audioToMain—UPDATE_PARAM(host-automation echo, keepspatchMaincurrent and moves the knob), plus UI-only telemetry:UPDATE_VU,UPDATE_VOICE_COUNT,UPDATE_CPU_USAGE,SEND_SAMPLE_RATE,MTS_POINTER.
handleAudioToMainMessage(Patch &dest, const AudioToMainMsg &) is static — it only
touches dest — so the editor, which has no Synth handle, can call it, and
drainAudioToMainInto shares it. sendEntirePatchToAudio is static for the same reason.
uiForceRebuild is bumped on an out-of-band write to patchMain (host stateLoad, preset
load, inactive paramsFlush) so an open editor rebuilds every widget on its next idle.
Patch name, author, dirty flag and macro names are main-thread-only and do not travel
through the queue.
snapAllParams() collapses every lagged parameter to its settled value without
allocating — used by tests and before streaming a snapshot.
AudioDawState (MPE dialect, smoothing times) is engine-session state, not patch state; it
travels by value in SET_AUDIO_DAW_STATE.
10. Output signal path
OutputNode ends in a chain of optional stages, all streamed enums from
configuration.h: saturation (SAT_NONE / SAT_SOFT / SAT_OJD), a fixed-frequency
lowpass, ZOH bit-rate reduction, bit-depth reduction, and a highpass. These have golden
tests in tests/output_stage_dsp.cpp — if you touch the saturators or the ZOH
downsampler, expect exact-value failures and regenerate deliberately.
11. Tests
ls tests/ for the set; tests/CMakeLists.txt is an explicit source list, so add new
files there. What each guards:
| File | Guards |
|---|---|
structure.cpp |
The version_110 parameter order. Fails if you moved something that streams. |
patch_sync.cpp |
The whole patch/patchMain contract — UI edit reaching audio, automation draining back, paramsFlushMainThread, DAW state round-trip, clap cookies. Read this before changing anything in §9. |
output_stage_dsp.cpp |
Golden values for the saturators and ZOH downsampler |
mpe_smoothing.cpp |
Attack snap vs mid-note smoothing, mod-source pointer rebinding |
factory_patches.cpp |
Every factory patch still loads |
preset_jog.cpp |
Preset identity and jog order — user/factory name clashes, session restore |
lfo_unison_random.cpp |
Unison-coherent random LFO phase |
phase_map_read_phase.cpp |
Phase-map read-phase streaming and rotation |
perf_scenarios.cpp |
The perf harness — voice counts, each extended mode, the no-feedback SIMD baseline |
12. Common changes
New parameter on an existing node: add to patch.h at an unused offset in that node's
stride, tag it with a version constant, bind a const float & in the DSP node's
constructor, use it in renderBlock(). Bump patchVersion.
New modulation source: add to ModMatrixConfig::Source inside the right family gap,
register it in mod_matrix.cpp, resolve it in ModulationSupport::bindModulation.
New waveform: append to SinTable::WaveForm before AUDIO_IN, which must stay last
before NUM_WAVEFORMS; populate quadrantTable in sintable.cpp; add the UI name.
Waveform values stream as integers, so inserting in the middle changes the sound of every
saved patch.
New extended mode: add to ExtendedMode, cache it in cacheEnums(), dispatch in the
inner loop, and add a perf scenario.
New matrix mode: add the case in MatrixNodeFrom::applyBlock, and the UI string.
13. Sharp edges
- Parameter IDs and streamed enum values are permanent. Append; never reorder.
- Mode enums latch at attack. A mid-note change is invisible until retrigger, by design.
- The CLAP adapter must never read
patch. OnlypatchMain. - The LFSR shift cap is a hang fix, not a taste decision.
firstBlockAfterAttacksnapping is load-bearing — removing it glides every note in from the wrong pitch under MPE.- The engine is oversampled: sample-rate-dependent constants must go through
SRProvider, not the host rate.