MPE: per-channel pitch / CC / aftertouch + MPE-aware voice stealing#1327
MPE: per-channel pitch / CC / aftertouch + MPE-aware voice stealing#1327rullopat wants to merge 22 commits into
Conversation
Refactor the global pitch/CC/aftertouch event vectors into a private nested ChannelState struct, owned by MidiState as a 16-element array indexed by MIDI channel (0..15). All public API still resolves to channelStates[masterChannel] (master = 0), so behavior is byte-for-byte identical to the prior implementation. This is a structural foundation. By itself it has no observable effect — no caller targets a non-master channel yet — but it is the storage shape needed for follow-up work where voices read modulation events from their originating MIDI channel rather than a single global state (needed for per-note pitch bend, per-note CC, and per-note aftertouch in MPE input streams). Lifecycle methods (flushEvents, setSamplesPerBlock, resetEventStates) intentionally still operate on the master channel only, to preserve current memory-allocation behavior. They will be expanded to iterate over active channels in a follow-up commit when channel-aware public API methods are added. Test results: all sfizz tests pass (52201 assertions in 477 test cases), including the 8 MidiState test cases (550 assertions).
Adds channel-aware overloads to MidiState's pitch/CC/aftertouch accessors: float getPitchBend(int channel) const noexcept; float getChannelAftertouch(int channel) const noexcept; float getPolyAftertouch(int channel, int noteNumber) const noexcept; float getCCValue(int channel, int ccNumber) const noexcept; float getCCValueAt(int channel, int ccNumber, int delay) const noexcept; const EventVector& getCCEvents(int channel, int ccIdx) const noexcept; const EventVector& getPitchEvents(int channel) const noexcept; const EventVector& getChannelAftertouchEvents(int channel) const noexcept; const EventVector& getPolyAftertouchEvents(int channel, int noteNumber) const noexcept; The existing no-arg signatures are retained and now forward to the channel-aware variant with channel = masterChannel, so the public ABI is unchanged and callers that don't care about channel keep their master-channel reads. Out-of-range channels return 0.0f / nullEvent. Voice gains a private triggerChannel_ field set in startVoice (0 / master for now — channel-aware noteOn dispatch follows in a separate commit). The per-voice modulation reads that matter for MPE-style expression — pitch bend smoother seed (startVoice), the per-block pitch envelope source, and crossfade-CC values + events — now route through triggerChannel_. Sustain/sostenuto pedal CCs and the extended/random CCs (alternate, unipolarRandom, etc.) intentionally still resolve via the master channel: per the MPE 1.0 spec, sustain is master-only, and the extended CCs are not channel-scoped modulation. Free-function setup calls in startVoice (regionDelay, basePitchVariation, baseVolumedB, noteGain, sampleEnd, sampleOffset, loopStart/loopEnd) still read from master too — those will follow as a separate commit when their signatures grow a channel argument. Behavior is byte-for-byte unchanged: triggerChannel_ defaults to 0 (master) so every voice reads from the master channel exactly as before. All sfizz tests pass (52201 assertions in 477 test cases).
Adds a parallel set of channel-aware public methods on Synth — one
per existing single-channel input method — plus the dispatch
plumbing that lets them route events to the per-channel slots
introduced in MidiState earlier:
void noteOnMPE / hdNoteOnMPE
void noteOffMPE / hdNoteOffMPE
void ccMPE / hdccMPE
void pitchWheelMPE / hdPitchWheelMPE
void channelAftertouchMPE / hdChannelAftertouchMPE
void polyAftertouchMPE / hdPolyAftertouchMPE
void setMPEEnabled / bool getMPEEnabled
void setMPEPitchBendRange / float getMPEMasterPitchBendRange
/ float getMPEPerNotePitchBendRange
The existing single-channel methods are retained and now forward to
the *MPE variants with channel = 0 (master), so the public ABI is
unchanged and existing callers see identical behavior. Hosts that
care about MPE call the *MPE variants on member channels (1..15);
events land in MidiState's per-channel state, and voices triggered
on a member channel pull modulation from the right channel slot via
the per-voice triggerChannel_ wired in earlier.
Other changes:
- MidiState gains channel-aware write overloads paralleling the
read overloads added previously: pitchBendEvent(delay, channel,
value), ccEvent(delay, channel, cc, value), channelAftertouch and
polyAftertouch with channel arg. Single-arg variants forward to
channel = masterChannel.
- TriggerEvent gains an int channel field defaulting to 0, so
brace-initialised call sites elsewhere keep working unchanged.
Voice::startVoice now sets triggerChannel_ from event.channel,
replacing the placeholder hardcoded 0.
- Sister-voice / voice-ordering comparators in Voice.h now compare
channel as well as type/number/value/age, so voices triggered by
the same MIDI note on different MPE member channels are correctly
treated as distinct sister rings.
- Synth::Impl dispatch helpers (noteOnDispatch, noteOffDispatch,
ccDispatch, performHdcc) gain a channel parameter that propagates
into the constructed TriggerEvent and into MidiState writes. All
old call sites pass 0 (master) directly via the legacy Synth API,
preserving prior behavior.
setMPEEnabled is informational for now: per-channel input dispatch
works whether or not the flag is set. The flag is consumed by
features that need zone awareness — RPN-driven pitch-bend range
application and MPE-aware voice stealing — added in follow-up
commits.
Layer- and voice-side registration loops in pitchWheel / aftertouch
dispatch still iterate over all layers/voices regardless of
channel. Per-voice modulation reads were made channel-aware
earlier, so the per-voice rendering path picks up the right values
via triggerChannel_; the pan-channel registration calls are
harmless cross-talk bookkeeping that a follow-up commit can filter
when MPE is enabled. This keeps backward compatibility byte-for-
byte exact when MPE is not used.
Test results: all sfizz tests pass (52201 assertions in 477 test
cases) and the autosampler test suite (98 cases) is green. No
behavior change for non-MPE callers.
Adds an optional preferredChannel parameter (default -1 = no
preference) to the VoiceStealer interface and propagates it through
VoiceManager and Synth::Impl::startVoice. When preferredChannel is
non-negative, each stealer prefers a victim whose triggering MIDI
channel matches before falling back to a cross-channel victim.
class VoiceStealer {
virtual Voice* checkRegionPolyphony(const Region*,
absl::Span<Voice*>, int preferredChannel = -1) = 0;
virtual Voice* checkPolyphony(absl::Span<Voice*>,
unsigned maxPolyphony, int preferredChannel = -1) = 0;
};
For FirstStealer and OldestStealer, the existing
genericPolyphonyCheck helper now tracks a second "same-channel"
candidate alongside the all-voices candidate. If polyphony is
exceeded, the same-channel candidate is returned in preference.
For EnvelopeAndAgeStealer, the per-block scoring runs first on a
same-channel-filtered subset of the candidate vector, falling back
to the full set only when the subset is empty.
The polyphony cap itself is still computed across all voices (the
engine-wide / region / group limits are global, not per-channel) —
preferredChannel only biases who gets stolen once the cap is
exceeded.
Synth::Impl::startVoice computes preferredChannel as
mpeEnabled_ ? triggerEvent.channel : -1. With MPE disabled, all
existing call sites pass -1 by default, and behavior is byte-for-
byte identical to the prior implementation. With MPE enabled,
voices triggered on a member channel preferentially steal from
their own channel — predictable per-finger note replacement on
MPE controllers like Osmose under heavy polyphony.
Test results: all sfizz tests pass (52201 assertions in 477 test
cases). The existing voice-stealing tests are unchanged because
preferredChannel = -1 reproduces the prior single-candidate path
exactly. A dedicated MPE-stealing regression test covering the
same-channel-preference behavior follows in the test commit.
Two related changes that close the loop on per-channel state
isolation:
1. Member-channel event vectors are now lazily populated. resetEventStates
continues to initialise only the master channel's event vectors with
the conventional {0, 0.0f} sentinel; member channels start empty and
only grow on first write to that channel. The channel-aware event-
vector getters (getCCEvents, getPitchEvents, getChannelAftertouchEvents,
getPolyAftertouchEvents) now return the static nullEvent {{0, 0.0f}}
when their channel's vector is empty, so downstream consumers like
linearEnvelope (which ASSERTs non-empty) keep working unchanged for
voices triggered on a channel that has never received modulation.
2. flushEvents now iterates all 16 channels, skipping empty vectors
cheaply. Previously only the master channel was flushed, which
would have allowed events written to a member channel to accumulate
indefinitely across blocks. flushEventVector early-returns on empty
so non-MPE workloads pay no cost beyond the master-channel work
they were already doing (plus 15 cheap empty-vector checks per
block).
3. tests/MPET.cpp adds 15 regression tests covering:
- per-channel pitch bend / CC / channel aftertouch / poly aftertouch
state isolation in MidiState
- out-of-range channel safety on writes and reads
- single-arg overloads forwarding to master channel
- Synth's *MPE public API routing events to the correct channel slot
(pitchWheelMPE, ccMPE, channelAftertouchMPE, polyAftertouchMPE)
- existing single-channel API still landing in master
- noteOnMPE tagging spawned voices with the originating channel
- setMPEEnabled / getMPEEnabled and setMPEPitchBendRange round-trip
- voice stealing preferring same-channel candidates when MPE is on
All 15 tests pass (68 assertions); full sfizz test suite at 492
cases / 52269 assertions, no regressions.
Forwards the *MPE input methods and the setMPEEnabled / setMPEPitchBendRange configuration through the public sfz::Sfizz wrapper to the underlying sfz::Synth implementation. Hosts that already use Sfizz directly (rather than the C API) can now drive MPE input without reaching into the engine internals. void noteOnMPE / hdNoteOnMPE void noteOffMPE / hdNoteOffMPE void ccMPE / hdccMPE void pitchWheelMPE / hdPitchWheelMPE void channelAftertouchMPE / hdChannelAftertouchMPE void polyAftertouchMPE / hdPolyAftertouchMPE void setMPEEnabled / bool getMPEEnabled void setMPEPitchBendRange float getMPEMasterPitchBendRange / getMPEPerNotePitchBendRange The C API in sfizz.h is not extended in this commit; it can follow once an upstream review path is decided. Tests: full sfizz suite remains at 492 cases / 52269 assertions with no regressions.
Five engine fixes uncovered by a hand-test against an
Osmose. Each was a coverage gap left by the earlier MPE work that didn't
surface in the regression suite because every test wrote events with
delay=0 and every voice ran on the master channel.
1. MidiState::insertEventInVector — seed the {0, 0.0f} sentinel into
any empty member-channel vector before inserting the first real
event. linearEnvelope downstream ASSERTs the vector starts at delay
zero; member channels are populated lazily and a first write at
delay>0 produced [{delay,value}] which SIGTRAP'd the audio thread on
the first MPE pitch-bend event. Master channels are pre-seeded at
construction so this is a no-op for them.
2. MidiState::getCCEvents / getPitchEvents / getChannelAftertouchEvents
/ getPolyAftertouchEvents — when a member channel's vector is empty,
fall back to master's vector. This implements MPE 1.0 inheritance
semantics: a member channel that has never received its own value
reads the master channel's value. Without this, sfizz's engine
defaults (CC7=Volume@~0.79, CC10=Pan@0.5, CC11=Expression@1.0) were
collapsing to 0 on member channels and voices played near-silent.
3. modulations/sources/Controller.cpp::generate — the channelAftertouch
case and the default CC case were reading from the master channel
only. cutoff_oncc74 (and any other mod-matrix CC binding) flowed
through here, so per-voice CC74 was collapsing globally even though
the per-channel plumbing was in place. Now resolves the voice's trigger
channel via voiceManager_ and reads ms.getCCEvents(channel, p.cc).
4. modulations/sources/ChannelAftertouch.cpp / PolyAftertouch.cpp — same
master-only read fixed parallel to (3). ChannelAftertouchSource was
discarding the VoiceManager constructor arg; promoted it to a member
so generate() can resolve the voice's channel.
5. tests/MPET.cpp — two regression tests covering the failure modes:
first member-channel event at delay>0 keeps the delay-0 sentinel
(catches the SIGTRAP regression), and empty member channels inherit
master CC / pitch / aftertouch state (catches the near-silent voice
regression).
Validation:
- AU validation succeeded
- Hand-test on Osmose: per-finger isolation pass for pitch (X), CC74
timbre (Y), and channel pressure (Z); same-channel voice stealing
pass; >15 voices clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for this PR. I assumed this is AI assisted in some way? (It's not an issue, mind you, just verifying) One bit of trivia, my earliest version of sfizz had multi-channel 🙂 Before diving in, my main concern is that I wouldn't have much in the ways to test this and the possible since I have no real MPE hardware available. |
|
@rullopat Delighted to see this! Building it now. I should set expectations that I'm not a C++ developer. Ad-hoc microtuning is something that I really really want, and I was considering taking some time this summer to see if I could make some sense out of it. Maybe with LLM assistance I might have made a little headway, but I'm not at all confident that I would have succeeded. I will certainly put this through some paces, and if I run into bugs, I might have a look at the sources. Substantial contributions are probably outside of my skill set, though. Sorry if my earlier message made it sound otherwise.
Neither do I, but SuperCollider / Max / Pd can generate any MIDI messages that are required. (I'll be running SuperCollider --> VSTPlugin extension --> sfizz.vst3.) |
|
@paulfd yes it's AI assisted, I have some experience in C++ programming but not with audio. I was doing a personal project with JUCE for an autosampler / rompler to use with my MPE controllers (I have an Expressive E Osmose 49 and an Ableton Push 3) that is creating SFZ files and samples in FLAC format, I did a plugin that is using Sfizz for testing the samplers and I needed this MPE features. @jamshark70 I think I'll try to update also the Sfizz UI so that it supports my fork. |
Forwards the *MPE input methods and the setMPEEnabled / setMPEPitchBendRange configuration through the C wrapper in sfizz.h to the underlying Synth implementation. Hosts that consume the C API (LV2 in particular) can now drive MPE input without reaching into engine internals or the C++ wrapper. The C++ Sfizz wrapper covered this for C++ consumers in cd7d7df; this commit fills the parallel gap on the C side. No new engine behavior; existing single-channel C API methods continue to forward to the *MPE variants with channel = 0 (master). New entry points (all delegate to synth->synth.*): sfizz_send_{note_on,note_off,cc,pitch_wheel,channel_aftertouch, poly_aftertouch}_mpe and their hd variants sfizz_{set,get}_mpe_enabled sfizz_set_mpe_pitch_bend_range sfizz_get_mpe_{master,per_note}_pitch_bend_range
|
Quick update: pushed |
|
Follow-up: opened sfztools/sfizz-ui#166 — VST3/AU + LV2 + PD dispatch through the new I'll try my Osmose and / or Push 3 as soon as I'll have a bit more free time, I just wanted to push the change to Sfizz UI to give the ability to you to test if you want. |
|
OK, I've got that and I now see the settings switch. Unfortunately, I don't hear any effect from pitchbend when the new MPE option is enabled. Also I tried some overlapping notes in different channels, and found that a note-off on the first channel cuts off all matching notes on all channels. If the sfizz core works in your specific case, maybe the VST plugin wrapper isn't passing the MIDI data accurately? I can confirm that I'm sending different channel numbers, but the plugin is reacting as if it's all one big channel. |
|
@jamshark70 I was trying in Logic Pro that has of course it's AU format with my own plugin in JUCE that it's using the C++ code of the Sfizz library directly, now I'm trying a VST in Ableton and see the problem, I'll let you know if I find out how to fix it, thanks for trying. |
Strange... I did a little more testing just now:
So the channel number is getting at least that far down. Out of time for now, but when I have a moment I'll try to trace through note-off and PB value access with MPE on. |
|
Here's another finding. I was trying to track down what happens to those pitchbend messages. With MPE off: With MPE on:
Had 5 minutes before starting -- in MPE mode, it goes through performHdcc, not through pitchBendEvent() so I'll have to start again with that later. |
Without this, a note-off on one MPE member channel releases voices on every other channel at the same pitch — overlapping notes across channels can't coexist. Synth::hdNoteOffMPE now forwards the channel and the Voice checks triggerEvent_.channel as well as the note number. Legacy single-channel callers come through hdNoteOffMPE(channel=0) and voices outside MPE have triggerChannel_=0, so existing behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @jamshark70 for the detailed traces — they made both bugs obvious. Two follow-ups based on what you found:
About your edit re |
cb3ba1d added the inheritance fallback to the event-vector getters (getPitchEvents / getCCEvents / getChannelAftertouchEvents / getPolyAftertouchEvents) but left the scalar variants — getPitchBend, getChannelAftertouch, getCCValue, getCCValueAt, getPolyAftertouch — still returning 0 for any member channel whose vector is empty. Those scalar reads are used at note-on time to initialize the per-voice smoothers (Voice.cpp:532 for the bend smoother in particular), so a note arriving on a member channel started with the smoother pinned at zero even when master had a non-zero value. The audible effect was that master bend, pressure and CC values didn't appear to propagate into member-channel voices in MPE mode. Same fix shape as in cb3ba1d: if the member channel's vector is empty, return the master-channel scalar instead of 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…PE 1.0
Two related bugs in how member-channel voices computed their pitch bend:
1. The configured MPE pitch bend ranges were stored on Synth::Impl by
setMPEPitchBendRange but never read by the engine. Region::getBendIn-
Cents used the region's bend_up / bend_down (default ±200 cents ≈
±2 st) for every voice, so an MPE controller sending bend over its
full ±48 st per-note range produced only ±2 st of audible pitch
movement — effectively silent for small gestures.
2. Voice::Impl::pitchEnvelope read getPitchEvents(triggerChannel), which
falls back to master events when the member channel's events are
empty. Once the member channel received any per-note bend, its
vector stayed populated across blocks (flushEvents preserves the
last value at delay 0), so master bend stopped reaching the voice
forever after. The two contributions should add, not replace.
Fix:
- MidiState gains setMPEPitchBendRange + getMPEBendRangeForChannel so
the per-channel range is reachable from Voice without a back-pointer
to Synth. Synth::setMPEPitchBendRange now mirrors into MidiState.
- New getPitchEventsRaw / getPitchBendRaw getters return the channel's
own events with no master fallback, for cases where the caller wants
to combine master and member contributions explicitly.
- Voice::Impl::pitchEnvelope splits into two paths. Master/legacy keeps
the existing region-opcode behaviour. Member channels read own and
master events separately and sum:
perNote × perNoteRange + master × masterRange (in cents)
Same shape for the bend smoother initialiser in startVoice.
After this, MPE per-note bend audibly reaches the configured range
(default 48 st), and master bend continues to nudge member-channel
voices on top of any per-note bend they already carry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Two more commits pushed:
Verified against the matching sfztools/sfizz-ui#166 with a Push 3 — per-note slide bend, master touch-strip bend, and combinations of the two are all audible at expected magnitudes. |
The MPE branch in b944917 reads getPitchEventsRaw(triggerChannel) and getPitchEventsRaw(0) directly to combine member + master bend. Member channels are populated lazily — their event vectors stay empty until first write — so when a member-channel note plays without any per-note bend yet, linearEnvelope receives an empty vector and fires the events.size() > 0 assertion in ModifierHelpers.h:77, SIGTRAP on the audio thread. Guard both linearEnvelope calls: if perNoteEvents is empty, zero the pitchSpan up front; if masterEvents is empty, skip the additive pass. Master is pre-seeded at construction so the second branch is mostly defensive. Fixes the regression caught by the PlayerEngine MPE tests after bumping its sfizz pointer to the mpe tip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
b117153 extended the master→member fallback to MidiState's scalar getters but left this test still expecting 0.0 for member-channel reads after a master write. Match the intentional behaviour: a member channel with no events of its own returns the master value.
Controllers can self-announce their zone (RPN 6 / MCM) and bend ranges (RPN 0 / Pitch Bend Sensitivity) per MPE 1.0 §2. The engine taps CCs 6/38/98/99/100/101 inside performHdcc: a complete RPN 6 on channel 0 calls setMPEEnabled, and RPN 0 on master / member channels updates the matching bend range via setMPEPitchBendRange. The CCs still flow through to MidiState afterwards so SFZ *_oncc bindings keep working. Two granular opt-outs (setMPEMasterBendAutoConfigEnabled, setMPEPerNoteBendAutoConfigEnabled) on Synth, mirrored through the C++ and C wrappers, let UIs pin a bend range without disabling the MCM auto-enable path. Default true (accept), matching the spec. Upper Zone (master = channel 15) and the CC 38 cents-fraction path are deferred — no commercial MPE controller in our target set uses them. NRPN sequences are tracked separately from RPN so a CC 6 following an NRPN selection doesn't get misinterpreted. 12 new [MPE] cases in MPET.cpp cover MCM enable / disable / non-master rejection, RPN 0 master vs member routing, null-RPN and NRPN cross-talk, both opt-out gates, parser-pass-through on CC propagation, and round-trip getters.
|
Pushed A complete Two granular opt-outs ( Upper Zone (master = channel 15) and the CC 38 cents-fraction path are deferred — no commercial MPE controller in scope uses them. NRPN sequences are tracked separately so a 12 new |
|
Quick follow-up — testing surfaced a bug on the wrapper side. Fix is in sfizz-ui across two commits — |
MPE 1.0 §2.2.7 and Appendix E Table 5 prohibit Polyphonic Key Pressure on Member Channels: per-note pressure flows through Channel Pressure under MPE, and Poly KP on a Member Channel would compound the per-note pressure response. Manager-Channel Poly KP remains permitted at the discretion of the implementer for compatibility with non-MPE-aware devices. hdPolyAftertouchMPE now gates the four poly-aftertouch entry points (legacy polyAftertouch / hdPolyAftertouch forward to channel 0 and so are unaffected; polyAftertouchMPE / hdPolyAftertouchMPE drop on non-zero channels when MPE is enabled). Dropped events neither update MidiState nor reach the mod-matrix; the polyphonicAftertouch ExtendedCC path further down runs only on accepted events. Adds a diagnostic counter (getDroppedPolyKpOnMemberCount) on Synth, mirrored through the C++ and C wrappers, so hosts can observe spec- violating traffic. The counter is not reset by setMPEEnabled, polyphony changes, or SFZ reloads. Three new [MPE] cases in MPET.cpp cover the Member drop, the Manager-Channel pass-through, and the MPE-disabled bypass.
|
Pushed The gate lives at the top of A diagnostic counter ( Three new |
Pedal CCs (64-69), mode/reset CCs (120, 121, 123, 124, 125) and Bank Select MSB/LSB (CC 0, CC 32) are zone-wide messages per MPE 1.0 §2.3.1 and §2.3.3 (Appendix E Table 5). When MPE is enabled they should only be honoured on the Manager Channel; arrivals on a Member Channel are spec violations. Filter at the top of Synth::Impl::performHdcc before the All-Notes-Off / Reset-All-Controllers global early-returns and before the RPN parser tap, so a Member-Channel CC 120/121/123 does not silently reset the zone and Bank Select on a Member Channel does not queue state for a later Program Change that the host would then have to filter separately. Gate on the asMidi entry so internal automation paths, which conceptually target the Manager Channel, keep working. CC#7 (Volume), CC#10 (Pan) and CC#11 (Expression) are not on the Manager-only list — the spec marks them optional on both channel types, so the existing per-channel routing is preserved. Diagnostic counter droppedManagerOnlyCCs_ on Synth::Impl, exposed via Synth::getDroppedManagerOnlyMessageCount, sfz::Sfizz wrapper, and the sfizz_get_dropped_manager_only_message_count C API. Counter is not reset by setMPEEnabled, polyphony changes, or SFZ reloads, mirroring the poly-KP counters. Program Change on Member Channels stays a host-side concern because the engine programChange API does not carry a channel argument. Eight new MPET cases cover Damper pass-through on Manager, drop on Member, all 64-69 pedal drops, All-Notes-Off / Reset-All / Bank-Select drops, MPE-disabled bypass, and a regression that RPN data CCs (6 / 38 / 98 / 99 / 100 / 101) still flow on Member Channels so the per-note bend-range auto-config keeps working.
|
Pushed The filter covers the pedal CCs (64-69 — Damper, Portamento, Sostenuto, Soft Pedal, Legato Footswitch, Hold 2), the mode/reset CCs (120, 121, 123, 124, 125), and Bank Select MSB/LSB (CC 0 / CC 32). It lives at the top of RPN data CCs (6 / 38 / 98 / 99 / 100 / 101) are deliberately excluded so the existing MPE RPN auto-config (MCM + Pitch Bend Sensitivity) continues to work on Member Channels. Program Change on Member Channels remains a host-side concern because the engine Diagnostic counter Eight new |
MPE 1.0 §2.2.6 / §2.2.7 / §2.2.8 (Appendix E §A.4.1) require a Released
Note to stop reacting to Pitch Bend, Channel Pressure and CC#74 sent on
its Member Channel after the Note Off message — the controller will
have reallocated that Member Channel to the next finger by then, so
keeping the read alive drags the previous note's release tail along
with whatever the user is playing now. Manager-Channel traffic must
continue to affect the release tail (§A.4.1).
Introduce Voice::expressionChannel(), a public method that returns the
voice's trigger channel for active voices (and for any voice triggered
on the Manager Channel) and falls back to channel 0 (Lower Zone
Manager) once the voice is released on a Member Channel. The helper
consults the existing released() flag and triggerChannel_; no new
lifecycle state is needed.
Route every per-block expression read through it:
* ControllerSource::generate (modulations/sources/Controller.cpp) for
both the ExtendedCC pitch-bend / channel-aftertouch branch and the
default CC route. This is the path CC#74 -> filter actually flows
through; earlier drafts had it elsewhere.
* ChannelAftertouchSource::generate (Channel Pressure mod-matrix
source) for §2.2.7.
* PolyAftertouchSource::generate as defense-in-depth on top of the
earlier Synth-level Poly KP drop on Member Channels (§2.2.7).
* Voice::Impl::applyCrossfades for per-block region xf_cc reads.
* Voice::Impl::pitchEnvelope skips the per-note bend contribution
entirely once the voice is released on a Member Channel; the
master contribution is preserved per §A.4.1. Reading the master
events as "per-note" would have double-applied the master bend,
so the gate is structural rather than just a channel swap here.
Voice::Impl::resetCrossfades stays untouched — it runs at voice
startup only, before any release is possible. Sustain / sostenuto CC
reads at voice init were already master-channel reads (single-arg
getCCValue, defaults to channel 0), correct under the spec.
Seven new MPET cases cover the helper's behaviour on active vs released
Member-Channel voices, voices triggered on the Manager Channel, post-
release Pitch Bend / Channel Pressure / CC#74 routing, and an active-
voice regression confirming the gate doesn't bleed into in-flight notes.
|
Pushed Single choke point: Seven new |
|
I'm continuing to test with my Push 3 in Ableton Live 12.4, but it seems that MPE support is complete right now and I didn't find any more bugs, but I need to use it a bit more for sure. @jamshark70 @paulfd in case you would have some free time to test the latest Sfizz with Sfizz-UI in any DAW, that would be great! Tell me when you think both this PR and the related one for Sfizz-UI are ready to be rewiewed, I would change the status of the PR from draft to ready for review. |
Here are the two quicky SuperCollider tests I've run. These don't in any way make up a comprehensive suite, but they show that per-channel release and pitch bend are happening -- but they happen whether MPE is turned on or off. That may be a difference in behavior compared to baseline sfizz. I.e., I can get the old sfizz behavior (where all messages collapse down to one channel) if I guarantee that all messages are on channel 1, but if I distribute the messages onto different channels, then they are always per-channel, regardless of the MPE setting. This is usable for me personally, but it might be an obstacle to an upstream merge. (I'm not sure if sfizz maintainers would insist on full backward compatibility if MPE is off or not.) Setup: Per-channel release: The last thing you hear should be a B diminished triad. If per-channel note release is not working, the first chord will cut off the B and D early, and you'll be left only with an F hanging over. I get the full triad 👍 Per-note pitch bend: I should hear a 3.5 semitone interval, and I do. Great stuff! Really appreciate this -- my sorta-just-intonation pianos are back 👿 |
|
@jamshark70 thank you for the testing, really appreciate! About your concern regarding MPE pitchbend, as you can in commit, I had to do it to be in line with the MPE specification:
But you say that now the behaviour changed when MPE is turned off, compared to how it was before, as soon as I have a bit of time I'll take a look, probably this evening after work. |
I don't think I have a concern about this -- it's working here, and doing exactly what I need!
To clarify -- if I recall correctly, the old sfizz behavior was to ignore MIDI channel completely, and act as if all messages were being sent on a single channel. With your fork, both MPE-on and MPE-off handle note on/off and pitchbend per channel. That's correct for MPE-on, obviously. For MPE-off, I don't know what the sfizz maintainers will say. They could say, "This is great! We always wanted per channel behavior, but it was too big an architecture change." Or they could say that there needs to be a way back to legacy behavior, for users who don't care about (or actively don't want) multichannel MIDI response. My personal opinion is twofold: 1/ I prefer the multichannel way, and that's how I plan to use it. 2/ I'm sensitive to the need for backward compatibility -- if somebody's DAW project using sfizz starts playing differently because MPE was added, that's bad. I would completely understand if sfizz maintainers argue that MPE-off should behave exactly like sfizz without MPE support at all. I don't need any changes -- I'm happy! But I wouldn't be surprised if this became a conversation when they review for merge. |
Defensive regression test for the MPE-off compatibility contract that
sits behind the wrapper-side toggle. In a mixed-API session — where the
host calls the channel-less legacy noteOn / pitchWheel / cc /
channelAftertouch API alongside the *MPE variants — a voice triggered
through the legacy path must:
- Get triggerChannel_ = 0 (its TriggerEvent.channel field).
- Read all modulation from the master channel only; non-master *MPE
writes (pitchWheelMPE, ccMPE, channelAftertouchMPE on channel 5)
must not leak into the master channel its voice reads from.
- Release on a channel-less legacy noteOff, which lands on
registerNoteOff(channel=0) and matches the voice's
triggerEvent_.channel == 0 check added previously.
Existing tests already cover that the legacy write path lands in the
master slot (the 'Existing single-channel API forwards to master-channel
slot' case). This case adds the converse: writes to non-master channels
through the *MPE API must not contaminate the master slot that legacy
voices read from. Together those two cases prove the legacy and *MPE
APIs share storage cleanly without cross-talk.
7 assertions, all green; full suite still passes (525 cases, 52373
assertions). Cheap insurance against future engine refactors
inadvertently coupling triggerChannel_=0 voices to per-channel reads.
|
@jamshark70 your MPE-off concern is fixed in Engine-side regression test in Worth re-running your two SuperCollider reproducers with the switch off — multi-channel input should collapse to one global channel. |
Pushes the MPE-off compatibility gating into the engine instead of
asking every consumer to choose between the legacy and *MPE API
surfaces. With this commit the *MPE methods become byte-for-byte
equivalent to the legacy channel-less API when mpeEnabled_ is false:
the channel argument is collapsed to 0 at each entry point, all
MidiState writes land in channel-0 storage, voices get
triggerChannel_=0, and registerNoteOff matches them via the existing
channel-aware check.
Normalization sites (each takes a single `if (!mpeEnabled_) channel = 0;`
line at the top of the hd* entry):
- hdNoteOnMPE / hdNoteOffMPE
- hdPitchWheelMPE
- hdChannelAftertouchMPE
- hdPolyAftertouchMPE
- performHdcc (Impl) — after handleRpnControlCC, not before. The
RPN parser needs the real channel to distinguish Manager-vs-Member
MCM and route RPN 0 (Pitch Bend Sensitivity) updates to the right
zone; normalizing earlier would silently accept MCM on Member
Channels and break the auto-config tests. Once the parser
has run, the MidiState write + voice CC updates are the
channel-aware storage path and inherit the normalization.
setMPEEnabled gains an on→off transition flush via allSoundOff(). The
inverse case (off→on) is harmless: existing channel-0 voices stay valid
and new notes pick up incoming channels naturally.
The legacy non-MPE API surface (Synth::noteOn / cc / pitchWheel /
channelAftertouch / polyAftertouch) is unchanged on purpose; it
already forwarded to *MPE(channel=0) and continues to do so. What
this commit changes is the *MPE surface, which now also behaves as
single-channel under MPE off — so the legacy and *MPE entry points
are equivalent in that mode, which is what consumers want.
Existing tests that asserted per-channel routing through *MPE methods
on a default-constructed Synth (which has mpeEnabled_=false) now
explicitly enable MPE first to opt into the channel-aware contract.
One per-channel polyAftertouchMPE test was redundant with the existing
MidiState per-channel test plus the Manager-Channel-pass-through
case — it now exercises only the Manager Channel (the only path the
spec compliance filter allows under MPE on). Two new cases assert the
normalization contract end-to-end:
- "When MPE is disabled, *MPE methods collapse channel to 0
(single-channel contract)" — noteOnMPE / pitchWheelMPE / ccMPE /
channelAftertouchMPE / noteOffMPE on channel 5 all land in
channel-0 storage and the resulting voice releases on a channel-0
note-off.
- "setMPEEnabled(false) flushes active voices triggered while MPE
was on" — voices spawned with triggerChannel_>0 are gone after
the flag flips.
Full sfizz suite green (526 cases, 52374 assertions).
After the previous commit's MPE-off normalization, the *MPE entry points handle channel routing for both MPE-on and MPE-off mode — the suffix is misleading. Renames the 12 channel-taking dispatch methods to drop the suffix in the C++ API, becoming overloads of the existing legacy names distinguished by argument count. C++ (Synth.h / Synth.cpp / sfizz.hpp / sfizz.cpp): noteOnMPE → noteOn (4-arg overload) hdNoteOnMPE → hdNoteOn (4-arg overload) noteOffMPE → noteOff (4-arg overload) hdNoteOffMPE → hdNoteOff (4-arg overload) ccMPE → cc (4-arg overload) hdccMPE → hdcc (4-arg overload) pitchWheelMPE → pitchWheel (3-arg overload) hdPitchWheelMPE → hdPitchWheel (3-arg overload) channelAftertouchMPE → channelAftertouch (3-arg overload) hdChannelAftertouchMPE → hdChannelAftertouch (3-arg overload) polyAftertouchMPE → polyAftertouch (4-arg overload) hdPolyAftertouchMPE → hdPolyAftertouch (4-arg overload) The pre-existing legacy methods (without the channel argument) stay as thin forwards that pass channel = 0 to the new overload — keeps backward compatibility for callers that don't care about channel. C API (sfizz.h / sfizz_wrapper.cpp) — distinct names because C has no overloads: sfizz_send_note_on_mpe → sfizz_send_note_on_channel sfizz_send_hd_note_on_mpe → sfizz_send_hd_note_on_channel sfizz_send_note_off_mpe → sfizz_send_note_off_channel sfizz_send_hd_note_off_mpe → sfizz_send_hd_note_off_channel sfizz_send_cc_mpe → sfizz_send_cc_channel sfizz_send_hdcc_mpe → sfizz_send_hdcc_channel sfizz_send_pitch_wheel_mpe → sfizz_send_pitch_wheel_channel sfizz_send_hd_pitch_wheel_mpe → sfizz_send_hd_pitch_wheel_channel sfizz_send_channel_aftertouch_mpe → sfizz_send_channel_aftertouch_channel sfizz_send_hd_channel_aftertouch_mpe → sfizz_send_hd_channel_aftertouch_channel sfizz_send_poly_aftertouch_mpe → sfizz_send_poly_aftertouch_channel sfizz_send_hd_poly_aftertouch_mpe → sfizz_send_hd_poly_aftertouch_channel The MPE config / status surface is unchanged — these methods are genuinely MPE-related (they control mode and report MPE-spec drops), not just channel-aware dispatch: setMPEEnabled / getMPEEnabled setMPEPitchBendRange / getMPEMasterPitchBendRange / getMPEPerNotePitchBendRange setMPEMasterBendAutoConfigEnabled / getMPEMasterBendAutoConfigEnabled setMPEPerNoteBendAutoConfigEnabled / getMPEPerNoteBendAutoConfigEnabled getDroppedPolyKpOnMemberCount / getDroppedManagerOnlyMessageCount Engine-internal call sites updated; legacy methods now forward to the renamed overloads with channel = 0. Test suite green (526 cases, 52374 assertions).
Doc-only sweep. After the previous commit's rename and the MPE-off
normalization commit before it, the existing API-section preamble was
out of date in two places:
- Referred to "the corresponding *MPE / _mpe method with channel = 0"
as the equivalence between legacy and channel-aware paths. Now
that the channel-aware methods don't have an MPE suffix in the
C++ API and use _channel in the C API, the prose should say so.
- Stated that "setMPEEnabled() is informational; per-channel input
dispatch works regardless of the flag". That's no longer true:
with MPE disabled the engine actively collapses channel to 0 in
the channel-taking entry points, so both API surfaces behave
identically under MPE off. The flag now gates dispatch behaviour,
not just voice stealing.
No behaviour change.
|
Public API rename in
Doc refresh in |
|
Hi, yes, the MPE switch in the GUI does make a difference in behavior now.
Couple of issues:
|
|
Thanks for testing — glad the legacy single-channel path (note stealing, ±2 semitone bend with MPE off) is preserved. On the two remaining points: 1. MCM not updating the GUI. Engine state is being applied — your audible ±48 vs ±2 confirms it. Whether the GUI should also reflect received MCM in the visible toggle / bend-range fields isn't industry standard: the MPE 1.0 spec mandates only receiver-side behavioural state. Most MPE-capable plugins treat the visible UI as authoritative and incoming RPN as a side-channel hint. Can we split this as a follow-up sfizz-ui issue rather than gating merge? Happy to track. 2. Bend-range number boxes unresponsive. Real UI bug — I'll fix it in sfztools/sfizz-ui#166. Thanks for catching it. |
|
Quick follow-up — the bend-range widget bug is fixed in sfztools/sfizz-ui#166 ( Also pushed
Should make it easier to see what the engine is actually using vs. what the user has staged. Would appreciate another pass when you have time. |
|
Will do, when I can, bit busy these days. Thanks for all the hard work on this! |
Two comment-only edits in tests/MPET.cpp: - The "MPE off → channel collapses to 0" contract test named a specific downstream consumer by project name. Generalized to "the VST3 / LV2 wrappers in sfizz-ui, or any downstream embedder" — the contract is what matters, not which embedder happens to rely on it. - The Manager-only message filtering section referenced an internal feature identifier when noting that RPN data CCs must flow on Member Channels for auto-config. Replaced with "RPN 0 (Pitch Bend Sensitivity) auto-config", the upstream-meaningful behavior under test. No functional changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This PR addresses the MPE feature request in #1313 — adding full per-note pitch bend, per-note CC modulation, and per-note channel aftertouch routing through sfizz so a polyphonic chord on an MPE controller (Osmose, Seaboard, LinnStrument, Push 3) renders with each finger's modulation applied to its own voice instead of collapsing globally.
Status: draft, inviting design review. Hand-tested against an Expressive E Osmose in Logic Pro and an Ableton Push 3 in Live 12. Full sfizz suite green (526 cases / 52374 assertions) including a new
tests/MPET.cppcovering per-channel state isolation, voice-stealing biasing, MCM / RPN 0 auto-configuration, MPE 1.0 spec-compliance filters, and the MPE-off compatibility contract.What's in the PR
Logically organised; each commit is independently buildable.
Core engine plumbing
36a6e09a—MidiState: per-channelChannelStatestruct (storage refactor; behaviour unchanged with master-only access)a8b28743—MidiState/Voice: per-voicetriggerChannel_plumbing for modulation reads3db6b80a—Synth: channel-aware public API + per-channel dispatche4812d8b—VoiceStealing: bias victim selection to a preferred MIDI channel5ad530a9—MidiState: lazy member-channel events + regression suitecb3ba1dd+002f1395/b117153f/b9449173/c2f81dc7/51e0e3a1— hand-test fixes (channel-awareVoice::registerNoteOff, master→member fallback for scalar getters, summed master+member pitch envelope, empty-events guard)Public C++ + C API
cd7d7df1—sfz::SfizzC++ wrapper exposes the new methods2977b355— parallel C API (sfizz_send_*_channel,sfizz_set_mpe_enabled, bend-range getters/setters)MPE 1.0 spec compliance
2b408275— MCM (RPN 6) + Pitch Bend Sensitivity (RPN 0) auto-configuration with per-axis opt-outs0a443c14— drop Poly KP on Member Channels (§2.2.7 / Appendix E Table 5)528a4b9d— drop Manager-only CCs (pedals, mode/reset, Bank Select) on Member Channels (§2.3.1 / §2.3.3)efaf7c2a— released-voice expression reads route through the Manager Channel (§2.2.6 / §2.2.7 / §2.2.8 / §A.4.1)MPE-off compatibility
36c92314— regression test asserting legacy-API voices isolate from*MPEper-channel writesdb9717aa— engine collapses channel argument to 0 in all channel-aware entry points when MPE is disabled, so legacy and channel-aware paths behave identically.setMPEEnabled(false)flushes active voices.API hygiene
13337446— dropped the*MPEsuffix from channel-aware methods now that they handle both modes; C++ becomes overloads of the legacy names, C API uses_channelsuffix. MPE config surface (setMPEEnabled, bend-range getters/setters, drop counters) unchanged.9eaef3fc— header doc refresh describing the new contract.Design notes
setMPEEnabled(bool)+setMPEPitchBendRange(masterSemitones, perNoteSemitones)+ the two RPN opt-out flags. Happy to grow this if you'd prefer an explicitsetMPEZone(masterChannel, memberCount)shape.*_oncc<N>opcodes "just work" via per-channel CC banks.Why a fork
Issue #1313 had been open since April 2025 with no maintainer engagement, and an MPE-capable engine was needed for a personal project. Rather than block, hard-forked at
f5c6e29fand have been iterating on the fork since. This PR exists to invite review and find a maintainer-preferred shape so the patch series doesn't have to live in a fork indefinitely.cc @paulfd, @jpcima, @jamshark70.