Skip to content
This repository was archived by the owner on Jun 21, 2026. It is now read-only.

MPE: route per-channel MIDI through the engine, expose enable + bend-range controls#166

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

MPE: route per-channel MIDI through the engine, expose enable + bend-range controls#166
rullopat wants to merge 25 commits into
sfztools:developfrom
rullopat:mpe

Conversation

@rullopat

@rullopat rullopat commented May 11, 2026

Copy link
Copy Markdown

Depends on sfztools/sfizz#1327 (the engine-side MPE PR). Draft for parity with that one — merge order should be engine first, this second.

What's in the Settings panel

  • MPE enabled — engine-wide channel routing gate. When off, the engine collapses channel to 0 in all channel-aware entry points so single-channel sessions behave exactly like pre-fork sfizz.
  • Master bend (st) / Per-note bend (st) — pitch-bend ranges (MPE 1.0 defaults: 2 / 48).
  • Ignore master-bend RPN / Ignore per-note-bend RPN — opt-outs that pin manual values against incoming RPN 0 from a controller. MCM (RPN 6) auto-enable is unconditional per the spec.

Plumbing

  • VST3 (SfizzVstProcessor.cpp): 16-channel event bus; playOrderedEvent calls the engine's channel-aware overloads with event.*.channel; kLegacyMIDICCOutEvent carries per-channel pitch-bend / channel pressure / poly pressure / CC; kNoteExpressionValueEvent (tuning, volume, brightness) is routed per-note via a noteId→channel registry. Per-channel paramIDs (kPidMPEPitchBendCh1..15, kPidMPEAftertouchCh1..15, kPidMPECC74Ch1..15) expose the same modulation as VST3 automation, for hosts that prefer parameters over events. Engine-driven MPE state changes (from MCM / RPN 0) feed back to the host via outputParameterChanges so the editor reflects auto-config and project saves persist it.
  • LV2 (plugins/lv2/sfizz.cpp): three new control input ports for the 23/24/25 stereo + 37/38/39 multi variants; MIDI dispatch uses sfizz_send_*_channel keyed on msg[0] & 0x0F.
  • Pure Data: raw-MIDI input uses the channel-aware C variants.

State versioned (5→6) in SfizzVstState.cpp with backwards-compat read. AU passes auval; VST3 passes pluginval at strictness 4.

What's in the PR (grouped commits)

Initial MPE wiring

  • f79e78a DRAFT submodule pin · e67291a MPE across VST3/AU, LV2, PD, editor · 9f8ed47 UI layout

Hand-test fixes

  • b0699c8 keep kPidPitchBend / kPidAftertouch in MPE mode · 65f23be VST3 NoteExpression → engine · 7faecf0 per-channel paramIDs

MCM / RPN 0 auto-configuration

  • e4e12e7 / 140e8d5 library bumps · b91fad3 library bump bringing in MCM parser · 1252c7a per-axis RPN opt-out checkboxes · 0c9c4f4 stop clobbering engine-driven state per-block · acd5007 feed engine-driven state back to the host · df8833c README MPE section

MPE-off compatibility

  • 4a789ff wrapper gates dispatch on the toggle · 68c7dea library bump with the engine-side isolation regression test · 954cf60 library bump moving normalization into the engine (setMPEEnabled(false) flushes voices) · 9e16932 drop the wrapper's now-redundant if (mpe) branches

API rename

  • 99eef74 engine dropped the *MPE suffix from dispatch methods; wrapper updates to call noteOn(delay, ch, ...) etc. and the LV2 plugin to sfizz_send_*_channel.

Bend-range read-out and UX fixes

  • 39e3107 populate the master / per-note value menus (were silently empty)
  • 2ced678 integer formatter, width align with preload / oversampling, disable when RPN-driven
  • 4fb3724 new "Current master / per-note bend value" read-out beside each override — shows engine's effective range; in override-on mode with RPN received, displays the incoming RPN value struck through

Help wanted

Smoke-tested by @jamshark70 via SuperCollider + the VSTPlugin extension during review. Additional MPE-controller passes would be welcome — load an SFZ with per-note modulation, toggle MPE on, confirm per-finger pitch-bend / CC74 / channel pressure each route to their own voice.

If a host routes per-channel pitch-bend through VST3 IMidiMapping (rather than emitting Vst::Events), this PR captures the bend via the per-channel paramID mirrors added in 7faecf0.

Submodule note

library/ currently tracks rullopat/sfizz @ 9eaef3fc so the build picks up the engine-side channel-aware symbols. Re-pin to whatever SHA the engine PR merges at once sfztools/sfizz#1327 lands.

cc @paulfd, @jamshark70.

