CLAP plugin hosting (isolated unsafe crate), stem bouncing

- soundcraft-clap-host: scans standard CLAP folders, loads plugins via
  clap-sys/libloading, implements the Plugin trait (audio, params, latency,
  notes); the only crate allowed unsafe, every block documented; stubs on wasm
- mixer, engine (mix.insert, engine.clap_plugins) and Mix window CLAP menu
- end-to-end tests against a Rust-built fixture plugin
- file.bounce_stems: one soloed file per track

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 23:44:22 -04:00
parent fd2a5fda4a
commit 2fc8e2ecb8
26 changed files with 2603 additions and 11 deletions

View File

@ -24,6 +24,7 @@ Pro Tools is installed on the dev machine and may be *observed* black-box: run i
## Never crash
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
- Errors are `Result<T, E>` + `?`. An unfinished feature returns an "unsupported" error.
- Input-derived numbers are hostile (files, commands, MCP/control params): `get()` not `[i]`, checked/saturating arithmetic, no NaN casts, cap allocations.
- The audio callback never blocks, allocates in steady state, or panics.

21
Cargo.lock generated
View File

@ -623,6 +623,12 @@ dependencies = [
"libc",
]
[[package]]
name = "clap-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@ -3382,6 +3388,18 @@ dependencies = [
"soundcraft-time",
]
[[package]]
name = "soundcraft-clap-host"
version = "0.1.0"
dependencies = [
"clap-sys",
"libloading",
"log",
"serde",
"soundcraft-dsp",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-cli"
version = "0.1.0"
@ -3413,6 +3431,7 @@ dependencies = [
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-midi",
"soundcraft-mix",
@ -3438,6 +3457,7 @@ dependencies = [
"log",
"rayon",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-midi",
"soundcraft-model",
@ -3490,6 +3510,7 @@ dependencies = [
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-engine",
"soundcraft-midi",

View File

@ -16,6 +16,7 @@ soundcraft-time = { path = "crates/time" }
soundcraft-audio-io = { path = "crates/audio-io" }
soundcraft-midi = { path = "crates/midi" }
soundcraft-dsp = { path = "crates/dsp" }
soundcraft-clap-host = { path = "crates/clap-host" }
soundcraft-model = { path = "crates/model" }
soundcraft-mix = { path = "crates/mix" }
soundcraft-engine = { path = "crates/engine" }

View File

@ -0,0 +1,30 @@
[package]
name = "soundcraft-clap-host"
description = "SoundCraft CLAP plugin host: scans, loads and runs third-party CLAP plugins behind the safe soundcraft-dsp Plugin trait (the only crate with unsafe code)"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
soundcraft-dsp.workspace = true
serde.workspace = true
thiserror.workspace = true
log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap-sys = "0.5"
libloading = "0.8"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
unsafe_code = "deny"
[lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"

845
crates/clap-host/src/ffi.rs Normal file
View File

@ -0,0 +1,845 @@
//! The FFI boundary: the only module in SoundCraft allowed to use `unsafe`.
//!
//! It loads CLAP binaries (`libloading`), calls the plugin entry/factory/plugin vtables
//! (`clap-sys` raw bindings) and implements the host callbacks. Everything it exposes to the rest
//! of the crate is a safe API returning `Result`.
//!
//! Soundness rests on the CLAP contract (the plugin honours the vtable signatures and the
//! documented pointer lifetimes). A plugin that violates it (e.g. dereferences garbage) can still
//! crash the process: hosting in-process can't prevent that. What this module guarantees is that
//! *SoundCraft* never hands the plugin dangling or mis-sized pointers, never calls a missing
//! (null) function pointer, and never reads plugin strings beyond their bounds.
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,
};
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::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency};
use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity};
use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports};
use clap_sys::ext::params::{
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
};
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
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::version::{CLAP_VERSION, clap_version_is_compatible};
use std::collections::HashMap;
use std::ffi::{CStr, CString, c_char, c_void};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
/// Most plugins one factory may report (hostile-input cap).
const MAX_PLUGINS_PER_FACTORY: u32 = 1024;
/// Most parameters read from one plugin.
pub(crate) const MAX_PARAMS: u32 = 4096;
/// Most audio ports per direction we provide buffers for.
pub(crate) const MAX_PORTS: u32 = 16;
/// Most channels per audio port.
pub(crate) const MAX_PORT_CHANNELS: u32 = 64;
/// Most feature strings read from a descriptor.
const MAX_FEATURES: usize = 64;
/// Capacity of the per-block event list (preallocated: `process` never allocates).
pub(crate) const EVENT_CAP: usize = 1024;
// ---- plugin strings ------------------------------------------------------------------------
/// Reads a NUL-terminated plugin string (`None` for null).
///
/// SAFETY contract for callers: `p` is null or points to a NUL-terminated string that stays valid
/// for the duration of the call (CLAP guarantees this for descriptor fields).
unsafe fn read_cstr(p: *const c_char) -> Option<String> {
if p.is_null() {
return None;
}
// SAFETY: non-null and NUL-terminated per this function's contract.
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
}
/// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array.
fn read_name(buf: &[c_char]) -> String {
let bytes: Vec<u8> = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect();
String::from_utf8_lossy(&bytes).into_owned()
}
// ---- loaded binaries -----------------------------------------------------------------------
/// A plugin descriptor as reported by the factory.
#[derive(Debug, Clone)]
pub(crate) struct RawDescriptor {
pub id: String,
pub name: String,
pub vendor: String,
pub version: String,
pub description: String,
pub features: Vec<String>,
}
/// A loaded, initialised CLAP binary. Loaded binaries are cached for the life of the process
/// (see [`Bundle::load`]): plugin code must never be unmapped while an instance may still run.
pub(crate) struct Bundle {
entry: *const clap_plugin_entry,
factory: *const clap_plugin_factory,
// Dropped last: the library must outlive every pointer above.
_lib: libloading::Library,
}
// SAFETY: CLAP requires the entry and the plugin factory to be thread-safe; the raw pointers are
// read-only vtables inside the loaded library, which `_lib` keeps mapped.
unsafe impl Send for Bundle {}
// SAFETY: as above; `Bundle` has no interior mutability on the Rust side.
unsafe impl Sync for Bundle {}
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = OnceLock::new();
L.get_or_init(|| Mutex::new(HashMap::new()))
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner);
if let Some(b) = map.get(path) {
return Ok(b.clone());
}
let b = Arc::new(Bundle::open(path)?);
map.insert(path.to_path_buf(), b.clone());
Ok(b)
}
fn open(path: &Path) -> Result<Bundle, ClapError> {
let err = |m: String| ClapError::Load(path.display().to_string(), m);
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
};
if entry.is_null() {
return Err(err("null clap_entry".into()));
}
// SAFETY: non-null pointer to the entry struct inside the library, which `lib` keeps mapped.
let e = unsafe { *entry };
if !clap_version_is_compatible(e.clap_version) {
return Err(err(format!("unsupported CLAP version {}.{}", e.clap_version.major, e.clap_version.minor)));
}
let (Some(init), Some(get_factory)) = (e.init, e.get_factory) else { return Err(err("incomplete clap_entry".into())) };
let cpath = CString::new(path.to_string_lossy().into_owned()).map_err(|_| err("path contains NUL".into()))?;
// SAFETY: `init` comes from a valid entry; the path is a valid NUL-terminated string that
// outlives the call.
if !unsafe { init(cpath.as_ptr()) } {
return Err(err("clap_entry.init failed".into()));
}
// SAFETY: the entry is initialised; the id is a static NUL-terminated string.
let factory = unsafe { get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } as *const clap_plugin_factory;
if factory.is_null() {
if let Some(deinit) = e.deinit {
// SAFETY: balanced with the successful `init` above.
unsafe { deinit() };
}
return Err(err("no plugin factory".into()));
}
Ok(Bundle { entry, factory, _lib: lib })
}
fn factory(&self) -> clap_plugin_factory {
// SAFETY: `factory` was non-null at load time and points into the still-mapped library.
unsafe { *self.factory }
}
/// The plugins this binary provides.
pub fn descriptors(&self) -> Vec<RawDescriptor> {
let f = self.factory();
let (Some(count), Some(get)) = (f.get_plugin_count, f.get_plugin_descriptor) else { return Vec::new() };
// SAFETY: vtable call with the factory pointer it belongs to.
let n = unsafe { count(self.factory) }.min(MAX_PLUGINS_PER_FACTORY);
let mut out = Vec::new();
for i in 0..n {
// SAFETY: index is below the reported count.
let d = unsafe { get(self.factory, i) };
if d.is_null() {
continue;
}
// SAFETY: non-null descriptor owned by the plugin library, valid while it is loaded.
let d = unsafe { *d };
// SAFETY: descriptor string fields are null or NUL-terminated (CLAP contract).
let Some(id) = (unsafe { read_cstr(d.id) }).filter(|s| !s.is_empty()) else { continue };
// SAFETY: as above.
let name = unsafe { read_cstr(d.name) }.unwrap_or_else(|| id.clone());
let mut features = Vec::new();
if !d.features.is_null() {
for k in 0..MAX_FEATURES {
// SAFETY: `features` is a NULL-terminated array; we stop at the terminator
// (or the cap) and never read past it.
let p = unsafe { *d.features.add(k) };
if p.is_null() {
break;
}
// SAFETY: a non-null entry of the feature array is a NUL-terminated string.
if let Some(s) = unsafe { read_cstr(p) } {
features.push(s);
}
}
}
out.push(RawDescriptor {
id,
name,
// SAFETY: descriptor string fields are null or NUL-terminated.
vendor: unsafe { read_cstr(d.vendor) }.unwrap_or_default(),
// SAFETY: as above.
version: unsafe { read_cstr(d.version) }.unwrap_or_default(),
// SAFETY: as above.
description: unsafe { read_cstr(d.description) }.unwrap_or_default(),
features,
});
}
out
}
/// Creates and initialises one plugin instance.
pub fn instantiate(self: &Arc<Self>, plugin_id: &str) -> Result<Instance, ClapError> {
let err = |m: &str| ClapError::Instantiate(plugin_id.to_string(), m.to_string());
let create = self.factory().create_plugin.ok_or_else(|| err("factory cannot create plugins"))?;
let cid = CString::new(plugin_id).map_err(|_| err("id contains NUL"))?;
let state = Box::new(HostState::default());
let host = Box::new(clap_host {
clap_version: CLAP_VERSION,
host_data: (&*state as *const HostState).cast_mut().cast::<c_void>(),
name: c"SoundCraft".as_ptr(),
vendor: c"SoundCraft contributors".as_ptr(),
url: c"https://github.com/storytold/soundcraft".as_ptr(),
version: c"0.1.0".as_ptr(),
get_extension: Some(host_get_extension),
request_restart: Some(host_request_restart),
request_process: Some(host_request_process),
request_callback: Some(host_request_callback),
});
// SAFETY: the factory is valid; `host` is boxed (stable address) and owned by the returned
// `Instance`, which destroys the plugin before dropping the box; `cid` outlives the call.
let plugin = unsafe { create(self.factory, &*host, cid.as_ptr()) };
if plugin.is_null() {
return Err(err("factory returned no instance"));
}
let mut inst = Instance {
plugin,
params: std::ptr::null(),
ports: std::ptr::null(),
latency: std::ptr::null(),
notes: std::ptr::null(),
raw: Vec::with_capacity(EVENT_CAP),
active: false,
processing: false,
max_frames: 0,
created: true,
_host: host,
state,
_bundle: self.clone(),
};
// SAFETY: `plugin` is a fresh non-null instance; we copy its vtable struct.
let vt = unsafe { *plugin };
let ok = match vt.init {
// SAFETY: `init` is called exactly once, right after creation, as CLAP requires.
Some(init) => unsafe { init(plugin) },
None => false,
};
if !ok {
// `Drop` destroys the instance (without deactivating: it never activated).
return Err(err("plugin init failed"));
}
inst.params = inst.extension::<clap_plugin_params>(CLAP_EXT_PARAMS);
inst.ports = inst.extension::<clap_plugin_audio_ports>(CLAP_EXT_AUDIO_PORTS);
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
Ok(inst)
}
}
impl Drop for Bundle {
fn drop(&mut self) {
// SAFETY: `entry` was valid at load time and `_lib` (dropped after this) keeps it mapped.
let e = unsafe { *self.entry };
if let Some(deinit) = e.deinit {
// SAFETY: balanced with the successful `init` in `open`; no instance holds an `Arc`
// to this bundle any more (they keep it alive), so no plugin code still runs.
unsafe { deinit() };
}
}
}
// ---- host side -----------------------------------------------------------------------------
/// Flags the plugin raises through the host callbacks (any thread).
#[derive(Default)]
pub(crate) struct HostState {
pub restart: AtomicBool,
pub process: AtomicBool,
pub callback: AtomicBool,
pub latency_changed: AtomicBool,
pub params_rescan: AtomicBool,
}
/// SAFETY contract: `host` is null or one of our `clap_host` structs, whose `host_data` points
/// to the `HostState` owned by the same `Instance`.
unsafe fn state<'a>(host: *const clap_host) -> Option<&'a HostState> {
if host.is_null() {
return None;
}
// SAFETY: non-null host created by `Bundle::instantiate` (contract above).
let data = unsafe { (*host).host_data } as *const HostState;
// SAFETY: `host_data` points to the boxed `HostState`, alive as long as the instance.
unsafe { data.as_ref() }
}
unsafe extern "C" fn host_request_restart(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.restart.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_request_process(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.process.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_request_callback(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.callback.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_log(_host: *const clap_host, severity: clap_log_severity, msg: *const c_char) {
// SAFETY: CLAP passes a NUL-terminated message (or null, handled).
let m = unsafe { read_cstr(msg) }.unwrap_or_default();
match severity {
CLAP_LOG_ERROR | CLAP_LOG_FATAL => log::error!("clap plugin: {m}"),
CLAP_LOG_WARNING => log::warn!("clap plugin: {m}"),
_ => log::debug!("clap plugin: {m}"),
}
}
unsafe extern "C" fn host_params_rescan(host: *const clap_host, _flags: clap_param_rescan_flags) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.params_rescan.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_params_clear(_host: *const clap_host, _id: clap_id, _flags: clap_param_clear_flags) {}
unsafe extern "C" fn host_params_request_flush(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.process.store(true, Ordering::Relaxed);
}
}
unsafe extern "C" fn host_latency_changed(host: *const clap_host) {
// SAFETY: the plugin passes back the host pointer we gave it.
if let Some(s) = unsafe { state(host) } {
s.latency_changed.store(true, Ordering::Relaxed);
}
}
static HOST_LOG: clap_host_log = clap_host_log { log: Some(host_log) };
static HOST_PARAMS: clap_host_params =
clap_host_params { rescan: Some(host_params_rescan), clear: Some(host_params_clear), request_flush: Some(host_params_request_flush) };
static HOST_LATENCY: clap_host_latency = clap_host_latency { changed: Some(host_latency_changed) };
unsafe extern "C" fn host_get_extension(_host: *const clap_host, id: *const c_char) -> *const c_void {
if id.is_null() {
return std::ptr::null();
}
// SAFETY: extension ids are NUL-terminated strings (CLAP contract); non-null checked above.
let id = unsafe { CStr::from_ptr(id) };
if id == CLAP_EXT_LOG {
(&HOST_LOG as *const clap_host_log).cast()
} else if id == CLAP_EXT_PARAMS {
(&HOST_PARAMS as *const clap_host_params).cast()
} else if id == CLAP_EXT_LATENCY {
(&HOST_LATENCY as *const clap_host_latency).cast()
} else {
// Everything else (gui, state, thread-check, timers, …) is unsupported: CLAP allows null.
std::ptr::null()
}
}
// ---- events --------------------------------------------------------------------------------
/// A host → plugin event, safe to build anywhere; converted to CLAP structs inside `process`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Event {
Param {
time: u32,
id: u32,
value: f64,
},
NoteOn {
time: u32,
key: u8,
velocity: f64,
},
NoteOff {
time: u32,
key: u8,
},
/// A raw MIDI 1.0 message (for plugins whose note port speaks only MIDI).
Midi {
time: u32,
data: [u8; 3],
},
}
impl Event {
pub fn time(&self) -> u32 {
match *self {
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => time,
}
}
pub fn set_time(&mut self, t: u32) {
match self {
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => *time = t,
}
}
}
#[derive(Clone, Copy)]
enum RawEvent {
Note(clap_event_note),
Param(clap_event_param_value),
Midi(clap_event_midi),
}
impl RawEvent {
fn header(&self) -> *const clap_event_header {
// The header is the first field of every `repr(C)` CLAP event struct, so a pointer to it is
// a pointer to the whole event, as CLAP expects.
match self {
RawEvent::Note(e) => &e.header,
RawEvent::Param(e) => &e.header,
RawEvent::Midi(e) => &e.header,
}
}
fn from_event(e: &Event, offset: u32) -> RawEvent {
let header = |size: usize, type_: u16, time: u32| clap_event_header {
size: u32::try_from(size).unwrap_or(u32::MAX),
time: time.saturating_sub(offset),
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_,
flags: 0,
};
match *e {
Event::Param { time, id, value } => RawEvent::Param(clap_event_param_value {
header: header(size_of::<clap_event_param_value>(), CLAP_EVENT_PARAM_VALUE, time),
param_id: id,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value,
}),
Event::NoteOn { time, key, velocity } => RawEvent::Note(clap_event_note {
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_ON, time),
note_id: -1,
port_index: 0,
channel: 0,
key: i16::from(key),
velocity,
}),
Event::NoteOff { time, key } => RawEvent::Note(clap_event_note {
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_OFF, time),
note_id: -1,
port_index: 0,
channel: 0,
key: i16::from(key),
velocity: 0.0,
}),
Event::Midi { time, data } => {
RawEvent::Midi(clap_event_midi { header: header(size_of::<clap_event_midi>(), CLAP_EVENT_MIDI, time), port_index: 0, data })
}
}
}
}
unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
if list.is_null() {
return 0;
}
// SAFETY: `list` is the `clap_input_events` we built in `process`; its ctx is the event Vec.
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
v.map_or(0, |v| u32::try_from(v.len()).unwrap_or(u32::MAX))
}
unsafe extern "C" fn in_events_get(list: *const clap_input_events, index: u32) -> *const clap_event_header {
if list.is_null() {
return std::ptr::null();
}
// SAFETY: as in `in_events_size`; the Vec is not mutated while the plugin processes.
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
v.and_then(|v| v.get(index as usize)).map_or(std::ptr::null(), RawEvent::header)
}
unsafe extern "C" fn out_events_try_push(_list: *const clap_output_events, _event: *const clap_event_header) -> bool {
// Plugin → host events (e.g. GUI parameter edits) are accepted and ignored: SoundCraft's
// session is the source of truth for parameter values.
true
}
// ---- audio buffers -------------------------------------------------------------------------
/// Planar f32 buffers for every audio port in one direction, with the pointer tables CLAP needs.
/// Allocated in `new`; `process` only rewrites pointers in place (no allocation).
pub(crate) struct PortBuffers {
data: Vec<Vec<Vec<f32>>>,
ptrs: Vec<Vec<*mut f32>>,
bufs: Vec<clap_audio_buffer>,
frames: usize,
}
// SAFETY: the raw pointers only ever point into `data`, which this struct owns; moving the struct
// to another thread moves that ownership with it.
unsafe impl Send for PortBuffers {}
impl PortBuffers {
/// One buffer set per port, `channels[p]` channels each (capped), `frames` long.
pub fn new(channels: &[u32], frames: usize) -> PortBuffers {
let data: Vec<Vec<Vec<f32>>> =
channels.iter().take(MAX_PORTS as usize).map(|&c| (0..c.min(MAX_PORT_CHANNELS)).map(|_| vec![0.0; frames]).collect()).collect();
let ptrs = data.iter().map(|p| vec![std::ptr::null_mut(); p.len()]).collect();
let bufs = data
.iter()
.map(|_| clap_audio_buffer { data32: std::ptr::null_mut(), data64: std::ptr::null_mut(), channel_count: 0, latency: 0, constant_mask: 0 })
.collect();
PortBuffers { data, ptrs, bufs, frames }
}
pub fn channels(&self, port: usize) -> usize {
self.data.get(port).map_or(0, Vec::len)
}
pub fn channel(&self, port: usize, ch: usize) -> Option<&[f32]> {
self.data.get(port).and_then(|p| p.get(ch)).map(Vec::as_slice)
}
pub fn channel_mut(&mut self, port: usize, ch: usize) -> Option<&mut [f32]> {
self.data.get_mut(port).and_then(|p| p.get_mut(ch)).map(Vec::as_mut_slice)
}
/// Points the CLAP tables at the current data (no allocation).
fn refresh(&mut self) {
for ((d, p), b) in self.data.iter_mut().zip(self.ptrs.iter_mut()).zip(self.bufs.iter_mut()) {
for (dc, pc) in d.iter_mut().zip(p.iter_mut()) {
*pc = dc.as_mut_ptr();
}
b.data32 = p.as_mut_ptr();
b.data64 = std::ptr::null_mut();
b.channel_count = u32::try_from(p.len()).unwrap_or(0);
b.constant_mask = 0;
}
}
}
// ---- instances -----------------------------------------------------------------------------
/// What one audio port looks like.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PortInfo {
pub channels: u32,
pub main: bool,
}
/// One parameter as reported by `clap_plugin_params.get_info`.
#[derive(Debug, Clone)]
pub(crate) struct RawParam {
pub id: u32,
pub name: String,
pub module: String,
pub flags: u32,
pub min: f64,
pub max: f64,
pub default: f64,
}
/// Note dialects of the plugin's first input note port.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct NotePort {
pub supported: u32,
pub preferred: u32,
}
/// A live plugin instance plus the host struct it was created with.
pub(crate) struct Instance {
plugin: *const clap_plugin,
params: *const clap_plugin_params,
ports: *const clap_plugin_audio_ports,
latency: *const clap_plugin_latency,
notes: *const clap_plugin_note_ports,
raw: Vec<RawEvent>,
active: bool,
processing: bool,
max_frames: usize,
created: bool,
// Field order matters for drop: the plugin is destroyed in `Drop::drop` first; then the host
// struct and its state, then the bundle (which keeps the code mapped) go.
_host: Box<clap_host>,
state: Box<HostState>,
_bundle: Arc<Bundle>,
}
// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always
// serialised. CLAP lets audio-thread functions run on any one thread at a time; SoundCraft creates
// instances on the engine/UI thread and then moves them into the mix engine (documented
// limitation: some main-thread calls such as `activate` may then happen on the audio thread).
unsafe impl Send for Instance {}
impl Instance {
fn vt(&self) -> clap_plugin {
// SAFETY: `plugin` is non-null (checked at creation) and alive until `Drop`.
unsafe { *self.plugin }
}
fn extension<T>(&self, id: &CStr) -> *const T {
match self.vt().get_extension {
// SAFETY: valid instance; `id` is a static NUL-terminated string.
Some(f) => unsafe { f(self.plugin, id.as_ptr()) }.cast::<T>(),
None => std::ptr::null(),
}
}
pub fn state(&self) -> &HostState {
&self.state
}
/// Runs `on_main_thread` if the plugin asked for a callback.
pub fn service_callback(&mut self) {
if self.state.callback.swap(false, Ordering::Relaxed)
&& let Some(f) = self.vt().on_main_thread
{
// SAFETY: valid instance; the plugin asked for this call via `request_callback`.
unsafe { f(self.plugin) };
}
}
pub fn params(&self) -> Vec<RawParam> {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(p) = (unsafe { self.params.as_ref() }) else { return Vec::new() };
let (Some(count), Some(get_info)) = (p.count, p.get_info) else { return Vec::new() };
// SAFETY: vtable call with the instance it belongs to.
let n = unsafe { count(self.plugin) }.min(MAX_PARAMS);
let mut out = Vec::new();
for i in 0..n {
// SAFETY: `clap_param_info` is plain data (integers, floats, char arrays, a nullable
// pointer); all-zero is a valid value.
let mut info: clap_param_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is a valid, writable struct.
if !unsafe { get_info(self.plugin, i, &mut info) } {
continue;
}
let (min, max) = (info.min_value, info.max_value);
if !(min.is_finite() && max.is_finite()) || max < min {
continue;
}
let default = if info.default_value.is_finite() { info.default_value.clamp(min, max) } else { min };
out.push(RawParam { id: info.id, name: read_name(&info.name), module: read_name(&info.module), flags: info.flags, min, max, default });
}
out
}
pub fn param_value(&self, id: u32) -> Option<f64> {
// SAFETY: see `params`.
let p = unsafe { self.params.as_ref() }?;
let get = p.get_value?;
let mut v = 0.0f64;
// SAFETY: valid instance; `v` is writable.
(unsafe { get(self.plugin, id, &mut v) } && v.is_finite()).then_some(v)
}
pub fn value_text(&self, id: u32, value: f64) -> Option<String> {
// SAFETY: see `params`.
let p = unsafe { self.params.as_ref() }?;
let f = p.value_to_text?;
let mut buf = [0 as c_char; 128];
// SAFETY: valid instance; we pass the buffer's true capacity.
let ok = unsafe { f(self.plugin, id, value, buf.as_mut_ptr(), buf.len() as u32) };
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
}
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
// SAFETY: see `params`.
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
// SAFETY: vtable call with its instance.
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
let mut out = Vec::new();
for i in 0..n {
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is writable.
if unsafe { get(self.plugin, i, is_input, &mut info) } {
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
} else {
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
out.push(PortInfo { channels: 0, main: false });
}
}
out
}
pub fn note_port(&self) -> Option<NotePort> {
// SAFETY: see `params`.
let p = unsafe { self.notes.as_ref() }?;
let (count, get) = (p.count?, p.get?);
// SAFETY: vtable call with its instance.
if unsafe { count(self.plugin, true) } == 0 {
return None;
}
// SAFETY: plain data; all-zero is valid.
let mut info: clap_note_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index 0 is below the non-zero count; `info` is writable.
unsafe { get(self.plugin, 0, true, &mut info) }.then_some(NotePort { supported: info.supported_dialects, preferred: info.preferred_dialect })
}
pub fn latency(&self) -> u32 {
// SAFETY: see `params`.
match unsafe { self.latency.as_ref() }.and_then(|l| l.get) {
// SAFETY: valid instance.
Some(f) => unsafe { f(self.plugin) },
None => 0,
}
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn activate(&mut self, sample_rate: f64, min_frames: u32, max_frames: u32) -> Result<(), ClapError> {
self.deactivate();
let f = self.vt().activate.ok_or_else(|| ClapError::Process("plugin has no activate".into()))?;
// SAFETY: valid, inactive instance; arguments are sane (checked by the caller).
if !unsafe { f(self.plugin, sample_rate, min_frames, max_frames) } {
return Err(ClapError::Process("activate failed".into()));
}
self.active = true;
self.max_frames = max_frames as usize;
Ok(())
}
pub fn deactivate(&mut self) {
self.stop_processing();
if self.active {
if let Some(f) = self.vt().deactivate {
// SAFETY: valid, active instance.
unsafe { f(self.plugin) };
}
self.active = false;
}
}
fn stop_processing(&mut self) {
if self.processing {
if let Some(f) = self.vt().stop_processing {
// SAFETY: valid instance in the processing state.
unsafe { f(self.plugin) };
}
self.processing = false;
}
}
pub fn reset(&mut self) {
if self.active
&& let Some(f) = self.vt().reset
{
// SAFETY: valid, active instance.
unsafe { f(self.plugin) };
}
}
/// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their
/// times are made relative to `offset`. Returns the CLAP process status.
pub fn process(
&mut self,
frames: usize,
steady_time: i64,
inputs: &mut PortBuffers,
outputs: &mut PortBuffers,
events: &[Event],
offset: u32,
) -> Result<i32, ClapError> {
if !self.active {
return Err(ClapError::Process("not active".into()));
}
if frames == 0 || frames > self.max_frames || frames > inputs.frames || frames > outputs.frames {
return Err(ClapError::Process("block larger than the activated size".into()));
}
if !self.processing {
let ok = match self.vt().start_processing {
// SAFETY: valid, active instance.
Some(f) => unsafe { f(self.plugin) },
None => true,
};
if !ok {
return Err(ClapError::Process("start_processing failed".into()));
}
self.processing = true;
}
let process = self.vt().process.ok_or_else(|| ClapError::Process("plugin has no process".into()))?;
self.raw.clear();
for e in events.iter().take(self.raw.capacity()) {
self.raw.push(RawEvent::from_event(e, offset));
}
inputs.refresh();
outputs.refresh();
let in_events = clap_input_events {
ctx: (&self.raw as *const Vec<RawEvent>).cast_mut().cast::<c_void>(),
size: Some(in_events_size),
get: Some(in_events_get),
};
let out_events = clap_output_events { ctx: std::ptr::null_mut(), try_push: Some(out_events_try_push) };
let p = clap_process {
steady_time,
frames_count: u32::try_from(frames).unwrap_or(0),
transport: std::ptr::null(),
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),
audio_outputs_count: u32::try_from(outputs.bufs.len()).unwrap_or(0),
in_events: &in_events,
out_events: &out_events,
};
// 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.
let status = unsafe { process(self.plugin, &p) };
if status == CLAP_PROCESS_ERROR { Err(ClapError::Process("plugin reported a processing error".into())) } else { Ok(status) }
}
}
impl Drop for Instance {
fn drop(&mut self) {
self.deactivate();
if self.created
&& let Some(f) = self.vt().destroy
{
// SAFETY: valid instance, deactivated above; destroyed exactly once.
unsafe { f(self.plugin) };
}
self.created = false;
}
}

