diff --git a/plinth-plugin/Cargo.toml b/plinth-plugin/Cargo.toml index 58dddce..f4d3430 100644 --- a/plinth-plugin/Cargo.toml +++ b/plinth-plugin/Cargo.toml @@ -12,6 +12,7 @@ license = "MIT" standalone = ["dep:cpal", "dep:midir", "dep:winit"] [dependencies] +atomic_refcell = "0.1" clap-sys = "0.5" keyboard-types.workspace = true num-derive = "0.4" diff --git a/plinth-plugin/src/event.rs b/plinth-plugin/src/event.rs index 808d43b..b99e85b 100644 --- a/plinth-plugin/src/event.rs +++ b/plinth-plugin/src/event.rs @@ -1,6 +1,5 @@ use std::marker::PhantomData; - use plinth_core::signals::{signal::SignalMut, slice::SignalSliceMut}; use crate::parameters::ParameterId; @@ -9,31 +8,237 @@ use crate::parameters::ParameterId; #[non_exhaustive] pub enum Event { // Note events + // + // Notes are addressed via a `(channel, key, note_id)` tuple, where: + // * `Some` values are validated values. + // * `None` values are wildcards, matching every voice regardless of that field. + + /// A note-on event. + /// + /// `note_id` usually will be `Some` in hosts with note expression support, else `None`. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT). NoteOn { sample_offset: usize, - channel: i16, - key: i16, - note: i32, + channel: u8, + note_id: Option, + key: u8, velocity: f64, }, + /// A note-off event. + /// + /// `note_id` likely will be `None` here even when the matching [`Event::NoteOn`] carried one, + /// as hosts may issue a note id at note-on only. + /// `channel` and `key` may be used as wildcards: a host may use this to release a whole + /// channel or all active voices at once. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT). NoteOff { sample_offset: usize, - channel: i16, - key: i16, - note: i32, + channel: Option, + note_id: Option, + key: Option, velocity: f64, }, - PitchBend { + // Per-note expression events (VST3/CLAP note expression) + // + // Addressed with the same `(channel, key, note_id)` tuple as note events, but: + // * VST3 uses `note_id` only, so `channel` and `key` always are `None`. + // * CLAP hosts usually pass `channel` and `key`, and optionally `note_id`. + + /// Per-note volume. `gain` is linear in [0, 4], where 1 is 0dB and 0 -INFdB. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_volume`](crate::NoteExpressions::with_volume). + PolyVolume { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + gain: f64, + }, + + /// Polyphonic key pressure (poly aftertouch, VST3's `kPolyPressureEvent` or + /// CLAP's `CLAP_NOTE_EXPRESSION_PRESSURE`). + /// + /// This is MPE's Z axis, labelled "Pressure" (or "Press") by hosts and controllers, + /// sent as channel pressure on the note's member channel over plain MIDI. + /// + /// `value` is in [0, 1]. The same value delivered as a raw MIDI byte message arrives as + /// [`Event::MidiPolyPressure`]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_pressure`](crate::NoteExpressions::with_pressure). + PolyPressure { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + value: f64, + }, + + /// Per-note panning. + /// + /// `pan` is in [-1, 1] (left..right). + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_pan`](crate::NoteExpressions::with_pan). + PolyPan { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + pan: f64, + }, + + /// Per-note tuning offset in semitones (CLAP's `CLAP_NOTE_EXPRESSION_TUNING`, VST3's + /// `kTuningTypeID`). + /// + /// This is MPE's X axis, labelled "Pitch" (or "Glide") by hosts and controllers, sent + /// as pitch bend on the note's member channel over plain MIDI. A channel-wide bend from + /// a non-MPE source arrives as [`Event::MidiPitchBend`] instead. + /// + /// `semitones` is in [-120, +120]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_tuning`](crate::NoteExpressions::with_tuning). + PolyTuning { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + semitones: f64, + }, + + /// Per-note vibrato (CLAP's `CLAP_NOTE_EXPRESSION_VIBRATO`, VST3's `kVibratoTypeID`). + /// Rarely (if at all) sent by hosts, but part of both specs. + /// + /// `amount` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_vibrato`](crate::NoteExpressions::with_vibrato). + PolyVibrato { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + amount: f64, + }, + + /// Per-note expression (CLAP's `CLAP_NOTE_EXPRESSION_EXPRESSION`, VST3's + /// `kExpressionTypeID`). Rarely (if at all) sent by hosts, but part of both specs. + /// + /// This is the breath / expression pedal dimension, *not* MPE's timbre. You usually + /// want to use [`Event::PolyBrightness`] instead. + /// + /// `amount` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_expression`](crate::NoteExpressions::with_expression). + PolyExpression { + sample_offset: usize, + channel: Option, + note_id: Option, + key: Option, + amount: f64, + }, + + /// Per-note brightness a.k.a. timbre (CLAP's `CLAP_NOTE_EXPRESSION_BRIGHTNESS`, + /// VST3's `kBrightnessTypeID`). + /// + /// This is MPE's third dimension: a controller's Y axis, sent as CC74 over plain + /// MIDI and labelled "Timbre" (or "Slide") by hosts and controllers. + /// + /// `amount` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::NOTE_EXPRESSIONS::with_brightness`](crate::NoteExpressions::with_brightness). + PolyBrightness { sample_offset: usize, - channel: i16, - key: i16, - note: i32, + channel: Option, + note_id: Option, + key: Option, + amount: f64, + }, + + // MIDI channel based events + // + // NB: `channel` and `key` come from raw MIDI bytes or from per channel host parameters + // and thus always are valid, never wildcards. + + /// Channel-wide MIDI pitch bend. + /// Per-note pitch offset (VST3/CLAP note expression) arrives as [`Event::PolyTuning`]. + /// + /// `semitones` is the current bend in semitones, using the standard +-2 semitone range. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::MIDI_CAPABILITIES::with_pitch_bend`](crate::MidiCapabilities::with_pitch_bend). + MidiPitchBend { + sample_offset: usize, + channel: u8, semitones: f64, }, + /// Channel-wide pressure (mono aftertouch). + /// See also [`Event::MidiPolyPressure`]. + /// + /// `value` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::MIDI_CAPABILITIES::with_channel_pressure`](crate::MidiCapabilities::with_channel_pressure). + MidiChannelPressure { + sample_offset: usize, + channel: u8, + value: f64, + }, + + /// Polyphonic key pressure (poly aftertouch), delivered as a raw MIDI byte message. + /// + /// The same value delivered via a native per-note mechanism (VST3 `kPolyPressureEvent` or + /// CLAP note expression) instead arrives as [`Event::PolyPressure`]. + /// See also [`Event::MidiChannelPressure`]. + /// + /// `value` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::MIDI_CAPABILITIES::with_poly_pressure`](crate::MidiCapabilities::with_poly_pressure). + MidiPolyPressure { + sample_offset: usize, + channel: u8, + key: u8, + value: f64, + }, + + /// MIDI Program Change. + /// + /// `program` is the program number (0..=127). + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) + /// and [`Plugin::MIDI_CAPABILITIES::with_program_change`](crate::MidiCapabilities::with_program_change). + MidiProgramChange { + sample_offset: usize, + channel: u8, + program: u8, + }, + + /// MIDI Control Change. + /// + /// `controller` is the CC number (0..=127). + /// `value` is in [0, 1]. + /// + /// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) and the corresponding CC to be enabled + /// via [`Plugin::MIDI_CAPABILITIES::with_control_change`](crate::MidiCapabilities::with_control_change). + MidiControlChange { + sample_offset: usize, + channel: u8, + controller: u8, + value: f64, + }, + // Parameter events + StartParameterChange { id: ParameterId, }, @@ -68,7 +273,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, @@ -85,7 +301,7 @@ where signal: *mut S, events: I, offset: usize, - + _phantom_lifetime: PhantomData<&'signal S>, } @@ -121,13 +337,13 @@ where let signal_len = signal.len(); let signal_slice = signal.slice_mut(self.offset..); self.offset = signal_len; - + return Some((signal_slice, None)); } else { return None; } }; - + match next_event { Event::ParameterValue { sample_offset, .. } | Event::ParameterModulation { sample_offset, .. } => { @@ -137,9 +353,9 @@ where self.offset = sample_offset; return Some(result); }, - + _ => { continue; }, - } + } } } } 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..28a3713 100644 --- a/plinth-plugin/src/formats/clap/event.rs +++ b/plinth-plugin/src/formats/clap/event.rs @@ -1,32 +1,36 @@ 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::{note_channel, note_id, note_key, 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, } } - fn parameter_info(&self, parameter_id: u32, cookie: *mut c_void) -> &ParameterInfo { + fn parameter_info(&self, parameter_id: u32, cookie: *mut c_void) -> Option<&ParameterInfo> { if !cookie.is_null() { - unsafe { &*(cookie as *mut ParameterInfo) } + Some(unsafe { &*(cookie as *mut ParameterInfo) }) } else { - self.parameter_info.get(¶meter_id).unwrap() + self.parameter_info.get(¶meter_id) } } } @@ -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,78 +53,180 @@ 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 { + let (Some(channel), Some(key)) = (note_channel(event.channel), note_key(event.key)) else { + // CLAP spec requires that valid channels and keys are specified for note-ons. + tracing::debug!("Ignoring note-on with invalid channel {} or key {}", event.channel, event.key); + continue; + }; + + Some(Event::NoteOn { sample_offset: event.header.time as _, - channel: event.channel, - key: event.key, - note: event.note_id, + channel, + key, + note_id: note_id(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, + channel: note_channel(event.channel), + key: note_key(event.key), + note_id: note_id(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 = note_channel(event.channel); + let note_id = note_id(event.note_id); + let key = note_key(event.key); + 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_id, + // fractional semitones, -120 to +120 + semitones: value, + }) + } + + CLAP_NOTE_EXPRESSION_PRESSURE if note_expressions.pressure() => { + Some(Event::PolyPressure { + sample_offset, + channel, + key, + note_id, + // pass value in [0..1] as it is + value, + }) + } + + CLAP_NOTE_EXPRESSION_VOLUME if note_expressions.volume() => { + Some(Event::PolyVolume { + sample_offset, + channel, + note_id, + key, + // pass value in [0..4] as it is + gain: value, + }) + } + + CLAP_NOTE_EXPRESSION_PAN if note_expressions.pan() => { + Some(Event::PolyPan { + sample_offset, + channel, + note_id, + key, + // 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, + note_id, + key, + // pass value in [0..1] as it is + amount: value, + }) + } + + CLAP_NOTE_EXPRESSION_EXPRESSION if note_expressions.expression() => { + Some(Event::PolyExpression { + sample_offset, + channel, + note_id, + key, + // pass value in [0..1] as it is + amount: value, + }) + } + + CLAP_NOTE_EXPRESSION_BRIGHTNESS if note_expressions.brightness() => { + Some(Event::PolyBrightness { + sample_offset, + channel, + note_id, + key, + // 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, - } + // Convert 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 => { let event = unsafe { &*(header as *const clap_event_param_value) }; - let parameter_info = self.parameter_info(event.param_id, event.cookie); + let Some(parameter_info) = self.parameter_info(event.param_id, event.cookie) else { + tracing::debug!("Ignoring parameter value event for unknown parameter id {}", event.param_id); + continue; + }; 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 Some(parameter_info) = self.parameter_info(event.param_id, event.cookie) else { + tracing::debug!("Ignoring parameter modulation event for unknown parameter id {}", event.param_id); + continue; + }; 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..a0e1a6f --- /dev/null +++ b/plinth-plugin/src/formats/midi.rs @@ -0,0 +1,126 @@ +use crate::midi_capabilities::MIDI_CHANNEL_COUNT; +use crate::{Event, MidiCapabilities}; + +/// Convert and validate a raw CLAP/VST3 MIDI channel field. +/// +/// `None` is the wildcard: a negative value is the `-1` sentinel both APIs define, and a +/// value outside `0..=15` is not an address we can represent, so degrade it to a wildcard as well. +pub(crate) fn note_channel(raw: i16) -> Option { + u8::try_from(raw).ok().filter(|&channel| channel < MIDI_CHANNEL_COUNT as u8) +} + +/// Convert and validate a raw CLAP/VST3 key field. See [`note_channel`] for the wildcard rule. +pub(crate) fn note_key(raw: i16) -> Option { + u8::try_from(raw).ok().filter(|&key| key < 128) +} + +/// Convert and validate a raw CLAP/VST3 note id. Negative means the host does not issue note ids. +pub(crate) fn note_id(raw: i32) -> Option { + u32::try_from(raw).ok() +} + +/// 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; + + 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, + channel, + key: data[1] & 0x7f, + note_id: None, + velocity: (data[2] & 0x7f) as f64 / 127.0, + }), + + // Note-off (explicit 0x80 or 0x90 with vel=0). + 0x80 | 0x90 => { + let velocity = if data.len() >= 3 { + (data[2] & 0x7f) as f64 / 127.0 + } else { + 0.0 + }; + Some(Event::NoteOff { + sample_offset, + channel: Some(channel), + key: Some(data[1] & 0x7f), + note_id: None, + 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] & 0x7f, + value: (data[2] & 0x7f) 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] & 0x7f, + }) + } + + // Control Change (0xB0) + 0xB0 if data.len() >= 3 => { + // NB: No mask needed, has_midi_control_change rejects controllers above 127. + let controller = data[1]; + if capabilities.has_midi_control_change(controller) { + Some(Event::MidiControlChange { + sample_offset, + channel, + controller, + value: (data[2] & 0x7f) 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] & 0x7f) as f64 / 127.0, + }) + } + + // Pitch Bend (0xE0) + 0xE0 if data.len() >= 3 && capabilities.midi_pitch_bend() => { + let lsb = (data[1] & 0x7f) as u16; + let msb = (data[2] & 0x7f) 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 f8c14d2..41fae35 100644 --- a/plinth-plugin/src/formats/standalone/runner.rs +++ b/plinth-plugin/src/formats/standalone/runner.rs @@ -177,7 +177,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.rs b/plinth-plugin/src/formats/vst3.rs index 884ff6c..4c7299e 100644 --- a/plinth-plugin/src/formats/vst3.rs +++ b/plinth-plugin/src/formats/vst3.rs @@ -5,6 +5,7 @@ mod factory; mod host; mod key_codes; mod macros; +mod note_expressions; mod parameters; mod plugin; mod stream; diff --git a/plinth-plugin/src/formats/vst3/component.rs b/plinth-plugin/src/formats/vst3/component.rs index d5f5956..664408b 100644 --- a/plinth-plugin/src/formats/vst3/component.rs +++ b/plinth-plugin/src/formats/vst3/component.rs @@ -6,23 +6,25 @@ use std::ptr::null_mut; use std::rc::Rc; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use atomic_refcell::AtomicRefCell; 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::{CtrlNumber, IMidiMapping, IMidiMappingTrait, INoteExpressionController, INoteExpressionPhysicalUIMapping}; 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}; +use vst3::Steinberg::Vst::{kInfiniteTail, kNoParentUnitId, kNoProgramListId, kNoTail, BusDirection, BusDirections, 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, MediaTypes_, ParamID, ParamValue, ParameterInfo_, ProcessData, ProcessSetup, ProgramListID, ProgramListInfo, RoutingInfo, SpeakerArr, SpeakerArrangement, String128, SymbolicSampleSizes_, TChar, UnitID, UnitInfo, ViewType::kEditor}; 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::{MidiParameter, MidiParameters}; +use crate::{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::midi_capabilities::{MIDI_CHANNEL_COUNT, MIDI_CONTROLLER_COUNT}; use super::{plugin::Vst3Plugin, stream::Stream, view::View}; @@ -47,14 +49,16 @@ impl Default for AudioThreadState

