Skip to content

MPE: per-channel pitch / CC / aftertouch + MPE-aware voice stealing#1327

Open
rullopat wants to merge 22 commits into
sfztools:developfrom
rullopat:mpe
Open

MPE: per-channel pitch / CC / aftertouch + MPE-aware voice stealing#1327
rullopat wants to merge 22 commits into
sfztools:developfrom
rullopat:mpe

Conversation

@rullopat

@rullopat rullopat commented May 10, 2026

Copy link
Copy Markdown

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.cpp covering 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

  • 36a6e09aMidiState: per-channel ChannelState struct (storage refactor; behaviour unchanged with master-only access)
  • a8b28743MidiState / Voice: per-voice triggerChannel_ plumbing for modulation reads
  • 3db6b80aSynth: channel-aware public API + per-channel dispatch
  • e4812d8bVoiceStealing: bias victim selection to a preferred MIDI channel
  • 5ad530a9MidiState: lazy member-channel events + regression suite
  • cb3ba1dd + 002f1395 / b117153f / b9449173 / c2f81dc7 / 51e0e3a1 — hand-test fixes (channel-aware Voice::registerNoteOff, master→member fallback for scalar getters, summed master+member pitch envelope, empty-events guard)

Public C++ + C API

  • cd7d7df1sfz::Sfizz C++ wrapper exposes the new methods
  • 2977b355 — 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-outs
  • 0a443c14 — 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 *MPE per-channel writes
  • db9717aa — 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 *MPE suffix from channel-aware methods now that they handle both modes; C++ becomes overloads of the legacy names, C API uses _channel suffix. MPE config surface (setMPEEnabled, bend-range getters/setters, drop counters) unchanged.
  • 9eaef3fc — header doc refresh describing the new contract.

Design notes

  • MPE config surface: minimal — setMPEEnabled(bool) + setMPEPitchBendRange(masterSemitones, perNoteSemitones) + the two RPN opt-out flags. Happy to grow this if you'd prefer an explicit setMPEZone(masterChannel, memberCount) shape.
  • CC inheritance: channel-aware getters fall back to master for empty member-channel vectors. Once a member channel writes a CC, it owns its state.
  • Master CCs (sustain pedal, modwheel, …) apply globally because they're sent on the master channel; member voices read their own channel's vector, find it empty, fall back to master.
  • No SFZ opcode changes: existing *_oncc<N> opcodes "just work" via per-channel CC banks.
  • Lower Zone only: Upper Zone (master = channel 16) is deferred — no consumer in scope.

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 f5c6e29f and 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.

rullopat and others added 7 commits May 7, 2026 19:23
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>
@paulfd

paulfd commented May 10, 2026

Copy link
Copy Markdown
Member

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.

@jamshark70

Copy link
Copy Markdown

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

@paulfd

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.