rullopat added 3 commits May 11, 2026 10:57
Repoints library/ submodule to the MPE fork at 2977b355 (rullopat/sfizz#mpe).
This SHA is the upstream PR sfztools/sfizz#1327 plus a C-API wrapper that
exposes sfizz_send_*_mpe and sfizz_{set,get}_mpe_* — needed so LV2 and
PD can drive MPE without reaching into the C++ wrapper.

This bump is for local testing of the sfizz-ui MPE patch only. Maintainers:
once sfizz#1327 lands upstream, drop this commit and re-pin library/ to the
merged engine SHA on sfztools/sfizz.
Routes incoming MIDI through the channel-aware *MPE engine variants in
all three plugin formats, declares a 16-channel event bus on VST3 so
hosts can deliver per-channel pitch-bend / aftertouch as raw MIDI
events, and adds three controls to the settings panel: MPE enabled,
MPE master pitch bend range, MPE per-note pitch bend range. Defaults
follow the MPE 1.0 spec (master = 2 st, per-note = 48 st).

Engine plumbing
- SfizzVstParameters.h: new pids kPidMPEEnabled / kPidMPEMasterPitchBendRange /
  kPidMPEPerNotePitchBendRange with default/min/max from the MPE spec.
- SfizzVstState.{h,cpp}: persist the three fields, bump currentStateVersion
  from 5 to 6 with a version-gated read.
- SfizzVstController.cpp: register the three params and sync them on
  setComponentState.
- SfizzVstEditor.cpp: bridge new pids ↔ new EditId entries (both directions).
- SfizzVstProcessor.cpp:
  * addEventInput bus is now 16 channels (was 1)
  * per-block: synth.setMPEEnabled(...) + setMPEPitchBendRange(...)
  * playOrderedParameter: store new pid values in _state; while MPE is on,
    drop incoming kPidAftertouch / kPidPitchBend (they're global params with
    no channel, so they'd collapse all member-channel data to the master)
  * playOrderedEvent: swap noteOn/noteOff/polyAftertouch to *MPE variants
    keyed on event.{noteOn,noteOff,polyPressure}.channel
  * playOrderedEvent: add a kLegacyMIDICCOutEvent case to route raw per-channel
    pitch-bend, channel pressure, poly pressure and CC through the *MPE engine
    methods. This is the only path VST3 has for per-channel pitch-bend and
    channel pressure, since the legacy parameter routing is single-channel.

LV2
- New port indices 23/24/25 (stereo) and 37/38/39 (multi) for MPE enabled +
  master/per-note pitch bend range, declared in sfizz_lv2.h and the TTL.
- sfizz_lv2_plugin.h gains three new const float* port pointers.
- connect_port_{stereo,multi} cases for the new indices.
- sfizz_lv2_process_midi_event swaps to sfizz_send_*_mpe / sfizz_send_hdcc_mpe,
  passing the MIDI channel (msg[0] & 0x0F).
- Per-block sets sfizz_set_mpe_enabled / sfizz_set_mpe_pitch_bend_range.
- sfizz_ui.cpp: bridge the three new ports ↔ EditIds in both directions for
  stereo and multi.

Pure Data
- sfizz_puredata.c: raw-MIDI dispatch in sfizz_tilde_midiin now uses the
  channel from the status byte and *_mpe C variants. The PD-specific
  message inputs (sfizz_tilde_list, explicit cc/bend/aftertouch messages)
  stay on master since PD's API has no channel concept.

Editor
- New EditId entries MPEEnabled / MPEMasterPitchBendRange /
  MPEPerNotePitchBendRange, with EditRange defaults matching the VST3 pids.
- New widget pointers in Editor.h + uiReceiveValue/controlAction wiring +
  adjustMinMaxToEditRange registration.
- main.fl adds three widgets to the kPanelSettings subpanel below the
  sustain-cancels checkbox (one checkbox + two value-input spinners).
  Layout-maker regenerates main.hpp on rebuild.

Validation
- AU passes `auval -v aumu samp Sfzt`.
- VST3 passes pluginval at strictness level 4 (full audio / automation /
  state surface). Strictness 5 crashes during Editor Automation immediately
  on view open (before any of the new widgets render); this appears to be
  a pre-existing macOS VSTGUI + pluginval interaction issue, not introduced
  by this change.
Previously the per-note bend slider was positioned in the second column
of the settings panel to save vertical space, which made it look like
it belonged to "Rendering quality" rather than the MPE group. Moving
it directly below master bend keeps the three MPE controls (enabled +
master bend + per-note bend) reading as one cohesive block.
rullopat and others added 5 commits May 12, 2026 09:18
The gate added in e67291a skipped the parameter-path dispatch for
both global controls when MPE was enabled, assuming per-channel
bend / pressure would arrive via kLegacyMIDICCOutEvent. For hosts
that don't emit that event, master bend / pressure delivered via
the kPid* parameters was simply discarded. Always forward both;
they land on master channel 0, which is correct.

Per-channel bend for member channels still needs a separate path —
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Declares INoteExpressionController + INoteExpressionPhysicalUIMapping
on the controller with three expression types — tuning, volume,
brightness — mapped to the X/Y/Pressure physical UI axes. The
processor keeps a fixed-size noteId -> channel table to correlate
incoming kNoteExpressionValueEvents back to the originating note's
member channel, then dispatches:

  kTuningTypeID     -> hdPitchWheelMPE        (over per-note bend range)
  kVolumeTypeID     -> hdChannelAftertouchMPE
  kBrightnessTypeID -> hdccMPE(channel, 74, value)

This is the canonical VST3 path for per-note expression. Hosts that
deliver MPE this way (Bitwig Studio, the SDK reference host) now reach
the engine correctly. Hosts that route MPE via IMidiMapping (Ableton
Live in particular) still need per-channel parameter IDs — that's a
separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds 45 hidden member-channel parameter IDs (15 per axis × 3 axes)
plus the IMidiMapping routing that lets hosts deliver per-note MPE
expression to each channel's own slot. Without this, sfizz's previous
getMidiControllerAssignment returned the same paramID for every
channel, so hosts that route MPE through IParameterChanges (Ableton
Live 12 with "Enable MPE mode" toggled on the device, in particular)
collapsed every member channel's pressure / slide / bend into a
single global parameter and only the last writer survived.

The new IDs reuse the enum value as the host-facing paramID (rather
than the pid++ counter used by the existing block) so the dispatch
in playOrderedParameter can match on the contiguous enum range
directly — the counter has diverged from the enum since the existing
kPidNumOutputs / kPidLevelLast sentinels, which would otherwise
require an interleaved enum layout to keep in sync.

Routes per-channel paramIDs to the channel-aware engine methods:

  kPidMPEPitchBendCh{1..15}  -> synth.hdPitchWheelMPE(channel, value)
  kPidMPEAftertouchCh{1..15} -> synth.hdChannelAftertouchMPE(channel, value)
  kPidMPECC74Ch{1..15}       -> synth.hdccMPE(channel, 74, value)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up three commits from the library mpe branch:

  Voice::registerNoteOff: also match channel
  MidiState: extend master→member fallback to the scalar getters
  Apply MPE per-note bend range and combine master + member bends per MPE 1.0

Together with the per-channel paramID work on the wrapper side, this
gives the full Push 3 / Ableton Live 12 MPE path: per-note bend (over
the configured per-note range), master touch-strip bend (over the
master range), and the two summing cleanly. Overlapping notes on
different member channels also release independently now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up the empty-event-vector guard for pitchEnvelope on top of
b9449173 — a member-channel noteOn with no prior per-note bend would
otherwise SIGTRAP in linearEnvelope's events.size() > 0 assertion.
Caught by sample-machine's PlayerEngine MPE tests; the same code path
trips in any host that delivers a noteOn on a member channel before
any per-note pitch bend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rullopat

Copy link
Copy Markdown
Author

Four commits on top of the existing branch + a library bump bring this to feature-complete on macOS hosts:

  • b0699c8kPidPitchBend / kPidAftertouch were being dropped when MPE was enabled, on the assumption that per-channel bend would arrive via kLegacyMIDICCOutEvent. Neither Ableton Live nor SuperCollider's VSTPlugin emit that event. Always forward both; in MPE they land on master channel 0 (correct per MPE 1.0).

  • 65f23be — VST3 NoteExpression wiring: INoteExpressionController + INoteExpressionPhysicalUIMapping, three expression types (tuning / volume / brightness) mapped to PUI X/Y/Pressure. Processor keeps a noteId → channel correlation table populated at NoteOn and dispatches kNoteExpressionValueEvent to the matching MPE engine method. Canonical VST3 MPE path for hosts that use Note Expression (Bitwig Studio).

  • 7faecf0 — 45 hidden per-channel paramIDs (15 × 3) for member-channel pitch bend / aftertouch / CC74. getMidiControllerAssignment returns the per-channel paramID for the relevant MIDI controllers, and playOrderedParameter dispatches them to channel-aware engine methods. Ableton Live 12's MPE route lands here — once the user toggles "Enable MPE mode" on the device from its right-click context menu.

  • e4e12e7 + 140e8d5 — bump library to sftools/sfizz#1327 at c2f81dc7 (channel-aware noteOff, master→member scalar fallback, MPE bend range + master/member contribution summing, empty-vector guard).

Verified end-to-end on Push 3 + Ableton Live 12 with the default *sine SFZ: per-note slide bend (over the configured per-note range, default 48 st), master touch-strip bend (over the master range, default 2 st), per-note pressure, per-note Y / CC74, and overlapping notes on different channels with independent release.

Phase 2's NoteExpression path is implemented but not yet exercised on a host that emits kNoteExpressionValueEvent — Bitwig would be the obvious test, deferred.

User-facing note for the eventual changelog: in Ableton Live 12, MPE has to be enabled per-plugin from the device's right-click context menu ("Enable MPE mode" / "MPE/Multi-channel Settings..."). It's the host-side toggle that switches Live from its non-MPE fallback (which collapses everything to master) to actual per-channel MIDI delivery.

Brief MPE section after "Using sfizz" describing the in-plug-in settings
(MPE enabled + the two bend-range spinners with MPE 1.0 defaults) and
the host-side step Ableton Live 12 currently requires: right-click the
device header → Enable MPE mode. Ableton ships an internal auto-detect
list of MPE-compatible plug-ins; sfizz isn't on it, so the toggle has
to be flipped per device instance (Live persists it in the project).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rullopat

Copy link
Copy Markdown
Author

Added a README section in df8833c documenting MPE usage and the Ableton Live 12 right-click toggle.

For context on the toggle: Ableton ships an internal auto-detect list of MPE-compatible plug-ins as part of Live releases (Kilohearts' Phase Plant / Multipass / Snap Heap were added in 12.2.5, per the release notes — no public mechanism to opt in from the plug-in side). Sfizz isn't on the list, so users currently need to right-click the device header → Enable MPE mode once per instance (Live persists it in the project). Worth flagging in the readme so it doesn't surprise people testing in Live.

rullopat added 6 commits May 12, 2026 12:35
Two new checkboxes under the existing MPE controls let users veto
incoming Pitch Bend Sensitivity (RPN 0) sequences on the master and
member channels independently. Default unchecked (accept), matching
the engine's auto-config default. MPE Configuration Messages (RPN 6)
remain unconditional — the existing MPE-enabled checkbox is still the
manual override.

Wired through EditId, the VST3 parameter set (kPidMPEMasterBendIgnoreRpn
/ kPidMPEPerNoteBendIgnoreRpn at the end of the enum so existing param
IDs don't shift), and SfizzVstState v7. The two checkboxes are
re-arranged with their corresponding bend-range spinner: Ignore master
→ master value, Ignore per-note → per-note value, so the pair reads top
to bottom in one glance.
process() pushed _state.mpeEnabled and the bend ranges to the synth on
every audio block. Once the engine started auto-configuring MPE from
RPN 6 (MCM) and RPN 0 (Pitch Bend Sensitivity), this turned into a
fight: the engine flipped its state from incoming MIDI for one block,
the wrapper re-sent its stale state on the next block, and the change
visibly snapped back.

Gate those three pushes on a diff against last-pushed snapshots. Host
or UI edits still propagate (they update _state, which differs from the
snapshot), but the wrapper no longer re-asserts itself against an
engine-driven update.

The opt-out flags are wrapper-write-only — the engine never modifies
them — so the per-block push there stays unconditional.

Wrapper state still goes stale after engine auto-config (UI controls
display the pre-MCM value, and a project save persists pre-MCM state).
Closing that gap is a separate engine→wrapper feedback change.
The previous commit (0c9c4f4) stopped the wrapper from clobbering
engine-side auto-config on the next block. The other half of the
problem was still open: _state never learnt that the engine had
auto-configured, so the UI kept showing the pre-MCM value, project
saves persisted that pre-MCM state, and host automation lanes never
saw the change.

After renderBlock, diff the three engine-writable MPE fields against
_state. On any mismatch, copy the engine value back into _state and
the _lastPushed* snapshot, then emit an outputParameterChange so the
host updates its controller, the editor refreshes, and a save now
captures the post-MCM value. The _lastPushed* update keeps the next
block's dirty check quiet — both sides are now in sync.

End-to-end: MCM enables MPE → engine flips → next renderBlock returns
→ wrapper picks the flip up → host gets the param change → UI checkbox
updates → project save persists the new state.
The wrapper unconditionally dispatched through the engine's *MPE methods,
passing the incoming MIDI channel verbatim regardless of whether the
user had the MPE switch on or off in Settings. The engine's per-channel
storage (introduced earlier in the fork) is correct under the *MPE API
contract, but the *legacy* channel-less API forwards to channel=0 and
yields byte-for-byte pre-fork sfizz behaviour. So whether a session
behaves "MPE off = single global channel" depends entirely on which
dispatch path the wrapper picks.

This change branches the four dispatch sites in playOrderedEvent and
the three per-channel paramID branches in playOrderedParameter on
_state.mpeEnabled:

  - Note on / note off / poly pressure / legacy CC out: use the legacy
    non-MPE engine API when the toggle is off. All incoming channels
    collapse into the master-channel slot in MidiState; voices get
    triggerChannel_=0; behaviour matches pre-fork sfizz exactly.

  - kNoteExpressionValueEvent: skip entirely when the toggle is off.
    VST3 NoteExpression is inherently per-note; routing it to a single
    master channel would defeat its purpose, and pre-fork sfizz never
    reacted to it anyway, so silent drop is the spec-faithful choice.

  - kPidMPEPitchBendCh1..15 / kPidMPEAftertouchCh1..15 /
    kPidMPECC74Ch1..15: per-channel paramIDs are MPE-only by design;
    skipped when the toggle is off. The global kPidPitchBend /
    kPidAftertouch parameter paths carry the master-channel state in
    that mode.

On→off transitions need a flush. Voices triggered while MPE was enabled
carry triggerChannel_>0, and a subsequent channel-less note-off via the
legacy path lands on channel=0 — won't match, voices hang. The transition
hook in process() (where mpeEnabled is pushed to the engine) now calls
allSoundOff() on the on→off edge before pushing the new state. Brief
silence on a manual toggle flip is acceptable; the off→on direction
needs no flush (existing channel-0 voices stay valid, new ones get the
incoming channel).

Verified against SuperCollider reproducers covering per-channel release
and per-note pitch bend: with the toggle off, multi-channel input now
behaves as one global channel; with it on, per-channel routing remains
intact. The library-side regression test "Voices triggered via the
legacy API are isolated from MPE per-channel writes" (engine commit
36c92314) covers the engine half of the contract.
Carries forward four engine commits since the previous library bump
(2b408275):

  - 0a443c14: drop Polyphonic Key Pressure on Member Channels per
    MPE 1.0 §2.2.7 (Appendix E Table 5)
  - 528a4b9d: drop Manager-only CCs (pedals, mode/reset, Bank Select)
    on Member Channels per MPE 1.0 §2.3.1 / §2.3.3
  - efaf7c2a: route released-note expression reads through the Manager
    Channel per MPE 1.0 §2.2.6 / §2.2.7 / §2.2.8
  - 36c92314: regression test asserting voices triggered via the legacy
    channel-less API are isolated from per-channel writes via the *MPE
    API — the engine half of the MPE-off compatibility contract that
    the previous commit (SfizzVstProcessor dispatch gating) relies on.

525 test cases / 52373 assertions in the library suite still green.
@rullopat

Copy link
Copy Markdown
Author

Addresses the MPE-off divergence noted on sftools/sfizz#1327.

  • 4a789ff — wrapper dispatch gated on _state.mpeEnabled; on→off flushes via allSoundOff().
  • 68c7dea — library bump to 36c92314 (adds engine-side regression test).

rullopat added 3 commits May 13, 2026 18:49
Engine now normalizes the channel argument to 0 in all *MPE entry points
when mpeEnabled_ is false. Makes the *MPE and legacy non-MPE API
surfaces equivalent under MPE off — consumers no longer need to choose
between them based on the toggle, and the engine owns the single
definition of what "MPE off" means.

Also moves the on→off voice flush into Synth::setMPEEnabled itself,
so wrappers don't have to call allSoundOff() manually on the transition.

Next commit removes the now-redundant MPE-off branches from
SfizzVstProcessor.
With the engine normalizing the channel argument to 0 internally when
mpeEnabled_ is false (previous library bump), the wrapper no longer
needs to choose between the legacy and *MPE API surfaces. Dispatch
unconditionally through the *MPE entry points; the engine handles the
single-channel collapse for MPE-off mode. Single source of truth lives
in sfz::Synth, not duplicated across every consumer that has to gate
on the toggle.

Also drops the wrapper's allSoundOff() call on the on→off transition —
Synth::setMPEEnabled flushes voices internally now.

The two skip-when-off cases that remain are about event shape, not
channel routing, and stay:
  - kNoteExpressionValueEvent: VST3 NoteExpression is inherently
    per-note; routing all of it to channel 0 would defeat its purpose,
    so the wrapper silently drops these events when MPE is off (pre-fork
    sfizz never reacted to them either).
  - kPidMPEPitchBendCh1..15 / kPidMPEAftertouchCh1..15 /
    kPidMPECC74Ch1..15: per-channel paramIDs are MPE-only by design;
    the global kPidPitchBend / kPidAftertouch paths carry master state
    in MPE-off mode.

Net: 64 lines removed from the wrapper, behaviour unchanged. Verified
against the same SuperCollider per-channel-release and per-note
pitch-bend reproducers from the engine PR review.
Bumps library submodule to 9eaef3fc and updates call sites in the VST3
and LV2 plugins to use the new public surface:

  C++ (SfizzVstProcessor.cpp):
    noteOnMPE / hdNoteOnMPE / noteOffMPE / hdNoteOffMPE → noteOn /
    hdNoteOn / noteOff / hdNoteOff (4-arg channel-taking overloads)
    ccMPE / hdccMPE → cc / hdcc
    pitchWheelMPE / hdPitchWheelMPE → pitchWheel / hdPitchWheel
    channelAftertouchMPE / hdChannelAftertouchMPE → channelAftertouch
    / hdChannelAftertouch
    polyAftertouchMPE / hdPolyAftertouchMPE → polyAftertouch /
    hdPolyAftertouch

  C (sfizz.cpp in LV2 plugin):
    sfizz_send_*_mpe → sfizz_send_*_channel for the six dispatch
    functions that the LV2 plugin calls (note on/off, hdcc, pitch
    wheel, channel aftertouch, poly aftertouch).

MPE config / status surface unchanged (setMPEEnabled, bend-range
getters/setters, drop counters).
@rullopat

Copy link
Copy Markdown
Author

Picks up the engine API rename in 99eef74:

  • Library bump to 9eaef3fc.
  • VST3 wrapper (SfizzVstProcessor.cpp) calls the renamed C++ overloads (noteOn / cc / pitchWheel etc. with a channel arg).
  • LV2 plugin (plugins/lv2/sfizz.cpp) calls the renamed C API (sfizz_send_*_channel).

rullopat and others added 2 commits May 13, 2026 21:39
Previously the wrapper gated `kNoteExpressionValueEvent` on the MPE
toggle, dropping the event entirely when MPE was off so that pre-fork
sfizz behaviour was preserved exactly (pre-fork had no NoteExpression
handling).

That gate breaks parity with the legacy MIDI path next door:
`kLegacyMIDICCOutEvent` dispatches unconditionally and lets the engine
collapse channel to 0 when MPE is off (Synth::hdPitchWheel et al.). The
result was that a per-note bend gesture from an MPE controller through
an MPE-aware host (e.g. Push 3 into Ableton Live 12) produced no audible
bend at all when the wrapper's MPE toggle was off, while the same
gesture forwarded as raw MIDI by the host did move pitch.

Remove the gate. NoteExpression now flows through to the engine in both
toggle states; with MPE off, the engine's internal channel normalization
folds a per-note bend on a member channel into a channel-0 bend that
moves the whole chord — the same contract the legacy MIDI path follows.

The wrapper's perNoteRange-based scaling is unchanged, so the audible
magnitude in MPE-off mode depends on the loaded SFZ's bend_up/bend_down
range relative to mpePerNotePitchBendRange. A follow-up could pick a
different scaling for the MPE-off case if that magnitude turns out to
be unhelpfully small in practice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the previous NoteExpression fix. The wrapper exposes
member-channel pitch bend / aftertouch / CC74 via dedicated parameter
IDs (kPidMPEPitchBendCh*, kPidMPEAftertouchCh*, kPidMPECC74Ch*) and
maps the underlying MIDI controllers to them in
getMidiControllerAssignment. MPE-aware hosts (Ableton Live 12 in
particular) therefore route per-note expression as parameter value
changes rather than VST3 events.

playOrderedParameter was gating those three parameter ranges on the
wrapper's MPE toggle and dropping them when MPE was off, which meant
a per-note bend gesture from an MPE controller through an MPE-aware
host produced no audible result at all when the user disabled the
plugin's MPE toggle. The legacy MIDI path next door, and the
NoteExpression path after the previous fix, both dispatch
unconditionally and rely on the engine's internal channel
normalization (Synth::hdPitchWheel et al. force channel to 0 when
MPE is off). The parameter-changes path is now consistent with both:
it dispatches in both toggle states, and the engine folds member
channels into ch 0 when MPE is off so a per-note expression gesture
moves the whole chord, just as a raw-MIDI bend on that channel would.

Verified end-to-end with Push 3 + Ableton Live 12 + MPE-aware track:
in MPE-off mode, a Push 3 per-pad slide audibly bends the entire
chord; in MPE-on mode, the same gesture continues to bend only the
touched note as before. The kPidPitchBend / kPidAftertouch global
fallbacks above remain unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rullopat

Copy link
Copy Markdown
Author

Two follow-ups to 9e16932 completing the same cleanup on the remaining dispatch paths:

  • 5877c4akNoteExpressionValueEvent. Per-note tuning / volume / brightness now dispatch in both MPE states; the engine collapses channel to 0 when MPE is off (Synth::hdPitchWheel et al.), so a per-note expression gesture from an MPE-aware host folds into channel 0 with MPE off, exactly as a raw-MIDI bend on the same channel would.

  • 10464fakPidMPEPitchBendCh* / kPidMPEAftertouchCh* / kPidMPECC74Ch*. Same pattern for the per-channel parameter mirrors that getMidiControllerAssignment exposes — which is the path Ableton Live 12 actually uses to route per-note expression to the plugin.

Both were silently dropping member-channel input when the wrapper's MPE toggle was off, instead of letting the engine apply its now-consistent channel normalization. Verified on Push 3 + Ableton Live 12:

  • MPE off: per-pad slide audibly bends the whole chord.
  • MPE on: per-pad slide bends only the touched note, as before.

@rullopat rullopat marked this pull request as ready for review May 13, 2026 20:48
rullopat added 2 commits May 15, 2026 17:20
The MPE Master and Per-Note pitch-bend-range SValueMenu widgets
were created and edit-range bound, but never populated with menu
entries. Clicks produced an empty popup with nothing to select.

Add a preset list covering the MPE 1.0 defaults (Master 2,
Member 48) plus the common controller deviations: 1, 7, 12, 24,
36, 60, 72, 96 semitones. All values are within the 0..96
EditRange already declared in EditIds.cpp.
…PN-driven

Three small fixes to the MPE bend-range menus:

- Display as integer (e.g. "2", "48") instead of "2.00" / "48.00".
  Both widgets are semitone counts; the .00 suffix was meaningless.
- Shrink width from 80 to 70 px so the menus align with the
  preload-size and oversampling fields above them.
- Disable (50% alpha, mouse events ignored) when the matching
  "Ignore master / per-note bend" toggle is off. In that mode the
  engine is the source of truth (driven by incoming RPN 0) and the
  manual menu value is just a read-out, not user-editable.
Add a read-only bend-range display next to each "MPE master / per-note
bend (st)" override menu, showing what the engine is currently using
and signalling when an incoming RPN 0 is being ignored.

Display rules:
  - !override, !RPN received -> default (2 / 48)
  - !override,  RPN received -> RPN-driven value
  -  override, !RPN received -> override value
  -  override,  RPN received -> RPN value, struck through (ignored)

Wrapper changes (SfizzVstProcessor):
  - Parse incoming RPN 0 (Pitch Bend Sensitivity) on the raw MIDI CC
    path. Per-channel state machine (CC 101 / CC 100 / CC 6 sequence);
    last-received value stored per axis as absl::optional<float>.
  - Decouple "override value" from "engine effective value": stop
    writing the engine's RPN-driven range back into _state.mpe*Range.
    The persisted state field is now strictly the user's saved override.
  - Push override-or-last-RPN-or-default to engine each block based on
    the ignore-toggle state; covers the override on/off transition
    (off -> on snaps engine to override, on -> off snaps to last RPN
    or default).
  - Four new read-only VST3 parameters mirror the engine state to the
    UI: kPidMPE{Master,PerNote}EffectiveBendRange (float) and
    kPidMPE{Master,PerNote}BendLastRpn (float with -1 sentinel for
    "not received"). kNoFlags so hosts don't expose them as automation.

UI changes (editor):
  - New SStrikethroughLabel widget (CTextLabel subclass with an
    optional horizontal strike drawn through the text).
  - main.fl gains two rows ("Current master bend" / "Current per-note
    bend") under each Ignore-RPN checkbox, plus a 60 px downward shift
    of the bend-range menus and per-note checkbox to make room.
  - Editor caches effective range, last RPN, and ignore state per axis
    from the new EditIds and recomputes each display string +
    strikethrough flag whenever any input changes.
@jamshark70

jamshark70 commented May 18, 2026

Copy link
Copy Markdown

Hi, finally had a bit of time to check this. I was getting some confusing results, so I wanted to double check everything.

"Ignore pb" settings

I had complained that the pitch bend sensitivity settings were disabled. Now I see this is because I didn't understand "ignore xxx bend" -- so I guess either the wording should be improved (though this is difficult, due to limited space in the window) or it's a future documentation need.

When the box is checked, it will use the user's setting in the preferences panel, and disregard RPN 0 settings. If it's unchecked then it's using RPN 0 and not using the UI preference, in which case, indeed, it does make sense not to allow the user to set a value that won't be used.

I can't think of a way to squeeze that into three or four words, so maybe a mouse-hover tooltip should be added?

RPN activation of MPE

AFAICS I am following the spec, but I still can't get it to activate MPE via CC messages.

I'll put in the full transcript of what I'm doing, with comments.

// 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;  // load an instrument

s.addr = DebugNetAddr("127.0.0.1", 57110);  // debug output

v = Voicer(15, \midi -> m, [mpe: true]);
v.gate([60, 63.5].midicps, 2, 0.6);

This is the "control group" -- SC is sending MPE messages, but sfizz has not enabled MPE. So I hear notes rounded to 12ET semitones.

Those messages are:

// pitch bend
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-27, 0, 64], 0]

// note
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-107, 60, 76], 0]