{ pub struct PluginComponent { plugin: Rc>>, - parameter_info: RefCell>, - parameter_groups: RefCell>, - pitch_bend_parameter_ids: RefCell<[ParameterId; 16]>, + // NB: AtomicRefCell instead of RefCell because parameter info may be read concurrently (UI and audio) + parameter_info: AtomicRefCell>, + parameter_groups: AtomicRefCell>, + midi_parameters: AtomicRefCell, - process_mode: RefCell, + process_mode: AtomicRefCell, processing: AtomicBool, tail_length: AtomicU32, latency: AtomicU32, + component_handler: Rc>>>, audio_thread_state: AudioThreadState

, @@ -67,7 +71,7 @@ impl PluginComponent

{ parameter_info: Default::default(), parameter_groups: Default::default(), - pitch_bend_parameter_ids: Default::default(), + midi_parameters: 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,25 +150,14 @@ impl IPluginBaseTrait for PluginComponent

{ group::from_parameters(parameters) }); - // Create parameters for MIDI pitch bend messages - 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 info = ParameterInfo::new(parameter_id, format!("MIDI Channel {} Pitch Bend", channel + 1)) - .hidden(); - - parameter_infos.push(info); - - *pitch_bend_parameter_id = parameter_id; - parameter_id += 1; - } - }); + // Allocate hidden reserved VST3 parameters for each MIDI message type the plugin requires + // via its MIDI_CAPABILITIES. MIDI needs a note input port, so allocate nothing without one. + if P::HAS_NOTE_INPUT { + let midi_parameters = plugin.with_parameters(|parameters| { + MidiParameters::new(&P::MIDI_CAPABILITIES, parameters.ids(), &mut parameter_infos) + }); + *self.midi_parameters.borrow_mut() = midi_parameters; + } *self.plugin.borrow_mut() = Some(plugin); @@ -177,6 +170,7 @@ impl IPluginBaseTrait for PluginComponent

{ *self.plugin.borrow_mut() = None; self.parameter_info.borrow_mut().clear(); self.parameter_groups.borrow_mut().clear(); + self.midi_parameters.borrow_mut().clear(); kResultOk } @@ -282,8 +276,9 @@ 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_parameters = self.midi_parameters.borrow(); + let parameter_change_iterator = ParameterChangeIterator::new(data.inputParameterChanges, &midi_parameters); + let event_iterator = EventIterator::new(data.inputEvents, 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; @@ -401,12 +396,24 @@ impl IComponentTrait for PluginComponent

{ unsafe fn getBusCount(&self, media_type: MediaType, dir: BusDirection) -> int32 { tracing::trace!("IComponent::getBusCount"); - // On some platforms, these casts are needed - #[allow(clippy::unnecessary_cast)] - if P::HAS_AUX_INPUT && media_type == MediaTypes_::kAudio as i32 && dir == BusDirections_::kInput as i32 { - 2 - } else { - 1 + let is_input = dir as BusDirections == BusDirections_::kInput; + + match media_type as MediaTypes { + MediaTypes_::kAudio => { + if is_input && P::HAS_AUX_INPUT { + 2 + } else { + 1 + } + } + MediaTypes_::kEvent => { + if is_input { + P::HAS_NOTE_INPUT as int32 + } else { + P::HAS_NOTE_OUTPUT as int32 + } + } + _ => 0 } } @@ -646,7 +653,7 @@ impl IEditControllerTrait for PluginComponent

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

{ if bus_index != 0 { return kResultFalse; } - if midi_controller_number != kPitchBend as i16 { - return kResultFalse; - } - if !(0..16).contains(&channel) { + if !(0..MIDI_CHANNEL_COUNT as i16).contains(&channel) { return kInvalidArgument; } - unsafe { *id = self.pitch_bend_parameter_ids.borrow()[channel as usize] as _ }; + let channel = channel as u8; - kResultTrue + let midi_parameter = if midi_controller_number == kPitchBend as i16 { + MidiParameter::PitchBend { channel } + } else if midi_controller_number == kAfterTouch as i16 { + MidiParameter::ChannelPressure { channel } + } else if midi_controller_number == kCtrlProgramChange as i16 { + MidiParameter::ProgramChange { channel } + } else if (0..MIDI_CONTROLLER_COUNT as i16).contains(&midi_controller_number) { + MidiParameter::ControlChange { channel, controller: midi_controller_number as u8 } + } else { + return kResultFalse; + }; + + if let Some(parameter_id) = self.midi_parameters.borrow().parameter_id(&midi_parameter) { + unsafe { *id = parameter_id as _ }; + kResultTrue + } else { + kResultFalse + } } } diff --git a/plinth-plugin/src/formats/vst3/event.rs b/plinth-plugin/src/formats/vst3/event.rs index 7da532f..4a25006 100644 --- a/plinth-plugin/src/formats/vst3/event.rs +++ b/plinth-plugin/src/formats/vst3/event.rs @@ -1,63 +1,182 @@ 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::formats::midi::{note_channel, note_id, note_key}; +use crate::{Event, NoteExpressions}; + +use super::note_expressions::NoteExpressionDescriptor; pub struct EventIterator<'a> { event_list: Option>, index: usize, + 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_expressions: NoteExpressions) -> Self { Self { event_list: unsafe { ComRef::from_raw(event_list) }, index: 0, - } + 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; + + // Avoid panics when the host passes a negative sample offset and default to 0 instead. + let sample_offset = usize::try_from(event.sampleOffset).unwrap_or(0); + + let event = match event.r#type as _ { + Vst::Event_::EventTypes_::kNoteOnEvent => unsafe { + let note_on = event.__field0.noteOn; + + // VST3 always supplies a valid channel and key on a note-on, but a wildcard + // or out of range value is invalid and should get skipped. + let (Some(channel), Some(key)) = (note_channel(note_on.channel), note_key(note_on.pitch)) else { + tracing::debug!("Ignoring note-on with invalid channel {} or key {}", note_on.channel, note_on.pitch); + continue; + }; + + Some(Event::NoteOn { + sample_offset, + channel, + key, + note_id: note_id(note_on.noteId), + velocity: note_on.velocity as _, + }) + }, + + Vst::Event_::EventTypes_::kNoteOffEvent => unsafe { + let note_off = event.__field0.noteOff; + + Some(Event::NoteOff { + sample_offset, + channel: note_channel(note_off.channel), + key: note_key(note_off.pitch), + note_id: note_id(note_off.noteId), + velocity: note_off.velocity as _, + }) + }, + + Vst::Event_::EventTypes_::kPolyPressureEvent if self.note_expressions.pressure() => + unsafe { + let poly_pressure = event.__field0.polyPressure; + + Some(Event::PolyPressure { + sample_offset, + channel: note_channel(poly_pressure.channel), + key: note_key(poly_pressure.pitch), + note_id: note_id(poly_pressure.noteId), + value: poly_pressure.pressure as _, + }) + }, + + Vst::Event_::EventTypes_::kNoteExpressionValueEvent => unsafe { + let note_expression = event.__field0.noteExpressionValue; + let value = note_expression.value; + + // Key and channel are not provided for VST3, just the note_id + let channel: Option = None; + let key: Option = None; + + // An expression with a missing note id addresses nothing at all, so skip it. + let note_id = note_id(note_expression.noteId); + if note_id.is_none() { + tracing::debug!("Ignoring note expression with invalid note id {}", note_expression.noteId); + continue; + } + + // 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_id, + gain: NoteExpressionDescriptor::normalized_to_gain(value), + }) + } + kPanTypeID if self.note_expressions.pan() => Some(Event::PolyPan { + sample_offset, + channel, + note_id, + key, + pan: NoteExpressionDescriptor::normalized_to_pan(value), + }), + kTuningTypeID if self.note_expressions.tuning() => { + Some(Event::PolyTuning { + sample_offset, + channel, + note_id, + key, + semitones: NoteExpressionDescriptor::normalized_to_semitones(value), + }) + } + kVibratoTypeID if self.note_expressions.vibrato() => { + Some(Event::PolyVibrato { + sample_offset, + channel, + note_id, + key, + amount: value, + }) + } + kExpressionTypeID if self.note_expressions.expression() => { + Some(Event::PolyExpression { + sample_offset, + channel, + note_id, + key, + amount: value, + }) + } + kBrightnessTypeID if self.note_expressions.brightness() => { + Some(Event::PolyBrightness { + sample_offset, + channel, + note_id, + key, + 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/note_expressions.rs b/plinth-plugin/src/formats/vst3/note_expressions.rs new file mode 100644 index 0000000..e90d64a --- /dev/null +++ b/plinth-plugin/src/formats/vst3/note_expressions.rs @@ -0,0 +1,299 @@ +use vst3::Steinberg::Vst::NoteExpressionTypeIDs_::{kBrightnessTypeID, kExpressionTypeID, kInvalidTypeID, kPanTypeID, kTuningTypeID, kVibratoTypeID, kVolumeTypeID}; +use vst3::Steinberg::Vst::PhysicalUITypeIDs_::{kPUIPressure, kPUIXMovement, kPUIYMovement}; +use vst3::Steinberg::Vst::{INoteExpressionControllerTrait, INoteExpressionPhysicalUIMappingTrait, NoteExpressionTypeID, NoteExpressionTypeInfo, NoteExpressionValue, PhysicalUIMapList, String128, TChar}; +use vst3::Steinberg::{int16, int32, kInvalidArgument, kResultFalse, kResultOk, tresult}; +use widestring::U16CStr; + +use crate::NoteExpressions; +use crate::string::copy_str_to_char16; +use crate::midi_capabilities::MIDI_CHANNEL_COUNT; + +use super::{component::PluginComponent, plugin::Vst3Plugin}; + +/// Same range as CLAP_NOTE_EXPRESSION_TUNING. See also [Event::PolyTuning]'s documented range. +const TUNING_RANGE_SEMITONES: f64 = 120.0; + +/// Same range as CLAP_NOTE_EXPRESSION_VOLUME. See also [Event::PolyVolume]'s documented range. +const VOLUME_RANGE_GAIN: f64 = 4.0; + +/// A description of a standard VST3 note-expression, along with the conversions between its +/// normalized value and the string the host displays. +pub(super) struct NoteExpressionDescriptor { + pub type_id: NoteExpressionTypeID, + pub title: &'static str, + pub short_title: &'static str, + pub units: &'static str, + pub default_value: NoteExpressionValue, + pub is_enabled: fn(&NoteExpressions) -> bool, + // Formats a normalized [0, 1] value for display. + pub format: fn(NoteExpressionValue) -> String, + // Parses a displayed value back into normalized [0, 1]. + pub parse: fn(&str) -> Option, +} + +// Standard VST3 note-expression types. Only the enabled subset (per `NoteExpressions` config) +// is exposed to the host. +// +// Pressure is excluded from the descriptors. It is delivered as `kPolyPressureEvent` in VST3. +// +// This set matches CLAPs default set of note expressions, as we currently don't allow registering +// VST3 note expressions dynamically and want to simplify cross plugin format compatibility. +static NOTE_EXPRESSION_DESCRIPTORS: [NoteExpressionDescriptor; 6] = [ + NoteExpressionDescriptor { + type_id: kVolumeTypeID, + title: "Volume", + short_title: "Volume", + units: "dB", + default_value: 0.25, // 0 dB + is_enabled: NoteExpressions::volume, + format: |value| format!("{:.2} dB", NoteExpressionDescriptor::normalized_to_db(value)), + parse: |string| NoteExpressionDescriptor::parse_number(string).map(NoteExpressionDescriptor::db_to_normalized), + }, + NoteExpressionDescriptor { + type_id: kPanTypeID, + title: "Panning", + short_title: "Panning", + units: "%", + default_value: 0.5, // center + is_enabled: NoteExpressions::pan, + format: |value| format!("{:.1} %", NoteExpressionDescriptor::normalized_to_pan(value) * 100.0), + parse: |string| NoteExpressionDescriptor::parse_number(string).map(|percent| NoteExpressionDescriptor::pan_to_normalized(percent / 100.0)), + }, + NoteExpressionDescriptor { + type_id: kTuningTypeID, + title: "Tuning", + short_title: "Tuning", + units: "st", + default_value: 0.5, // center + is_enabled: NoteExpressions::tuning, + format: |value| format!("{:+.2} st", NoteExpressionDescriptor::normalized_to_semitones(value)), + parse: |string| NoteExpressionDescriptor::parse_number(string).map(NoteExpressionDescriptor::semitones_to_normalized), + }, + NoteExpressionDescriptor { + type_id: kVibratoTypeID, + title: "Vibrato", + short_title: "Vibrato", + units: "", + default_value: 0.0, + is_enabled: NoteExpressions::vibrato, + format: NoteExpressionDescriptor::format_normalized, + parse: NoteExpressionDescriptor::parse_normalized, + }, + NoteExpressionDescriptor { + type_id: kExpressionTypeID, + title: "Expression", + short_title: "Expression", + units: "", + default_value: 0.0, + is_enabled: NoteExpressions::expression, + format: NoteExpressionDescriptor::format_normalized, + parse: NoteExpressionDescriptor::parse_normalized, + }, + NoteExpressionDescriptor { + type_id: kBrightnessTypeID, + title: "Brightness", + short_title: "Brightness", + units: "", + default_value: 0.0, + is_enabled: NoteExpressions::brightness, + format: NoteExpressionDescriptor::format_normalized, + parse: NoteExpressionDescriptor::parse_normalized, + }, +]; + +impl NoteExpressionDescriptor { + /// Lookup a descriptor for the given `type_id`. The plugin needs that expression enabled. + pub fn find( + note_expressions: NoteExpressions, + type_id: NoteExpressionTypeID, + ) -> Option<&'static Self> { + Self::enabled_expressions(note_expressions).find(|descriptor| descriptor.type_id == type_id) + } + + /// The enabled expressions, in the order the host sees them. + pub fn enabled_expressions( + note_expressions: NoteExpressions, + ) -> impl Iterator { + NOTE_EXPRESSION_DESCRIPTORS + .iter() + .filter(move |descriptor| (descriptor.is_enabled)(¬e_expressions)) + } + + /// Maps a normalized [0, 1] volume value to a linear gain in [0, 4], where 1 is 0 dB. + pub fn normalized_to_gain(value: NoteExpressionValue) -> f64 { + value * VOLUME_RANGE_GAIN + } + + /// Maps a normalized [0, 1] volume value to dB. Returns -inf for silence. + pub fn normalized_to_db(value: NoteExpressionValue) -> f64 { + 20.0 * Self::normalized_to_gain(value).log10() + } + + /// Maps dB back to a normalized [0, 1] volume value. + pub fn db_to_normalized(db: f64) -> NoteExpressionValue { + (10f64.powf(db / 20.0) / VOLUME_RANGE_GAIN).clamp(0.0, 1.0) + } + + /// Maps a normalized [0, 1] tuning value to a +- semitones value. + pub fn normalized_to_semitones(value: NoteExpressionValue) -> f64 { + value * (2.0 * TUNING_RANGE_SEMITONES) - TUNING_RANGE_SEMITONES + } + + /// Maps semitones back to a normalized [0, 1] tuning value. + pub fn semitones_to_normalized(semitones: f64) -> NoteExpressionValue { + ((semitones + TUNING_RANGE_SEMITONES) / (2.0 * TUNING_RANGE_SEMITONES)).clamp(0.0, 1.0) + } + + /// Maps a normalized [0, 1] panning value to [-1, 1], where 0 is center. + pub fn normalized_to_pan(value: NoteExpressionValue) -> f64 { + value * 2.0 - 1.0 + } + + /// Maps a [-1, 1] panning value back to a normalized [0, 1] value. + pub fn pan_to_normalized(pan: f64) -> NoteExpressionValue { + ((pan + 1.0) / 2.0).clamp(0.0, 1.0) + } + + /// Fills the host's note expression type info from this descriptor. + fn fill_type_info(&self, info: &mut NoteExpressionTypeInfo) { + info.typeId = self.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 = self.default_value; + copy_str_to_char16(self.title, &mut info.title); + copy_str_to_char16(self.short_title, &mut info.shortTitle); + copy_str_to_char16(self.units, &mut info.units); + } + + /// Parses a string as raw number value, stripping all non numeric characters. + fn parse_number(string: &str) -> Option { + let digits: String = string.chars().filter(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')).collect(); + digits.parse::().ok() + } + + /// Formats a plain normalized [0, 1] value. + fn format_normalized(value: NoteExpressionValue) -> String { + format!("{:.2}", value) + } + + /// Parses a plain normalized value, clamped to [0, 1]. + fn parse_normalized(string: &str) -> Option { + Self::parse_number(string).map(|value| value.clamp(0.0, 1.0)) + } +} + +impl INoteExpressionControllerTrait for PluginComponent

{ + unsafe fn getNoteExpressionCount(&self, bus_index: int32, channel: int16) -> int32 { + tracing::trace!("INoteExpressionController::getNoteExpressionCount"); + + if !P::HAS_NOTE_INPUT || bus_index != 0 || !(0..MIDI_CHANNEL_COUNT as i16).contains(&channel) { + return 0; + } + + NoteExpressionDescriptor::enabled_expressions(P::NOTE_EXPRESSIONS).count() as i32 + } + + unsafe fn getNoteExpressionInfo(&self, bus_index: int32, channel: int16, note_expression_index: int32, info: *mut NoteExpressionTypeInfo) -> tresult { + tracing::trace!("INoteExpressionController::getNoteExpressionInfo"); + + if !P::HAS_NOTE_INPUT || bus_index != 0 || !(0..MIDI_CHANNEL_COUNT as i16).contains(&channel) || info.is_null() { + return kInvalidArgument; + } + + let Ok(index) = usize::try_from(note_expression_index) else { + return kInvalidArgument; + }; + + let Some(descriptor) = NoteExpressionDescriptor::enabled_expressions(P::NOTE_EXPRESSIONS).nth(index) else { + return kInvalidArgument; + }; + + descriptor.fill_type_info(unsafe { &mut *info }); + kResultOk + } + + unsafe fn getNoteExpressionStringByValue(&self, bus_index: int32, channel: int16, id: NoteExpressionTypeID, value_normalized: NoteExpressionValue, string: *mut String128) -> tresult { + tracing::trace!("INoteExpressionController::getNoteExpressionStringByValue"); + + if !P::HAS_NOTE_INPUT || bus_index != 0 || !(0..MIDI_CHANNEL_COUNT as i16).contains(&channel) || string.is_null() { + return kInvalidArgument; + } + + // Only expressions that we advertised to the host should resolve here + let Some(descriptor) = NoteExpressionDescriptor::find(P::NOTE_EXPRESSIONS, id) else { + return kInvalidArgument; + }; + + copy_str_to_char16(&(descriptor.format)(value_normalized), unsafe { &mut *string }); + 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::HAS_NOTE_INPUT || bus_index != 0 || !(0..MIDI_CHANNEL_COUNT as i16).contains(&channel) || string.is_null() || value_normalized.is_null() { + return kInvalidArgument; + } + + // Only expressions that we advertised to the host should resolve here + let Some(descriptor) = NoteExpressionDescriptor::find(P::NOTE_EXPRESSIONS, id) else { + return kInvalidArgument; + }; + + let string = unsafe { U16CStr::from_ptr_str(string as _) }; + let Ok(string) = string.to_string() else { + return kInvalidArgument; + }; + + let Some(value) = (descriptor.parse)(&string) else { + return kResultFalse; + }; + + unsafe { *value_normalized = value }; + kResultOk + } +} + +impl INoteExpressionPhysicalUIMappingTrait for PluginComponent

{ + unsafe fn getPhysicalUIMapping(&self, bus_index: int32, channel: int16, list: *mut PhysicalUIMapList) -> tresult { + tracing::trace!("INoteExpressionPhysicalUIMapping::getPhysicalUIMapping"); + + if !P::HAS_NOTE_INPUT || bus_index != 0 || !(0..MIDI_CHANNEL_COUNT as i16).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/parameters.rs b/plinth-plugin/src/formats/vst3/parameters.rs index 00a97f1..4ffd718 100644 --- a/plinth-plugin/src/formats/vst3/parameters.rs +++ b/plinth-plugin/src/formats/vst3/parameters.rs @@ -1,42 +1,173 @@ use std::cmp; +use std::collections::HashMap; use vst3::{ComRef, Steinberg::{kResultOk, Vst::{IParamValueQueueTrait, IParameterChanges, IParameterChangesTrait, ParamID, ParamValue}}}; -use crate::{event::Event, ParameterId}; +use crate::event::Event; +use crate::midi_capabilities::{MidiCapabilities, MIDI_CHANNEL_COUNT}; +use crate::parameters::info::ParameterInfo; +use crate::ParameterId; + +/// A hidden VST3 parameter which maps to a MIDI event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) enum MidiParameter { + PitchBend { channel: u8 }, + ChannelPressure { channel: u8 }, + ProgramChange { channel: u8 }, + ControlChange { channel: u8, controller: u8 }, +} + +/// The hidden VST3 parameters which map to MIDI events, and their lookup tables. +#[derive(Default)] +pub(super) struct MidiParameters { + /// Hidden parameter ID -> MIDI event. + parameters: HashMap, + /// MIDI event -> Hidden parameter ID. + parameter_ids: HashMap, +} + +impl MidiParameters { + /// Creates hidden VST3 MIDI parameters for each MIDI message type enabled in `capabilities`. + /// + /// This also appends one hidden [`ParameterInfo`] per MIDI channel to `parameter_infos`, so 16 + /// parameters are added in total per enabled MIDI message type. + pub fn new( + capabilities: &MidiCapabilities, + user_ids: &[ParameterId], + parameter_infos: &mut Vec, + ) -> Self { + // Use any ids that do not collide with existing user_ids. MIDI parameters are not persistent, + // so it shouldn't matter if they change, after e.g. new user parameters got added. + let mut next_id: ParamID = 1; + + let mut alloc_block = | + midi_parameters: &mut Self, + infos: &mut Vec, + midi_parameter: &dyn Fn(u8) -> MidiParameter, + name: &str| { + for channel in 0..MIDI_CHANNEL_COUNT { + while user_ids.contains(&next_id) { + next_id += 1; + } + infos.push(ParameterInfo::new(next_id, + format!("MIDI Channel {} {}", channel + 1, name)).hidden()); + let midi_parameter = midi_parameter(channel as u8); + midi_parameters.parameters.insert(next_id, midi_parameter); + midi_parameters.parameter_ids.insert(midi_parameter, next_id); + next_id += 1; + } + }; -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; + let mut midi_parameters = Self::default(); - Event::PitchBend { - sample_offset: offset, - channel: channel as _, - key: -1, // TODO - note: -1, // TODO - semitones, + if capabilities.midi_pitch_bend() { + alloc_block( + &mut midi_parameters, + parameter_infos, + &|channel| MidiParameter::PitchBend { channel }, + "Pitch Bend", + ); } - } else { - Event::ParameterValue { - sample_offset: offset, - id, - value, + + if capabilities.midi_channel_pressure() { + alloc_block( + &mut midi_parameters, + parameter_infos, + &|channel| MidiParameter::ChannelPressure { channel }, + "Channel Pressure", + ); + } + + if capabilities.midi_program_change() { + alloc_block( + &mut midi_parameters, + parameter_infos, + &|channel| MidiParameter::ProgramChange { channel }, + "Program Change", + ); + } + + for cc in capabilities.midi_control_changes() { + alloc_block( + &mut midi_parameters, + parameter_infos, + &|channel| MidiParameter::ControlChange { channel, controller: cc }, + &format!("CC {}", cc), + ); + } + + midi_parameters + } + + /// Clear all memorized parameters and IDs. + pub fn clear(&mut self) { + self.parameters.clear(); + self.parameter_ids.clear(); + } + + /// Try to resolve a hidden parameter ID which represents the given MIDI event. + pub fn parameter_id(&self, midi_parameter: &MidiParameter) -> Option { + self.parameter_ids.get(midi_parameter).copied() + } + + /// Map a VST3 parameter change event to an `Event`, converting reserved MIDI block + /// parameters to `Event::Midi*`. All others default to `Event::ParameterValue` as + /// regular user parameters. + pub fn parameter_change_to_event( + &self, + id: ParamID, + value: ParamValue, + sample_offset: usize, + ) -> Event { + match self.parameters.get(&id) { + // Pitch bend: VST3 normalizes to [0, 1]: map to [-2, +2] semitones. + Some(&MidiParameter::PitchBend { channel }) => Event::MidiPitchBend { + sample_offset, + channel, + semitones: (value - 0.5) * 4.0, + }, + // Channel pressure: VST3 normalizes to [0, 1]: passed through as it is. + Some(&MidiParameter::ChannelPressure { channel }) => Event::MidiChannelPressure { + sample_offset, + channel, + value, + }, + // Program change: VST3 normalizes to [0, 1]: round to the nearest program number. + Some(&MidiParameter::ProgramChange { channel }) => Event::MidiProgramChange { + sample_offset, + channel, + program: (value * 127.0).round() as u8, + }, + // MIDI CC: VST3 normalizes to [0, 1]: passed through as it is. + Some(&MidiParameter::ControlChange { channel, controller }) => Event::MidiControlChange { + sample_offset, + channel, + controller, + value, + }, + // Ordinary user parameter + None => Event::ParameterValue { + sample_offset, + id, + value, + }, } } } pub struct ParameterChangeIterator<'a> { parameter_changes: Option>, - pitch_bend_parameter_ids: [ParameterId; 16], + midi_parameters: &'a MidiParameters, 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_parameters: &'a MidiParameters) -> Self { Self { parameter_changes: unsafe { ComRef::from_raw(parameter_changes) }, - pitch_bend_parameter_ids, + midi_parameters, offset: 0, index: 0, finished: false, @@ -90,7 +221,7 @@ impl Iterator for ParameterChangeIterator<'_> { } else { nth += 1; None - } + } }, cmp::Ordering::Greater => Some((id, offset, value)), @@ -116,8 +247,7 @@ impl Iterator for ParameterChangeIterator<'_> { self.index += 1; } - let event = parameter_change_to_event(id, value, offset, &self.pitch_bend_parameter_ids); - + let event = self.midi_parameters.parameter_change_to_event(id, value, offset); 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..64ecfd6 --- /dev/null +++ b/plinth-plugin/src/midi_capabilities.rs @@ -0,0 +1,141 @@ +/// Number of MIDI channels on the plugin's event bus. +pub(super) const MIDI_CHANNEL_COUNT: usize = 16; +/// Number of MIDI CC controller values that can be enabled. +pub(super) const MIDI_CONTROLLER_COUNT: usize = 128; + +/// Compile-time declaration of which raw MIDI messages a plugin wants to receive as `Event`s. +/// +/// Only covers plain MIDI wire messages. Per-note expressions are a separate mechanism enabled +/// via [`Plugin::NOTE_EXPRESSIONS`](crate::Plugin::NOTE_EXPRESSIONS). +/// +/// Requires [`Plugin::HAS_NOTE_INPUT`](crate::Plugin::HAS_NOTE_INPUT) to be enabled as well. +/// +/// 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`](crate::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`](crate::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`](crate::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`](crate::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`](crate::Event::MidiControlChange). + pub const fn with_control_change(mut self, cc: u8) -> Self { + assert!(cc < MIDI_CONTROLLER_COUNT as u8, "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`](crate::Event::MidiControlChange). + pub const fn with_control_change_range(mut self, start: u8, end: u8) -> Self { + assert!( + start <= end && end < MIDI_CONTROLLER_COUNT as u8, + "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 >= MIDI_CONTROLLER_COUNT as u8 { + 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 midi_control_changes(&self) -> impl Iterator + '_ { + (0u8..MIDI_CONTROLLER_COUNT as u8).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..64c2f4d --- /dev/null +++ b/plinth-plugin/src/note_expressions.rs @@ -0,0 +1,151 @@ +/// 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`]. + /// This is MPE's X axis ("pitch" or "glide"). + 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 (breath / expression pedal) as + /// [`crate::Event::PolyExpression`]. This is not MPE's timbre: see [`Self::with_brightness`]. + pub const fn with_expression(mut self) -> Self { + self.expression = true; + self + } + + /// Enable delivery of per-note brightness a.k.a. timbre as + /// [`crate::Event::PolyBrightness`]. This is MPE's third dimension, a controller's + /// Y axis ("slide", CC74). + pub const fn with_brightness(mut self) -> Self { + self.brightness = true; + self + } + + /// Enable delivery of per-note pressure (poly aftertouch) as + /// [`crate::Event::PolyPressure`]. This is MPE's Z axis ("pressure"). + 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 (MPE's X axis) 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 (breath / expression pedal) is enabled. + pub const fn expression(&self) -> bool { + self.expression + } + + /// Returns `true` if per-note brightness a.k.a. timbre is enabled. + pub const fn brightness(&self) -> bool { + self.brightness + } + + /// Returns `true` if per-note pressure (poly aftertouch, MPE's Z axis) 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;