# Sst Voicemanager

> Reference for the sst-voicemanager library. Covers the responder contract, voice lifecycle, play modes (poly/mono/legato/piano), voice stealing, mono priority, hierarchical polyphony groups, continuation data, MPE, MIDI routing, realtime safety, and the test infrastructure.

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

---


# sst-voicemanager

**Repo:** `surge-synthesizer/sst-voicemanager`. Header-only C++20 INTERFACE library. Also
vendored into consumers at `libs/sst/sst-voicemanager/`.

```bash
cmake --build $B --target sst-voicemanager-test    # $B is your configure dir
$B/sst-voicemanager-test
$B/sst-voicemanager-test "[some tag]"
```

Tests build with `-Wall -Wextra -Wpedantic -Werror` on Clang and GCC — a warning is a build
failure.

---

## The shape of it

`VoiceManager<Cfg, Responder, MonoResponder>` decides *which* voices exist. It never owns
one. The synth creates voices and hands back `Cfg::voice_t*` cookies; the manager tracks
state in an internal `VoiceInfo` array and tells the synth what to do with each voice.

- **`Cfg`** supplies `maxVoiceCount` (the physical pool size, a compile-time constant) and
  `voice_t`. Optionally `continuationData_t` — see §7.
- **`Responder`** does per-voice work: create, move, retrigger, release, terminate, route
  expression.
- **`MonoResponder`** does channel-wide MIDI: pitch bend, CC, channel pressure.

Voice end is reported back through the callback registered with `setVoiceEndCallback`.

## Files

```
include/sst/voicemanager/
├── voicemanager.h              public API, enums, template declaration
├── voicemanager_impl.h         the implementation, included from the bottom of the above
├── voicemanager_constraints.h  static checks on the responder interface
└── midi1_to_voicemanager.h     applyMidi1Message — MIDI-1 byte stream dispatcher
```

`ls tests/` is the index of test coverage; each file is named for what it covers.

---

## 1. Realtime safety

**The audio-thread API is allocation-free and must stay that way.** Every hot-path entry
point is tagged:

```cpp
#define SST_VOICEMANAGER_NONBLOCKING noexcept SST_CPPUTILS_NONBLOCKING
```

The `noexcept` is unconditional — it is the realtime contract, and clang requires it of a
nonblocking function. The `[[clang::nonblocking]]` half only bites under a realtime-sanitizer
build and is a no-op elsewhere. Tagged: `processNoteOnEvent`, `processNoteOffEvent`,
`updateSustainPedal`, every `route*` method, `allNotesOff` / `allSoundsOff` /
`allSoundsOffMatching`, the voice counters, and the getters.

Configuration calls (`guaranteeGroup`, `guaranteePort`, `setPlaymode`,
`setPolyphonyGroupParent`, `setPolyphonyGroupVoiceLimit`, the priority setters) are *not*
tagged. They may allocate. **Call them off the audio thread.**

To build with RTSan:

```bash
cmake -S . -B $B -DUSE_RTSAN=TRUE
```

Needs real LLVM clang with the realtime sanitizer (Apple clang may not have it).

**If you add a hot-path method, tag it, and check that anything it touches is
pre-allocated.** The per-event containers are member-scope
`sst::cpputils::SmallHashMap` with inline capacity, not locals — that is deliberate, and
reintroducing a local `std::unordered_map` in an event handler undoes it.

### Ports must be guaranteed

Port 0 is pre-allocated. **Any other port needs `guaranteePort(port)` called off the audio
thread before a note arrives on it.** Per-port key state is large and the note path will
not lazily create it — it cannot, without allocating. A note on an unguaranteed port
violates the nonblocking contract.

---

## 2. Public API

### Note and MIDI events

```cpp
bool processNoteOnEvent(port, channel, key, noteid, velocity, retune);
void processNoteOffEvent(port, channel, key, noteid, velocity);
void updateSustainPedal(port, channel, level);
void routeMIDIPitchBend(port, channel, pb14bit);
void routeMIDI1CC(port, channel, cc, val);
void routePolyphonicAftertouch(port, channel, key, pat);
void routeChannelPressure(port, channel, pat);
void routeNoteExpression(port, channel, key, noteid, expression, value);
void routePolyphonicParameterModulation(port, channel, key, voiceid, parameter, value);
void routeMonophonicParameterModulation(port, channel, key, parameter, value);
```

