From 6bef23826218be8873fe425f84ed2e2ee3c09867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduard=20M=C3=BCller?= Date: Tue, 7 Jul 2026 14:45:00 +0200 Subject: [PATCH] Add NoteExpression and MIDI event support Allows plugins (especially synths) to react on standard CLAP NoteExpressions and MIDI messages (as e.g. requeired for MPE support). Event capabilities, receiving is gated via compile time consts in Plugin in order to avoid overhead. Also extract raw MIDI-byte parsing into a new shared helper function, used by both the CLAP MIDI event path and the standalone host's MIDI input. --- plinth-plugin/src/event.rs | 133 ++++++- plinth-plugin/src/formats.rs | 1 + plinth-plugin/src/formats/clap/event.rs | 150 ++++++-- .../src/formats/clap/extensions/note_ports.rs | 7 +- .../src/formats/clap/extensions/params.rs | 2 +- .../src/formats/clap/plugin_instance.rs | 4 +- plinth-plugin/src/formats/midi.rs | 107 ++++++ plinth-plugin/src/formats/standalone/midi.rs | 53 +-- .../src/formats/standalone/runner.rs | 2 +- plinth-plugin/src/formats/vst3/component.rs | 345 ++++++++++++++++-- plinth-plugin/src/formats/vst3/event.rs | 245 +++++++++++-- plinth-plugin/src/formats/vst3/parameters.rs | 90 ++++- plinth-plugin/src/lib.rs | 4 + plinth-plugin/src/midi_capabilities.rs | 134 +++++++ plinth-plugin/src/note_expressions.rs | 146 ++++++++ plinth-plugin/src/plugin.rs | 8 +- 16 files changed, 1265 insertions(+), 166 deletions(-) create mode 100644 plinth-plugin/src/formats/midi.rs create mode 100644 plinth-plugin/src/midi_capabilities.rs create mode 100644 plinth-plugin/src/note_expressions.rs diff --git a/plinth-plugin/src/event.rs b/plinth-plugin/src/event.rs index 808d43b..0287986 100644 --- a/plinth-plugin/src/event.rs +++ b/plinth-plugin/src/event.rs @@ -9,6 +9,7 @@ use crate::parameters::ParameterId; #[non_exhaustive] pub enum Event { // Note events + NoteOn { sample_offset: usize, channel: i16, @@ -25,15 +26,132 @@ pub enum Event { velocity: f64, }, - PitchBend { + // Per-note expression events (VST3/CLAP note expression) + + /// Per-note volume. `gain` is linear in [0, 4], where 1 is 0dB and 0 -INFdB. + /// Requires [`crate::NoteExpressions::with_volume`]. + PolyVolume { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + gain: f64, + }, + + /// Polyphonic key pressure (poly aftertouch) (VST3 `kPolyPressureEvent` or CLAP's `CLAP_NOTE_EXPRESSION_PRESSURE`). + /// `value` is in [0, 1]. The same value delivered as a raw MIDI byte message arrives as [`Event::MidiPolyPressure`]. + /// Requires [`crate::NoteExpressions::with_pressure`]. + PolyPressure { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + value: f64, + }, + + /// Per-note panning. `pan` is in [-1, 1] (left..right). + /// Requires [`crate::NoteExpressions::with_pan`]. + PolyPan { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + pan: f64, + }, + + /// Per-note tuning offset in semitones. `semitones` is in [-120, +120]. + /// Requires [`crate::NoteExpressions::with_tuning`]. + PolyTuning { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + semitones: f64, + }, + + /// Per-note vibrato. `amount` is in [0, 1]. + /// Rarely (if at all) used by hosts, but part of the CLAP specs. + /// Requires [`crate::NoteExpressions::with_vibrato`]. + PolyVibrato { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + amount: f64, + }, + + /// Per-note expression (MIDI MPE "slide" / CC 74 equivalent). `amount` is in [0, 1]. + /// Rarely (if at all) used by hosts, but part of the CLAP specs. Use `PolyBrightness` instead. + /// Requires [`crate::NoteExpressions::with_expression`]. + PolyExpression { sample_offset: usize, channel: i16, key: i16, note: i32, + amount: f64, + }, + + /// Per-note brightness a.k.a. timbre (CLAP brightness / VST3 brightness). `amount` is in [0, 1]. + /// Requires [`crate::NoteExpressions::with_brightness`]. + PolyBrightness { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + amount: f64, + }, + + // MIDI channel based events + + /// Channel-wide MIDI pitch bend. + /// `semitones` is the current bend in semitones, using the standard +-2 semitone range. + /// Per-note pitch offset (VST3/CLAP note expression) arrives as [`Event::PolyTuning`]. + /// Requires [`MidiCapabilities::with_pitch_bend`]. + MidiPitchBend { + sample_offset: usize, + channel: i16, semitones: f64, }, + /// Channel-wide pressure (mono aftertouch). `value` is in [0, 1]. + /// Requires [`MidiCapabilities::with_channel_pressure`]. See also [`Event::MidiPolyPressure`]. + MidiChannelPressure { + sample_offset: usize, + channel: i16, + value: f64, + }, + + /// Polyphonic key pressure (poly aftertouch), delivered as a raw MIDI byte message. + /// `value` is in [0, 1]. Same value delivered via a native per-note mechanism (VST3 `kPolyPressureEvent` + /// or CLAP note expression) instead arrives as [`Event::PolyPressure`]. + /// Requires [`MidiCapabilities::with_poly_pressure`]. See also [`Event::MidiChannelPressure`]. + MidiPolyPressure { + sample_offset: usize, + channel: i16, + key: i16, + note: i32, + value: f64, + }, + + /// MIDI Program Change. `program` is the program number (0..=127). + /// Requires [`MidiCapabilities::with_program_change`]. + MidiProgramChange { + sample_offset: usize, + channel: i16, + program: u8, + }, + + /// MIDI Control Change. `controller` is the CC number (0..=127), `value` is in [0, 1]. + /// Requires the corresponding CC to be enabled via [`MidiCapabilities::with_control_change`]. + MidiControlChange { + sample_offset: usize, + channel: i16, + controller: u8, + value: f64, + }, + // Parameter events + StartParameterChange { id: ParameterId, }, @@ -68,7 +186,18 @@ impl Event { match self { Event::NoteOn { sample_offset, .. } => *sample_offset, Event::NoteOff { sample_offset, .. } => *sample_offset, - Event::PitchBend { sample_offset, .. } => *sample_offset, + Event::PolyVolume { sample_offset, .. } => *sample_offset, + Event::PolyPan { sample_offset, .. } => *sample_offset, + Event::PolyTuning { sample_offset, .. } => *sample_offset, + Event::PolyVibrato { sample_offset, .. } => *sample_offset, + Event::PolyExpression { sample_offset, .. } => *sample_offset, + Event::PolyBrightness { sample_offset, .. } => *sample_offset, + Event::PolyPressure { sample_offset, .. } => *sample_offset, + Event::MidiPitchBend { sample_offset, .. } => *sample_offset, + Event::MidiChannelPressure { sample_offset, .. } => *sample_offset, + Event::MidiPolyPressure { sample_offset, .. } => *sample_offset, + Event::MidiProgramChange { sample_offset, .. } => *sample_offset, + Event::MidiControlChange { sample_offset, .. } => *sample_offset, Event::ParameterValue { sample_offset, .. } => *sample_offset, Event::ParameterModulation { sample_offset, .. } => *sample_offset, diff --git a/plinth-plugin/src/formats.rs b/plinth-plugin/src/formats.rs index 6a88a86..bbc7158 100644 --- a/plinth-plugin/src/formats.rs +++ b/plinth-plugin/src/formats.rs @@ -3,6 +3,7 @@ use std::fmt::Display; #[cfg(target_os="macos")] pub mod auv3; pub mod clap; +pub mod midi; #[cfg(feature = "standalone")] pub mod standalone; pub mod vst3; diff --git a/plinth-plugin/src/formats/clap/event.rs b/plinth-plugin/src/formats/clap/event.rs index a62641c..61cb5a0 100644 --- a/plinth-plugin/src/formats/clap/event.rs +++ b/plinth-plugin/src/formats/clap/event.rs @@ -1,21 +1,25 @@ use std::collections::BTreeMap; use std::ffi::c_void; -use clap_sys::events::{clap_event_note, clap_event_note_expression, clap_event_param_mod, clap_event_param_value, clap_input_events, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_EXPRESSION, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_MOD, CLAP_EVENT_PARAM_VALUE, CLAP_NOTE_EXPRESSION_TUNING}; +use clap_sys::events::{clap_event_note, clap_event_note_expression, clap_event_param_mod, clap_event_param_value, clap_event_midi, clap_input_events, CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_NOTE_EXPRESSION, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_MOD, CLAP_EVENT_PARAM_VALUE, CLAP_NOTE_EXPRESSION_TUNING, CLAP_EVENT_MIDI, CLAP_NOTE_EXPRESSION_BRIGHTNESS, CLAP_NOTE_EXPRESSION_EXPRESSION, CLAP_NOTE_EXPRESSION_PAN, CLAP_NOTE_EXPRESSION_PRESSURE, CLAP_NOTE_EXPRESSION_VIBRATO, CLAP_NOTE_EXPRESSION_VOLUME}; -use crate::{parameters::info::ParameterInfo, Event, ParameterId}; +use crate::{formats::midi::parse_midi_event, parameters::info::ParameterInfo, Event, MidiCapabilities, NoteExpressions, ParameterId}; use super::parameters::map_parameter_value_from_clap; pub struct EventIterator<'a> { + note_expressions: NoteExpressions, + midi_capabilities: MidiCapabilities, parameter_info: &'a BTreeMap, events: &'a clap_input_events, index: u32, } impl<'a> EventIterator<'a> { - pub fn new(parameter_info: &'a BTreeMap, events: &'a clap_input_events) -> Self { + pub fn new(parameter_info: &'a BTreeMap, events: &'a clap_input_events, midi_capabilities: MidiCapabilities, note_expressions: NoteExpressions) -> Self { Self { + midi_capabilities, + note_expressions, parameter_info, events, index: 0, @@ -41,7 +45,7 @@ impl Iterator for EventIterator<'_> { if self.index >= events_size { return None; } - + let header = unsafe { (self.events.get.unwrap())(self.events, self.index) }; self.index += 1; @@ -49,44 +53,131 @@ impl Iterator for EventIterator<'_> { continue; } - let event = match (unsafe { *header }).type_ { + let event: Option = match (unsafe { *header }).type_ { CLAP_EVENT_NOTE_ON => { let event = unsafe { &*(header as *const clap_event_note) }; - Event::NoteOn { + Some(Event::NoteOn { sample_offset: event.header.time as _, channel: event.channel, key: event.key, note: event.note_id, velocity: event.velocity, - } + }) } CLAP_EVENT_NOTE_OFF => { let event = unsafe { &*(header as *const clap_event_note) }; - Event::NoteOff { + Some(Event::NoteOff { sample_offset: event.header.time as _, channel: event.channel, key: event.key, note: event.note_id, velocity: event.velocity, - } + }) } CLAP_EVENT_NOTE_EXPRESSION => { let event = unsafe { &*(header as *const clap_event_note_expression) }; - if event.expression_id != CLAP_NOTE_EXPRESSION_TUNING { - continue; + let note_expressions = self.note_expressions; + let channel = event.channel; + let key = event.key; + let note = event.note_id; + let value = event.value; + let sample_offset = event.header.time as usize; + + match event.expression_id { + CLAP_NOTE_EXPRESSION_TUNING if note_expressions.tuning() => { + Some(Event::PolyTuning { + sample_offset, + channel, + key, + note, + // fractional semitones, -120 to +120 + semitones: value, + }) + } + + CLAP_NOTE_EXPRESSION_PRESSURE if note_expressions.pressure() => { + Some(Event::PolyPressure { + sample_offset, + channel, + key, + note, + // pass value in [0..1] as it is + value, + }) + } + + CLAP_NOTE_EXPRESSION_VOLUME if note_expressions.volume() => { + Some(Event::PolyVolume { + sample_offset, + channel, + key, + note, + // pass value in [0..4] as it is + gain: value, + }) + } + + CLAP_NOTE_EXPRESSION_PAN if note_expressions.pan() => { + Some(Event::PolyPan { + sample_offset, + channel, + key, + note, + // CLAP pan: 0=left, 0.5=center, 1=right -> map to [-1, +1] + pan: value * 2.0 - 1.0, + }) + } + + CLAP_NOTE_EXPRESSION_VIBRATO if note_expressions.vibrato() => { + Some(Event::PolyVibrato { + sample_offset, + channel, + key, + note, + // pass value in [0..1] as it is + amount: value, + }) + } + + CLAP_NOTE_EXPRESSION_EXPRESSION if note_expressions.expression() => { + Some(Event::PolyExpression { + sample_offset, + channel, + key, + note, + // pass value in [0..1] as it is + amount: value, + }) + } + + CLAP_NOTE_EXPRESSION_BRIGHTNESS if note_expressions.brightness() => { + Some(Event::PolyBrightness { + sample_offset, + channel, + key, + note, + // pass value in [0..1] as it is + amount: value, + }) + } + + // Unknown or unsupported expression ID + _ => None, } + } - Event::PitchBend { - sample_offset: event.header.time as _, - channel: event.channel, - key: event.key, - note: event.note_id, - semitones: event.value, - } + // Covert raw MIDI bytes to CC / channel pressure / pitch bend / poly pressure events. + CLAP_EVENT_MIDI => { + let event = unsafe { &*(header as *const clap_event_midi) }; + parse_midi_event( + &event.data, + event.header.time as usize, + self.midi_capabilities, + ) } CLAP_EVENT_PARAM_VALUE => { @@ -95,32 +186,35 @@ impl Iterator for EventIterator<'_> { let value = map_parameter_value_from_clap(parameter_info, event.value); - Event::ParameterValue { + Some(Event::ParameterValue { sample_offset: event.header.time as _, id: event.param_id, value, - } + }) }, - + CLAP_EVENT_PARAM_MOD => { let event = unsafe { &*(header as *const clap_event_param_mod) }; let parameter_info = self.parameter_info(event.param_id, event.cookie); let amount = map_parameter_value_from_clap(parameter_info, event.amount); - Event::ParameterModulation { + Some(Event::ParameterModulation { sample_offset: event.header.time as _, id: event.param_id, amount, - } + }) }, - - _ => { - continue; - } + + // All other event types (MIDI2, sysex, etc.) are unsupported and skipped. + _ => None, }; - return Some(event); + if event.is_some() { + return event; + } else { + continue; + } } } } diff --git a/plinth-plugin/src/formats/clap/extensions/note_ports.rs b/plinth-plugin/src/formats/clap/extensions/note_ports.rs index 13913de..7b128f0 100644 --- a/plinth-plugin/src/formats/clap/extensions/note_ports.rs +++ b/plinth-plugin/src/formats/clap/extensions/note_ports.rs @@ -1,6 +1,6 @@ use std::marker::PhantomData; -use clap_sys::{ext::note_ports::{clap_note_port_info, clap_plugin_note_ports, CLAP_NOTE_DIALECT_CLAP}, plugin::clap_plugin}; +use clap_sys::{ext::note_ports::{clap_note_port_info, clap_plugin_note_ports, CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}, plugin::clap_plugin}; use crate::{clap::ClapPlugin, string::copy_str_to_char8}; @@ -43,7 +43,7 @@ impl NotePorts