223
crates/clap-host/src/lib.rs Normal file
View File

@ -0,0 +1,223 @@
//! SoundCraft's CLAP plugin host.
//!
//! Scans the standard CLAP folders (plus `CLAP_PATH`), loads `.clap` binaries in-process and
//! exposes each plugin as a [`soundcraft_dsp::Plugin`], so the mix engine treats third-party
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
//!
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
//! (segfault) takes the process down with it; we cannot sandbox native code.
//!
//! `PluginInfo` must be `&'static`: the registry leaks exactly one `PluginInfo` (with its param
//! table) per plugin *type* the first time that type is instantiated, and caches it. The leak is
//! bounded by the number of distinct installed plugins actually used. Loaded binaries likewise
//! stay mapped for the life of the process.
//!
//! On `wasm32` the crate compiles to stubs: scans are empty and nothing can be created.
//!
//! Plugin GUIs (`clap.gui`) are not hosted yet; SoundCraft shows its generic parameter editor.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod scan;
#[cfg(not(target_arch = "wasm32"))]
#[allow(unsafe_code)]
mod ffi;
#[cfg(not(target_arch = "wasm32"))]
mod plugin;
#[cfg(not(target_arch = "wasm32"))]
mod registry;
#[cfg(not(target_arch = "wasm32"))]
pub use plugin::ClapPlugin;
use soundcraft_dsp::{Category, Plugin, PluginInfo};
use std::path::{Path, PathBuf};
/// Prefix of every hosted-CLAP plugin id.
pub const ID_PREFIX: &str = "clap:";
/// The CLAP plugin id inside a `clap:<id>` SoundCraft id (`None` for other ids or an empty id).
pub fn parse_id(id: &str) -> Option<&str> {
id.strip_prefix(ID_PREFIX).filter(|s| !s.is_empty() && !s.contains('\0'))
}
/// One plugin found by a scan.
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ClapDescriptor {
/// SoundCraft id: `clap:<plugin_id>`.
pub id: String,
/// The CLAP plugin id (reverse-DNS, e.g. `com.vendor.reverb`).
pub plugin_id: String,
pub name: String,
pub vendor: String,
pub version: String,
pub description: String,
pub features: Vec<String>,
pub category: Category,
pub is_instrument: bool,
/// The `.clap` file or bundle it lives in.
pub path: PathBuf,
}
/// Why a CLAP operation failed.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum ClapError {
#[error("cannot load CLAP plugin {0}: {1}")]
Load(String, String),
#[error("no CLAP plugin `{0}`")]
NotFound(String),
#[error("cannot create CLAP plugin `{0}`: {1}")]
Instantiate(String, String),
#[error("CLAP processing: {0}")]
Process(String),
#[error("CLAP plugins are not supported on this platform")]
Unsupported,
}
#[cfg(not(target_arch = "wasm32"))]
mod api {
use super::*;
pub fn scan() -> Vec<ClapDescriptor> {
registry::scan()
}
pub fn rescan() -> Vec<ClapDescriptor> {
registry::rescan()
}
pub fn add_search_dir(dir: &Path) -> usize {
registry::add_search_dir(dir)
}
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
registry::scan_paths(dirs)
}
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
registry::load_bundle(path)
}
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
registry::instantiate(id)
}
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
registry::plugin_info(id)
}
}
#[cfg(target_arch = "wasm32")]
mod api {
use super::*;
pub fn scan() -> Vec<ClapDescriptor> {
Vec::new()
}
pub fn rescan() -> Vec<ClapDescriptor> {
Vec::new()
}
pub fn add_search_dir(_dir: &Path) -> usize {
0
}
pub fn scan_paths(_dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
Vec::new()
}
pub fn load_bundle(_path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
Err(ClapError::Unsupported)
}
pub fn instantiate(_id: &str) -> Result<Box<dyn Plugin>, ClapError> {
Err(ClapError::Unsupported)
}
pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> {
None
}
}
/// Every plugin in the standard CLAP folders, `CLAP_PATH` and folders added with
/// [`add_search_dir`]. Scanned on first call (loads each binary's entry, but creates no plugin
/// instances), then cached in memory.
pub fn scan() -> Vec<ClapDescriptor> {
api::scan()
}
/// Forgets the cached scan (and remembered failures) and scans again.
pub fn rescan() -> Vec<ClapDescriptor> {
api::rescan()
}
/// Adds a folder to the search path and merges its plugins into the cache. Returns how many
/// plugins the folder holds.
pub fn add_search_dir(dir: &Path) -> usize {
api::add_search_dir(dir)
}
/// Scans the given folders without touching the cache.
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
api::scan_paths(dirs)
}
/// The plugins in one `.clap` file or bundle.
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
api::load_bundle(path)
}
/// Creates a plugin by `clap:<id>`, prepared for 48 kHz stereo with 1024-frame blocks (like
/// `soundcraft_dsp::create`), with the reason when it fails.
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
api::instantiate(id)
}
/// Creates a plugin by `clap:<id>`; `None` if unknown or it fails to load.
pub fn create(id: &str) -> Option<Box<dyn Plugin>> {
parse_id(id)?;
match api::instantiate(id) {
Ok(p) => Some(p),
Err(e) => {
log::warn!("{e}");
None
}
}
}
/// The description of a `clap:<id>` plugin. The first call for a type instantiates it once to
/// read its parameters; the result is cached (and leaked, see the crate docs).
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
api::plugin_info(id)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_parse() {
assert_eq!(parse_id("clap:com.u-he.diva"), Some("com.u-he.diva"));
assert_eq!(parse_id("clap:"), None);
assert_eq!(parse_id("eq_7band"), None);
assert_eq!(parse_id("CLAP:x"), None);
assert_eq!(parse_id("clap:a\0b"), None);
}
#[test]
fn non_clap_ids_are_rejected_without_scanning() {
assert!(create("eq_7band").is_none());
assert!(plugin_info("reverb").is_none());
}
#[test]
fn garbage_files_are_errors_not_crashes() {
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
std::fs::write(d.join("empty.clap"), b"").unwrap();
std::fs::write(d.join("Bundle.clap/Contents/MacOS/Bundle"), [0xcf, 0xfa, 0xed, 0xfe, 1, 2, 3]).unwrap();
for f in ["garbage.clap", "empty.clap", "Bundle.clap", "missing.clap"] {
assert!(load_bundle(&d.join(f)).is_err(), "{f}");
}
assert!(scan_paths(std::slice::from_ref(&d)).is_empty());
assert!(create("clap:does.not.exist").is_none());
assert!(plugin_info("clap:does.not.exist").is_none());
assert!(instantiate("clap:does.not.exist").is_err());
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@ -0,0 +1,246 @@
//! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait.
use crate::ffi::{EVENT_CAP, Event, Instance, NotePort, PortBuffers};
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
use soundcraft_dsp::{Plugin, PluginInfo};
use std::sync::atomic::Ordering;
/// Largest block we activate a plugin for.
const MAX_BLOCK: usize = 1 << 16;
/// Most channels a host strip may ask for.
const MAX_CHANNELS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq)]
enum NoteMode {
None,
Clap,
Midi,
}
/// A hosted CLAP plugin. Parameter ids are the CLAP param ids as decimal strings ("0", "17", …);
/// their human names are in `info().params`.
pub struct ClapPlugin {
inst: Instance,
info: &'static PluginInfo,
/// CLAP id of each `info.params` entry (same order).
ids: Vec<u32>,
values: Vec<f32>,
/// Pending events, kept sorted by time (stable for equal times). Never grows past `EVENT_CAP`.
queue: Vec<Event>,
inputs: PortBuffers,
outputs: PortBuffers,
in_main: Option<usize>,
out_main: Option<usize>,
notes: NoteMode,
held: [bool; 128],
channels: usize,
max_block: usize,
latency: usize,
steady: i64,
}
impl ClapPlugin {
pub(crate) fn new(inst: Instance, info: &'static PluginInfo) -> ClapPlugin {
let ids: Vec<u32> = info.params.iter().map(|p| p.id.parse::<u32>().unwrap_or(u32::MAX)).collect();
let values = info.params.iter().zip(&ids).map(|(p, id)| inst.param_value(*id).map_or(p.default, |v| p.clamp(v as f32))).collect();
ClapPlugin {
inst,
info,
ids,
values,
queue: Vec::with_capacity(EVENT_CAP),
inputs: PortBuffers::new(&[], 0),
outputs: PortBuffers::new(&[], 0),
in_main: None,
out_main: None,
notes: NoteMode::None,
held: [false; 128],
channels: 2,
max_block: 0,
latency: 0,
steady: 0,
}
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
fn push(&mut self, e: Event) {
if self.queue.len() >= self.queue.capacity() {
return;
}
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.queue.insert(pos, e);
}
}
fn pick_main(ports: &[crate::ffi::PortInfo]) -> Option<usize> {
ports.iter().position(|p| p.main && p.channels > 0).or_else(|| ports.iter().position(|p| p.channels > 0))
}
fn note_mode(p: Option<NotePort>) -> NoteMode {
match p {
Some(p) if p.supported & CLAP_NOTE_DIALECT_CLAP != 0 => NoteMode::Clap,
Some(p) if p.supported & CLAP_NOTE_DIALECT_MIDI != 0 => NoteMode::Midi,
_ => NoteMode::None,
}
}
impl Plugin for ClapPlugin {
fn info(&self) -> &'static PluginInfo {
self.info
}
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) {
let sr = if sample_rate.is_finite() && sample_rate > 0.0 { f64::from(sample_rate) } else { 48_000.0 };
let mb = max_block.clamp(1, MAX_BLOCK);
self.channels = channels.clamp(1, MAX_CHANNELS);
self.inst.deactivate();
self.inst.service_callback();
let ins = self.inst.audio_ports(true);
let outs = self.inst.audio_ports(false);
self.in_main = pick_main(&ins);
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());
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)) {
Ok(()) => {
self.latency = self.inst.latency() as usize;
self.inst.state().latency_changed.store(false, Ordering::Relaxed);
}
Err(e) => {
log::warn!("{}: {e}", self.info.id);
self.latency = 0;
}
}
}
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
self.held = [false; 128];
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
let Some(i) = self.info.params.iter().position(|p| p.id == id) else { return false };
let (Some(pi), Some(&cid)) = (self.info.params.get(i), self.ids.get(i)) else { return false };
let v = pi.clamp(value);
if let Some(slot) = self.values.get_mut(i) {
*slot = v;
}
// Coalesce with a pending change of the same parameter.
if let Some(Event::Param { value, .. }) = self.queue.iter_mut().find(|e| matches!(e, Event::Param { id, .. } if *id == cid)) {
*value = f64::from(v);
} else {
self.push(Event::Param { time: 0, id: cid, value: f64::from(v) });
}
true
}
fn param(&self, id: &str) -> Option<f32> {
let i = self.info.params.iter().position(|p| p.id == id)?;
self.values.get(i).copied()
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
if !self.inst.is_active() || self.max_block == 0 {
return;
}
let ch = self.channels.min(io.len());
let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min);
if frames == 0 || ch == 0 {
return;
}
let last = u32::try_from(frames - 1).unwrap_or(u32::MAX);
for e in &mut self.queue {
if e.time() > last {
e.set_time(last);
}
}
let mut start = 0usize;
while start < frames {
let n = (frames - start).min(self.max_block);
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input port (mono sources fill every port channel).
if let Some(p) = self.in_main {
for c in 0..self.inputs.channels(p) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
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);
self.queue.drain(..k);
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
match (r, self.out_main) {
(Ok(_), Some(p)) => {
let oc = self.outputs.channels(p);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
_ => {}
}
start += n;
}
}
fn latency(&self) -> usize {
self.latency
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
if note > 127 {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
let vel = velocity.min(127);
match self.notes {
NoteMode::Clap => self.push(Event::NoteOn { time, key: note, velocity: f64::from(vel) / 127.0 }),
NoteMode::Midi => self.push(Event::Midi { time, data: [0x90, note, vel] }),
NoteMode::None => return,
}
if let Some(h) = self.held.get_mut(note as usize) {
*h = true;
}
}
fn note_off(&mut self, offset: usize, note: u8) {
if note > 127 {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
match self.notes {
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
NoteMode::None => return,
}
if let Some(h) = self.held.get_mut(note as usize) {
*h = false;
}
}
fn all_notes_off(&mut self) {
for k in 0..128u8 {
if self.held.get(k as usize).copied().unwrap_or(false) {
self.note_off(0, k);
}
}
}
}

View File

@ -0,0 +1,209 @@
//! The process-wide CLAP registry: scan cache, leaked `PluginInfo`s, instantiation.
use crate::ffi::{Bundle, Instance, RawParam};
use crate::plugin::ClapPlugin;
use crate::scan as paths;
use crate::{ClapDescriptor, ClapError, parse_id};
use clap_sys::ext::params::{CLAP_PARAM_IS_ENUM, CLAP_PARAM_IS_HIDDEN, CLAP_PARAM_IS_READONLY, CLAP_PARAM_IS_STEPPED};
use soundcraft_dsp::{ParamInfo, Plugin, PluginInfo, Taper, Unit};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
/// Most labels read for an enum parameter.
const MAX_CHOICES: f64 = 128.0;
#[derive(Default)]
struct Registry {
scanned: Option<Vec<ClapDescriptor>>,
extra_dirs: Vec<PathBuf>,
/// One leaked `PluginInfo` per plugin type that was actually instantiated (bounded by the
/// number of distinct installed plugins).
infos: HashMap<String, &'static PluginInfo>,
/// Ids that failed to instantiate (not retried every UI frame).
failed: HashSet<String>,
}
fn registry() -> &'static Mutex<Registry> {
static R: OnceLock<Mutex<Registry>> = OnceLock::new();
R.get_or_init(|| Mutex::new(Registry::default()))
}
/// Descriptors of every plugin in one `.clap` file or bundle.
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
let b = Bundle::load(path)?;
Ok(b.descriptors()
.into_iter()
.map(|d| ClapDescriptor {
id: format!("{}{}", crate::ID_PREFIX, d.id),
category: paths::category_from_features(&d.features),
is_instrument: paths::is_instrument(&d.features),
plugin_id: d.id,
name: d.name,
vendor: d.vendor,
version: d.version,
description: d.description,
features: d.features,
path: path.to_path_buf(),
})
.collect())
}
/// Scans `dirs` (uncached). Unloadable files are logged and skipped; the first plugin with a
/// given id wins.
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
let mut out: Vec<ClapDescriptor> = Vec::new();
for path in paths::find_bundles(dirs) {
match load_bundle(&path) {
Ok(ds) => {
for d in ds {
if !out.iter().any(|o| o.id == d.id) {
out.push(d);
}
}
}
Err(e) => log::warn!("{e}"),
}
}
out.sort_by_key(|d| d.name.to_lowercase());
out
}
fn ensure_scanned(r: &mut Registry) -> &Vec<ClapDescriptor> {
if r.scanned.is_none() {
let mut dirs = paths::default_search_paths();
dirs.extend(r.extra_dirs.iter().cloned());
r.scanned = Some(scan_paths(&dirs));
}
r.scanned.get_or_insert_with(Vec::new)
}
pub fn scan() -> Vec<ClapDescriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
ensure_scanned(&mut r).clone()
}
pub fn rescan() -> Vec<ClapDescriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
r.scanned = None;
r.failed.clear();
ensure_scanned(&mut r).clone()
}
pub fn add_search_dir(dir: &Path) -> usize {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
if !r.extra_dirs.iter().any(|d| d == dir) {
r.extra_dirs.push(dir.to_path_buf());
}
let found = scan_paths(&[dir.to_path_buf()]);
let n = found.len();
let list = r.scanned.get_or_insert_with(Vec::new);
for d in found {
if !list.iter().any(|o| o.id == d.id) {
list.push(d);
}
}
n
}
fn leak(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
fn param_info(inst: &Instance, p: &RawParam) -> ParamInfo {
let stepped = p.flags & CLAP_PARAM_IS_STEPPED != 0;
let is_enum = p.flags & CLAP_PARAM_IS_ENUM != 0;
let (mut unit, mut choices): (Unit, &'static [&'static str]) = (Unit::None, &[]);
if stepped && p.min == 0.0 && p.max == 1.0 && !is_enum {
unit = Unit::Toggle;
} else if stepped && p.min == 0.0 && p.max >= 1.0 && p.max <= MAX_CHOICES {
let labels: Vec<&'static str> =
(0..=p.max as u32).map(|i| leak(inst.value_text(p.id, f64::from(i)).unwrap_or_else(|| i.to_string()))).collect();
unit = Unit::Choice;
choices = Box::leak(labels.into_boxed_slice());
}
let name = match (p.module.is_empty(), p.name.is_empty()) {
(_, true) => format!("Param {}", p.id),
(true, false) => p.name.clone(),
(false, false) => format!("{}/{}", p.module, p.name),
};
ParamInfo {
id: leak(p.id.to_string()),
name: leak(name),
min: p.min as f32,
max: p.max as f32,
default: p.default as f32,
unit,
taper: Taper::Linear,
choices,
}
}
/// Builds and leaks the `PluginInfo` for one plugin type. Called at most once per id (cached).
fn build_info(d: &ClapDescriptor, inst: &Instance) -> &'static PluginInfo {
let params: Vec<ParamInfo> =
inst.params().iter().filter(|p| p.flags & (CLAP_PARAM_IS_HIDDEN | CLAP_PARAM_IS_READONLY) == 0).map(|p| param_info(inst, p)).collect();
let short: String = d.name.chars().take(8).collect();
Box::leak(Box::new(PluginInfo {
id: leak(d.id.clone()),
name: leak(d.name.clone()),
short_name: leak(short),
category: d.category,
params: Box::leak(params.into_boxed_slice()),
is_instrument: d.is_instrument,
audiosuite: false,
}))
}
/// Finds the descriptor, loads its binary and creates an instance plus its (cached) info.
fn instantiate_raw(id: &str) -> Result<(Instance, &'static PluginInfo), ClapError> {
let plugin_id = parse_id(id).ok_or_else(|| ClapError::NotFound(id.to_string()))?;
let desc = {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
if r.failed.contains(id) {
return Err(ClapError::Instantiate(plugin_id.to_string(), "failed before".into()));
}
ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| ClapError::NotFound(id.to_string()))?
};
let made = Bundle::load(&desc.path).and_then(|b| b.instantiate(plugin_id));
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
match made {
Ok(inst) => {
let info = match r.infos.get(id) {
Some(i) => *i,
None => {
let i = build_info(&desc, &inst);
r.infos.insert(id.to_string(), i);
i
}
};
Ok((inst, info))
}
Err(e) => {
r.failed.insert(id.to_string());
Err(e)
}
}
}
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
let (inst, info) = instantiate_raw(id)?;
let mut p = ClapPlugin::new(inst, info);
p.prepare(48_000.0, 1024, 2);
Ok(Box::new(p))
}
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
parse_id(id)?;
{
let r = registry().lock().unwrap_or_else(PoisonError::into_inner);
if let Some(i) = r.infos.get(id) {
return Some(*i);
}
if r.failed.contains(id) {
return None;
}
}
// Instantiate once to learn the parameters (CLAP exposes them only on an instance).
instantiate_raw(id).ok().map(|(_, info)| info)
}

View File

@ -0,0 +1,215 @@
//! Finding CLAP plugins on disk (safe, pure `std::fs`) and mapping CLAP features to categories.
use soundcraft_dsp::Category;
use std::path::{Path, PathBuf};
/// Directory recursion limit while scanning (CLAP folders may be nested, never deeply).
const MAX_DEPTH: usize = 8;
/// Upper bound on bundles collected by one scan (hostile or huge folders).
const MAX_BUNDLES: usize = 4096;
/// Upper bound on directory entries visited by one scan.
const MAX_ENTRIES: usize = 100_000;
/// The standard CLAP search locations for this OS (existing or not), followed by every entry
/// of the `CLAP_PATH` environment variable.
pub fn default_search_paths() -> Vec<PathBuf> {
let mut v = os_search_paths(
std::env::var_os("HOME").map(PathBuf::from),
std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),
std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
);
if let Some(cp) = std::env::var_os("CLAP_PATH") {
v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));
}
v
}
/// The OS-specific standard locations, given the relevant environment values.
pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {
let mut v = Vec::new();
if cfg!(target_os = "macos") {
if let Some(h) = home {
v.push(h.join("Library/Audio/Plug-Ins/CLAP"));
}
v.push(PathBuf::from("/Library/Audio/Plug-Ins/CLAP"));
} else if cfg!(windows) {
if let Some(c) = common_program_files {
v.push(c.join("CLAP"));
}
if let Some(l) = local_app_data {
v.push(l.join("Programs").join("Common").join("CLAP"));
}
} else {
if let Some(h) = home {
v.push(h.join(".clap"));
}
v.push(PathBuf::from("/usr/lib/clap"));
v.push(PathBuf::from("/usr/local/lib/clap"));
}
v
}
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
pub fn is_clap_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
}
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
/// A `.clap` directory is a bundle and is not descended into. The result is sorted and deduplicated.
pub fn find_bundles(dirs: &[PathBuf]) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut budget = MAX_ENTRIES;
for d in dirs {
if is_clap_path(d) && d.exists() {
out.push(d.clone());
} else {
walk(d, 0, &mut out, &mut budget);
}
}
out.sort();
out.dedup();
out.truncate(MAX_BUNDLES);
out
}
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>, budget: &mut usize) {
if depth > MAX_DEPTH || out.len() >= MAX_BUNDLES {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
for entry in rd.flatten() {
if *budget == 0 || out.len() >= MAX_BUNDLES {
return;
}
*budget -= 1;
let p = entry.path();
// `metadata` follows symlinks (plugins are often symlinked in); errors are skipped.
let Ok(md) = std::fs::metadata(&p) else { continue };
if is_clap_path(&p) {
out.push(p);
} else if md.is_dir() {
walk(&p, depth + 1, out, budget);
}
}
}
/// The loadable binary of a plugin: the file itself, or for a macOS bundle directory
/// `Contents/MacOS/<name>` (the executable named like the bundle, else the only/first file there).
pub fn bundle_executable(path: &Path) -> Option<PathBuf> {
if path.is_file() {
return Some(path.to_path_buf());
}
if !path.is_dir() {
return None;
}
let macos = path.join("Contents").join("MacOS");
if let Some(stem) = path.file_stem() {
let p = macos.join(stem);
if p.is_file() {
return Some(p);
}
}
let mut files: Vec<PathBuf> = std::fs::read_dir(&macos).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).take(64).collect();
files.sort();
files.into_iter().next()
}
/// Maps CLAP feature strings to a SoundCraft insert-menu category.
pub fn category_from_features<S: AsRef<str>>(features: &[S]) -> Category {
let has = |names: &[&str]| features.iter().any(|f| names.iter().any(|n| f.as_ref().eq_ignore_ascii_case(n)));
if has(&["instrument", "synthesizer", "sampler", "drum", "drum-machine"]) {
Category::Instrument
} else if has(&["equalizer", "filter"]) {
Category::Eq
} else if has(&["compressor", "limiter", "expander", "gate", "de-esser", "transient-shaper"]) {
Category::Dynamics
} else if has(&["reverb"]) {
Category::Reverb
} else if has(&["delay"]) {
Category::Delay
} else if has(&["chorus", "flanger", "phaser", "tremolo"]) {
Category::Modulation
} else if has(&["distortion"]) {
Category::Harmonic
} else if has(&["pitch-shifter", "pitch-correction"]) {
Category::PitchShift
} else {
Category::Other
}
}
/// True when the features mark an instrument (CLAP `instrument`).
pub fn is_instrument<S: AsRef<str>>(features: &[S]) -> bool {
features.iter().any(|f| f.as_ref().eq_ignore_ascii_case("instrument"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn finds_files_and_bundles_recursively_but_not_inside_bundles() {
let d = tmp("find");
std::fs::write(d.join("a.clap"), b"x").unwrap();
std::fs::write(d.join("readme.txt"), b"x").unwrap();
std::fs::create_dir_all(d.join("vendor/sub")).unwrap();
std::fs::write(d.join("vendor/sub/B.CLAP"), b"x").unwrap();
std::fs::create_dir_all(d.join("c.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("c.clap/Contents/MacOS/c"), b"x").unwrap();
std::fs::write(d.join("c.clap/Contents/inner.clap"), b"x").unwrap();
let found = find_bundles(&[d.clone(), d.join("missing")]);
let names: Vec<String> = found.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
assert_eq!(names, vec!["a.clap", "c.clap", "vendor/sub/B.CLAP"]);
assert_eq!(bundle_executable(&d.join("c.clap")), Some(d.join("c.clap/Contents/MacOS/c")));
assert_eq!(bundle_executable(&d.join("a.clap")), Some(d.join("a.clap")));
assert_eq!(bundle_executable(&d.join("nope.clap")), None);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn bundle_executable_falls_back_to_first_file() {
let d = tmp("exe");
std::fs::create_dir_all(d.join("X.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("X.clap/Contents/MacOS/other"), b"x").unwrap();
assert_eq!(bundle_executable(&d.join("X.clap")), Some(d.join("X.clap/Contents/MacOS/other")));
std::fs::create_dir_all(d.join("Empty.clap")).unwrap();
assert_eq!(bundle_executable(&d.join("Empty.clap")), None);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn os_paths_cover_the_standard_locations() {
let v = os_search_paths(Some("/home/u".into()), Some("C:/CPF".into()), Some("C:/LAD".into()));
if cfg!(target_os = "macos") {
assert_eq!(v, vec![PathBuf::from("/home/u/Library/Audio/Plug-Ins/CLAP"), PathBuf::from("/Library/Audio/Plug-Ins/CLAP")]);
} else if cfg!(windows) {
assert_eq!(v.len(), 2);
assert!(v[0].ends_with("CLAP") && v[1].ends_with("CLAP"));
} else {
assert_eq!(v, vec![PathBuf::from("/home/u/.clap"), PathBuf::from("/usr/lib/clap"), PathBuf::from("/usr/local/lib/clap")]);
}
assert!(default_search_paths().len() >= v.len().saturating_sub(1));
}
#[test]
fn features_map_to_categories() {
assert_eq!(category_from_features(&["audio-effect", "reverb", "stereo"]), Category::Reverb);
assert_eq!(category_from_features(&["instrument", "synthesizer"]), Category::Instrument);
assert_eq!(category_from_features(&["audio-effect", "Compressor"]), Category::Dynamics);
assert_eq!(category_from_features(&["audio-effect", "equalizer"]), Category::Eq);
assert_eq!(category_from_features(&["chorus"]), Category::Modulation);
assert_eq!(category_from_features(&["distortion"]), Category::Harmonic);
assert_eq!(category_from_features(&["pitch-shifter"]), Category::PitchShift);
assert_eq!(category_from_features(&["delay"]), Category::Delay);
assert_eq!(category_from_features::<&str>(&[]), Category::Other);
assert!(is_instrument(&["instrument"]));
assert!(!is_instrument(&["audio-effect"]));
}
}

View File

@ -0,0 +1,16 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "clap-fixture"
version = "0.0.0"
dependencies = [
"clap-sys",
]
[[package]]
name = "clap-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154"

View File

@ -0,0 +1,18 @@
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
# with `cargo build` and loads the resulting library. Never shipped.
[package]
name = "clap-fixture"
version = "0.0.0"
edition = "2024"
publish = false
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib"]
path = "src/lib.rs"
[dependencies]
clap-sys = "0.5"
[workspace]

View File

@ -0,0 +1,398 @@
//! Test-only CLAP plugins for soundcraft-clap-host: a gain effect and a "DC synth".
//!
//! - `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.
#![allow(clippy::missing_safety_doc, non_upper_case_globals)]
use clap_sys::audio_buffer::clap_audio_buffer;
use clap_sys::entry::clap_plugin_entry;
use clap_sys::events::*;
use clap_sys::ext::audio_ports::*;
use clap_sys::ext::latency::*;
use clap_sys::ext::log::*;
use clap_sys::ext::note_ports::*;
use clap_sys::ext::params::*;
use clap_sys::factory::plugin_factory::*;
use clap_sys::host::clap_host;
use clap_sys::id::clap_id;
use clap_sys::plugin::*;
use clap_sys::process::*;
use clap_sys::version::CLAP_VERSION;
use std::ffi::{CStr, c_char, c_void};
struct Features<const N: usize>([*const c_char; N]);
unsafe impl<const N: usize> Sync for Features<N> {}
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: c"org.soundcraft.test.gain".as_ptr(),
name: c"Fixture Gain".as_ptr(),
vendor: c"SoundCraft tests".as_ptr(),
url: c"".as_ptr(),
manual_url: c"".as_ptr(),
support_url: c"".as_ptr(),
version: c"1.0.0".as_ptr(),
description: c"test gain".as_ptr(),
features: GAIN_FEATURES.0.as_ptr(),
};
static SYNTH_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: c"org.soundcraft.test.synth".as_ptr(),
name: c"Fixture Synth".as_ptr(),
vendor: c"SoundCraft tests".as_ptr(),
url: std::ptr::null(),
manual_url: std::ptr::null(),
support_url: std::ptr::null(),
version: c"1.0.0".as_ptr(),
description: std::ptr::null(),
features: SYNTH_FEATURES.0.as_ptr(),
};
struct Fx {
plugin: clap_plugin,
host: *const clap_host,
synth: bool,
gain: f64,
mode: f64,
level: f64,
active: bool,
processing: bool,
}
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
unsafe { &mut *((*p).plugin_data as *mut Fx) }
}
unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
let f = unsafe { fx(p) };
// Exercise the host's extension lookup + log callback.
let host = unsafe { &*f.host };
if let Some(ge) = host.get_extension {
let log = unsafe { ge(f.host, CLAP_EXT_LOG.as_ptr()) } as *const clap_host_log;
if let Some(l) = unsafe { log.as_ref() }.and_then(|l| l.log) {
unsafe { l(f.host, CLAP_LOG_INFO, c"fixture init".as_ptr()) };
}
let none = unsafe { ge(f.host, c"clap.does-not-exist".as_ptr()) };
if !none.is_null() {
return false;
}
}
true
}
unsafe extern "C" fn destroy(p: *const clap_plugin) {
let data = unsafe { (*p).plugin_data } as *mut Fx;
drop(unsafe { Box::from_raw(data) });
}
unsafe extern "C" fn activate(p: *const clap_plugin, sr: f64, min: u32, max: u32) -> bool {
let f = unsafe { fx(p) };
if f.active || !(sr > 0.0) || min == 0 || max < min {
return false;
}
f.active = true;
true
}
unsafe extern "C" fn deactivate(p: *const clap_plugin) {
let f = unsafe { fx(p) };
assert!(!f.processing, "deactivate while processing");
f.active = false;
}
unsafe extern "C" fn start_processing(p: *const clap_plugin) -> bool {
let f = unsafe { fx(p) };
assert!(f.active, "start_processing while inactive");
f.processing = true;
true
}
unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
unsafe { fx(p) }.processing = false;
}
unsafe extern "C" fn reset(p: *const clap_plugin) {
unsafe { fx(p) }.level = 0.0;
}
fn apply(f: &mut Fx, h: &clap_event_header) {
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
return;
}
match h.type_ {
CLAP_EVENT_PARAM_VALUE => {
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_param_value) };
match e.param_id {
7 => f.gain = e.value,
3 => f.mode = e.value,
_ => {}
}
}
CLAP_EVENT_NOTE_ON => {
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_note) };
f.level = e.velocity;
}
CLAP_EVENT_NOTE_OFF => f.level = 0.0,
_ => {}
}
}
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
let f = unsafe { fx(p) };
let pr = unsafe { &*pr };
assert!(f.processing, "process while not processing");
let n = pr.frames_count as usize;
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if pr.audio_outputs_count != 1 {
return CLAP_PROCESS_ERROR;
}
let out: &clap_audio_buffer = unsafe { &*pr.audio_outputs };
let input: Option<&clap_audio_buffer> = if f.synth {
if pr.audio_inputs_count != 0 {
return CLAP_PROCESS_ERROR;
}
None
} else {
if pr.audio_inputs_count != 1 {
return CLAP_PROCESS_ERROR;
}
Some(unsafe { &*pr.audio_inputs })
};
let mut next = 0u32;
let mut last_time = 0u32;
for i in 0..n {
while next < count {
let h = unsafe { &*(ev.get.unwrap())(pr.in_events, next) };
assert!(h.time >= last_time, "events not sorted");
assert!((h.time as usize) < n, "event time outside the block");
if h.time as usize > i {
break;
}
last_time = h.time;
apply(f, h);
next += 1;
}
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
let v = match input {
Some(inp) => {
let x = unsafe { *(*inp.data32.add(c.min(inp.channel_count as usize - 1))).add(i) };
x * f.gain as f32
}
None => f.level as f32,
};
unsafe { *o.add(i) = v };
}
}
CLAP_PROCESS_CONTINUE
}
// ---- extensions ----
unsafe extern "C" fn params_count(p: *const clap_plugin) -> u32 {
if unsafe { fx(p) }.synth { 0 } else { 3 }
}
fn write_name(dst: &mut [c_char], s: &CStr) {
for (d, b) in dst.iter_mut().zip(s.to_bytes_with_nul()) {
*d = *b as c_char;
}
}
unsafe extern "C" fn params_get_info(_p: *const clap_plugin, index: u32, info: *mut clap_param_info) -> bool {
let info = unsafe { &mut *info };
let (id, name, flags, min, max, def): (clap_id, &CStr, u32, f64, f64, f64) = match index {
0 => (7, c"Gain", CLAP_PARAM_IS_AUTOMATABLE, 0.0, 2.0, 1.0),
1 => (3, c"Mode", CLAP_PARAM_IS_STEPPED | CLAP_PARAM_IS_ENUM, 0.0, 2.0, 0.0),
2 => (9, c"Secret", CLAP_PARAM_IS_HIDDEN, 0.0, 1.0, 0.0),
_ => return false,
};
info.id = id;
info.flags = flags;
info.cookie = std::ptr::null_mut();
write_name(&mut info.name, name);
write_name(&mut info.module, c"");
info.min_value = min;
info.max_value = max;
info.default_value = def;
true
}
unsafe extern "C" fn params_get_value(p: *const clap_plugin, id: clap_id, out: *mut f64) -> bool {
let f = unsafe { fx(p) };
let v = match id {
7 => f.gain,
3 => f.mode,
9 => 0.0,
_ => return false,
};
unsafe { *out = v };
true
}
unsafe extern "C" fn params_value_to_text(_p: *const clap_plugin, id: clap_id, v: f64, buf: *mut c_char, cap: u32) -> bool {
if id != 3 || cap < 2 {
return false;
}
let label = [b'A', b'B', b'C'].get(v.round() as usize).copied().unwrap_or(b'?');
unsafe {
*buf = label as c_char;
*buf.add(1) = 0;
}
true
}
unsafe extern "C" fn params_flush(p: *const clap_plugin, inp: *const clap_input_events, _out: *const clap_output_events) {
let f = unsafe { fx(p) };
let ev = unsafe { &*inp };
let n = unsafe { (ev.size.unwrap())(inp) };
for i in 0..n {
apply(f, unsafe { &*(ev.get.unwrap())(inp, i) });
}
}
static PARAMS: clap_plugin_params = clap_plugin_params {
count: Some(params_count),
get_info: Some(params_get_info),
get_value: Some(params_get_value),
value_to_text: Some(params_value_to_text),
text_to_value: None,
flush: Some(params_flush),
};
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
}
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
return false;
}
let info = unsafe { &mut *info };
info.id = if is_input { 0 } else { 1 };
write_name(&mut info.name, c"main");
info.flags = CLAP_AUDIO_PORT_IS_MAIN;
info.channel_count = 2;
info.port_type = CLAP_PORT_STEREO.as_ptr();
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
true
}
static PORTS: clap_plugin_audio_ports = clap_plugin_audio_ports { count: Some(ports_count), get: Some(ports_get) };
unsafe extern "C" fn latency_get(_p: *const clap_plugin) -> u32 {
3
}
static LATENCY: clap_plugin_latency = clap_plugin_latency { get: Some(latency_get) };
unsafe extern "C" fn notes_count(p: *const clap_plugin, is_input: bool) -> u32 {
if is_input && unsafe { fx(p) }.synth { 1 } else { 0 }
}
unsafe extern "C" fn notes_get(_p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_note_port_info) -> bool {
if index != 0 || !is_input {
return false;
}
let info = unsafe { &mut *info };
info.id = 0;
info.supported_dialects = CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI;
info.preferred_dialect = CLAP_NOTE_DIALECT_CLAP;
write_name(&mut info.name, c"notes");
true
}
static NOTES: clap_plugin_note_ports = clap_plugin_note_ports { count: Some(notes_count), get: Some(notes_get) };
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
let id = unsafe { CStr::from_ptr(id) };
let synth = unsafe { fx(p) }.synth;
if id == CLAP_EXT_PARAMS {
&PARAMS as *const _ as *const c_void
} else if id == CLAP_EXT_AUDIO_PORTS {
&PORTS as *const _ as *const c_void
} else if id == CLAP_EXT_LATENCY && !synth {
&LATENCY as *const _ as *const c_void
} else if id == CLAP_EXT_NOTE_PORTS && synth {
&NOTES as *const _ as *const c_void
} else {
std::ptr::null()
}
}
unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
// ---- factory + entry ----
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
2
}
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
match i {
0 => &GAIN_DESC,
1 => &SYNTH_DESC,
_ => std::ptr::null(),
}
}
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
let id = unsafe { CStr::from_ptr(id) };
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
(&GAIN_DESC as *const _, false)
} else if id == c"org.soundcraft.test.synth" {
(&SYNTH_DESC as *const _, true)
} else {
return std::ptr::null();
};
let b = Box::new(Fx {
plugin: clap_plugin {
desc,
plugin_data: std::ptr::null_mut(),
init: Some(init),
destroy: Some(destroy),
activate: Some(activate),
deactivate: Some(deactivate),
start_processing: Some(start_processing),
stop_processing: Some(stop_processing),
reset: Some(reset),
process: Some(process),
get_extension: Some(get_extension),
on_main_thread: Some(on_main_thread),
},
host,
synth,
gain: 1.0,
mode: 0.0,
level: 0.0,
active: false,
processing: false,
});
let raw = Box::into_raw(b);
unsafe {
(*raw).plugin.plugin_data = raw as *mut c_void;
&(*raw).plugin
}
}
static FACTORY: clap_plugin_factory =
clap_plugin_factory { get_plugin_count: Some(factory_count), get_plugin_descriptor: Some(factory_desc), create_plugin: Some(factory_create) };
unsafe extern "C" fn entry_init(_path: *const c_char) -> bool {
true
}
unsafe extern "C" fn entry_deinit() {}
unsafe extern "C" fn entry_get_factory(id: *const c_char) -> *const c_void {
if unsafe { CStr::from_ptr(id) } == CLAP_PLUGIN_FACTORY_ID { &FACTORY as *const _ as *const c_void } else { std::ptr::null() }
}
#[unsafe(no_mangle)]
pub static clap_entry: clap_plugin_entry =
clap_plugin_entry { clap_version: CLAP_VERSION, init: Some(entry_init), deinit: Some(entry_deinit), get_factory: Some(entry_get_factory) };

