# Six Sines Dsp

> Reference for the Six Sines synthesizer DSP and synth engine (src/dsp/, src/synth/). Covers the node model and its patch/DSP mirroring, parameter IDs and streaming versions, signal flow, extended source modes, modulation routing, voice lifecycle, the patch/patchMain threading model, and the test suite.

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

---


# 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.

```bash
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`:

```cpp
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:

```cpp
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`:

```cpp
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:

1. **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. `firstBlockAfterAttack` exists 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.
2. **Refresh unison scalars** from the smoothed mono values, so unison spread and pan track
   automation mid-note.
3. **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.
4. **Macros**: each `MacroVoiceNode` either runs its own env/LFO (power on) or passes the
   mono macro value through.
5. **Per operator 0→5**: skip if inactive; zero inputs; set base frequency; apply every
   `MatrixNodeFrom` where this operator is the target; apply self-feedback; render the
   oscillator; render its mixer node.
6. **Output node**: accumulate the mixer outputs that route to main, apply env, LFO, level,
   velocity sensitivity, pan.
7. **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 in `ADD_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:

```cpp
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, keeps `patchMain` current 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`.** Only `patchMain`.
- **The LFSR shift cap is a hang fix**, not a taste decision.
- **`firstBlockAfterAttack` snapping 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.