`processNoteOnEvent` **returns bool** — whether the note actually produced or moved a
voice. A mono group where the new note loses on priority returns false. Covered in
`tests/note_on_return.cpp`.

### Lifecycle

```cpp
void allNotesOff();     // CC 123 — release gated voices
void allSoundsOff();    // CC 120 — terminate everything now
void allSoundsOffMatching(std::function<bool(voice_t *)>);
```

### Configuration

```cpp
void guaranteePort(int16_t port);
void guaranteeGroup(uint64_t groupId);
void setPolyphonyGroupVoiceLimit(uint64_t groupId, int32_t limit);
bool setPolyphonyGroupParent(uint64_t child, uint64_t parent);
bool setPlaymode(uint64_t groupId, PlayMode pm, uint64_t features = NONE);
void setStealingPriorityMode(uint64_t groupId, StealingPriorityMode pm);
void setMonoPriorityMode(uint64_t groupId, MonoPriorityMode pm);
```

`setPlaymode` and `setPolyphonyGroupParent` **return bool and can refuse** — see §4. Check
the result; a silent no-op otherwise looks like a voice-allocation bug much later.

### Counting and state

```cpp
size_t getVoiceCount() const;        // gated + releasing
size_t getGatedVoiceCount() const;   // sustain-held voices are still gated
PlayMode getPlaymode(uint64_t groupId) const;
int32_t getPolyphonyGroupVoiceLimit(uint64_t groupId) const;

MIDI1Dialect dialect;                     // MIDI1 or MIDI1_MPE
RepeatedKeyMode repeatedKeyMode;          // MULTI_VOICE or PIANO
int8_t mpeGlobalChannel{0};
static constexpr int8_t mpeTimbreCC{74};
std::array<std::array<bool, 128>, 16> heldMIDIKeyByChannel;
```

`gated` stays true while the sustain pedal holds a voice. A voice is fully ungated only
after the pedal lets it go. This trips people up in voice-count assertions.

---

## 3. Enums

**PlayMode:** `POLY_VOICES`, `MONO_NOTES`.

**MonoPlayModeFeatures** (bit flags in the `features` argument): `MONO_RETRIGGER`,
`MONO_LEGATO`, `ON_RELEASE_TO_LATEST` / `_HIGHEST` / `_LOWEST`, plus the combinations
`NATURAL_MONO` and `NATURAL_LEGATO`.

**StealingPriorityMode** — who gets evicted in poly mode when a limit is hit: `OLDEST`
(default), `HIGHEST`, `LOWEST` by key. **Ungated voices are always preferred over gated
ones, whatever the mode.**

**MonoPriorityMode** — who *wins* in mono when a note arrives over a sounding one:
`LATEST` (default), `HIGHEST`, `LOWEST`. Distinct from stealing. A losing note is still
recorded in held-key state, so `ON_RELEASE_TO_*` moves the voice correctly later; no voice
is created and `discardHostVoice(noteid)` is called.

**MIDI1Dialect:** `MIDI1`, `MIDI1_MPE`.

---

## 4. Polyphony groups

Per-group state is one `GroupState` in a single `std::unordered_map<uint64_t, GroupState>`:
poly limit, used voices, both priority modes, play mode, features, and `parentGroup`.

Groups are created lazily and default to roots. `guaranteeGroup(0)` runs in the
constructor, so group 0 always exists. **The responder assigns a voice's group** by
populating `polyphonyGroup` in `beginVoiceCreationTransaction`; the manager then asserts
the group exists. Naming a brand-new group there without a prior `guaranteeGroup` or
`setPlaymode` trips that assert. There is no group teardown API.

### Hierarchy

`setPolyphonyGroupParent(child, parent)` makes a child's voices count against the parent
and every ancestor above it. A voice still belongs to exactly one group but is budgeted by
the whole chain — which is how "8 voices of brass, 4 of strings, 10 total" is expressed.

`noPolyphonyGroupParent` detaches, restoring root behaviour.

Two rules the API enforces by returning false:

- **No cycles**, self-parenting included.
- **Only leaf groups may be MONO.** `setPlaymode` refuses a mono mode for a group with
  children, and `setPolyphonyGroupParent` refuses a mono parent.

Reparenting a group that has live voices cannot be applied incrementally, so it triggers a
full `recomputeUsedVoices()` scan. That is fine off the audio thread, which is where
configuration belongs.

Budgeting per prospective poly voice:

