Merge pull request #367 from grovebit/plugin-transport-midi

Give plugins the session's tempo and transport, and play MIDI controllers
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 06:30:26 -04:00
commit bbafeb85f6
18 files changed
+864 -36

No files matched your search

+211 -6
View File
@@ -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<BeatAndTempoProc>,
musical_time_location: Option<MusicalTimeProc>,
transport_state: Option<TransportStateProc>,
transport_state2: Option<TransportState2Proc>,
}
/// 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<T>(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<InputState>` read by `input_callback`; freed in `Drop` after disposal.
input: *mut InputState,
/// Leaked `Box<HostTime>` read by the host callbacks; freed in `Drop` after disposal.
time: *mut HostTime,
outputs: Vec<Vec<f32>>,
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<T>(&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::<c_void>();
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::<u8>(), ptr::null_mut::<f64>());
// 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::<AudioComponentDescription>(), 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::<AuRenderCallbackStruct>(), 16);
assert_eq!(std::mem::size_of::<HostCallbackInfo>(), 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);
+4 -2
View File
@@ -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.
+12 -1
View File
@@ -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) {
+19
View File
@@ -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<dyn Plugin>, 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}");
}
+75 -5
View File
@@ -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::<clap_event_transport>()).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<i32, ClapError> {
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::<c_void>(), 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::<clap_event_transport>()));
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);
}
}
+20 -3
View File
@@ -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<usize>,
out_main: Option<usize>,
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<Transport>,
}
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::<Vec<_>>(), mb);
self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::<Vec<_>>(), 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) {
+8 -1
View File
@@ -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;
}
}
_ => {}
}
}
+3
View File
@@ -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]
+23
View File
@@ -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.
+1 -1
View File
@@ -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 {
+1
View File
@@ -299,6 +299,7 @@ impl Sampler {
v.env.gate_off();
}
}
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
}
}
+60 -4
View File
@@ -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<f32>], 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);
}
+28
View File
@@ -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");
+217 -3
View File
@@ -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<Arc<SourceAudio>>,
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
held: Vec<u8>,
/// 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<u8>) {
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<dyn Plugin>,
seen: Arc<std::sync::Mutex<Vec<Transport>>>,
}
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<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], 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<dyn Plugin>,
heard: Arc<std::sync::Mutex<Vec<(usize, [u8; 3])>>>,
}
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<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], 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<dyn Plugin>,
heard: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
+87 -5
View File
@@ -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<ComPtr<sv::IAudioProcessor>>,
@@ -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<Option<u32>> {
let Some(m) = self.controller.as_ref().and_then(|c| c.cast::<sv::IMidiMapping>()) 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();
+51 -3
View File
@@ -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<bool>,
/// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`.
notes: Vec<NoteEvent>,
/// The parameter each MIDI controller drives (see `Instance::midi_mapping`).
midi_map: Vec<Option<u32>>,
/// 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<Transport>,
}
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) {
+34 -2
View File
@@ -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);
+10
View File
@@ -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]];