sst-voicemanager
Repo: surge-synthesizer/sst-voicemanager. Header-only C++20 INTERFACE library. Also
vendored into consumers at libs/sst/sst-voicemanager/.
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.
CfgsuppliesmaxVoiceCount(the physical pool size, a compile-time constant) andvoice_t. OptionallycontinuationData_t— see §7.Responderdoes per-voice work: create, move, retrigger, release, terminate, route expression.MonoResponderdoes 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:
#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:
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
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
void allNotesOff(); // CC 123 — release gated voices
void allSoundsOff(); // CC 120 — terminate everything now
void allSoundsOffMatching(std::function<bool(voice_t *)>);
Configuration
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
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.
setPlaymoderefuses a mono mode for a group with children, andsetPolyphonyGroupParentrefuses 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
- Mark
heldMIDIKeyByChannel. - PIANO mode: a key that was held but ungated (sustain) retriggers via
retriggerVoiceWithNewNoteIDrather than making a new voice. responder.beginVoiceCreationTransaction(...)— the synth fills in how many voices and which polygroup each belongs to.- Per group in the transaction: if MONO and already sounding, apply
MonoPriorityMode— winner moves (legato) or terminates-and-recreates (retrigger); loser getsSKIPanddiscardHostVoice. Otherwise compute free slots and steal as needed. responder.initializeMultipleVoices(...)— the synth creates them and fills the buffer.- Populate
VoiceInfoslots, push the note id. - Replay cached pitch bend and CC to the new voices.
responder.endVoiceCreationTransaction(...).
Note off
- Clear
heldMIDIKeyByChannel. - Per matching voice: MONO groups check for other held keys and either move (legato),
terminate-and-retrigger, or fall through; sustain down sets
gatedDueToSustain; otherwisereleaseVoice. - Drop the key from key state.
doMonoRetriggerfor 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:
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:
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:
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. terminateInstantlydefaults true. Set it false for delayed-termination tests, whereusedVoiceslegitimately outruns the actual count for a while.polyGroupForKeyassigns groups per key. SubclassesTwoGroupsEveryKeyandThreeGroupsEveryKeyput every note in several groups at once.
Assertion macros:
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
gatedincludes sustain-held. Count assertions must account for it.setPlaymodeandsetPolyphonyGroupParentreturn 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.