Neither do I, but SuperCollider / Max / Pd can generate any MIDI messages that are required. (I'll be running SuperCollider --> VSTPlugin extension --> sfizz.vst3.)

@rullopat

Copy link
Copy Markdown
Author

@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
@rullopat

Copy link
Copy Markdown
Author

Quick update: pushed 2977b355 to this branch — adds the parallel C API surface (sfizz_send_*_mpe, sfizz_{set,get}_mpe_enabled, sfizz_{set,get}_mpe_*_pitch_bend_range). One-line delegations to the engine, no new behavior. This unblocks updating sfizz-ui so the LV2 / VST3 builds can drive MPE without reaching into the C++ wrapper — I'll take a stab at that side next.

@rullopat

rullopat commented May 11, 2026

Copy link
Copy Markdown
Author

Follow-up: opened sfztools/sfizz-ui#166 — VST3/AU + LV2 + PD dispatch through the new *_mpe API plus MPE enable / master bend / per-note bend controls in the Settings panel. Draft because I don't have an MPE controller on hand to verify the end-to-end round-trip. @jamshark70 if your SuperCollider → sfizz.vst3 path is still on, that PR should now let you drive MPE through it.

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.

@jamshark70

Copy link
Copy Markdown

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.

@rullopat

Copy link
Copy Markdown
Author

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

@jamshark70

Copy link
Copy Markdown

now I'm trying a VST in Ableton and see the problem

Strange...

I did a little more testing just now:

  • Using plugdata (Pd-as-a-VST) in SC's VSTPlugin, I confirm that VSTPlugin (which is loading sfizz into SC) passes channel numbers through correctly.
  • Adding a few debugging posts into sfizz, I get:
note on channel = 1
noteOnDispatch channel = 1
synth startVoice channel = 1
note on channel = 2
noteOnDispatch channel = 2
synth startVoice channel = 2

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.

@jamshark70

jamshark70 commented May 12, 2026

Copy link
Copy Markdown

Here's another finding. I was trying to track down what happens to those pitchbend messages.

With MPE off:

inserting bend = 0.000061 into channel 0
getPitchBend check events for channel = 1
getPitchBend check events for channel = 1
notenum = 60, channel = 1, bend = 0.000000
inserting bend = 0.854605 into channel 0
getPitchBend check events for channel = 2
getPitchBend check events for channel = 2
notenum = 63, channel = 2, bend = 0.000000
inserting bend = 0.000061 into channel 0

With MPE on:

getPitchBend check events for channel = 1
getPitchBend check events for channel = 1
notenum = 60, channel = 1, bend = 0.000000
getPitchBend check events for channel = 2
getPitchBend check events for channel = 2
notenum = 63, channel = 2, bend = 0.000000

In both cases, when playing a note, it's looking to the right channel for a pitchbend data. But with MPE on, it never reaches pitchBendEvent(). So nothing is stored into the MIDI state for the channels on which notes are being played.

I wonder if something similar is causing note events to be stored in the wrong place (but now I really am out of time -- need to go to my real job --)

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>
@rullopat

Copy link
Copy Markdown
Author

Thanks @jamshark70 for the detailed traces — they made both bugs obvious.

Two follow-ups based on what you found:

  1. 002f1395 in this branch — Voice::registerNoteOff now also matches on channel, so a note-off on one member channel no longer releases voices on every other channel at the same pitch.
  2. The "no pitch bend in MPE mode" half wasn't in the library — it was the VST3 wrapper dropping kPidPitchBend / kPidAftertouch when MPE was enabled. Fixed in MPE: route per-channel MIDI through the engine, expose enable + bend-range controls sfizz-ui#166 at b0699c8.

About your edit re performHdcc reached vs. pitchBendEvent — I haven't reproduced that path yet. After the two fixes above, per-channel pitch bend still won't propagate end-to-end because the VST3 wrapper's getMidiControllerAssignment returns the same paramID regardless of channel, so per-channel bend collapses to one global parameter. That's a separate follow-up.

rullopat and others added 2 commits May 12, 2026 10:31
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>
@rullopat

Copy link
Copy Markdown
Author

Two more commits pushed:

  • b117153f — extends the master→member fallback (cb3ba1dd) to the scalar MidiState getters too. Voice::Impl::startVoice seeds the per-voice bend smoother from the scalar getPitchBend; without this it started at 0 for member-channel voices even when master had a non-zero bend.
  • b9449173 — actually applies the MPE bend ranges. mpeMasterPitchBendRange_ / mpePerNotePitchBendRange_ were stored but never read, so member-channel voices used the region's bend_up/bend_down (default ±2 st) instead of the configured ±48 st per-note range. pitchEnvelope also wasn't summing master + member contributions: once a member channel had any per-note bend, its events vector stayed populated and master bend stopped reaching the voice. Now reads own and master events separately (via the new getPitchEventsRaw) and combines: perNote × perNoteRange + master × masterRange.

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.

rullopat and others added 3 commits May 12, 2026 11:22
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.
@rullopat

Copy link
Copy Markdown
Author

Pushed 2b408275: the engine now parses RPN 6 (MPE Configuration Message) and RPN 0 (Pitch Bend Sensitivity) per MPE 1.0 §2.

A complete CC 101=0 / CC 100=6 / CC 6=N sequence on channel 0 (Lower Zone master) calls setMPEEnabled(N>0). An CC 101=0 / CC 100=0 / CC 6=semitones sequence on the master updates the master bend range; on a member channel it updates the per-note range. CCs still propagate to MidiState so SFZ *_oncc bindings on the relevant CC numbers keep working — the parser is a tap, not a filter.

Two granular opt-outs (setMPEMasterBendAutoConfigEnabled, setMPEPerNoteBendAutoConfigEnabled, mirrored through the C++ and C wrappers, default true) let UIs pin a bend range without disabling the MCM auto-enable path. MCM enable is unconditional because that's the spec contract.

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 CC 6 following an NRPN selection doesn't get misinterpreted as RPN data entry.

12 new [MPE] cases in tests/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, and parser pass-through on CC propagation. Verified with Push 3 in Live 12.

@rullopat

Copy link
Copy Markdown
Author

Quick follow-up — testing surfaced a bug on the wrapper side. SfizzVstProcessor::process() was re-sending mpeEnabled and the bend ranges to the synth on every block, which meant the engine's auto-config from MCM / RPN 0 got clobbered straight away: engine flipped on, wrapper pushed its stale state next block, engine flipped back off. Push 3 didn't catch it (Live's MPE mode doesn't send MCM); something like an Osmose, which announces itself on connect, would have.

Fix is in sfizz-ui across two commits — 0c9c4f4 stops the wrapper re-asserting on every block, and acd5007 feeds the engine's auto-configured values back to the host via outputParameterChanges, so the editor's MPE controls update in real time and project saves persist the post-MCM state.

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.
@rullopat

Copy link
Copy Markdown
Author

Pushed 0a443c14: drops Polyphonic Key Pressure events on Member Channels when MPE is enabled, per MPE 1.0 §2.2.7 (and Appendix E Table 5, which marks Poly KP on Member Channels as Prohibited for both Tx and Rx).

The gate lives at the top of Synth::hdPolyAftertouchMPE, so all four poly-aftertouch entry points (polyAftertouch / hdPolyAftertouch / polyAftertouchMPE / hdPolyAftertouchMPE) funnel through it; the legacy single-channel paths forward to channel 0 and remain unaffected. Dropped events neither update MidiState nor reach the mod-matrix polyphonicAftertouch ExtendedCC. Manager-Channel Poly KP is permitted under the spec's "discretion of the implementer" clause and continues to route as today.

A diagnostic counter (getDroppedPolyKpOnMemberCount, mirrored through the C++ and C wrappers) lets hosts observe spec-violating traffic for triage.

Three new [MPE] cases in MPET.cpp cover the Member drop, the Manager-Channel pass-through, and the MPE-disabled bypass.

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.
@rullopat

Copy link
Copy Markdown
Author

Pushed 528a4b9d: drops Manager-only CC messages received on Member Channels when MPE is enabled, per MPE 1.0 §2.3.1 / §2.3.3 (Appendix E Table 5 marks pedals, mode/reset and Bank Select as honored only on the Manager Channel).

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 Synth::Impl::performHdcc, ahead of the global All-Notes-Off / Reset-All-Controllers early-returns and the RPN parser tap, so a Member-Channel CC 120/121/123 no longer resets the zone by accident and Bank Select on a Member Channel doesn't queue state for a Program Change the host would have to filter separately. Gated on the asMidi entry, so internal automation paths (which conceptually target the Manager Channel) are unaffected. CC#7 / CC#10 / CC#11 are intentionally NOT in the list — the spec marks them optional on both channel types.

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 programChange API does not carry a channel argument.

Diagnostic counter getDroppedManagerOnlyMessageCount is mirrored through the C++ and C wrappers.

Eight new [MPE] cases in MPET.cpp cover Damper drop on Member, Damper pass-through on Manager, all six pedal CCs drop, All-Notes-Off / Reset-All / Bank-Select drops, the MPE-disabled bypass, and a regression that RPN data CCs still flow on Member Channels.

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.
@rullopat

Copy link
Copy Markdown
Author

Pushed efaf7c2a: a released voice stops reacting to Pitch Bend, Channel Pressure, CC#74 and Poly Aftertouch sent on its Member Channel after Note Off, per MPE 1.0 §2.2.6 / §2.2.7 / §2.2.8. Manager-Channel traffic still reaches the release tail (§A.4.1).

Single choke point: Voice::expressionChannel() returns the trigger channel for active voices and 0 for released voices triggered on a Member Channel. The mod-matrix CC / Channel Pressure / Poly Aftertouch sources, the per-block pitch envelope, and the region crossfade reads all route through it.

Seven new [MPE] cases in MPET.cpp.

@rullopat

Copy link
Copy Markdown
Author

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.

@jamshark70

Copy link
Copy Markdown

in case you would have some free time to test the latest Sfizz with Sfizz-UI in any DAW, that would be great!

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:

(
SynthDef(\vst, { |out = 0|
	Out.ar(out, VSTPlugin.ar(numOut: 2));
}).add;
)

a = Synth(\vst);
c = VSTPluginController(a);
c.open("sfizz.vst3"/*, mode: \sandbox*/);
m = VSTPluginMIDISender(c);

c.editor;  // and switch MPE on here

Per-channel release:

(
p = Ppar([
	Pbind(
		\type, \midisend,
		\midisend, m,
		\degree, Pn(Pseries(-3, 1, 12), inf) + #[0, 2, 4],
		\chan, Pn(Pseries(1, 1, 15), inf).clump(3).trace,
		\legato, 2.5,
		\amp, 0.5
	),
]).play;
)

p.stop;

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:

(
r = fork {
	var n = MIDINoteMessage(device: m, latency: s.latency);
	var p = MIDIBendMessage(device: m, latency: s.latency);
	// middle C, channel 2
	// play for 4 beats
	s.makeBundle(s.latency, {
		p.play(0, 1);
		n.play(60, 64, 4, 1);
	});

	1.0.wait;
	// middle Eb, channel 3, one beat later, up a quarter tone
	s.makeBundle(s.latency, {
		p.play((8192 / 48 * 0.5).asInteger, 2);
		n.play(63, 64, 4, 2);
	});
};
)

I should hear a 3.5 semitone interval, and I do.

Great stuff! Really appreciate this -- my sorta-just-intonation pianos are back 👿

@rullopat

Copy link
Copy Markdown
Author

@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:

Pushed efaf7c2a: a released voice stops reacting to Pitch Bend, Channel Pressure, CC#74 and Poly Aftertouch sent on its Member Channel after Note Off, per MPE 1.0 §2.2.6 / §2.2.7 / §2.2.8. Manager-Channel traffic still reaches the release tail (§A.4.1).

Single choke point: Voice::expressionChannel() returns the trigger channel for active voices and 0 for released voices triggered on a Member Channel. The mod-matrix CC / Channel Pressure / Poly Aftertouch sources, the per-block pitch envelope, and the region crossfade reads all route through it.

Seven new [MPE] cases in MPET.cpp.

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.

@jamshark70

Copy link
Copy Markdown

About your concern regarding MPE pitchbend

I don't think I have a concern about this -- it's working here, and doing exactly what I need!

But you say that now the behaviour changed when MPE is turned off

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.
@rullopat

Copy link
Copy Markdown
Author

@jamshark70 your MPE-off concern is fixed in sftools/sfizz-ui 4a789ffSfizzVstProcessor now branches dispatch on the toggle: legacy channel-less API when off (collapses to master channel in MidiState), *MPE when on. NoteExpression and per-channel paramIDs are skipped when off; allSoundOff() on the on→off edge flushes voices with triggerChannel_>0 that would otherwise hang.

Engine-side regression test in rullopat/sfizz 36c92314 asserts legacy-API voices are isolated from *MPE per-channel writes. Library bump in sftools/sfizz-ui 68c7dea.

Worth re-running your two SuperCollider reproducers with the switch off — multi-channel input should collapse to one global channel.

rullopat added 3 commits May 13, 2026 18:49
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.
@rullopat

Copy link
Copy Markdown
Author

Public API rename in 9eaef3fc (preceded by 13337446): dropped the *MPE suffix from the channel-aware dispatch methods now that they handle both MPE-on and MPE-off routing.

  • C++: noteOnMPE / ccMPE / pitchWheelMPE / channelAftertouchMPE / polyAftertouchMPE (+ hd* variants) → overloads of the existing legacy names, distinguished by argument count.
  • C: sfizz_send_*_mpesfizz_send_*_channel.
  • MPE config surface unchanged (setMPEEnabled, bend-range getters/setters, drop counters) — those are genuinely MPE-related.

Doc refresh in 9eaef3fc updates the API preamble to describe the new contract: with MPE disabled the channel-aware methods collapse channel to 0 internally, so legacy and channel-aware paths are equivalent in that mode. 526/526 tests green.

@rullopat rullopat marked this pull request as ready for review May 13, 2026 20:48
@jamshark70

Copy link
Copy Markdown

Hi, yes, the MPE switch in the GUI does make a difference in behavior now.

  • MPE on: Pitch bend on the order of +/- 57 is audible (due to 48 semitone MPE bend range)
  • MPE off: Same pitch bend is not audible (due to 2 semitone main bend range), and overlapped notes do get cut off.

Couple of issues:

  1. The CC messages to switch on MPE are still not working for me. The exact sequence of bytes I'm sending is B0 65 00 B0 64 06 B0 06 0F (copied from debugging output), and the MPE spec says [0xBn 0x65 0x00] [0xBn 0x64 0x06] [0xBn 0x06 <mm>] -- I'm only using the lower zone so n = 0. But there's no effect on the settings page or on the sound.
  2. I can't seem to change either pitch bend range. Clicking on the bend range number boxes (either of them) has no effect, and I can't type into them either.

@rullopat

Copy link
Copy Markdown
Author

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.

@rullopat

Copy link
Copy Markdown
Author

Quick follow-up — the bend-range widget bug is fixed in sfztools/sfizz-ui#166 (39e3107 populates the value menus that were silently empty, and 2ced678 adds the integer formatter / width alignment / "disabled when RPN-driven" treatment).

Also pushed 4fb3724 which adds a read-only "Current master / per-note bend value" row beside each override:

  • !override / no RPN → MPE default
  • !override / RPN received → engine's RPN-driven value
  • override / no RPN → override value
  • override / RPN received → incoming RPN value, struck through to signal "engine is ignoring this"

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.

@jamshark70

Copy link
Copy Markdown

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants