mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
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:
18 files changed
+864
-36
No files matched your search
+211
-6
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -299,6 +299,7 @@ impl Sampler {
|
||||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)>>>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]];
|
||||
|
||||
Reference in new issue
Block a user