// pitch bend
// (63*128)+43 = 8107 = -85, quarter tone down
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-26, 43, 63], 0]

// note: 63.5 rounded up to 64
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-106, 64, 76], 0]

// note-offs, 2 sec later
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-123, 60, 0], 0]
	['/u_cmd', 1000, 1, '/midi_msg', Int8Array[-122, 64, 0], 0]

Now I send the bytes [0xB0 0x65 0x00] [0xB0 0x64 0x06] [0xB0 0x06 0x0F] per Appendix B example 1 in the MPE spec.

MIDIControlMessage(device: m).play(0x65, 0).play(0x64, 6).play(6, 15);

// sent
Int8Array[-80, 101, 0, -80, 100, 6, -80, 6, 15].prettyPrint
B0 65 00 B0 64 06 B0 06 0F

// play again
v.gate([60, 63.5].midicps, 2, 0.6);

Experimental group: MPE on by CCs. But I still hear 12ET, so, unsuccessful.

Then I manually click the MPE switch in the UI, play it again, and then I hear 24ET (so I know I'm sending valid messages, and the messages are transmitted through VSTPlugin correctly).

I suppose it's possible I might be missing some code...? But I think I'm doing this right, and the CC messages are just not taking effect.

I also verified that saving a preset file does persist the MPE-on setting. That should cover my needs. Just puzzled why the RPN isn't working for me.

(Sorry for dup'ed message -- when reloaded the page, it didn't show this message, so I thought I needed to resubmit.)

(Also, here's an example of 8th-tone microtuning working perfectly in sfizz -- for upstream maintainers, this is good stuff, just hammering out a couple of final details. https://www.youtube.com/watch?v=UtpvPEGn0dA )

@jamshark70

Copy link
Copy Markdown

FWIW, a quick survey of the few MPE capable VSTis on my system:

MPE button MPE RPN enable
sfizz OK no
Vital OK no
Six Sines OK no
Surge XT OK OK

So it seems that it's fairly common for VST instruments to ignore RPN for MPE, but there is also precedent where at least one VST instrument does respond to the RPN.

Surge also updates the UI upon receipt of the RPN.

@rullopat

Copy link
Copy Markdown
Author

Thanks for the survey — useful. The engine intends to auto-enable on RPN 6 per §2.2.1, so the fact that it doesn't is a wrapper bug; tracing and will fix.

Will also add tooltips on the existing RPN checkboxes, plus a new "Ignore MCM RPN" opt-out (default off, per Appendix A.1) for anyone who prefers pinning MPE state to the UI toggle.

And good catch on Surge mirroring the UI on RPN receipt — our feedback path is wired for that, just needs the dispatch bug fixed first.

rullopat and others added 2 commits May 18, 2026 08:28
Three related changes in response to upstream review:

- SfizzVstProcessor: dispatch CCs arriving via IMidiMapping → param
  changes (kPidCC0..kPidCCLast) through the engine's MIDI-semantic
  hdcc(channel, ...) path instead of automateHdcc. automateHdcc sets
  asMidi=false in the engine, which skips the RPN/MCM state machine —
  so hosts that translate raw MIDI to parameter changes (SuperCollider
  VSTPlugin, Reaper, Cubase) were silently dropping the MCM enable
  sequence on the floor. Also tracks RPN 0 via the wrapper's parser on
  this path so the bend-range display reflects controller-driven
  ranges regardless of how the host delivered the CCs.

- kPidMPEIgnoreMcm + mpeIgnoreMcm state (v7 → v8 with backward-compat
  read). When set, the wrapper drops engine-driven mpeEnabled changes
  from the outputParameterChanges feedback path and re-asserts the
  user's value into the engine via setMPEEnabled. Lets users pin MPE
  state to the panel toggle and ignore RPN 6 from the controller,
  per MPE 1.0 Appendix A.1 ("Ignore a received MCM..."). Default off
  so the spec-mandated §2.2.1 behavior is the out-of-box experience.

- Editor: in-frame hover text on the three "Ignore" checkboxes, via
  IViewEventListener routing MouseEnter/MouseExit to the existing
  lblHover_ label. VSTGUI's CTooltipSupport was unsuitable here — it
  creates a borderless NSWindow that AU host sandboxes (Logic) won't
  display above the plug-in view. Also fixes a long-standing coord-
  space bug in buttonHoverEnter that only surfaces for controls
  nested inside sub-panels: translate btn rect from its parent space
  to lblHover_'s parent space, and disable mouse on lblHover_ so it
  can't steal hover from the underlying control (which was producing
  a show/hide flicker).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment-only follow-up — drops downstream-specific identifiers from
the MPE test comments. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rullopat

Copy link
Copy Markdown
Author

Fixes are in (791c0b0, engine bump to 6d02b0a6). The bug was on the wrapper side: when the host translated your CCs into VST3 parameter changes (which SuperCollider's VSTPlugin does), the wrapper was forwarding them as automation values instead of as MIDI — so they reached the engine but bypassed the RPN parser, which is why MPE never flipped on. Now they go through the MIDI path, so the parser sees the B0 65 00 / B0 64 06 / B0 06 0F sequence properly.

Also added the "Ignore MCM on/off" opt-out checkbox we discussed (default off, honors MCM per §2.2.1; checked gives the Surge-XT-style "panel toggle only" behavior, per Appendix A.1) and hover-text on all three "Ignore" checkboxes.

Same SC test should now hear 24ET after the RPN sequence without manually toggling the UI — let me know if it does.

@jamshark70

Copy link
Copy Markdown

when the host translated your CCs into VST3 parameter changes (which SuperCollider's VSTPlugin does)

D'oh! I see. So the other VSTis may also support MIDI RPN but not parameter changes.

Sorry for noise then, I hadn't realized that was the mechanism. (But, nice to cover another base.)

I think I have no further complaints (though I'm sure I haven't tested all MPE features). Really hope this makes it into the main branch; I'm going to use it routinely.

@rullopat

Copy link
Copy Markdown
Author

Really hope this makes it into the main branch; I'm going to use it routinely.

I'm not that confident now that I look at the project state: last release tagged is from 2 years and a half ago and I don't see much activity in the main branch, but I really hope to be proven wrong.

Worst case scenario I'll continue in my own fork.

@jamshark70

Copy link
Copy Markdown

last release tagged is from 2 years and a half ago and I don't see much activity in the main branch, but I really hope to be proven wrong.

"Circumstances beyond one's control" -- in any case, I am going to keep using the MPE features! I've been waiting over a year to get arbitrary microtuning back. I'm crazy about this development -- thanks for doing it!

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants