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;