```
groupFree  = polyLimit(g) - usedVoices(g)      // may go negative under delayed termination
globalFree = maxVoiceCount - totalUsedVoices
toSteal    = max(needed - min(groupFree, globalFree), 0)
```

When the group has headroom but the physical pool is exhausted, the steal runs with
`ignorePolygroup = true` — it takes from other groups to satisfy this one.

Covered by `tests/hierarchical_groups.cpp` and `tests/stealing_groups.cpp`.

---

## 5. Internal state

### VoiceInfo (one per physical slot)

Current note coordinates (`port`, `channel`, `key`), the originals at creation, the current
`noteId`, a `voiceId` stable across legato and piano moves, a `voiceCounter` for OLDEST
stealing, a `transactionId` shared by all voices from one note-on, `gated` and
`gatedDueToSustain`, the `polyGroup`, and `activeVoiceCookie` (null means the slot is free).

Each slot also embeds `std::array<int32_t, 256> noteIdStack` plus a position. That is about
a kilobyte per slot, and it dominates the static footprint at high voice counts. The stack
accumulates note ids as keys stack up in legato; `matches()` searches the whole stack.
Writes mask with `& (noteIdStackSize - 1)`, so a 257th distinct stacked id wraps rather
than overflowing.

### Key state

`keyStateByPort` maps a port to a `[16][128]` array of `SmallHashMap<uint64_t,
IndividualKeyState, 4>` — one small map per channel/key cell, keyed by polygroup, with
inline capacity for four groups before it spills to the heap. This is the largest structure
in the library and why ports must be guaranteed up front.

`IndividualKeyState` holds the transaction that pressed the key, the inception velocity,
and whether it is held only by sustain.

---

## 6. Lifecycle detail

### Note on

1. Mark `heldMIDIKeyByChannel`.
2. PIANO mode: a key that was held but ungated (sustain) retriggers via
   `retriggerVoiceWithNewNoteID` rather than making a new voice.
3. `responder.beginVoiceCreationTransaction(...)` — the synth fills in how many voices and
   which polygroup each belongs to.
4. Per group in the transaction: if MONO and already sounding, apply `MonoPriorityMode` —
   winner moves (legato) or terminates-and-recreates (retrigger); loser gets `SKIP` and
   `discardHostVoice`. Otherwise compute free slots and steal as needed.
5. `responder.initializeMultipleVoices(...)` — the synth creates them and fills the buffer.
6. Populate `VoiceInfo` slots, push the note id.
7. Replay cached pitch bend and CC to the new voices.
8. `responder.endVoiceCreationTransaction(...)`.

### Note off

1. Clear `heldMIDIKeyByChannel`.
2. Per matching voice: MONO groups check for other held keys and either move (legato),
   terminate-and-retrigger, or fall through; sustain down sets `gatedDueToSustain`;
   otherwise `releaseVoice`.
3. Drop the key from key state.
4. `doMonoRetrigger` for any group that needs it.

### Stealing

`findNextStealableVoiceInfo()` plus the loop in `processNoteOnEvent`. Ungated before
gated; within a tier, the group's `StealingPriorityMode`. **Transaction cohesion:** stealing
a voice steals every voice sharing its `transactionId`, so a multi-voice note is never half
killed. Global pool exhaustion always steals OLDEST regardless of group policy.

`alreadyStole` is a transient flag so one pass cannot pick the same slot twice; it resets
on placement.

### Sustain

Per channel in `sustainOn`. On release, everything `gatedDueToSustain` is released or
terminated, and mono groups additionally run `doMonoRetrigger` to fall back to a still-held
key.

### Play mode changes

Changing a group's play mode terminates that group's voices and clears its held-key state —
switching between poly and mono mid-note otherwise leaves incoherent state. A no-op change
does nothing. See `tests/playmode_change.cpp`.

---

## 7. Continuation data

Optional, enabled by defining `continuationData_t` on `Cfg`:

```cpp
template <typename Cfg>
concept HasVoiceContinuationData = requires { typename Cfg::continuationData_t; };
```

When stealing or mono-retriggering, the manager calls `responder.getContinuationData(old)`
and passes the result in `VoiceInitInstructionsEntry::continuationData`, letting the synth
carry filter state or phase across voice reuse. Without the typedef the field is an `int`
placeholder and the stealing path writes the old key into it, harmlessly.

---

## 8. MPE

Set `vm.dialect = MIDI1Dialect::MIDI1_MPE`.

