diff --git a/crates/au-host/src/ffi.rs b/crates/au-host/src/ffi.rs index a115244..641d16b 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 7d07e15..39172c0 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 c76aab0..23717aa 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 } @@ -227,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 2e24675..2cef419 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/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..28d017d 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 6fe1abc..ec375cd 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; @@ -33,11 +33,14 @@ 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, latency: usize, steady: i64, + transport: Option, } impl ClapPlugin { @@ -55,11 +58,13 @@ impl ClapPlugin { in_main: None, out_main: None, notes: NoteMode::None, + midi_in: false, held: [false; 128], channels: 2, max_block: 0, latency: 0, steady: 0, + transport: None, } } @@ -114,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)) { @@ -187,7 +194,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 +224,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; @@ -248,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 40de13c..85a7999 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 2216487..b1bb354 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 62142b2..9bef75e 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,9 +213,14 @@ 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) {} + /// 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 75a7976..ca3a52b 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 c181687..ee85178 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 be662c6..1e0b92a 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 78ee226..ad55d83 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -449,6 +449,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 a8c8b56..44fbfa0 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -52,11 +52,12 @@ #![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_midi::CtrlKind; 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::ops::Bound; use std::sync::Arc; @@ -200,6 +201,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. @@ -277,6 +280,7 @@ impl Strip { instrument: None, sample: None, held: Vec::new(), + chase: true, muted: false, gr: 0.0, clip_fx: HashMap::new(), @@ -485,6 +489,7 @@ impl MixEngine { i.plugin.reset(); } s.held.clear(); + s.chase = true; s.align.clear(); s.align_pre.clear(); s.out_extra.clear(); @@ -592,6 +597,7 @@ impl MixEngine { i.plugin.all_notes_off(); } st.held.clear(); + st.chase = true; } } self.last_end = pos.saturating_add(frames as i64); @@ -686,6 +692,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()) { @@ -699,6 +706,7 @@ impl MixEngine { && !ins.bypass { apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); } } @@ -834,6 +842,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. @@ -914,6 +923,10 @@ fn process_strip( if let Some(ins) = &t.instrument { 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); @@ -946,6 +959,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 { @@ -1369,7 +1383,7 @@ fn midi_offset(s: &Session, t: &Track) -> i64 { .map_or(0, |v| v as i64) } -/// 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; // MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so @@ -1420,6 +1434,82 @@ 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); + // 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; + } + 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]); + let pos = pos.saturating_sub(midi_offset(s, t)); + 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.iter().filter(|c| c.tick >= 0) { + 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)); + } } } @@ -1504,6 +1594,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 @@ -2271,6 +2382,109 @@ 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); + } + + 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()); + } + struct Keys { inner: Box, heard: Arc>>, diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 884eb52..eb2d859 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}; @@ -24,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}; @@ -81,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. @@ -958,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, } } @@ -987,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 } @@ -1072,6 +1082,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>, @@ -1239,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() + } + /// Tells the controller about a value the host set (`setParamNormalized`). pub fn set_normalized(&self, id: u32, n: f64) { let Some(c) = &self.controller else { return }; @@ -1401,16 +1446,20 @@ 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, + transport: Option<&Transport>, ) -> Result<(), Vst3Error> { if !self.active { return Err(Vst3Error::Process("not active".into())); @@ -1429,6 +1478,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| { @@ -1442,8 +1496,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, @@ -1890,6 +1947,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 ba30610..56d5d31 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 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; use vst3::ComWrapper; @@ -39,6 +39,10 @@ pub struct Vst3Plugin { unsynced: Vec, /// 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, @@ -50,6 +54,7 @@ pub struct Vst3Plugin { latency: usize, tail: usize, position: i64, + transport: Option, } impl Vst3Plugin { @@ -82,6 +87,8 @@ impl Vst3Plugin { pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), unsynced: vec![false; ids.len()], 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, @@ -93,6 +100,7 @@ impl Vst3Plugin { latency: 0, tail: 0, position: 0, + transport: None, ids, maps, inst, @@ -184,6 +192,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); @@ -201,6 +210,7 @@ impl Plugin for Vst3Plugin { fn reset(&mut self) { self.notes.clear(); + self.controls.clear(); self.held = [false; 128]; } @@ -246,6 +256,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); @@ -262,7 +275,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); + 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)); @@ -287,12 +310,17 @@ impl Plugin for Vst3Plugin { } start += n; } + self.controls.clear(); } fn latency(&self) -> usize { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn tail_samples(&self) -> usize { self.tail } @@ -319,6 +347,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 a537ef6..935d4c6 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 b36e9bb..290b2b1 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]];