View File

@ -0,0 +1,160 @@
//! End to end: build the test CLAP plugin in `tests/fixture` (a cdylib outside the workspace),
//! install it as a `.clap` in a temp folder, then scan → instantiate → process → params → notes.
use soundcraft_dsp::{Category, Unit};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
/// Builds the fixture once and returns a folder containing `Fixture.clap`.
fn fixture_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let st = Command::new(cargo)
.args(["build", "--quiet", "--manifest-path"])
.arg(&manifest)
.arg("--target-dir")
.arg(&target)
.env_remove("RUSTFLAGS")
.status()
.expect("run cargo");
assert!(st.success(), "building the CLAP fixture failed");
let lib = if cfg!(target_os = "macos") {
"libclap_fixture.dylib"
} else if cfg!(windows) {
"clap_fixture.dll"
} else {
"libclap_fixture.so"
};
let built = target.join("debug").join(lib);
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("clap-fixture-install-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
if cfg!(target_os = "macos") {
// Install as a real macOS bundle to exercise bundle resolution.
let exe = dir.join("vendor/Fixture.clap/Contents/MacOS");
std::fs::create_dir_all(&exe).unwrap();
std::fs::copy(&built, exe.join("Fixture")).unwrap();
} else {
std::fs::create_dir_all(dir.join("vendor")).unwrap();
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
}
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
dir
})
}
const GAIN: &str = "clap:org.soundcraft.test.gain";
const SYNTH: &str = "clap:org.soundcraft.test.synth";
#[test]
fn scan_lists_both_fixture_plugins() {
let dir = fixture_dir();
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 2);
let g = found.iter().find(|d| d.id == GAIN).unwrap();
assert_eq!(g.name, "Fixture Gain");
assert_eq!(g.vendor, "SoundCraft tests");
assert_eq!(g.features, vec!["audio-effect", "stereo"]);
assert_eq!(g.category, Category::Other);
assert!(!g.is_instrument);
let s = found.iter().find(|d| d.id == SYNTH).unwrap();
assert_eq!(s.category, Category::Instrument);
assert!(s.is_instrument);
let global = soundcraft_clap_host::scan();
assert!(global.iter().any(|d| d.id == GAIN) && global.iter().any(|d| d.id == SYNTH));
}
#[test]
fn info_exposes_visible_params_with_names() {
fixture_dir();
let info = soundcraft_clap_host::plugin_info(GAIN).unwrap();
assert_eq!(info.id, GAIN);
assert_eq!(info.short_name, "Fixture ");
let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect();
assert_eq!(ids, vec!["7", "3"], "hidden param 9 is skipped");
let gain = info.param("7").unwrap();
assert_eq!((gain.name, gain.min, gain.max, gain.default), ("Gain", 0.0, 2.0, 1.0));
let mode = info.param("3").unwrap();
assert_eq!(mode.unit, Unit::Choice);
assert_eq!(mode.choices, &["A", "B", "C"]);
// Cached: the same leaked info every time.
assert!(std::ptr::eq(info, soundcraft_clap_host::plugin_info(GAIN).unwrap()));
}
#[test]
fn gain_processes_audio_and_follows_params() {
fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
p.prepare(44_100.0, 256, 2);
assert_eq!(p.latency(), 3);
assert_eq!(p.param("7"), Some(1.0));
let mut io = vec![vec![0.5f32; 256], vec![-0.25f32; 256]];
p.process(&mut io, 256);
assert!(io[0].iter().all(|&x| (x - 0.5).abs() < 1e-6));
assert!(p.set_param("7", 2.0));
assert!(!p.set_param("9", 1.0), "hidden params are not exposed");
assert!(!p.set_param("Gain", 1.0), "ids are CLAP numbers");
assert!(p.set_param("7", 5.0), "clamped");
assert_eq!(p.param("7"), Some(2.0));
// A block larger than max_block is chunked.
let mut io = vec![vec![0.5f32; 700], vec![-0.25f32; 700]];
p.process(&mut io, 700);
assert!(io[0].iter().all(|&x| (x - 1.0).abs() < 1e-6));
assert!(io[1].iter().all(|&x| (x + 0.5).abs() < 1e-6));
// Mono strip: input duplicated into the stereo port, stereo output averaged back.
p.prepare(48_000.0, 128, 1);
let mut mono = vec![vec![0.25f32; 128]];
p.process(&mut mono, 128);
assert!(mono[0].iter().all(|&x| (x - 0.5).abs() < 1e-6));
// Hostile shapes don't crash.
p.process(&mut [], 64);
let mut short = vec![vec![1.0f32; 4]];
p.process(&mut short, 1_000_000);
assert!(p.set_param("7", f32::NAN), "NaN becomes the default");
assert_eq!(p.param("7"), Some(1.0));
p.reset();
p.prepare(f32::NAN, 0, 0);
let mut io = vec![vec![0.1f32; 8]];
p.process(&mut io, 8);
}
#[test]
fn synth_plays_notes_at_their_offsets() {
fixture_dir();
let info = soundcraft_clap_host::plugin_info(SYNTH).unwrap();
assert!(info.is_instrument);
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
p.prepare(48_000.0, 64, 2);
let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]];
p.note_on(10, 60, 127);
p.note_off(40, 60);
p.process(&mut io, 64);
for (i, &x) in io[0].iter().enumerate() {
let want = if (10..40).contains(&i) { 1.0 } else { 0.0 };
assert!((x - want).abs() < 1e-6, "frame {i}: {x}");
}
assert_eq!(io[0], io[1]);
// Offsets past the block are clamped into it; all_notes_off releases held notes.
p.note_on(1000, 61, 64);
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
p.process(&mut io, 64);
assert!(io[0][62].abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6);
p.all_notes_off();
p.process(&mut io, 64);
assert!(io[0].iter().all(|x| x.abs() < 1e-6));
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
for _ in 0..50 {
let mut a = soundcraft_clap_host::create(GAIN).unwrap();
let b = soundcraft_clap_host::create(SYNTH).unwrap();
let mut io = vec![vec![0.0f32; 32]; 2];
a.process(&mut io, 32);
drop(b);
}
}