`mpeGlobalChannel` (default 0) behaves as an ordinary MIDI channel — notes on it use
mono-responder routing, not per-voice. Every other channel gets per-voice pitch bend,
channel pressure and CC 74 (`mpeTimbreCC`). Where a channel has gated voices, MPE messages
go only to those, not to releasing ones.

Pitch bend and CC are cached per channel (`lastPBByChannel`, `midiCCCache`) and replayed to
each newly created voice, so a note started after a bend hears it.

---

## 9. Note id vs voice id

| | Note ID | Voice ID |
|---|---|---|
| Means | The musical note event, host-assigned | The voice slot, assigned at creation |
| Poly | Tracks the note | Same as note id |
| Legato | New id per press, stacked | Stable across moves |
| Piano | New id on retrigger | Updated to the new note id |
| Used by | `routeNoteExpression` | `routePolyphonicParameterModulation` |

---

## 10. Responder contract

Checked by `voicemanager_constraints.h`. The Responder must provide:

```cpp
void setVoiceEndCallback(std::function<void(voice_t *)>);
int  beginVoiceCreationTransaction(buffer_t &, port, ch, key, noteid, vel);
int32_t initializeMultipleVoices(count, const instructions_t &, initBuffer_t &,
                                 port, ch, key, noteid, vel, retune);
void endVoiceCreationTransaction(port, ch, key, noteid, vel);

void terminateVoice(voice_t *);
void releaseVoice(voice_t *, float velocity);
void retriggerVoiceWithNewNoteID(voice_t *, int32_t noteid, float velocity);
void moveVoice(voice_t *, port, ch, key, vel);
void moveAndRetriggerVoice(voice_t *, port, ch, key, vel);
void discardHostVoice(int32_t voiceId);

void setNoteExpression(voice_t *, int32_t expression, double value);
void setVoicePolyphonicParameterModulation(voice_t *, uint32_t param, double value);
void setVoiceMonophonicParameterModulation(voice_t *, uint32_t param, double value);
void setPolyphonicAftertouch(voice_t *, int8_t value);
void setVoiceMIDIMPEChannelPitchBend(voice_t *, uint16_t pb14bit);
void setVoiceMIDIMPEChannelPressure(voice_t *, int8_t pressure);
void setVoiceMIDIMPETimbre(voice_t *, int8_t timbre);

continuationData_t getContinuationData(voice_t *);   // only if Cfg has continuationData_t
```

MonoResponder:

```cpp
void setMIDIPitchBend(int16_t channel, int16_t pb14bit);
void setMIDI1CC(int16_t channel, int16_t cc, int8_t val);
void setMIDIChannelPressure(int16_t channel, int16_t pressure);
```

`initializeMultipleVoices` returns how many voices it *actually* created, which may be
fewer than asked. The manager copes; do not lie about it.

---

## 11. Testing

`tests/test_player.h` has `TestPlayer<N, doLog>`, implementing both responder roles.

- Keys ≤ 72 produce one voice per note; keys > 72 produce three. That is how multi-voice
  transaction behaviour gets exercised — use a high key when you need it.
- A released voice has `releaseCountdown = 5`; `process()` decrements and fires the end
  callback at zero.
- `terminateInstantly` defaults true. Set it false for delayed-termination tests, where
  `usedVoices` legitimately outruns the actual count for a while.
- `polyGroupForKey` assigns groups per key. Subclasses `TwoGroupsEveryKey` and
  `ThreeGroupsEveryKey` put every note in several groups at once.

Assertion macros:

```cpp
REQUIRE_NO_VOICES
REQUIRE_VOICE_COUNTS(total, gated)
REQUIRE_VOICE_MATCH(count, v.key() == 60)
REQUIRE_VOICE_MATCH_FN(count, lambda)
```

When adding behaviour, add the test in the file named for that behaviour and add the file
to `tests/CMakeLists.txt` if it is new.

---

## 12. Sharp edges

- **`gated` includes sustain-held.** Count assertions must account for it.
- **`setPlaymode` and `setPolyphonyGroupParent` return bool.** Ignoring a refusal produces
  a mysterious allocation bug much later.
- **Configuration allocates; note events must not.** Keep the split.
- **Guarantee every port before use.**
- **Transaction cohesion is load-bearing.** Anything that terminates one voice of a
  multi-voice note without its siblings will produce stuck partial notes.
- **A group's used-voice count is subtree-inclusive.** Reading it as "voices directly in
  this group" is wrong once a hierarchy exists.

