MPE: route per-channel MIDI through the engine, expose enable + bend-range controls#166
MPE: route per-channel MIDI through the engine, expose enable + bend-range controls#166rullopat wants to merge 25 commits into
Conversation
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.
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>
|
Four commits on top of the existing branch + a library bump bring this to feature-complete on macOS hosts:
Verified end-to-end on Push 3 + Ableton Live 12 with the default Phase 2's NoteExpression path is implemented but not yet exercised on a host that emits 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>
|
Added a README section in 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. |
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.
|
Addresses the MPE-off divergence noted on
|
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).
|
Picks up the engine API rename in
|
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>
|
Two follow-ups to
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:
|
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.
|
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" settingsI 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 MPEAFAICS 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. 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: Now I send the bytes 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 ) |
|
FWIW, a quick survey of the few MPE capable VSTis on my system:
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. |
|
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. |
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>
|
Fixes are in ( 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. |
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. |
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. |
"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! |
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
Plumbing
SfizzVstProcessor.cpp): 16-channel event bus;playOrderedEventcalls the engine's channel-aware overloads withevent.*.channel;kLegacyMIDICCOutEventcarries 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 viaoutputParameterChangesso the editor reflects auto-config and project saves persist it.plugins/lv2/sfizz.cpp): three new control input ports for the 23/24/25 stereo + 37/38/39 multi variants; MIDI dispatch usessfizz_send_*_channelkeyed onmsg[0] & 0x0F.State versioned (5→6) in
SfizzVstState.cppwith backwards-compat read. AU passesauval; VST3 passes pluginval at strictness 4.What's in the PR (grouped commits)
Initial MPE wiring
f79e78aDRAFT submodule pin ·e67291aMPE across VST3/AU, LV2, PD, editor ·9f8ed47UI layoutHand-test fixes
b0699c8keepkPidPitchBend/kPidAftertouchin MPE mode ·65f23beVST3 NoteExpression → engine ·7faecf0per-channel paramIDsMCM / RPN 0 auto-configuration
e4e12e7/140e8d5library bumps ·b91fad3library bump bringing in MCM parser ·1252c7aper-axis RPN opt-out checkboxes ·0c9c4f4stop clobbering engine-driven state per-block ·acd5007feed engine-driven state back to the host ·df8833cREADME MPE sectionMPE-off compatibility
4a789ffwrapper gates dispatch on the toggle ·68c7dealibrary bump with the engine-side isolation regression test ·954cf60library bump moving normalization into the engine (setMPEEnabled(false)flushes voices) ·9e16932drop the wrapper's now-redundantif (mpe)branchesAPI rename
99eef74engine dropped the*MPEsuffix from dispatch methods; wrapper updates to callnoteOn(delay, ch, ...)etc. and the LV2 plugin tosfizz_send_*_channel.Bend-range read-out and UX fixes
39e3107populate the master / per-note value menus (were silently empty)2ced678integer formatter, width align with preload / oversampling, disable when RPN-driven4fb3724new "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 throughHelp wanted
Smoke-tested by
@jamshark70via 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 in7faecf0.Submodule note
library/currently tracksrullopat/sfizz@ 9eaef3fcso the build picks up the engine-side channel-aware symbols. Re-pin to whatever SHA the engine PR merges at oncesfztools/sfizz#1327lands.cc @paulfd, @jamshark70.