View File

@ -12,6 +12,7 @@ soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
serde.workspace = true

View File

@ -0,0 +1,55 @@
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
//! commands. Hosting itself lives in `soundcraft-clap-host`.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_dsp::PluginInfo;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "engine.clap_plugins",
"List CLAP Plugins",
[],
None,
"{rescan?: bool} → [{id: \"clap:…\", plugin_id, name, vendor, version, description, features, category, is_instrument, path}]",
always,
|_, p| {
let list = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) {
soundcraft_clap_host::rescan()
} else {
soundcraft_clap_host::scan()
};
Ok(json!(list))
}
)]
}
/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:<id>`).
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
#[test]
fn clap_plugins_query_returns_a_list() {
let mut e = Engine::default();
let v = e.execute("engine.clap_plugins", &json!({})).unwrap();
assert!(v.is_array());
}
#[test]
fn unknown_clap_insert_is_an_error() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
let r = e.execute("mix.insert", &json!({"track": t, "plugin": "clap:no.such.plugin"}));
assert!(r.is_err());
assert!(super::plugin_info("eq_7band").is_some());
assert!(super::plugin_info("clap:no.such.plugin").is_none());
}
}

View File

@ -63,6 +63,23 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"tracks": tracks}))
}),
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true}", always, bounce),
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?}", has_tracks, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format = match str_param(p, "format") {
Some("aiff") | Some("aif") => soundcraft_audio_io::FileFormat::Aiff,
Some("flac") => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
};
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let files = crate::io::bounce_stems(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None })?;
Ok(json!({"files": files}))
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
let bytes = crate::io::export_midi(e)?;

View File

@ -278,7 +278,7 @@ fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId,
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
let info = soundcraft_dsp::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
let explicit = slot_of(p, "slot", INSERT_SLOTS);
let s = e.session_mut();
@ -335,7 +335,7 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ins =
s.track_mut(t).and_then(|tr| tr.mixer.inserts.get_mut(slot)).and_then(Option::as_mut).ok_or_else(|| bad("mix.insert_param", "empty slot"))?;
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
let pi =
info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?;
let v = value.clamp(pi.min, pi.max);

View File

@ -7,6 +7,7 @@ use soundcraft_time::{Range, Samples};
use std::sync::OnceLock;
mod audiosuite;
mod clap;
mod clip;
mod clip_more;
mod edit;
@ -108,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(clip_more::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
v
})
}

View File

@ -148,6 +148,38 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
Ok((bytes, (peak, lufs, true_peak)))
}
/// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included)
/// into `dir`. Returns the written paths.
pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Result<Vec<String>> {
let s = e.session();
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
let ext = match opts.format {
FileFormat::Aiff => "aif",
FileFormat::Flac => "flac",
_ => "wav",
};
let mut out = Vec::new();
let stems: Vec<(TrackId, String)> = s
.tracks
.iter()
.filter(|t| matches!(t.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi) && !t.inactive && !t.mixer.mute)
.map(|t| (t.id, t.name.clone()))
.collect();
for (id, name) in stems {
let mut solo = s.clone();
for t in &mut solo.tracks {
t.mixer.solo = t.id == id;
}
let ch = soundcraft_mix::render_range(&solo, r, 1024);
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?;
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
out.push(path.to_string_lossy().into_owned());
}
Ok(out)
}
/// Transient positions (absolute) in a track's clips within `r`.
pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Samples> {
let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r };
@ -466,3 +498,18 @@ mod save_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod stem_tests {
#[test]
fn stems_one_file_per_track() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-stems-{}", std::process::id()));
let r = e
.execute("file.bounce_stems", &serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 11.0}}))
.unwrap();
let n = r["files"].as_array().map_or(0, Vec::len);
assert_eq!(n, 7, "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -0,0 +1,65 @@
//! A hosted CLAP plugin through the whole stack: `mix.insert` / `mix.insert_param` accept a
//! `clap:` id and the mix engine renders through it. Uses the test plugin built from
//! `crates/clap-host/tests/fixture` (a gain whose param "7" is linear gain).
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
fn install_fixture() {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let st = Command::new(cargo)
.args(["build", "--quiet", "--manifest-path"])
.arg(&manifest)
.arg("--target-dir")
.arg(&target)
.env_remove("RUSTFLAGS")
.status()
.unwrap();
assert!(st.success());
let lib = if cfg!(target_os = "macos") {
"libclap_fixture.dylib"
} else if cfg!(windows) {
"clap_fixture.dll"
} else {
"libclap_fixture.so"
};
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("engine-clap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
}
#[test]
fn clap_insert_renders_through_the_mix() {
install_fixture();
let mut e = Engine::default();
let list = e.execute("engine.clap_plugins", &json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|d| d["id"] == "clap:org.soundcraft.test.gain"));
let s = e.session_mut();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(77), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4800]] })));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "dc", SourceId(77), 0, 0, 4800));
let dry = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
assert!(dry.abs() > 0.01);
let r = e.execute("mix.insert", &json!({"track": t.0, "plugin": "clap:org.soundcraft.test.gain", "params": {"7": 2.0}})).unwrap();
assert_eq!(r["plugin"], "clap:org.soundcraft.test.gain");
let wet = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
assert!((wet - 2.0 * dry).abs() < 1e-4, "dry {dry} wet {wet}");
e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "7", "value": 0.5})).unwrap();
let half = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
assert!((half - 0.5 * dry).abs() < 1e-4, "dry {dry} half {half}");
assert!(e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "Gain", "value": 1.0})).is_err());
}

View File

@ -12,6 +12,7 @@ soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
soundcraft-model.workspace = true
log.workspace = true

View File

@ -209,7 +209,7 @@ impl MixEngine {
for (slot, ins) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()) {
match ins {
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => {
*slot = soundcraft_dsp::create(&i.plugin).map(|mut p| {
*slot = create_plugin(&i.plugin).map(|mut p| {
p.prepare(sr, mb, ch);
PluginSlot { id: i.plugin.clone(), plugin: p, applied: Vec::new() }
});
@ -221,7 +221,7 @@ impl MixEngine {
let want = t.instrument.as_ref().map(|i| i.plugin.as_str());
if strip.instrument.as_ref().map(|p| p.id.as_str()) != want {
strip.instrument = want.and_then(|id| {
soundcraft_dsp::create(id).map(|mut p| {
create_plugin(id).map(|mut p| {
p.prepare(sr, mb, ch.max(2));
PluginSlot { id: id.to_string(), plugin: p, applied: Vec::new() }
})
@ -287,11 +287,8 @@ impl MixEngine {
// for the slowest aux return (aux latencies are from the previous block; they are stable).
let pdc = s.edit.delay_compensation;
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
let lmax_a = if pdc {
self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0)
} else {
0
};
let lmax_a =
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
self.latency = lmax_t + lmax_a;
for (t, st) in work.iter_mut() {
let ch = st.buf.len();
@ -1107,6 +1104,11 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<
out
}
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
soundcraft_dsp::create(id).or_else(|| soundcraft_clap_host::create(id))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -12,6 +12,7 @@ soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-engine.workspace = true

View File

@ -285,7 +285,7 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
StrokeKind::Inside,
);
let label = ins.as_ref().and_then(|i| soundcraft_dsp::plugin_info(&i.plugin)).map_or("", |p| p.short_name);
let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name);
if label.is_empty() {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
} else {
@ -324,6 +324,23 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
}
});
}
// Third-party CLAP plugins (scanned once, cached by soundcraft-clap-host).
ui.menu_button("CLAP", |ui| {
let found: Vec<_> = soundcraft_clap_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
if found.is_empty() {
ui.label("No CLAP plugins found");
}
for d in found {
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
}
}
});
}
/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:<id>`).
pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> {
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id))
}
fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {

View File

@ -248,7 +248,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
else {
continue;
};
let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) else { continue };
let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue };
let mut open = true;
egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name))
.id(egui::Id::new(("plugin", tid.0, slot)))

View File

@ -22,6 +22,7 @@ const LAYERS: &[(&str, u8)] = &[
("audio-io", 0),
("midi", 0),
("dsp", 1),
("clap-host", 1),
("model", 2),
("mix", 3),
("engine", 4),