{ unsafe extern "C" fn get( _plugin: *const clap_plugin, index: u32, - _is_input: bool, + is_input: bool, info: *mut clap_note_port_info, ) -> bool { @@ -51,6 +51,9 @@ impl NotePorts

{ info.id = index; info.supported_dialects = CLAP_NOTE_DIALECT_CLAP; + if is_input && !P::MIDI_CAPABILITIES.is_empty() { + info.supported_dialects |= CLAP_NOTE_DIALECT_MIDI; + } info.preferred_dialect = CLAP_NOTE_DIALECT_CLAP; copy_str_to_char8("Main", &mut info.name); diff --git a/plinth-plugin/src/formats/clap/extensions/params.rs b/plinth-plugin/src/formats/clap/extensions/params.rs index 31738ee..28880d6 100644 --- a/plinth-plugin/src/formats/clap/extensions/params.rs +++ b/plinth-plugin/src/formats/clap/extensions/params.rs @@ -142,7 +142,7 @@ impl Params

{ PluginInstance::with_plugin_instance(plugin, |instance: &mut PluginInstance

| { instance.process_events_to_plugin(); - let host_events = EventIterator::new(&instance.parameter_info, unsafe { &*in_events }); + let host_events = EventIterator::new(&instance.parameter_info, unsafe { &*in_events }, P::MIDI_CAPABILITIES, P::NOTE_EXPRESSIONS); let editor_events = instance.parameter_event_map.iter_and_send_to_host(&instance.parameter_info, out_events); let all_events = host_events.chain(editor_events); diff --git a/plinth-plugin/src/formats/clap/plugin_instance.rs b/plinth-plugin/src/formats/clap/plugin_instance.rs index ba8425c..25de5b4 100644 --- a/plinth-plugin/src/formats/clap/plugin_instance.rs +++ b/plinth-plugin/src/formats/clap/plugin_instance.rs @@ -163,7 +163,7 @@ impl PluginInstance

{ } pub(super) fn send_events_to_plugin(&mut self, in_events: *const clap_input_events) { - let events = EventIterator::new(&self.parameter_info, unsafe { &*in_events }); + let events = EventIterator::new(&self.parameter_info, unsafe { &*in_events }, P::MIDI_CAPABILITIES, P::NOTE_EXPRESSIONS); for event in events { match self.to_plugin_event_sender.push(event) { @@ -336,7 +336,7 @@ impl PluginInstance

{ }; // Process events coming from the host and events coming from the editor - let host_events = EventIterator::new(&instance.parameter_info, unsafe { &*process.in_events }); + let host_events = EventIterator::new(&instance.parameter_info, unsafe { &*process.in_events }, P::MIDI_CAPABILITIES, P::NOTE_EXPRESSIONS); let events = host_events.chain(editor_events); let result = match processor.process(&mut output, aux.as_ref(), transport, events) { diff --git a/plinth-plugin/src/formats/midi.rs b/plinth-plugin/src/formats/midi.rs new file mode 100644 index 0000000..e845d2f --- /dev/null +++ b/plinth-plugin/src/formats/midi.rs @@ -0,0 +1,107 @@ +use crate::{Event, MidiCapabilities}; + +/// Parse a raw MIDI message into an `Event`, filtered by the given MIDI `capabilities`. +/// `sample_offset` is the sample offset within the audio buffer. +/// +/// Returns `None` for: +/// - Messages shorter than the minimum expected length. +/// - Messages whose capability is not enabled in `capabilities`. +/// - Unsupported Message types such as MIDI SysEx. +pub(crate) fn parse_midi_event( + data: &[u8], + sample_offset: usize, + capabilities: MidiCapabilities, +) -> Option { + if data.len() < 2 { + return None; + } + + let status = data[0] & 0xF0; + let channel = (data[0] & 0x0F) as i16; + + match status { + // Note-on: velocity 0 is treated as note-off per MIDI spec. + 0x90 if data.len() >= 3 && data[2] > 0 => Some(Event::NoteOn { + sample_offset: 0, + channel, + key: data[1] as i16, + note: -1, + velocity: data[2] as f64 / 127.0, + }), + + // Note-off (explicit 0x80 or 0x90 with vel=0). + 0x80 | 0x90 => { + let velocity = if data.len() >= 3 { + data[2] as f64 / 127.0 + } else { + 0.0 + }; + Some(Event::NoteOff { + sample_offset: 0, + channel, + key: data[1] as i16, + note: -1, + velocity, + }) + } + + // Polyphonic Key Pressure / poly aftertouch (0xA0) + 0xA0 if data.len() >= 3 && capabilities.midi_poly_pressure() => { + Some(Event::MidiPolyPressure { + sample_offset, + channel, + key: data[1] as i16, + note: -1, + value: data[2] as f64 / 127.0, + }) + } + + // Program Change (0xC0) + 0xC0 if data.len() >= 2 && capabilities.midi_program_change() => { + Some(Event::MidiProgramChange { + sample_offset, + channel, + program: data[1], + }) + } + + // Control Change (0xB0) + 0xB0 if data.len() >= 3 => { + let controller = data[1]; + if capabilities.has_midi_control_change(controller) { + Some(Event::MidiControlChange { + sample_offset, + channel, + controller, + value: data[2] as f64 / 127.0, + }) + } else { + None + } + } + + // Channel Pressure / mono aftertouch (0xD0) + 0xD0 if data.len() >= 2 && capabilities.midi_channel_pressure() => { + Some(Event::MidiChannelPressure { + sample_offset, + channel, + value: data[1] as f64 / 127.0, + }) + } + + // Pitch Bend (0xE0) + 0xE0 if data.len() >= 3 && capabilities.midi_pitch_bend() => { + let lsb = data[1] as u16; + let msb = data[2] as u16; + let raw = (msb << 7) | lsb; + let semitones = (raw as f64 - 8192.0) / 8192.0 * 2.0; + Some(Event::MidiPitchBend { + sample_offset, + channel, + semitones, + }) + } + + _ => None, + } +} diff --git a/plinth-plugin/src/formats/standalone/midi.rs b/plinth-plugin/src/formats/standalone/midi.rs index d43d9f2..b8be894 100644 --- a/plinth-plugin/src/formats/standalone/midi.rs +++ b/plinth-plugin/src/formats/standalone/midi.rs @@ -3,11 +3,14 @@ use std::sync::mpsc::Sender; use midir::{MidiInput, MidiInputConnection}; use super::config::MidiInputConfig; -use crate::Event; +use crate::formats::midi::parse_midi_event; +use crate::{Event, MidiCapabilities}; +/// Connect MIDI input ports and translate raw MIDI bytes into `Event`s, filtered by `capabilities`. Each enabled port gets its own `MidiInputConnection`. pub fn connect_inputs( config: &MidiInputConfig, sender: Sender, + capabilities: MidiCapabilities, ) -> Vec> { let midi_in = match MidiInput::new("plinth-standalone") { Ok(m) => m, @@ -45,7 +48,7 @@ pub fn connect_inputs( port, "plinth-midi-input", move |_timestamp, data, _| { - if let Some(event) = parse_midi(data) { + if let Some(event) = parse_midi_event(data, 0, capabilities) { let _ = sender.send(event); } }, @@ -61,49 +64,3 @@ pub fn connect_inputs( connections } - -fn parse_midi(data: &[u8]) -> Option { - if data.len() < 2 { - return None; - } - - let status = data[0] & 0xF0; - let channel = (data[0] & 0x0F) as i16; - let key = data[1] as i16; - let velocity = if data.len() >= 3 { - data[2] as f64 / 127.0 - } else { - 0.0 - }; - - match status { - 0x90 if data.len() >= 3 && data[2] > 0 => Some(Event::NoteOn { - sample_offset: 0, - channel, - key, - note: -1, - velocity, - }), - 0x80 | 0x90 => Some(Event::NoteOff { - sample_offset: 0, - channel, - key, - note: -1, - velocity, - }), - 0xE0 if data.len() >= 3 => { - let lsb = data[1] as i16; - let msb = data[2] as i16; - let bend = (msb << 7 | lsb) - 8192; - let semitones = bend as f64 / 8192.0 * 2.0; - Some(Event::PitchBend { - sample_offset: 0, - channel, - key: -1, - note: -1, - semitones, - }) - } - _ => None, - } -} diff --git a/plinth-plugin/src/formats/standalone/runner.rs b/plinth-plugin/src/formats/standalone/runner.rs index 67e7af5..9ff48ec 100644 --- a/plinth-plugin/src/formats/standalone/runner.rs +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -166,7 +166,7 @@ pub fn run_standalone_with_config( // Open MIDI connections if plugin accepts note inputs let midi_connections = if P::HAS_NOTE_INPUT { - midi::connect_inputs(&midi_config, midi_sender) + midi::connect_inputs(&midi_config, midi_sender, P::MIDI_CAPABILITIES) } else { vec![] }; diff --git a/plinth-plugin/src/formats/vst3/component.rs b/plinth-plugin/src/formats/vst3/component.rs index d5f5956..c24b7bf 100644 --- a/plinth-plugin/src/formats/vst3/component.rs +++ b/plinth-plugin/src/formats/vst3/component.rs @@ -8,8 +8,10 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use plinth_core::signals::ptr_signal::{PtrSignal, PtrSignalMut}; use plinth_core::signals::signal::SignalMut; -use vst3::Steinberg::Vst::ControllerNumbers_::kPitchBend; -use vst3::Steinberg::Vst::{CtrlNumber, IMidiMapping, IMidiMappingTrait}; +use vst3::Steinberg::Vst::ControllerNumbers_::{kAfterTouch, kCtrlProgramChange, kPitchBend}; +use vst3::Steinberg::Vst::NoteExpressionTypeIDs_::{kBrightnessTypeID, kExpressionTypeID, kInvalidTypeID, kPanTypeID, kTuningTypeID, kVibratoTypeID, kVolumeTypeID}; +use vst3::Steinberg::Vst::PhysicalUITypeIDs_::{kPUIPressure, kPUIXMovement, kPUIYMovement}; +use vst3::Steinberg::Vst::{CtrlNumber, IMidiMapping, IMidiMappingTrait, INoteExpressionController, INoteExpressionControllerTrait, INoteExpressionPhysicalUIMapping, INoteExpressionPhysicalUIMappingTrait, NoteExpressionTypeID, NoteExpressionTypeInfo, NoteExpressionValue, PhysicalUIMapList}; use vst3::{ComPtr, ComRef}; use vst3::Steinberg::{int16, int32, kInvalidArgument, kNoInterface, kResultFalse, kResultOk, kResultTrue, tresult, uint32, FIDString, FUnknown, IBStream, IPlugView, IPluginBaseTrait, TBool, TUID}; use vst3::Steinberg::Vst::{kInfiniteTail, kNoParentUnitId, kNoProgramListId, kNoTail, BusDirection, BusDirections_, BusInfo, BusInfo_::BusFlags_, BusTypes_, CString, IAudioProcessor, IAudioProcessorTrait, IComponent, IComponentHandler, IComponentTrait, IEditController, IEditController2, IEditController2Trait, IEditControllerTrait, IHostApplication, IHostApplicationTrait, IProcessContextRequirements, IProcessContextRequirementsTrait, IProcessContextRequirements_, IUnitInfo, IUnitInfoTrait, IoMode, IoModes_, KnobMode, MediaType, MediaTypes_, ParamID, ParamValue, ParameterInfo_, ProcessData, ProcessSetup, ProgramListID, ProgramListInfo, RoutingInfo, SpeakerArr, SpeakerArrangement, String128, SymbolicSampleSizes_, TChar, UnitID, UnitInfo, ViewType::kEditor}; @@ -17,12 +19,12 @@ use widestring::U16CStr; use crate::formats::PluginFormat; use crate::host::HostInfo; -use crate::vst3::parameters::parameter_change_to_event; -use crate::{ParameterId, Parameters, ProcessMode, ProcessState, Processor, ProcessorConfig}; +use crate::vst3::parameters::{parameter_change_to_event, MidiParameterIds}; +use crate::{NoteExpressions, ParameterId, Parameters, ProcessMode, ProcessState, Processor, ProcessorConfig}; use crate::editor::NoEditor; use crate::parameters::{group::{self, ParameterGroupRef}, has_duplicates, info::ParameterInfo}; use crate::string::{char16_to_string, copy_str_to_char16}; -use crate::vst3::{event::EventIterator, parameters::ParameterChangeIterator}; +use crate::vst3::{event::{EventIterator, NoteIdMap}, parameters::ParameterChangeIterator}; use super::{plugin::Vst3Plugin, stream::Stream, view::View}; @@ -49,7 +51,8 @@ pub struct PluginComponent { parameter_info: RefCell>, parameter_groups: RefCell>, - pitch_bend_parameter_ids: RefCell<[ParameterId; 16]>, + midi_parameter_ids: RefCell, + note_id_map: RefCell, process_mode: RefCell, processing: AtomicBool, @@ -67,7 +70,8 @@ impl PluginComponent

{ parameter_info: Default::default(), parameter_groups: Default::default(), - pitch_bend_parameter_ids: Default::default(), + midi_parameter_ids: Default::default(), + note_id_map: Default::default(), process_mode: ProcessMode::default().into(), processing: AtomicBool::new(false), @@ -92,7 +96,7 @@ impl PluginComponent

{ } impl vst3::Class for PluginComponent

{ - type Interfaces = (IAudioProcessor, IComponent, IComponent, IEditController, IEditController2, IMidiMapping, IProcessContextRequirements, IUnitInfo); + type Interfaces = (IAudioProcessor, IComponent, IComponent, IEditController, IEditController2, IMidiMapping, IProcessContextRequirements, IUnitInfo, INoteExpressionController, INoteExpressionPhysicalUIMapping); } impl IPluginBaseTrait for PluginComponent

{ @@ -146,24 +150,45 @@ impl IPluginBaseTrait for PluginComponent

{ group::from_parameters(parameters) }); - // Create parameters for MIDI pitch bend messages + // Allocate hidden reserved VST3 parameters for each MIDI message type the plugin requires via its MIDI_CAPABILITIES. plugin.with_parameters(|parameters| { - let mut parameter_id = 1; - let ids = parameters.ids(); - - for (channel, pitch_bend_parameter_id) in self.pitch_bend_parameter_ids.borrow_mut().iter_mut().enumerate() { - while ids.contains(¶meter_id) { - parameter_id += 1; + let user_ids = parameters.ids(); + let mut next_id: ParameterId = 1; + + // Allocate one 16-channel block, pushing hidden ParameterInfos. + let mut alloc_block = |infos: &mut Vec, name_fn: &dyn Fn(usize) -> String| -> [ParameterId; 16] { + let mut block = [0u32; 16]; + for (channel, slot) in block.iter_mut().enumerate() { + while user_ids.contains(&next_id) { + next_id += 1; + } + infos.push(ParameterInfo::new(next_id, name_fn(channel)).hidden()); + *slot = next_id; + next_id += 1; } + block + }; + + let mut midi_ids = MidiParameterIds::default(); - let info = ParameterInfo::new(parameter_id, format!("MIDI Channel {} Pitch Bend", channel + 1)) - .hidden(); + if P::MIDI_CAPABILITIES.midi_pitch_bend() { + midi_ids.pitch_bend = Some(alloc_block(&mut parameter_infos, &|channel| format!("MIDI Channel {} Pitch Bend", channel + 1))); + } - parameter_infos.push(info); + if P::MIDI_CAPABILITIES.midi_channel_pressure() { + midi_ids.channel_pressure = Some(alloc_block(&mut parameter_infos, &|channel| format!("MIDI Channel {} Channel Pressure", channel + 1))); + } - *pitch_bend_parameter_id = parameter_id; - parameter_id += 1; + if P::MIDI_CAPABILITIES.midi_program_change() { + midi_ids.program_change = Some(alloc_block(&mut parameter_infos, &|channel| format!("MIDI Channel {} Program Change", channel + 1))); } + + for cc in P::MIDI_CAPABILITIES.enabled_midi_control_changes() { + let block = alloc_block(&mut parameter_infos, &|channel| format!("MIDI Channel {} CC {}", channel + 1, cc)); + midi_ids.cc.insert(cc, block); + } + + *self.midi_parameter_ids.borrow_mut() = midi_ids; }); *self.plugin.borrow_mut() = Some(plugin); @@ -275,6 +300,10 @@ impl IAudioProcessorTrait for PluginComponent

{ processor.reset(); } + if let Ok(mut note_id_map) = self.note_id_map.try_borrow_mut() && !processing { + note_id_map.reset(); + } + kResultOk } @@ -282,8 +311,10 @@ impl IAudioProcessorTrait for PluginComponent

{ unsafe fn process(&self, data: *mut ProcessData) -> tresult { let data = unsafe { &mut *data }; - let parameter_change_iterator = ParameterChangeIterator::new(data.inputParameterChanges, *self.pitch_bend_parameter_ids.borrow()); - let event_iterator = EventIterator::new(data.inputEvents); + let midi_ids = self.midi_parameter_ids.borrow(); + let parameter_change_iterator = ParameterChangeIterator::new(data.inputParameterChanges, &midi_ids); + let mut note_id_map = self.note_id_map.borrow_mut(); + let event_iterator = EventIterator::new(data.inputEvents, &mut note_id_map, P::NOTE_EXPRESSIONS); let all_events = event_iterator.chain(parameter_change_iterator); let is_data_dump = data.inputs.is_null() || data.outputs.is_null() || data.numInputs == 0 || data.numSamples == 0; @@ -646,7 +677,7 @@ impl IEditControllerTrait for PluginComponent

{ return kResultFalse; }; - let event = parameter_change_to_event(id, value, 0, &self.pitch_bend_parameter_ids.borrow()); + let event = parameter_change_to_event(id, value, 0, &self.midi_parameter_ids.borrow()); plugin.process_event(&event); kResultOk @@ -720,16 +751,37 @@ impl IMidiMappingTrait for PluginComponent

{ if bus_index != 0 { return kResultFalse; } - if midi_controller_number != kPitchBend as i16 { - return kResultFalse; - } if !(0..16).contains(&channel) { return kInvalidArgument; } - unsafe { *id = self.pitch_bend_parameter_ids.borrow()[channel as usize] as _ }; + let midi_ids = self.midi_parameter_ids.borrow(); + let channel = channel as usize; - kResultTrue + if midi_controller_number == kPitchBend as i16 { + if let Some(pb_ids) = &midi_ids.pitch_bend { + unsafe { *id = pb_ids[channel] as _ }; + return kResultTrue; + } + } else if midi_controller_number == kAfterTouch as i16 { + if let Some(cp_ids) = &midi_ids.channel_pressure { + unsafe { *id = cp_ids[channel] as _ }; + return kResultTrue; + } + } else if midi_controller_number == kCtrlProgramChange as i16 { + if let Some(pc_ids) = &midi_ids.program_change { + unsafe { *id = pc_ids[channel] as _ }; + return kResultTrue; + } + } else if (0..128).contains(&midi_controller_number) { + let cc = midi_controller_number as u8; + if let Some(cc_ids) = midi_ids.cc.get(&cc) { + unsafe { *id = cc_ids[channel] as _ }; + return kResultTrue; + } + } + + kResultFalse } } @@ -839,3 +891,240 @@ impl IUnitInfoTrait for PluginComponent

{ kInvalidArgument } } + +// Standard VST3 note-expression types. Only the enabled subset (per `NoteExpressions` config) is exposed to the host. +struct NoteExpressionDescriptor { + type_id: NoteExpressionTypeID, + title: &'static str, + short_title: &'static str, + units: &'static str, + default_value: NoteExpressionValue, + enabled: fn(&NoteExpressions) -> bool, +} + +const NOTE_EXPRESSION_DESCRIPTORS: [NoteExpressionDescriptor; 6] = [ + NoteExpressionDescriptor { + type_id: kVolumeTypeID, + title: "Volume", + short_title: "Volume", + units: "", + default_value: 1.0, + enabled: NoteExpressions::volume, + }, + NoteExpressionDescriptor { + type_id: kPanTypeID, + title: "Panning", + short_title: "Panning", + units: "", + default_value: 0.5, // center + enabled: NoteExpressions::pan, + }, + NoteExpressionDescriptor { + type_id: kTuningTypeID, + title: "Tuning", + short_title: "Tuning", + units: "semitones", + default_value: 0.5, // center + enabled: NoteExpressions::tuning, + }, + NoteExpressionDescriptor { + type_id: kVibratoTypeID, + title: "Vibrato", + short_title: "Vibrato", + units: "", + default_value: 0.0, + enabled: NoteExpressions::vibrato, + }, + NoteExpressionDescriptor { + type_id: kExpressionTypeID, + title: "Expression", + short_title: "Expression", + units: "", + default_value: 0.0, + enabled: NoteExpressions::expression, + }, + NoteExpressionDescriptor { + type_id: kBrightnessTypeID, + title: "Brightness", + short_title: "Brightness", + units: "", + default_value: 0.0, + enabled: NoteExpressions::brightness, + }, +]; + +fn fill_note_expression_info(note_expressions: NoteExpressions, index: i32, info: &mut NoteExpressionTypeInfo) -> bool { + let Ok(index) = usize::try_from(index) else { + return false; + }; + let Some(descriptor) = NOTE_EXPRESSION_DESCRIPTORS.iter().filter(|descriptor| (descriptor.enabled)(¬e_expressions)).nth(index) else { + return false; + }; + + info.typeId = descriptor.type_id; + info.unitId = -1; + info.associatedParameterId = u32::MAX; + info.flags = 0; + info.valueDesc.minimum = 0.0; + info.valueDesc.maximum = 1.0; + info.valueDesc.stepCount = 0; + info.valueDesc.defaultValue = descriptor.default_value; + copy_str_to_char16(descriptor.title, &mut info.title); + copy_str_to_char16(descriptor.short_title, &mut info.shortTitle); + copy_str_to_char16(descriptor.units, &mut info.units); + true +} + +impl INoteExpressionControllerTrait for PluginComponent

{ + unsafe fn getNoteExpressionCount(&self, bus_index: int32, channel: int16) -> int32 { + tracing::trace!("INoteExpressionController::getNoteExpressionCount"); + if bus_index == 0 && (0..16).contains(&channel) { + // NB: Pressure is excluded here. It is delivered as `kPolyPressureEvent`. + P::NOTE_EXPRESSIONS.volume() as i32 + + P::NOTE_EXPRESSIONS.pan() as i32 + + P::NOTE_EXPRESSIONS.tuning() as i32 + + P::NOTE_EXPRESSIONS.vibrato() as i32 + + P::NOTE_EXPRESSIONS.expression() as i32 + + P::NOTE_EXPRESSIONS.brightness() as i32 + } else { + 0 + } + } + + unsafe fn getNoteExpressionInfo(&self, bus_index: int32, channel: int16, note_expression_index: int32, info: *mut NoteExpressionTypeInfo) -> tresult { + tracing::trace!("INoteExpressionController::getNoteExpressionInfo"); + + if bus_index != 0 || !(0..16).contains(&channel) || info.is_null() { + return kInvalidArgument; + } + + let info = unsafe { &mut *info }; + if fill_note_expression_info(P::NOTE_EXPRESSIONS, note_expression_index, info) { + kResultOk + } else { + kInvalidArgument + } + } + + unsafe fn getNoteExpressionStringByValue(&self, bus_index: int32, channel: int16, id: NoteExpressionTypeID, value_normalized: NoteExpressionValue, string: *mut String128) -> tresult { + tracing::trace!("INoteExpressionController::getNoteExpressionStringByValue"); + + if P::NOTE_EXPRESSIONS.is_empty() { + return kInvalidArgument; + } + if bus_index != 0 || !(0..16).contains(&channel) || string.is_null() { + return kInvalidArgument; + } + + let s = unsafe { &mut *string }; + + #[allow(non_upper_case_globals)] + let formatted = match id { + kVolumeTypeID => format!("{:.2}", value_normalized), + kPanTypeID => { + let pan = value_normalized * 200.0 - 100.0; + format!("{:.1} %", pan) + } + kTuningTypeID => { + let semitones = value_normalized * 240.0 - 120.0; + format!("{:+.2} st", semitones) + } + kVibratoTypeID | kExpressionTypeID | kBrightnessTypeID => { + format!("{:.2}", value_normalized) + } + _ => return kInvalidArgument, + }; + copy_str_to_char16(&formatted, s); + kResultOk + } + + unsafe fn getNoteExpressionValueByString(&self, bus_index: int32, channel: int16, id: NoteExpressionTypeID, string: *const TChar, value_normalized: *mut NoteExpressionValue) -> tresult { + tracing::trace!("INoteExpressionController::getNoteExpressionValueByString"); + + if P::NOTE_EXPRESSIONS.is_empty() { + return kInvalidArgument; + } + if bus_index != 0 || !(0..16).contains(&channel) || string.is_null() || value_normalized.is_null() { + return kInvalidArgument; + } + + let s = unsafe { U16CStr::from_ptr_str(string as _) }; + let Ok(s) = s.to_string() else { + return kInvalidArgument; + }; + // Strip everything except digits to get a plain number string + let digits: String = s.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')).collect(); + + #[allow(non_upper_case_globals)] + match id { + kTuningTypeID => { + if let Ok(semitones) = digits.parse::() { + let value = (semitones + 120.0) / 240.0; + unsafe { *value_normalized = value.clamp(0.0, 1.0) }; + kResultOk + } else { + kResultFalse + } + } + kPanTypeID => { + if let Ok(pan_pct) = digits.parse::() { + // Input may be a % in -100..100 + let value = (pan_pct + 100.0) / 200.0; + unsafe { *value_normalized = value.clamp(0.0, 1.0) }; + kResultOk + } else { + kResultFalse + } + } + kVolumeTypeID | kVibratoTypeID | kExpressionTypeID | kBrightnessTypeID => { + if let Ok(value) = digits.parse::() { + unsafe { *value_normalized = value.clamp(0.0, 1.0) }; + kResultOk + } else { + kResultFalse + } + } + _ => kInvalidArgument, + } + } +} + +impl INoteExpressionPhysicalUIMappingTrait for PluginComponent

{ + unsafe fn getPhysicalUIMapping(&self, bus_index: int32, channel: int16, list: *mut PhysicalUIMapList) -> tresult { + tracing::trace!("INoteExpressionPhysicalUIMapping::getPhysicalUIMapping"); + + if bus_index != 0 || !(0..16).contains(&channel) || list.is_null() { + return kInvalidArgument; + } + + let list = unsafe { &mut *list }; + + for i in 0..list.count as usize { + let entry = unsafe { &mut *list.map.add(i) }; + #[allow(non_upper_case_globals)] + let ne_type = if entry.physicalUITypeID == kPUIXMovement as u32 { + // Horizontal (slide left/right) -> per-note pitch / tuning + if P::NOTE_EXPRESSIONS.tuning() { + kTuningTypeID + } else { + kInvalidTypeID + } + } else if entry.physicalUITypeID == kPUIYMovement as u32 { + // Vertical (slide up/down) -> brightness / timbre + if P::NOTE_EXPRESSIONS.brightness() { + kBrightnessTypeID + } else { + kInvalidTypeID + } + } else if entry.physicalUITypeID == kPUIPressure as u32 { + // Pressure (Z-axis) -> delivered as kPolyPressureEvent, not note expression + kInvalidTypeID + } else { + kInvalidTypeID + }; + entry.noteExpressionTypeID = ne_type; + } + + kResultOk + } +} diff --git a/plinth-plugin/src/formats/vst3/event.rs b/plinth-plugin/src/formats/vst3/event.rs index 7da532f..6ee5f17 100644 --- a/plinth-plugin/src/formats/vst3/event.rs +++ b/plinth-plugin/src/formats/vst3/event.rs @@ -1,63 +1,234 @@ use std::mem; +use vst3::Steinberg::Vst::NoteExpressionTypeIDs_::{kBrightnessTypeID, kExpressionTypeID, kPanTypeID, kTuningTypeID, kVibratoTypeID, kVolumeTypeID}; use vst3::{ComRef, Steinberg::{kResultOk, Vst::{self, IEventList, IEventListTrait}}}; -use crate::Event; +use crate::{Event, NoteExpressions}; + +#[derive(Clone, Copy)] +struct NoteIdEntry { + note_id: i32, + channel: i16, + key: i16, +} + +/// Fixed-capacity mapping of VST3 `note_id -> (channel, key)`. +pub(super) struct NoteIdMap { + entries: [NoteIdEntry; Self::CAPACITY], + /// Index of the next slot to write into in the ring buffer. + write_index: usize, + /// Number of valid entries, capped at CAPACITY. + count: usize, +} + +impl NoteIdMap { + const CAPACITY: usize = 128; // max note count + + pub fn new() -> Self { + const EMPTY_ENTRY: NoteIdEntry = NoteIdEntry { + note_id: 0, + channel: 0, + key: 0, + }; + Self { + entries: [EMPTY_ENTRY; Self::CAPACITY], + write_index: 0, + count: 0, + } + } + + pub fn reset(&mut self) { + *self = Self::new(); + } + + fn insert(&mut self, note_id: i32, channel: i16, key: i16) { + self.entries[self.write_index] = NoteIdEntry { + note_id, + channel, + key, + }; + self.write_index = (self.write_index + 1) % Self::CAPACITY; + if self.count < Self::CAPACITY { + self.count += 1; + } + } + + fn lookup(&self, note_id: i32) -> Option<(i16, i16)> { + for i in 0..self.count { + let index = (self.write_index + Self::CAPACITY - 1 - i) % Self::CAPACITY; + let entry = &self.entries[index]; + if entry.note_id == note_id { + return Some((entry.channel, entry.key)); + } + } + None + } +} + +impl Default for NoteIdMap { + fn default() -> Self { + Self::new() + } +} pub struct EventIterator<'a> { event_list: Option>, index: usize, + note_id_map: &'a mut NoteIdMap, + note_expressions: NoteExpressions, } -impl EventIterator<'_> { - pub fn new(event_list: *mut IEventList) -> Self { +impl<'a> EventIterator<'a> { + pub fn new(event_list: *mut IEventList, note_id_map: &'a mut NoteIdMap, note_expressions: NoteExpressions) -> Self { Self { event_list: unsafe { ComRef::from_raw(event_list) }, index: 0, - } + note_id_map, + note_expressions, + } } } impl Iterator for EventIterator<'_> { type Item = Event; - + fn next(&mut self) -> Option { let event_list = self.event_list?; - if self.index >= unsafe { event_list.getEventCount() } as usize { - return None; - } + loop { + if self.index >= unsafe { event_list.getEventCount() } as usize { + return None; + } - let mut event: vst3::Steinberg::Vst::Event = unsafe { mem::zeroed() }; - let result = unsafe { event_list.getEvent(self.index as _, &mut event) }; - if result != kResultOk { - return None; - } + let mut event: vst3::Steinberg::Vst::Event = unsafe { mem::zeroed() }; + let result = unsafe { event_list.getEvent(self.index as _, &mut event) }; + if result != kResultOk { + return None; + } + + self.index += 1; + + let event = match event.r#type as _ { + Vst::Event_::EventTypes_::kNoteOnEvent => unsafe { + // Track note ID -> (channel, key) for later note-expression lookups + self.note_id_map.insert( + event.__field0.noteOn.noteId, + event.__field0.noteOn.channel, + event.__field0.noteOn.pitch, + ); + Some(Event::NoteOn { + sample_offset: event.sampleOffset as _, + channel: event.__field0.noteOn.channel, + key: event.__field0.noteOn.pitch, + note: event.__field0.noteOn.noteId, + velocity: event.__field0.noteOn.velocity as _, + }) + }, + + Vst::Event_::EventTypes_::kNoteOffEvent => unsafe { + Some(Event::NoteOff { + sample_offset: event.sampleOffset as _, + channel: event.__field0.noteOff.channel, + key: event.__field0.noteOff.pitch, + note: event.__field0.noteOff.noteId, + velocity: event.__field0.noteOff.velocity as _, + }) + }, + + Vst::Event_::EventTypes_::kPolyPressureEvent if self.note_expressions.pressure() => + unsafe { + Some(Event::PolyPressure { + sample_offset: event.sampleOffset as _, + channel: event.__field0.polyPressure.channel, + key: event.__field0.polyPressure.pitch, + note: event.__field0.polyPressure.noteId, + value: event.__field0.polyPressure.pressure as _, + }) + }, + + Vst::Event_::EventTypes_::kNoteExpressionValueEvent => unsafe { + let sample_offset = event.sampleOffset as usize; + let note_expression = event.__field0.noteExpressionValue; + let note = note_expression.noteId; + let value = note_expression.value; + + // Resolve the note's channel and key from the note map. + let (channel, key) = self.note_id_map.lookup(note).unwrap_or((-1, -1)); + + // NB: All VST3 note-expression values arrive normalized to [0, 1]. + #[allow(non_upper_case_globals)] + match note_expression.typeId { + kVolumeTypeID if self.note_expressions.volume() => { + Some(Event::PolyVolume { + sample_offset, + channel, + key, + note, + // NB: CLAP's PolyVolume is 0..4, where 1 is 0db, so we only use the 0..1 volume range here + gain: value, + }) + } + kPanTypeID if self.note_expressions.pan() => Some(Event::PolyPan { + sample_offset, + channel, + key, + note, + // Registered min=0, max=1, center=0.5 -> map to [-1, 1] + pan: value * 2.0 - 1.0, + }), + kTuningTypeID if self.note_expressions.tuning() => { + Some(Event::PolyTuning { + sample_offset, + channel, + key, + note, + // Registered min=0, max=1, center=0.5 -> map to [-120, +120] semitones + semitones: value * 240.0 - 120.0, + }) + } + kVibratoTypeID if self.note_expressions.vibrato() => { + Some(Event::PolyVibrato { + sample_offset, + channel, + key, + note, + amount: value, + }) + } + kExpressionTypeID if self.note_expressions.expression() => { + Some(Event::PolyExpression { + sample_offset, + channel, + key, + note, + amount: value, + }) + } + kBrightnessTypeID if self.note_expressions.brightness() => { + Some(Event::PolyBrightness { + sample_offset, + channel, + key, + note, + amount: value, + }) + } + // Unknown, unsupported, or gated-off type ID. Skip, but do not stop iteration. + _ => { + None + } + } + }, + + // Unhandled event type (or bypassed via capabilities). Skip to next event. + _ => None, + }; - self.index += 1; - - match event.r#type as _ { - Vst::Event_::EventTypes_::kNoteOnEvent => unsafe { - Some(Event::NoteOn { - sample_offset: event.sampleOffset as _, - channel: event.__field0.noteOn.channel, - key: event.__field0.noteOn.pitch, - note: event.__field0.noteOn.noteId, - velocity: event.__field0.noteOn.velocity as _, - }) - }, - - Vst::Event_::EventTypes_::kNoteOffEvent => unsafe { - Some(Event::NoteOff { - sample_offset: event.sampleOffset as _, - channel: event.__field0.noteOff.channel, - key: event.__field0.noteOff.pitch, - note: event.__field0.noteOn.noteId, - velocity: event.__field0.noteOff.velocity as _, - }) - }, - - _ => None + if event.is_some() { + return event; + } else { + continue; + } } } } diff --git a/plinth-plugin/src/formats/vst3/parameters.rs b/plinth-plugin/src/formats/vst3/parameters.rs index 00a97f1..665759e 100644 --- a/plinth-plugin/src/formats/vst3/parameters.rs +++ b/plinth-plugin/src/formats/vst3/parameters.rs @@ -1,42 +1,100 @@ use std::cmp; +use std::collections::BTreeMap; use vst3::{ComRef, Steinberg::{kResultOk, Vst::{IParamValueQueueTrait, IParameterChanges, IParameterChangesTrait, ParamID, ParamValue}}}; use crate::{event::Event, ParameterId}; -pub(super) fn parameter_change_to_event(id: ParamID, value: ParamValue, offset: usize, pitch_bend_parameter_ids: &[ParameterId; 16]) -> Event { - if let Some(channel) = pitch_bend_parameter_ids.iter().position(|&pitch_bend_id| pitch_bend_id == id) { - let semitones = (value - 0.5) * 4.0; +/// Reserved VST3 parameter-ID blocks. +/// +/// Each block holds 16 hidden parameters, one per MIDI channel. +#[derive(Default)] +pub(super) struct MidiParameterIds { + pub pitch_bend: Option<[ParameterId; 16]>, + pub channel_pressure: Option<[ParameterId; 16]>, + pub program_change: Option<[ParameterId; 16]>, + pub cc: BTreeMap, +} - Event::PitchBend { +/// Map a VST3 parameter change event to an `Event`, recognising reserved MIDI blocks. +/// +/// Checks pitch-bend, channel-pressure, CC blocks in that order, defaulting to `Event::ParameterValue` for user parameters. +pub(super) fn parameter_change_to_event( + id: ParamID, + value: ParamValue, + offset: usize, + midi_ids: &MidiParameterIds, +) -> Event { + // Pitch bend: VST3 normalizes to [0, 1]: map to [-2, +2] semitones + if let Some(channel) = &midi_ids + .pitch_bend + .and_then(|pb_ids| pb_ids.iter().position(|&pid| pid == id)) + { + let semitones = (value - 0.5) * 4.0; + return Event::MidiPitchBend { sample_offset: offset, - channel: channel as _, - key: -1, // TODO - note: -1, // TODO + channel: *channel as _, semitones, - } - } else { - Event::ParameterValue { + }; + } + + // Channel pressure: VST3 normalizes to [0, 1]: passed through as it is. + if let Some(channel) = &midi_ids + .channel_pressure + .and_then(|cp_ids| cp_ids.iter().position(|&pid| pid == id)) + { + return Event::MidiChannelPressure { sample_offset: offset, - id, + channel: *channel as _, value, + }; + } + + // Program change: VST3 normalizes to [0, 1]: round to the nearest program number. + if let Some(channel) = &midi_ids + .program_change + .and_then(|pc_ids| pc_ids.iter().position(|&pid| pid == id)) + { + return Event::MidiProgramChange { + sample_offset: offset, + channel: *channel as _, + program: (value * 127.0).round() as u8, + }; + } + + // MIDI CC: VST3 normalizes to [0, 1]: passed through as it is. + for (&cc, cc_ids) in &midi_ids.cc { + if let Some(channel) = cc_ids.iter().position(|&pid| pid == id) { + return Event::MidiControlChange { + sample_offset: offset, + channel: channel as _, + controller: cc, + value, + }; } } + + // Fall through to ordinary parameter + Event::ParameterValue { + sample_offset: offset, + id, + value, + } } pub struct ParameterChangeIterator<'a> { parameter_changes: Option>, - pitch_bend_parameter_ids: [ParameterId; 16], + midi_parameter_ids: &'a MidiParameterIds, offset: usize, index: usize, finished: bool, } -impl ParameterChangeIterator<'_> { - pub fn new(parameter_changes: *mut IParameterChanges, pitch_bend_parameter_ids: [ParameterId; 16]) -> Self { +impl<'a> ParameterChangeIterator<'a> { + pub fn new(parameter_changes: *mut IParameterChanges, midi_parameter_ids: &'a MidiParameterIds) -> Self { Self { parameter_changes: unsafe { ComRef::from_raw(parameter_changes) }, - pitch_bend_parameter_ids, + midi_parameter_ids, offset: 0, index: 0, finished: false, @@ -116,7 +174,7 @@ impl Iterator for ParameterChangeIterator<'_> { self.index += 1; } - let event = parameter_change_to_event(id, value, offset, &self.pitch_bend_parameter_ids); + let event = parameter_change_to_event(id, value, offset, self.midi_parameter_ids); Some(event) } diff --git a/plinth-plugin/src/lib.rs b/plinth-plugin/src/lib.rs index 3020ca0..bef8dac 100644 --- a/plinth-plugin/src/lib.rs +++ b/plinth-plugin/src/lib.rs @@ -1,6 +1,8 @@ pub use editor::{Editor, NoEditor}; pub use error::Error; pub use event::Event; +pub use midi_capabilities::MidiCapabilities; +pub use note_expressions::NoteExpressions; pub use host::{Host, HostInfo}; pub use formats::{clap, vst3}; #[cfg(feature = "standalone")] @@ -32,6 +34,8 @@ mod editor; pub mod error; mod event; mod host; +mod midi_capabilities; +mod note_expressions; mod formats; pub mod parameters; mod plugin; diff --git a/plinth-plugin/src/midi_capabilities.rs b/plinth-plugin/src/midi_capabilities.rs new file mode 100644 index 0000000..08be16b --- /dev/null +++ b/plinth-plugin/src/midi_capabilities.rs @@ -0,0 +1,134 @@ +/// Compile-time declaration of which raw MIDI messages a plugin wants to receive as `Event`s. +/// +/// Only covers plain MIDI wire messages. Per-note expression are a separate mechanism enabled +/// via [`crate::NoteExpressions`]. +/// +/// Midi events do register dummy, hidden, per channel parameters in VST3 plugins, so only necessary +/// event types and CCs should be enabled to avoid adding lots of dummy parameters! +/// +/// Example: +/// ```ignore +/// const MIDI_CAPABILITIES: MidiCapabilities = MidiCapabilities::NONE +/// .with_pitch_bend() +/// .with_channel_pressure() +/// .with_control_change(1) +/// .with_control_change_range(20, 31); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MidiCapabilities { + pitch_bend: bool, + channel_pressure: bool, + poly_pressure: bool, + program_change: bool, + cc_mask: u128, +} + +impl Default for MidiCapabilities { + fn default() -> Self { + Self::NONE + } +} + +impl MidiCapabilities { + /// No MIDI capabilities. + pub const NONE: Self = Self { + pitch_bend: false, + channel_pressure: false, + program_change: false, + poly_pressure: false, + cc_mask: 0, + }; + + /// Enable delivery of channel-wide pitch bend as [`Event::MidiPitchBend`]. + pub const fn with_pitch_bend(mut self) -> Self { + self.pitch_bend = true; + self + } + + /// Enable delivery of channel pressure (mono aftertouch) as [`Event::MidiChannelPressure`]. + pub const fn with_channel_pressure(mut self) -> Self { + self.channel_pressure = true; + self + } + + /// Enable delivery of polyphonic key pressure (poly aftertouch) delivered as a raw MIDI byte + /// message, as [`Event::MidiPolyPressure`]. + pub const fn with_poly_pressure(mut self) -> Self { + self.poly_pressure = true; + self + } + + /// Enable delivery of program change messages as [`Event::MidiProgramChange`]. + pub const fn with_program_change(mut self) -> Self { + self.program_change = true; + self + } + + /// Enable delivery of the given CC number as [`Event::MidiControlChange`]. + pub const fn with_control_change(mut self, cc: u8) -> Self { + assert!(cc < 128, "MIDI CC number must be 0..=127"); + self.cc_mask |= 1u128 << cc; + self + } + + /// Enable delivery of all CC numbers in the inclusive range `[start, end]` as [`Event::MidiControlChange`]. + pub const fn with_control_change_range(mut self, start: u8, end: u8) -> Self { + assert!( + start <= end && end < 128, + "invalid CC range: must be start <= end <= 127" + ); + let mut cc = start; + while cc <= end { + self = self.with_control_change(cc); + cc += 1; + } + self + } + + /// Returns `true` when no capabilities are enabled. + pub const fn is_empty(&self) -> bool { + self.cc_mask == 0 + && !self.pitch_bend + && !self.channel_pressure + && !self.program_change + && !self.poly_pressure + } + + /// Returns `true` if pitch bend is enabled. + pub const fn midi_pitch_bend(&self) -> bool { + self.pitch_bend + } + + /// Returns `true` if channel pressure (mono aftertouch) is enabled. + pub const fn midi_channel_pressure(&self) -> bool { + self.channel_pressure + } + + /// Returns `true` if raw-MIDI-delivered polyphonic key pressure is enabled. + pub const fn midi_poly_pressure(&self) -> bool { + self.poly_pressure + } + + /// Returns `true` if program change is enabled. + pub const fn midi_program_change(&self) -> bool { + self.program_change + } + + /// Returns `true` if the given CC number is enabled. + pub const fn has_midi_control_change(&self, cc: u8) -> bool { + if cc >= 128 { + return false; + } + (self.cc_mask >> cc) & 1 != 0 + } + + /// Returns the number of enabled CC numbers. + pub const fn midi_control_change_count(&self) -> u32 { + self.cc_mask.count_ones() + } + + /// Iterates over enabled CC numbers in ascending order. + pub fn enabled_midi_control_changes(&self) -> impl Iterator + '_ { + (0u8..128).filter(move |&cc| self.has_midi_control_change(cc)) + } +} diff --git a/plinth-plugin/src/note_expressions.rs b/plinth-plugin/src/note_expressions.rs new file mode 100644 index 0000000..93ee061 --- /dev/null +++ b/plinth-plugin/src/note_expressions.rs @@ -0,0 +1,146 @@ +/// Compile-time declaration of which per-note expression dimensions a plugin wants to receive +/// as `Event`s (VST3 note expression / CLAP note expression). +/// +/// Example: +/// ```ignore +/// const NOTE_EXPRESSIONS: NoteExpressions = NoteExpressions::NONE +/// .with_tuning() +/// .with_brightness(); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NoteExpressions { + volume: bool, + pan: bool, + tuning: bool, + vibrato: bool, + expression: bool, + brightness: bool, + pressure: bool, +} + +impl Default for NoteExpressions { + fn default() -> Self { + Self::DEFAULT + } +} + +impl NoteExpressions { + /// No note expressions. + pub const NONE: Self = Self { + volume: false, + pan: false, + tuning: false, + vibrato: false, + expression: false, + brightness: false, + pressure: false, + }; + + /// All note expressions. + pub const ALL: Self = Self { + volume: true, + pan: true, + tuning: true, + vibrato: true, + expression: true, + brightness: true, + pressure: true, + }; + + /// A sensible common subset: volume, pan, tuning, brightness and pressure. + /// Excludes vibrato and expression, which are rarely (if at all) sent by hosts. + pub const DEFAULT: Self = Self::NONE + .with_volume() + .with_pan() + .with_tuning() + .with_brightness() + .with_pressure(); + + /// Enable delivery of per-note volume as [`crate::Event::PolyVolume`]. + pub const fn with_volume(mut self) -> Self { + self.volume = true; + self + } + + /// Enable delivery of per-note panning as [`crate::Event::PolyPan`]. + pub const fn with_pan(mut self) -> Self { + self.pan = true; + self + } + + /// Enable delivery of per-note tuning offset as [`crate::Event::PolyTuning`]. + pub const fn with_tuning(mut self) -> Self { + self.tuning = true; + self + } + + /// Enable delivery of per-note vibrato as [`crate::Event::PolyVibrato`]. + pub const fn with_vibrato(mut self) -> Self { + self.vibrato = true; + self + } + + /// Enable delivery of per-note expression (MPE "slide") as [`crate::Event::PolyExpression`]. + pub const fn with_expression(mut self) -> Self { + self.expression = true; + self + } + + /// Enable delivery of per-note brightness as [`crate::Event::PolyBrightness`]. + pub const fn with_brightness(mut self) -> Self { + self.brightness = true; + self + } + + /// Enable delivery of per-note pressure (poly aftertouch) as [`crate::Event::PolyPressure`]. + pub const fn with_pressure(mut self) -> Self { + self.pressure = true; + self + } + + /// Returns `true` when no note expressions are enabled. + pub const fn is_empty(&self) -> bool { + !self.volume + && !self.pan + && !self.tuning + && !self.vibrato + && !self.expression + && !self.brightness + && !self.pressure + } + + /// Returns `true` if per-note volume is enabled. + pub const fn volume(&self) -> bool { + self.volume + } + + /// Returns `true` if per-note panning is enabled. + pub const fn pan(&self) -> bool { + self.pan + } + + /// Returns `true` if per-note tuning is enabled. + pub const fn tuning(&self) -> bool { + self.tuning + } + + /// Returns `true` if per-note vibrato is enabled. + pub const fn vibrato(&self) -> bool { + self.vibrato + } + + /// Returns `true` if per-note expression (MPE "slide") is enabled. + pub const fn expression(&self) -> bool { + self.expression + } + + /// Returns `true` if per-note brightness is enabled. + pub const fn brightness(&self) -> bool { + self.brightness + } + + /// Returns `true` if per-note pressure (poly aftertouch) is enabled. + pub const fn pressure(&self) -> bool { + self.pressure + } +} diff --git a/plinth-plugin/src/plugin.rs b/plinth-plugin/src/plugin.rs index b6c4d44..d0218b5 100644 --- a/plinth-plugin/src/plugin.rs +++ b/plinth-plugin/src/plugin.rs @@ -1,6 +1,6 @@ use std::{io::{Read, Write}, rc::Rc}; -use crate::{error::Error, host::HostInfo, processor::ProcessorConfig, Editor, Event, Host, Parameters, Processor}; +use crate::{error::Error, host::HostInfo, midi_capabilities::MidiCapabilities, note_expressions::NoteExpressions, processor::ProcessorConfig, Editor, Event, Host, Parameters, Processor}; pub trait Plugin { const NAME: &'static str; @@ -10,8 +10,14 @@ pub trait Plugin { const URL: Option<&'static str> = None; const HAS_AUX_INPUT: bool = false; + // Enables note, midi event input ports. const HAS_NOTE_INPUT: bool = false; + // Enables note, midi event output ports (currently unused). const HAS_NOTE_OUTPUT: bool = false; + // Enables delivery of the specified per-note expression dimensions (VST3 note expression / CLAP note expression) when HAS_NOTE_INPUT is true. + const NOTE_EXPRESSIONS: NoteExpressions = if Self::HAS_NOTE_INPUT { NoteExpressions::DEFAULT } else { NoteExpressions::NONE }; + // Enables specified MIDI events when HAS_NOTE_INPUT is true. Creates hidden parameter overhead for VST3, so keep disabled when unused. + const MIDI_CAPABILITIES: MidiCapabilities = MidiCapabilities::NONE; type Processor: Processor; type Editor: Editor;