From 33ad8264c857f746758f7291a1579940c376896b Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:23:22 +0100 Subject: [PATCH 1/3] Give plugins the session's tempo and transport Hosted plugins never learned the session's tempo or where the song was: - CLAP got a null transport; - VST3 got a fixed 120 BPM 4/4 context that never played and counted processed samples as the song position; - Audio Units had no host callbacks. Tempo-synced delays, LFOs and arpeggiators ran at 120 BPM and ignored the transport. Before each block the mix engine now hands every insert, instrument and master-fader plugin a soundcraft_dsp::Transport (Plugin::set_transport): play and record state, tempo and meter at the block start, and the timeline position in samples, seconds and quarter notes, with the current bar. Built-in plugins ignore it. - CLAP: a clap_event_transport (fixed-point beats and seconds, bar start and number, time signature, play and record flags). - VST3: ProcessContext tempo, time signature, project time in samples and quarter notes, bar position, kPlaying and kRecording. Continuous time stays the processed-sample count. - AU: kAudioUnitProperty_HostCallbacks (beat and tempo, musical time location, transport state 1 and 2), read from atomics the instance owns and frees after disposing the unit. Loop (cycle) ranges aren't reported yet: playback keeps the loop range to itself. --- crates/au-host/src/ffi.rs | 217 ++++++++++++++++++++++++++++++++- crates/au-host/src/lib.rs | 6 +- crates/au-host/src/plugin.rs | 6 +- crates/clap-host/src/ffi.rs | 80 +++++++++++- crates/clap-host/src/plugin.rs | 10 +- crates/dsp/src/lib.rs | 20 +++ crates/mix/src/lib.rs | 81 +++++++++++- crates/vst3-host/src/ffi.rs | 53 +++++++- crates/vst3-host/src/plugin.rs | 10 +- 9 files changed, 462 insertions(+), 21 deletions(-) diff --git a/crates/au-host/src/ffi.rs b/crates/au-host/src/ffi.rs index a1152449..641d16bc 100644 --- a/crates/au-host/src/ffi.rs +++ b/crates/au-host/src/ffi.rs @@ -2,11 +2,14 @@ //! thin safe wrapper over one Audio Unit instance. //! //! Every pointer handed to the Audio Unit points into memory this module owns for at least as -//! long as the unit can use it: the input state the render callback reads is a leaked `Box` -//! freed only after the unit is disposed, and output buffers are re-pointed before each render. +//! long as the unit can use it: the input state the render callback reads and the host time the +//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers +//! are re-pointed before each render. +use soundcraft_dsp::Transport; use std::ffi::{CStr, c_char, c_void}; use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; type OSStatus = i32; type AudioComponent = *mut c_void; @@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14; const PROP_PARAMETER_VALUE_STRINGS: u32 = 16; const PROP_TAIL_TIME: u32 = 20; const PROP_SET_RENDER_CALLBACK: u32 = 23; +const PROP_HOST_CALLBACKS: u32 = 27; const SCOPE_GLOBAL: u32 = 0; const SCOPE_INPUT: u32 = 1; @@ -140,6 +144,153 @@ struct AudioBufferListHeader { first: AudioBuffer, } +type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus; +type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus; +type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; +type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; + +#[repr(C)] +struct HostCallbackInfo { + host_user_data: *mut c_void, + beat_and_tempo: Option, + musical_time_location: Option, + transport_state: Option, + transport_state2: Option, +} + +/// What the host callbacks report: the transport of the block being rendered. Atomics, because a +/// unit may also ask from another thread. +struct HostTime { + playing: AtomicBool, + recording: AtomicBool, + changed: AtomicBool, + samples: AtomicI64, + tempo: AtomicU64, + quarters: AtomicU64, + bar_start: AtomicU64, + numerator: AtomicU32, + denominator: AtomicU32, + sample_rate: AtomicU64, +} + +impl HostTime { + fn new() -> HostTime { + HostTime { + playing: AtomicBool::new(false), + recording: AtomicBool::new(false), + changed: AtomicBool::new(false), + samples: AtomicI64::new(0), + tempo: AtomicU64::new(120f64.to_bits()), + quarters: AtomicU64::new(0), + bar_start: AtomicU64::new(0), + numerator: AtomicU32::new(4), + denominator: AtomicU32::new(4), + sample_rate: AtomicU64::new(48_000f64.to_bits()), + } + } + + fn set(&self, t: &Transport) { + if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing { + self.changed.store(true, Ordering::Relaxed); + } + self.recording.store(t.recording, Ordering::Relaxed); + self.samples.store(t.samples, Ordering::Relaxed); + self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed); + self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed); + self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed); + self.numerator.store(t.numerator, Ordering::Relaxed); + self.denominator.store(t.denominator, Ordering::Relaxed); + } +} + +fn load_f64(a: &AtomicU64) -> f64 { + f64::from_bits(a.load(Ordering::Relaxed)) +} + +/// Writes through an out-pointer the unit may leave null. +/// +/// # Safety +/// `p` is null or valid for writing a `T`. +unsafe fn put(p: *mut T, v: T) { + // SAFETY: per the contract above. + if let Some(p) = unsafe { p.as_mut() } { + *p = v; + } +} + +unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus { + // SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is + // disposed. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(beat, load_f64(&t.quarters)); + put(tempo, load_f64(&t.tempo)); + } + 0 +} + +unsafe extern "C" fn musical_time( + user: *mut c_void, + to_next_beat: *mut u32, + numerator: *mut f32, + denominator: *mut u32, + downbeat: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + let q = load_f64(&t.quarters); + let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0); + let next = ((q.ceil() - q) * per_quarter).round(); + let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(to_next_beat, next); + put(numerator, t.numerator.load(Ordering::Relaxed) as f32); + put(denominator, t.denominator.load(Ordering::Relaxed)); + put(downbeat, load_f64(&t.bar_start)); + } + 0 +} + +unsafe extern "C" fn transport_state2( + user: *mut c_void, + playing: *mut u8, + recording: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(playing, u8::from(t.playing.load(Ordering::Relaxed))); + put(recording, u8::from(t.recording.load(Ordering::Relaxed))); + put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed))); + put(sample, t.samples.load(Ordering::Relaxed) as f64); + put(cycling, 0); + put(cycle_start, 0.0); + put(cycle_end, 0.0); + } + 0 +} + +unsafe extern "C" fn transport_state( + user: *mut c_void, + playing: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: the same pointers as `transport_state2`, without the recording flag. + unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) } +} + type AuRenderCallback = unsafe extern "C" fn( ref_con: *mut c_void, action_flags: *mut u32, @@ -361,6 +512,8 @@ pub struct Instance { unit: AudioUnit, /// Leaked `Box` read by `input_callback`; freed in `Drop` after disposal. input: *mut InputState, + /// Leaked `Box` read by the host callbacks; freed in `Drop` after disposal. + time: *mut HostTime, outputs: Vec>, abl: AudioBufferList, has_input: bool, @@ -373,7 +526,8 @@ pub struct Instance { // SAFETY: an Audio Unit instance may be used from any thread as long as calls are not // concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin` -// contract keeps on one thread at a time. The input state is owned exclusively by it. +// contract keeps on one thread at a time. The input state and host time are owned exclusively by +// it (the host time is all atomics). unsafe impl Send for Instance {} impl Instance { @@ -398,10 +552,12 @@ impl Instance { return Err(format!("AudioComponentInstanceNew failed ({st})")); } let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 })); + let time = Box::into_raw(Box::new(HostTime::new())); let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() }; - Ok(Instance { + let inst = Instance { unit, input, + time, outputs: Vec::new(), abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] }, has_input, @@ -410,7 +566,27 @@ impl Instance { max_frames: 0, sample_rate: 48_000.0, sample_time: 0.0, - }) + }; + let callbacks = HostCallbackInfo { + host_user_data: time.cast(), + beat_and_tempo: Some(beat_and_tempo), + musical_time_location: Some(musical_time), + transport_state: Some(transport_state), + transport_state2: Some(transport_state2), + }; + // Units that never ask for host time may refuse it. + let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks); + Ok(inst) + } + + fn time(&self) -> &HostTime { + // SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`. + unsafe { &*self.time } + } + + /// The transport the host callbacks report during the next renders. + pub fn set_transport(&self, t: &Transport) { + self.time().set(t); } fn set_property(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus { @@ -490,6 +666,7 @@ impl Instance { self.initialized = true; self.channels = ch; self.sample_rate = sample_rate; + self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed); self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect(); // SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now // (we hold `&mut self`), so this exclusive access does not alias. @@ -736,13 +913,15 @@ impl Instance { impl Drop for Instance { fn drop(&mut self) { // SAFETY: the unit is live (disposed only here); after disposal nothing can call the - // input callback, so the leaked input state can be freed. + // input callback or the host callbacks, so the leaked input state and host time can be + // freed. unsafe { if self.initialized { AudioUnitUninitialize(self.unit); } AudioComponentInstanceDispose(self.unit); drop(Box::from_raw(self.input)); + drop(Box::from_raw(self.time)); } } } @@ -751,6 +930,31 @@ impl Drop for Instance { mod tests { use super::*; + #[test] + fn host_callbacks_report_the_transport() { + let time = HostTime::new(); + time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() }); + let user = ptr::from_ref(&time).cast_mut().cast::(); + let (mut beat, mut tempo) = (0.0, 0.0); + // SAFETY: `user` points at a live `HostTime`; the out-pointers are locals. + assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0); + assert_eq!((beat, tempo), (2.25, 120.0)); + let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0); + // SAFETY: as above. + assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0); + assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0)); + let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0); + let (no_flag, no_beat) = (ptr::null_mut::(), ptr::null_mut::()); + // SAFETY: as above; the unit may pass null for what it doesn't want. + let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) }; + assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0)); + // SAFETY: as above. + unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) }; + assert_eq!(changed, 0, "a change is reported once"); + // SAFETY: a null user pointer is refused before anything is read. + assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50); + } + #[test] fn struct_layouts_match_the_c_headers() { assert_eq!(std::mem::size_of::(), 20); @@ -761,6 +965,7 @@ mod tests { assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8); assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8); assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96); diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 7d07e15c..39172c0b 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -23,8 +23,10 @@ //! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and //! sets it, then re-reads the parameter values. //! -//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, -//! parameters outside the global scope, and host callbacks (tempo, transport). +//! Host callbacks report the session's tempo, meter, position and play state. +//! +//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and +//! parameters outside the global scope. //! //! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be //! created. diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index c76aab00..6f3e373e 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -1,7 +1,7 @@ //! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait. use crate::ffi::Instance; -use soundcraft_dsp::{Plugin, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginInfo, Transport}; /// Most MIDI events queued between two blocks (extra events are dropped). const EVENT_CAP: usize = 1024; @@ -200,6 +200,10 @@ impl Plugin for AuPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.inst.set_transport(transport); + } + fn tail_samples(&self) -> usize { self.tail } diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33f..28d017db 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -14,8 +14,10 @@ use crate::{ClapError, scan}; use clap_sys::audio_buffer::clap_audio_buffer; use clap_sys::entry::clap_plugin_entry; use clap_sys::events::{ - CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi, - clap_event_note, clap_event_param_value, clap_input_events, clap_output_events, + CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT, + CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE, + CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, clap_event_header, clap_event_midi, clap_event_note, clap_event_param_value, + clap_event_transport, clap_input_events, clap_output_events, }; use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports}; use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui}; @@ -27,12 +29,14 @@ use clap_sys::ext::params::{ }; use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state}; use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory}; +use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR}; use clap_sys::host::clap_host; use clap_sys::id::clap_id; use clap_sys::plugin::clap_plugin; use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process}; use clap_sys::stream::{clap_istream, clap_ostream}; use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; +use soundcraft_dsp::Transport; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf}; @@ -579,6 +583,40 @@ impl RawEvent { } } +fn transport_event(t: &Transport) -> clap_event_transport { + let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 }; + let mut flags = + CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE; + if t.playing { + flags |= CLAP_TRANSPORT_IS_PLAYING; + } + if t.recording { + flags |= CLAP_TRANSPORT_IS_RECORDING; + } + clap_event_transport { + header: clap_event_header { + size: u32::try_from(size_of::()).unwrap_or(u32::MAX), + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_TRANSPORT, + flags: 0, + }, + flags, + song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR), + song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR), + tempo: t.tempo, + tempo_inc: 0.0, + loop_start_beats: 0, + loop_end_beats: 0, + loop_start_seconds: 0, + loop_end_seconds: 0, + bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR), + bar_number: i32::try_from(t.bar).unwrap_or(0), + tsig_num: u16::try_from(t.numerator).unwrap_or(4), + tsig_denom: u16::try_from(t.denominator).unwrap_or(4), + } +} + unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 { if list.is_null() { return 0; @@ -1186,6 +1224,7 @@ impl Instance { outputs: &mut PortBuffers, events: &[Event], offset: u32, + transport: Option<&Transport>, ) -> Result { if !self.active { return Err(ClapError::Process("not active".into())); @@ -1219,10 +1258,11 @@ impl Instance { self.out_params.clear(); let out_events = clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::(), try_push: Some(out_events_try_push) }; + let transport = transport.map(transport_event); let p = clap_process { steady_time, frames_count: u32::try_from(frames).unwrap_or(0), - transport: std::ptr::null(), + transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), audio_inputs: inputs.bufs.as_ptr(), audio_outputs: outputs.bufs.as_mut_ptr(), audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0), @@ -1232,8 +1272,8 @@ impl Instance { }; // SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more) // valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event - // lists and their contexts live on this stack frame / in `self.raw`, untouched until the - // call returns. + // lists and their contexts and the transport live on this stack frame / in `self.raw`, + // untouched until the call returns. let status = unsafe { process(self.plugin, &p) }; if !self.out_params.is_empty() && let Ok(mut edits) = self.state.edits.try_lock() @@ -1264,3 +1304,33 @@ impl Drop for Instance { self.created = false; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_transport_reaches_clap_in_fixed_point() { + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + seconds: 2.0, + quarters: 3.5, + bar_start: 3.0, + bar: 1, + ..Transport::default() + }; + let e = transport_event(&t); + assert_eq!((e.header.type_, e.header.size as usize), (CLAP_EVENT_TRANSPORT, size_of::())); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_PLAYING); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_RECORDING, 0); + assert_eq!((e.song_pos_beats, e.bar_start), (7 * CLAP_BEATTIME_FACTOR / 2, 3 * CLAP_BEATTIME_FACTOR)); + assert_eq!(e.song_pos_seconds, 2 * CLAP_SECTIME_FACTOR); + assert_eq!((e.tempo, e.bar_number, e.tsig_num, e.tsig_denom), (90.0, 1, 3, 4)); + let nan = transport_event(&Transport { quarters: f64::NAN, ..t }); + assert_eq!(nan.song_pos_beats, 0); + } +} diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6fe1abcf..6adf269c 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -2,7 +2,7 @@ use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers}; use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -38,6 +38,7 @@ pub struct ClapPlugin { max_block: usize, latency: usize, steady: i64, + transport: Option, } impl ClapPlugin { @@ -60,6 +61,7 @@ impl ClapPlugin { max_block: 0, latency: 0, steady: 0, + transport: None, } } @@ -187,7 +189,7 @@ impl Plugin for ClapPlugin { let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let events = self.queue.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset); + let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref()); self.queue.drain(..k); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { @@ -217,6 +219,10 @@ impl Plugin for ClapPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { if note > 127 { return; diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 3d5bd331..09d43279 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -169,6 +169,24 @@ impl PluginInfo { } } +/// The host's transport at the start of a block, for tempo-synced plugins. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Transport { + pub playing: bool, + pub recording: bool, + /// Quarter notes per minute. + pub tempo: f64, + pub numerator: u32, + pub denominator: u32, + /// Timeline position. + pub samples: i64, + pub seconds: f64, + pub quarters: f64, + /// Start of the current bar, in quarter notes, and its number (0 for the first bar). + pub bar_start: f64, + pub bar: i64, +} + /// A realtime audio processor or instrument. /// /// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames @@ -195,6 +213,8 @@ pub trait Plugin: Send { fn gain_reduction_db(&self) -> f32 { 0.0 } + /// The transport for the next block, given before `process`. + fn set_transport(&mut self, _transport: &Transport) {} /// Instruments: note events at sample offsets within the next block. fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab3..8e09a524 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -52,11 +52,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; -use soundcraft_dsp::{Plugin, SampleSource, db_to_gain}; +use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; use soundcraft_model::{ AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind, }; -use soundcraft_time::{Range, Samples}; +use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap; use std::sync::Arc; @@ -670,6 +670,7 @@ impl MixEngine { } self.order = order; // Master faders process the main mix. + let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { if let Some(strip) = self.strips.get_mut(&t.id) { for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) { @@ -683,6 +684,7 @@ impl MixEngine { && !ins.bypass { apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); } } @@ -814,6 +816,7 @@ fn process_strip( for c in strip.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } + let transport = transport_at(s, pos, !live.monitor_only, live.recording); match t.kind { TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { // Input monitoring: the track hears its hardware input instead of its clips. @@ -894,6 +897,7 @@ fn process_strip( if let Some(ins) = &t.instrument { apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } + inst.plugin.set_transport(&transport); schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -926,6 +930,7 @@ fn process_strip( continue; } apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); latency = latency.saturating_add(slot.plugin.latency()); if gr == 0.0 { @@ -1436,6 +1441,27 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im } } +fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport { + let sr = s.sample_rate; + let ticks = Some(s.tempo.samples_to_ticks_f(pos, sr)).filter(|t| t.is_finite()).unwrap_or(0.0); + let tick = ticks.floor() as i64; + let meter = s.tempo.meter_at_tick(tick); + let bar = s.tempo.bar_beat_at_tick(tick).bar; + let q = TICKS_PER_QUARTER as f64; + Transport { + playing, + recording, + tempo: s.tempo.tempo_at_tick(tick), + numerator: meter.numerator, + denominator: meter.denominator, + samples: pos, + seconds: pos as f64 / sr.as_f64().max(1.0), + quarters: ticks / q, + bar_start: s.tempo.bar_start_tick(bar) as f64 / q, + bar: bar - 1, + } +} + fn volume_db_at(t: &Track, pos: Samples) -> f32 { if t.mixer.automation_mode.reads() && t.mixer.automation_mode != soundcraft_model::AutomationMode::Write @@ -2168,6 +2194,57 @@ mod tests { } } + struct Clock { + inner: Box, + seen: Arc>>, + } + + impl Plugin for Clock { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.inner.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + fn set_transport(&mut self, t: &Transport) { + self.seen.lock().unwrap().push(*t); + } + } + + #[test] + fn plugins_get_the_session_transport() { + let (mut s, t) = session_with_dc(0.5, 48_000 * 4); + s.tempo.set_tempo(0, 90.0).unwrap(); + s.tempo.set_meter(0, 3, 4).unwrap(); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.clock")); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let clock = Clock { inner: soundcraft_dsp::create("gain").unwrap(), seen: Arc::clone(&seen) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.clock".into(), ch: 1, plugin: Box::new(clock) }]); + render_block(&mut eng, &s, 96_000); + eng.monitor_only = true; + render_block(&mut eng, &s, 96_000); + let seen = seen.lock().unwrap(); + let t0 = seen[0]; + assert!(t0.playing && !t0.recording); + assert_eq!((t0.tempo, t0.numerator, t0.denominator, t0.samples, t0.bar), (90.0, 3, 4, 96_000, 1)); + assert!((t0.seconds - 2.0).abs() < 1e-9 && (t0.quarters - 3.0).abs() < 1e-9 && (t0.bar_start - 3.0).abs() < 1e-9); + assert!(!seen[1].playing); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out); diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad534..185fa6a1 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -15,6 +15,7 @@ #![allow(clippy::unnecessary_cast)] use crate::{Vst3Error, scan}; +use soundcraft_dsp::Transport; use std::ffi::{c_char, c_int, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -1072,6 +1073,27 @@ pub(crate) struct Layout { pub event_input: bool, } +fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { + use sv::ProcessContext_::StatesAndFlags_::*; + let mut state = kTempoValid | kTimeSigValid | kProjectTimeMusicValid | kBarPositionValid | kContTimeValid; + if t.playing { + state |= kPlaying; + } + if t.recording { + state |= kRecording; + } + // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere. + #[allow(clippy::unnecessary_cast)] + let state = state as u32; + c.state = state; + c.tempo = t.tempo; + c.timeSigNumerator = i32::try_from(t.numerator).unwrap_or(4); + c.timeSigDenominator = i32::try_from(t.denominator).unwrap_or(4); + c.projectTimeSamples = t.samples; + c.projectTimeMusic = t.quarters; + c.barPositionMusic = t.bar_start; +} + /// A live plugin: component + audio processor (+ edit controller) and the host objects. pub(crate) struct Instance { processor: Option>, @@ -1402,6 +1424,7 @@ impl Instance { notes: &[NoteEvent], offset: u32, project_time: i64, + transport: Option<&Transport>, ) -> Result<(), Vst3Error> { if !self.active { return Err(Vst3Error::Process("not active".into())); @@ -1433,8 +1456,11 @@ impl Instance { self.out_events.with(Vec::clear); inputs.refresh(); outputs.refresh(); - self.context.projectTimeSamples = project_time; self.context.continousTimeSamples = project_time; + match transport { + Some(t) => apply_transport(&mut self.context, t), + None => self.context.projectTimeSamples = project_time, + } let mut data = sv::ProcessData { processMode: sv::ProcessModes_::kRealtime as i32, symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, @@ -1881,6 +1907,31 @@ mod window { mod tests { use super::*; + #[test] + fn the_transport_fills_the_process_context() { + use sv::ProcessContext_::StatesAndFlags_::*; + // SAFETY: `ProcessContext` is plain data (numbers and small structs); all-zero is valid. + let mut c: sv::ProcessContext = unsafe { std::mem::zeroed() }; + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + quarters: 3.5, + bar_start: 3.0, + ..Transport::default() + }; + apply_transport(&mut c, &t); + let playing = kPlaying as u32; + assert_eq!(c.state & playing, playing); + assert_eq!(c.state & kRecording as u32, 0); + assert_eq!((c.tempo, c.timeSigNumerator, c.timeSigDenominator), (90.0, 3, 4)); + assert_eq!((c.projectTimeSamples, c.projectTimeMusic, c.barPositionMusic), (96_000, 3.5, 3.0)); + apply_transport(&mut c, &Transport { playing: false, ..t }); + assert_eq!(c.state & playing, 0); + } + #[test] fn state_container_parses_strictly() { let mut blob = b"SCV3".to_vec(); diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 254671f5..d88f6d63 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,7 +1,7 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. use crate::ffi::{BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Unit}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::sync::Arc; use std::sync::atomic::Ordering; use vst3::ComWrapper; @@ -46,6 +46,7 @@ pub struct Vst3Plugin { latency: usize, tail: usize, position: i64, + transport: Option, } impl Vst3Plugin { @@ -88,6 +89,7 @@ impl Vst3Plugin { latency: 0, tail: 0, position: 0, + transport: None, ids, maps, inst, @@ -253,7 +255,7 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position); + let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position, self.transport.as_ref()); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); @@ -284,6 +286,10 @@ impl Plugin for Vst3Plugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn tail_samples(&self) -> usize { self.tail } From acd9e7d54e6a566b2f988841f2db484230f0921f Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:39:05 +0100 Subject: [PATCH 2/3] Play MIDI controllers through instruments Imported MIDI keeps its controller events: sustain pedal and other CCs, pitch bend, channel and poly pressure, program changes. Playback sent instruments only note-ons and note-offs, so a piano part lost its pedal and bends went flat. - Plugin::midi takes any other MIDI 1.0 channel message at a sample offset. The mix engine sends each clip's controllers at their sample, after the Real-Time Properties delay (and transpose, for poly pressure), on channel 1 like the notes. - After a seek, a stop or a loop wrap, an instrument first gets Reset All Controllers (CC 121), then the last value each controller had on its track before the playhead. A pedal held before the start point is still down. - CLAP: raw MIDI events when the note port speaks the MIDI dialect. - VST3: CCs, aftertouch and pitch bend become parameter changes at their offset, through the plugin's IMidiMapping. It is asked once in prepare, on the main thread, and there is one point per parameter per block. Poly pressure is a PolyPressureEvent. Program changes have no VST3 mapping and are dropped. - AU: MusicDeviceMIDIEvent for instruments and music effects. - The built-in synth follows the sustain pedal, pitch bend (2 semitones each way), Reset All Controllers and All Notes Off. - The sampler ignores the pedal, pitch bend and Reset All Controllers for now. The AU check of MIDI volume lives inside the existing DLS test. Creating two DLSMusicDevice instances at once on two threads crashes inside the unit, with or without this change. Two instances created one after another render in parallel fine. --- crates/au-host/src/plugin.rs | 7 ++ crates/au-host/tests/system_units.rs | 19 +++ crates/clap-host/src/plugin.rs | 13 +- crates/clap-host/tests/fixture/src/lib.rs | 9 +- crates/clap-host/tests/fixture_e2e.rs | 3 + crates/dsp/src/lib.rs | 3 + crates/dsp/src/plugins/drums.rs | 2 +- crates/dsp/src/plugins/sampler.rs | 1 + crates/dsp/src/plugins/synth.rs | 64 +++++++++- crates/dsp/tests/plugins.rs | 28 +++++ crates/mix/src/lib.rs | 137 +++++++++++++++++++++- crates/vst3-host/src/ffi.rs | 39 +++++- crates/vst3-host/src/plugin.rs | 46 +++++++- crates/vst3-host/tests/fixture/src/lib.rs | 36 +++++- crates/vst3-host/tests/fixture_e2e.rs | 10 ++ 15 files changed, 401 insertions(+), 16 deletions(-) diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index 6f3e373e..23717aa9 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -231,6 +231,13 @@ impl Plugin for AuPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, data1, data2] = msg; + if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 { + self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/system_units.rs index 2e246758..2cef4195 100644 --- a/crates/au-host/tests/system_units.rs +++ b/crates/au-host/tests/system_units.rs @@ -167,4 +167,23 @@ fn dls_synth_plays_a_note() { assert!(e > 1e-3, "{e}"); p.note_off(0, 64); p.reset(); + // Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating + // two DLS instances at once on two threads crashes inside the unit.) + p.prepare(48_000.0, 512, 2); + let loudest = |p: &mut Box, volume: u8| { + p.midi(0, [0xB0, 7, volume]); + p.note_on(0, 60, 110); + let mut energy = 0.0f32; + for _ in 0..20 { + let mut io = vec![vec![0.0f32; 512]; 2]; + p.process(&mut io, 512); + energy = energy.max(rms(&io[0])); + } + p.note_off(0, 60); + p.reset(); + energy + }; + let full = loudest(&mut p, 127); + let muted = loudest(&mut p, 0); + assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}"); } diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6adf269c..ec375cd9 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -33,6 +33,8 @@ pub struct ClapPlugin { in_main: Option, out_main: Option, notes: NoteMode, + /// The note port takes raw MIDI (controllers, pitch bend, program changes). + midi_in: bool, held: [bool; 128], channels: usize, max_block: usize, @@ -56,6 +58,7 @@ impl ClapPlugin { in_main: None, out_main: None, notes: NoteMode::None, + midi_in: false, held: [false; 128], channels: 2, max_block: 0, @@ -116,7 +119,9 @@ impl Plugin for ClapPlugin { self.out_main = pick_main(&outs); self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::>(), mb); self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::>(), mb); - self.notes = note_mode(self.inst.note_port()); + let port = self.inst.note_port(); + self.notes = note_mode(port); + self.midi_in = port.is_some_and(|p| p.supported & CLAP_NOTE_DIALECT_MIDI != 0); self.max_block = mb; self.inst.state().restart.store(false, Ordering::Relaxed); match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) { @@ -254,6 +259,12 @@ impl Plugin for ClapPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + if self.midi_in { + self.push(Event::Midi { time: u32::try_from(offset).unwrap_or(u32::MAX), data: msg }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index 40de13ce..85a7999b 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -3,7 +3,8 @@ //! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode" //! (stepped enum A/B/C), hidden param 9, latency 3 samples. //! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal -//! to the velocity of the held note (0 when none), switching at the event's sample offset. +//! to the velocity of the held note (0 when none), switching at the event's sample offset. A +//! MIDI CC 7 (volume) sets the constant to its value / 127. //! //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, @@ -150,6 +151,12 @@ fn apply(f: &mut Fx, h: &clap_event_header) { f.level = e.velocity; } CLAP_EVENT_NOTE_OFF => f.level = 0.0, + CLAP_EVENT_MIDI => { + let e = unsafe { &*(h as *const clap_event_header as *const clap_event_midi) }; + if e.data[0] & 0xF0 == 0xB0 && e.data[1] == 7 { + f.level = f64::from(e.data[2]) / 127.0; + } + } _ => {} } } diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/fixture_e2e.rs index 22164875..b1bb3544 100644 --- a/crates/clap-host/tests/fixture_e2e.rs +++ b/crates/clap-host/tests/fixture_e2e.rs @@ -105,6 +105,9 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + p.midi(20, [0xB0, 7, 64]); + p.process(&mut io, 64); + assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset"); } #[test] diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 09d43279..0308834b 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -218,6 +218,9 @@ pub trait Plugin: Send { /// Instruments: note events at sample offsets within the next block. fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} + /// Instruments: any other MIDI 1.0 channel message (control change, pitch bend, pressure, + /// program change) at a sample offset within the next block. + fn midi(&mut self, _offset: usize, _msg: [u8; 3]) {} fn all_notes_off(&mut self) {} /// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it. /// `false` when the plugin does not play samples. Does not allocate; sounding voices stop. diff --git a/crates/dsp/src/plugins/drums.rs b/crates/dsp/src/plugins/drums.rs index 75a7976c..ca3a52b9 100644 --- a/crates/dsp/src/plugins/drums.rs +++ b/crates/dsp/src/plugins/drums.rs @@ -387,7 +387,7 @@ impl DrumSynth { fn apply(&mut self, kind: EventKind) { match kind { EventKind::On(n, v) => self.trigger(n, v), - EventKind::Off(_) => {} + EventKind::Off(_) | EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} EventKind::AllOff => { let fast = decay60(30.0, self.sr); for v in &mut self.voices { diff --git a/crates/dsp/src/plugins/sampler.rs b/crates/dsp/src/plugins/sampler.rs index c1816878..ee851789 100644 --- a/crates/dsp/src/plugins/sampler.rs +++ b/crates/dsp/src/plugins/sampler.rs @@ -299,6 +299,7 @@ impl Sampler { v.env.gate_off(); } } + EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} } } diff --git a/crates/dsp/src/plugins/synth.rs b/crates/dsp/src/plugins/synth.rs index be662c65..1e0b92a7 100644 --- a/crates/dsp/src/plugins/synth.rs +++ b/crates/dsp/src/plugins/synth.rs @@ -13,8 +13,15 @@ pub(crate) enum EventKind { On(u8, u8), Off(u8), AllOff, + Sustain(bool), + /// Pitch bend as a frequency ratio. + Bend(f32), + ResetControllers, } +/// Pitch bend range, semitones each way. +const BEND_RANGE: f32 = 2.0; + #[derive(Debug, Clone, Copy)] pub(crate) struct Event { pub(crate) offset: usize, @@ -212,6 +219,8 @@ struct Voice { svf: Svf, age: u64, held: bool, + /// Released while the sustain pedal was down. + sustained: bool, } impl Voice { @@ -228,6 +237,7 @@ impl Voice { svf: Svf::default(), age: 0, held: false, + sustained: false, } } @@ -250,6 +260,8 @@ pub struct SubtractiveSynth { counter: u64, level: Smoother, cutoff: Smoother, + sustain: bool, + bend: f32, } impl Default for SubtractiveSynth { @@ -277,6 +289,8 @@ impl SubtractiveSynth { counter: 0, level: Smoother::new(0.5), cutoff: Smoother::new(2000.0), + sustain: false, + bend: 1.0, }; s.update(); s @@ -337,15 +351,32 @@ impl SubtractiveSynth { v.filt.gate_on(); v.age = counter; v.held = true; + v.sustained = false; } self.last_freq = Some(freq); } fn stop_note(&mut self, note: u8) { + let sustain = self.sustain; for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) { v.held = false; - v.amp.gate_off(); - v.filt.gate_off(); + if sustain { + v.sustained = true; + } else { + v.amp.gate_off(); + v.filt.gate_off(); + } + } + } + + fn set_sustain(&mut self, on: bool) { + self.sustain = on; + if !on { + for v in self.voices.iter_mut().filter(|v| v.sustained) { + v.sustained = false; + v.amp.gate_off(); + v.filt.gate_off(); + } } } @@ -356,10 +387,17 @@ impl SubtractiveSynth { EventKind::AllOff => { for v in &mut self.voices { v.held = false; + v.sustained = false; v.amp.gate_off(); v.filt.gate_off(); } } + EventKind::Sustain(on) => self.set_sustain(on), + EventKind::Bend(ratio) => self.bend = ratio, + EventKind::ResetControllers => { + self.set_sustain(false); + self.bend = 1.0; + } } } @@ -374,6 +412,7 @@ impl SubtractiveSynth { let env_oct = self.p.v(7) / 100.0 * 6.0; let cutoff = self.cutoff.next(); let nyq = sr * 0.45; + let bend = self.bend; let mut out = 0.0; for v in &mut self.voices { if !v.active() { @@ -386,8 +425,8 @@ impl SubtractiveSynth { } let a = v.amp.next(&self.amp_rates); let fe = v.filt.next(&self.filt_rates); - let s1 = v.osc1.next(w1, v.freq / sr); - let s2 = v.osc2.next(w2, v.freq * ratio2 / sr); + let s1 = v.osc1.next(w1, v.freq * bend / sr); + let s2 = v.osc2.next(w2, v.freq * bend * ratio2 / sr); let raw = s1 * (1.0 - mix) + s2 * mix; let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq); let g = (std::f32::consts::PI * fc / sr).tan(); @@ -418,6 +457,8 @@ impl Plugin for SubtractiveSynth { self.last_freq = None; self.level.snap(); self.cutoff.snap(); + self.sustain = false; + self.bend = 1.0; } fn process(&mut self, io: &mut [Vec], frames: usize) { @@ -467,6 +508,21 @@ impl Plugin for SubtractiveSynth { self.events.push(offset, EventKind::Off(note)); } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, d1, d2] = msg; + let kind = match (status & 0xF0, d1) { + (0xB0, 64) => EventKind::Sustain(d2 >= 64), + (0xB0, 120 | 123) => EventKind::AllOff, + (0xB0, 121) => EventKind::ResetControllers, + (0xE0, _) => { + let v = (i32::from(d2 & 0x7F) << 7 | i32::from(d1 & 0x7F)) - 8192; + EventKind::Bend(2f32.powf(v as f32 / 8192.0 * BEND_RANGE / 12.0)) + } + _ => return, + }; + self.events.push(offset, kind); + } + fn all_notes_off(&mut self) { self.events.push(0, EventKind::AllOff); } diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f3..048aece5 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -394,6 +394,34 @@ fn synth_note_on_sounds_and_note_off_decays() { assert!(peak(&out[0][72_000..]) < 1e-6); } +#[test] +fn synth_follows_the_sustain_pedal_and_pitch_bend() { + let mut p = prepared("subtractive_synth"); + p.midi(0, [0xB0, 64, 127]); + p.note_on(0, 60, 100); + run(p.as_mut(), &vec![vec![0.0; 4800]; 2]); + p.note_off(0, 60); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note"); + p.midi(0, [0xB0, 64, 0]); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it"); + + let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64; + let mut p = prepared("subtractive_synth"); + p.set_param("osc1_wave", 2.0); + p.set_param("osc_mix", 0.0); + p.note_on(0, 69, 100); + let plain = run(p.as_mut(), &[vec![0.0; 48_000]]); + p.midi(0, [0xE0, 0x7F, 0x7F]); + let bent = run(p.as_mut(), &[vec![0.0; 48_000]]); + let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]); + assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}"); + p.midi(0, [0xB0, 121, 0]); + let reset = run(p.as_mut(), &[vec![0.0; 48_000]]); + assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01); +} + #[test] fn synth_event_offsets_are_sample_accurate() { let mut p = prepared("subtractive_synth"); diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 8e09a524..bae85452 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -53,6 +53,7 @@ use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; +use soundcraft_midi::CtrlKind; use soundcraft_model::{ AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind, }; @@ -199,6 +200,8 @@ struct Strip { sample: Option>, /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. held: Vec, + /// The instrument's next block starts somewhere new: send it the controllers in effect there. + chase: bool, muted: bool, gr: f32, /// Per-clip effect chains (Clip Effects) and a scratch buffer to render clips into. @@ -273,6 +276,7 @@ impl Strip { instrument: None, sample: None, held: Vec::new(), + chase: true, muted: false, gr: 0.0, clip_fx: HashMap::new(), @@ -474,6 +478,7 @@ impl MixEngine { i.plugin.reset(); } s.held.clear(); + s.chase = true; } self.meters.clear(); self.main_meter = StripMeter::default(); @@ -576,6 +581,7 @@ impl MixEngine { i.plugin.all_notes_off(); } st.held.clear(); + st.chase = true; } } self.last_end = pos.saturating_add(frames as i64); @@ -898,6 +904,9 @@ fn process_strip( apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } inst.plugin.set_transport(&transport); + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -1310,7 +1319,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut } } -/// Send note on/offs for MIDI clips overlapping the block. +/// Send note on/offs and controllers for MIDI clips overlapping the block. fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec) { let sr = s.sample_rate; let end = pos.saturating_add(frames as i64); @@ -1357,6 +1366,80 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu held.retain(|p| *p != n.pitch); } } + let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) + ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); + for c in &sequence.ctrls { + let tick = c.tick.saturating_add(delay).max(0); + if !near.contains(&tick) { + continue; + } + let at = s.tempo.tick_to_samples(base.saturating_add(tick), sr); + if at >= pos && at < end && at < clip.end() { + inst.plugin.midi(usize::try_from(at - pos).unwrap_or(0), ctrl_message(c.kind, c.value, dp)); + } + } + } +} + +/// A controller event as a MIDI 1.0 message on channel 1 (where the notes go), with `transpose` +/// applied to a poly-pressure key. +fn ctrl_message(kind: CtrlKind, value: i32, transpose: i64) -> [u8; 3] { + let v7 = value.clamp(0, 127) as u8; + match kind { + CtrlKind::Cc(n) => [0xB0, n & 0x7F, v7], + CtrlKind::PitchBend => { + let v = value.saturating_add(8192).clamp(0, 16383); + [0xE0, (v & 0x7F) as u8, (v >> 7) as u8] + } + CtrlKind::ChannelPressure => [0xD0, v7, 0], + CtrlKind::PolyPressure(k) => [0xA0, (i64::from(k) + transpose).clamp(0, 127) as u8, v7], + CtrlKind::Program => [0xC0, v7, 0], + } +} + +/// Before an instrument plays from `pos`: reset its controllers, then send each one the last value +/// it had on this track before `pos`. +fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot) { + inst.plugin.midi(0, [0xB0, 121, 0]); + const BEND: usize = 128; + const PRESSURE: usize = 129; + const PROGRAM: usize = 130; + let mut last = [(i64::MIN, 0i32); 131]; + let sr = s.sample_rate; + for clip in t.clips() { + let ClipContent::Midi { sequence } = &clip.content else { continue }; + if clip.muted || clip.start >= pos { + continue; + } + let base = s.tempo.samples_to_ticks(clip.start, sr); + let until = pos.min(clip.end()); + for c in &sequence.ctrls { + let slot = match c.kind { + CtrlKind::Cc(n) => usize::from(n & 0x7F), + CtrlKind::PitchBend => BEND, + CtrlKind::ChannelPressure => PRESSURE, + CtrlKind::Program => PROGRAM, + CtrlKind::PolyPressure(_) => continue, + }; + let at = s.tempo.tick_to_samples(base.saturating_add(c.tick), sr); + if at < until + && let Some(l) = last.get_mut(slot) + && at >= l.0 + { + *l = (at, c.value); + } + } + } + for (slot, &(at, value)) in last.iter().enumerate() { + let kind = match slot { + BEND => CtrlKind::PitchBend, + PRESSURE => CtrlKind::ChannelPressure, + PROGRAM => CtrlKind::Program, + n => CtrlKind::Cc(u8::try_from(n).unwrap_or(0)), + }; + if at != i64::MIN { + inst.plugin.midi(0, ctrl_message(kind, value, 0)); + } } } @@ -2245,6 +2328,58 @@ mod tests { assert!(!seen[1].playing); } + struct Ears { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Ears { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.inner.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + self.heard.lock().unwrap().push((offset, msg)); + } + } + + #[test] + fn controllers_play_at_their_offsets_and_are_chased_after_a_seek() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.ears")); + let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value }; + let ctrls = vec![cc(4, CtrlKind::Cc(64), 127), cc(960, CtrlKind::PitchBend, -8192), cc(1920, CtrlKind::Cc(64), 0)]; + let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() }; + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq)); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let ears = Ears { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.ears".into(), ch: 2, plugin: Box::new(ears) }]); + render_block(&mut eng, &s, 0); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (100, [0xB0, 64, 127])]); + render_block(&mut eng, &s, 36_000); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 64, 127]), (0, [0xE0, 0, 0])]); + render_block(&mut eng, &s, 36_512); + assert!(heard.lock().unwrap().is_empty()); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out); diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 185fa6a1..736117c7 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -25,7 +25,7 @@ use std::time::Duration; use vst3::Steinberg::Vst::{self as sv}; use vst3::Steinberg::Vst::{ IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait, - IHostApplicationTrait, IParamValueQueueTrait, IParameterChangesTrait, + IHostApplicationTrait, IMidiMappingTrait, IParamValueQueueTrait, IParameterChangesTrait, }; use vst3::Steinberg::{self as sb}; use vst3::Steinberg::{FUnknown, IBStreamTrait, IPlugFrameTrait, IPlugViewTrait, IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait}; @@ -82,6 +82,10 @@ fn ok(r: sb::tresult) -> bool { r == sb::kResultOk } +/// VST3's numbers for the MIDI controllers that aren't CCs. +pub(crate) const AFTERTOUCH: usize = sv::ControllerNumbers_::kAfterTouch as usize; +pub(crate) const PITCH_BEND: usize = sv::ControllerNumbers_::kPitchBend as usize; + // ---- loaded binaries ----------------------------------------------------------------------- /// One class as reported by the factory. @@ -959,17 +963,18 @@ impl IBStreamTrait for MemoryStream { pub(crate) enum NoteEvent { On { time: u32, key: u8, velocity: f32 }, Off { time: u32, key: u8 }, + Pressure { time: u32, key: u8, pressure: f32 }, } impl NoteEvent { pub fn time(&self) -> u32 { match *self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => time, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => time, } } pub fn set_time(&mut self, t: u32) { match self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => *time = t, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => *time = t, } } @@ -988,6 +993,10 @@ impl NoteEvent { e.r#type = sv::Event_::EventTypes_::kNoteOffEvent as u16; e.__field0.noteOff = sv::NoteOffEvent { channel: 0, pitch: i16::from(key), velocity: 0.0, noteId: -1, tuning: 0.0 }; } + NoteEvent::Pressure { key, pressure, .. } => { + e.r#type = sv::Event_::EventTypes_::kPolyPressureEvent as u16; + e.__field0.polyPressure = sv::PolyPressureEvent { channel: 0, pitch: i16::from(key), pressure, noteId: -1 }; + } } e } @@ -1261,6 +1270,20 @@ impl Instance { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// The parameter each MIDI controller drives on channel 1 (`IMidiMapping`), indexed by VST3's + /// controller numbers: CCs 0–127, then [`AFTERTOUCH`] and [`PITCH_BEND`]. Main thread only. + pub fn midi_mapping(&self) -> Vec> { + let Some(m) = self.controller.as_ref().and_then(|c| c.cast::()) else { return Vec::new() }; + (0..=PITCH_BEND) + .map(|n| { + let mut id: sv::ParamID = 0; + let n = i16::try_from(n).unwrap_or(0); + // SAFETY: valid mapping interface on the main thread; `id` is a local. + ok(unsafe { m.getMidiControllerAssignment(0, 0, n, &mut id) }).then_some(id) + }) + .collect() + } + pub fn value_text(&self, id: u32, normalized: f64) -> Option { let c = self.controller.as_ref()?; let mut s: sv::String128 = [0; 128]; @@ -1414,13 +1437,16 @@ impl Instance { } /// Runs one `process` call over `frames` frames: `params` become one point each at offset 0, - /// `notes` (sorted by time) have their times made relative to `offset`. + /// `controls` (time, id, value) one point at their time when it falls in this call (the last + /// one wins per parameter), and `notes` (sorted by time) have their times made relative to + /// `offset`. pub fn process( &mut self, frames: usize, inputs: &mut BusBuffers, outputs: &mut BusBuffers, params: &[(u32, f64)], + controls: &[(u32, u32, f64)], notes: &[NoteEvent], offset: u32, project_time: i64, @@ -1443,6 +1469,11 @@ impl Instance { for &(id, v) in params.iter().take(PARAM_QUEUE_CAP) { self.in_params.set(id, 0, v.clamp(0.0, 1.0)); } + for &(time, id, v) in controls { + if let Some(at) = time.checked_sub(offset).filter(|&t| (t as usize) < frames) { + self.in_params.set(id, i32::try_from(at).unwrap_or(0), v.clamp(0.0, 1.0)); + } + } self.out_params.clear(); let event_in = self.event_in; self.in_events.with(|v| { diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index d88f6d63..321261ea 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,6 +1,6 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP}; +use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -35,6 +35,10 @@ pub struct Vst3Plugin { pending: Vec<(u32, f64)>, /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. notes: Vec, + /// The parameter each MIDI controller drives (see `Instance::midi_mapping`). + midi_map: Vec>, + /// Controller points for the next block: (time, param id, normalized value). Bounded. + controls: Vec<(u32, u32, f64)>, inputs: BusBuffers, outputs: BusBuffers, has_input: bool, @@ -78,6 +82,8 @@ impl Vst3Plugin { values: Vec::with_capacity(ids.len()), pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), notes: Vec::with_capacity(EVENT_CAP), + midi_map: Vec::new(), + controls: Vec::with_capacity(EVENT_CAP), inputs: BusBuffers::new(&[], 0), outputs: BusBuffers::new(&[], 0), has_input: false, @@ -180,6 +186,7 @@ impl Plugin for Vst3Plugin { self.max_block = mb; self.latency = self.inst.latency() as usize; self.tail = self.inst.tail() as usize; + self.midi_map = self.inst.midi_mapping(); } Err(e) => { log::warn!("{}: {e}", self.info.id); @@ -197,6 +204,7 @@ impl Plugin for Vst3Plugin { fn reset(&mut self) { self.notes.clear(); + self.controls.clear(); self.held = [false; 128]; } @@ -239,6 +247,9 @@ impl Plugin for Vst3Plugin { e.set_time(last); } } + for c in &mut self.controls { + c.0 = c.0.min(last); + } let mut start = 0usize; while start < frames { let n = (frames - start).min(self.max_block); @@ -255,7 +266,17 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position, self.transport.as_ref()); + let r = self.inst.process( + n, + &mut self.inputs, + &mut self.outputs, + &self.pending, + &self.controls, + notes, + offset, + self.position, + self.transport.as_ref(), + ); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); @@ -280,6 +301,7 @@ impl Plugin for Vst3Plugin { } start += n; } + self.controls.clear(); } fn latency(&self) -> usize { @@ -316,6 +338,26 @@ impl Plugin for Vst3Plugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let time = u32::try_from(offset).unwrap_or(u32::MAX); + let [_, d1, d2] = msg.map(|b| b & 0x7F); + let (ctrl, value) = match msg[0] & 0xF0 { + 0xB0 => (usize::from(d1), f64::from(d2) / 127.0), + 0xD0 => (AFTERTOUCH, f64::from(d1) / 127.0), + 0xE0 => (PITCH_BEND, f64::from(u16::from(d2) << 7 | u16::from(d1)) / 16383.0), + 0xA0 if self.event_input => { + self.push_note(NoteEvent::Pressure { time, key: d1, pressure: f32::from(d2) / 127.0 }); + return; + } + _ => return, + }; + if let Some(&Some(id)) = self.midi_map.get(ctrl) + && self.controls.len() < self.controls.capacity() + { + self.controls.push((time, id, value)); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c61..ba1348ac 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -9,7 +9,8 @@ //! 320×240, no real drawing) supporting NSView/HWND/X11. //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also //! its edit controller). No audio input, one stereo output, one event input; every output -//! sample is the velocity of the held note (sample-accurate), 0 when none. +//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and +//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -417,7 +418,21 @@ struct Synth { } impl Class for Synth { - type Interfaces = (IComponent, IAudioProcessor, IEditController); + type Interfaces = (IComponent, IAudioProcessor, IEditController, IMidiMapping); +} + +/// The synth's hidden parameter that MIDI CC 7 drives. +const VOLUME_PARAM: ParamID = 1; + +impl IMidiMappingTrait for Synth { + unsafe fn getMidiControllerAssignment(&self, bus: i32, channel: i16, ctrl: CtrlNumber, id: *mut ParamID) -> tresult { + if bus == 0 && channel == 0 && ctrl == 7 { + unsafe { *id = VOLUME_PARAM }; + kResultOk + } else { + kResultFalse + } + } } impl IPluginBaseTrait for Synth { @@ -521,6 +536,16 @@ impl IAudioProcessorTrait for Synth { } } } + let mut volume = None; + if let Some(changes) = unsafe { ComRef::from_raw(d.inputParameterChanges) } { + for i in 0..unsafe { changes.getParameterCount() } { + let Some(q) = (unsafe { ComRef::from_raw(changes.getParameterData(i)) }) else { continue }; + let (mut at, mut v) = (0i32, 0f64); + if unsafe { q.getParameterId() } == VOLUME_PARAM && unsafe { q.getPoint(0, &mut at, &mut v) } == kResultOk { + volume = Some((at.max(0) as usize, v as f32)); + } + } + } let outs = unsafe { channels(&*d.outputs, frames) }; let mut level = f32::from_bits(self.level.load(Ordering::Relaxed)); let mut out = vec![0.0f32; frames]; @@ -530,8 +555,15 @@ impl IAudioProcessorTrait for Synth { level = unsafe { e.__field0.noteOn.velocity }; } else if e.r#type == Event_::EventTypes_::kNoteOffEvent as u16 { level = 0.0; + } else if e.r#type == Event_::EventTypes_::kPolyPressureEvent as u16 { + level = unsafe { e.__field0.polyPressure.pressure }; } } + if let Some((at, v)) = volume + && at == i + { + level = v; + } *o = level; } self.level.store(level.to_bits(), Ordering::Relaxed); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7a..86cc7a10 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -121,6 +121,16 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + // MIDI CC 7 reaches the parameter the plugin maps it to, and poly pressure arrives as an event, + // each at its offset; an unmapped controller is dropped. + p.midi(10, [0xA0, 60, 32]); + p.midi(20, [0xB0, 7, 64]); + p.midi(30, [0xB0, 1, 127]); + p.process(&mut io, 64); + assert!(io[0][9].abs() < 1e-6 && (io[0][10] - 32.0 / 127.0).abs() < 1e-6); + assert!((io[0][20] - 64.0 / 127.0).abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6); + p.midi(0, [0xB0, 7, 0]); + p.process(&mut io, 64); // Notes in a later sub-block of a chunked call land at the right frame. p.prepare(48_000.0, 32, 2); let mut io = vec![vec![0.0f32; 96], vec![0.0f32; 96]]; From f188b66209b70e54333518b9a58b143bd15272d0 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:42:15 +0100 Subject: [PATCH 3/3] Leave controller events before a clip's start silent A MIDI clip whose start was trimmed may keep the events before its new start at negative ticks, hidden until the start is trimmed out again (midi-clip-trim does this). The controller loop clamped every event's tick to 0, so a hidden sustain pedal or pitch bend played at the clip start, and the chase after a seek counted hidden values as the state in effect. Both loops now skip events before tick 0. Nothing changes for clips without hidden events: notes and controllers made by the app never start before tick 0. Test: a sustain-pedal event at tick -480 is neither sent when the clip starts nor chased after a seek into the clip, while a visible controller is still chased. --- crates/mix/src/lib.rs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index bae85452..5510a831 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -1368,7 +1368,8 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); - for c in &sequence.ctrls { + // Events before tick 0 are what a start trim hid: they don't play. + for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) { let tick = c.tick.saturating_add(delay).max(0); if !near.contains(&tick) { continue; @@ -1413,7 +1414,7 @@ fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot } let base = s.tempo.samples_to_ticks(clip.start, sr); let until = pos.min(clip.end()); - for c in &sequence.ctrls { + for c in sequence.ctrls.iter().filter(|c| c.tick >= 0) { let slot = match c.kind { CtrlKind::Cc(n) => usize::from(n & 0x7F), CtrlKind::PitchBend => BEND, @@ -2380,6 +2381,27 @@ mod tests { assert!(heard.lock().unwrap().is_empty()); } + #[test] + fn controllers_hidden_before_a_clip_start_are_neither_played_nor_chased() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.ears")); + let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value }; + let ctrls = vec![cc(-480, CtrlKind::Cc(64), 127), cc(960, CtrlKind::Cc(1), 64)]; + let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() }; + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq)); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let ears = Ears { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.ears".into(), ch: 2, plugin: Box::new(ears) }]); + render_block(&mut eng, &s, 0); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0])]); + render_block(&mut eng, &s, 36_000); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 1, 64])]); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out);