Skip to main content

truce_core/
wrapper.rs

1//! Helpers shared across format wrappers (CLAP, VST3, VST2, AU, AAX, LV2).
2//!
3//! Each wrapper still owns its format-specific descriptor types and
4//! callback tables; those don't unify cleanly. What unifies is the
5//! "boring" boundary glue: building `CStrings` from `ParamInfo`
6//! fields, picking the default bus layout, and resolving install-time
7//! name overrides.
8//!
9//! Each helper is a single small function so the wrappers stay
10//! greppable - the per-format vtable construction code reads as
11//! "for each param, get cstrings, build descriptor" without inlined
12//! `CString::new(...).unwrap_or_default()` boilerplate.
13//!
14//! Adding a new format wrapper? Reach for these first; only fall back
15//! to direct `CString::new` etc. when the format genuinely needs
16//! something none of the other formats does.
17
18use std::any::type_name;
19use std::ffi::CString;
20use std::panic::{AssertUnwindSafe, catch_unwind};
21use std::sync::{Arc, Mutex, MutexGuard, PoisonError, TryLockError};
22
23use truce_params::ParamInfo;
24
25use crate::bus::BusLayout;
26use crate::export::PluginExport;
27
28/// The mediation lock every format wrapper puts around its plugin
29/// instance. The audio thread holds the lock for the duration of a
30/// block (`process`, `reset`, the queued state apply); host-thread
31/// state callbacks and the editor's `get_state` closure block for
32/// the read - safe in that direction, and bounded by the block the
33/// audio thread is finishing. Meters ride the lock-free `MeterStore`
34/// instead, so per-frame GUI paints never touch this lock.
35///
36/// `std::sync::Mutex`, not `parking_lot`: on macOS std sits on
37/// `os_unfair_lock`, which donates the waiter's priority to the
38/// owner - so a GUI thread preempted mid-`save_state` gets boosted
39/// to the waiting audio thread's priority instead of leaving the
40/// render at the scheduler's mercy. `parking_lot` has no priority
41/// inheritance anywhere. Uncontended cost is a CAS either way.
42/// Poisoning is deliberately forgiven (see [`lock_plugin`]).
43///
44/// A `Mutex` rather than an `RwLock`: the only non-audio accessors
45/// are a host state save and an editor preset capture - both rare -
46/// so reader parallelism buys nothing, and `Mutex<P>: Sync` needs
47/// only `P: Send` (an `RwLock` would force `Sync` onto every plugin
48/// type).
49///
50/// The `Arc` is what makes GUI closures sound: they clone the handle
51/// instead of stashing a raw pointer into the instance struct (whose
52/// `&mut` the audio thread holds during callbacks).
53pub type SharedPlugin<P> = Arc<Mutex<P>>;
54
55/// Wrap a freshly created plugin in the wrapper-standard mediation
56/// lock. See [`SharedPlugin`].
57pub fn shared_plugin<P>(plugin: P) -> SharedPlugin<P> {
58    Arc::new(Mutex::new(plugin))
59}
60
61/// Lock the mediation lock, forgiving poison. A poisoned lock means a
62/// panic already escaped somewhere and was reported by the wrapper's
63/// panic guard; refusing every later block would turn one bad block
64/// into permanent silence, and the plugin's state is no more suspect
65/// than after any other caught panic.
66pub fn lock_plugin<P>(plugin: &Mutex<P>) -> MutexGuard<'_, P> {
67    plugin.lock().unwrap_or_else(PoisonError::into_inner)
68}
69
70/// [`lock_plugin`]'s non-blocking twin: `None` only when the lock is
71/// genuinely held (poison is forgiven, same rationale).
72pub fn try_lock_plugin<P>(plugin: &Mutex<P>) -> Option<MutexGuard<'_, P>> {
73    match plugin.try_lock() {
74        Ok(guard) => Some(guard),
75        Err(TryLockError::Poisoned(poisoned)) => Some(poisoned.into_inner()),
76        Err(TryLockError::WouldBlock) => None,
77    }
78}
79
80/// `CStrings` derived from a single `ParamInfo`. All four conversions
81/// follow the same pattern (`unwrap_or_default()` so a `\0` in metadata
82/// degrades to an empty C string instead of panicking the host); pulling
83/// them into one struct keeps the per-format vtable loops uniform.
84pub struct ParamCStrings {
85    pub name: CString,
86    pub short_name: CString,
87    pub unit: CString,
88    pub group: CString,
89}
90
91impl ParamCStrings {
92    /// Build all four `CStrings` for one parameter.
93    #[must_use]
94    pub fn from_info(info: &ParamInfo) -> Self {
95        Self {
96            name: CString::new(info.name).unwrap_or_default(),
97            short_name: CString::new(info.short_name).unwrap_or_default(),
98            unit: CString::new(info.unit.as_str()).unwrap_or_default(),
99            group: CString::new(info.group).unwrap_or_default(),
100        }
101    }
102}
103
104/// `(input_channels, output_channels)` for the plugin's default bus
105/// layout, or `None` when the plugin declares no layouts.
106/// Used by every format's vtable / descriptor to advertise channel
107/// counts at registration time.
108///
109/// **Note for `aumi` (MIDI processor) plugins:** the convention is
110/// `bus_layouts: [BusLayout::new()]`, which has zero input *and* zero
111/// output channels. This helper returns `Some((0, 0))` for that case,
112/// which is correct for AU (the AU shim's `channelCapabilities`
113/// returns `[0, 0]` and the host treats the plugin as MIDI-only) but
114/// **wrong for AAX**, which requires every plugin to advertise at
115/// least stereo audio I/O. AAX maps `(0, 0)` to `(2, 2)` (synthesizing
116/// a stereo passthrough) after this helper returns. Don't push that
117/// remap into this helper; only AAX needs it.
118///
119/// `None` indicates a plugin-author bug: zero-bus plugins must return
120/// `vec![BusLayout::new()]` explicitly. Callers should log a
121/// diagnostic and skip registration (see how each `register_*` entry
122/// point handles this) rather than substitute a silent default that
123/// would misreport channel counts to the host.
124#[must_use]
125pub fn default_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
126    P::bus_layouts()
127        .first()
128        .map(|l| (l.total_input_channels(), l.total_output_channels()))
129}
130
131/// Pick the plugin's first bus layout, or `None` when the plugin
132/// declares no layouts.
133/// Used by wrappers (AAX, VST2) that need to read the layout *before*
134/// host-side bus-config negotiation, where a missing layout would
135/// otherwise produce silently-misreported channel counts.
136///
137/// For `aumi` plugins the returned layout is typically `BusLayout::new()`
138/// (zero in / zero out). AAX synthesizes `(2, 2)` from that case in
139/// `register_aax`; see [`default_io_channels`] for the rationale.
140///
141/// `None` is the same plugin-author-bug indicator as
142/// [`default_io_channels`]: log a diagnostic and skip registration.
143#[must_use]
144pub fn first_bus_layout<P: PluginExport>() -> Option<BusLayout> {
145    P::bus_layouts().into_iter().next()
146}
147
148/// Standard diagnostic emitted by `register_*` when [`first_bus_layout`]
149/// or [`default_io_channels`] returns `None`. Centralised so every
150/// wrapper prints the same actionable message.
151pub fn log_missing_bus_layout<P: PluginExport>(format: &str) {
152    eprintln!(
153        "[truce {format}] {}::bus_layouts() returned an empty list - \
154         plugin will not register. Plugins with no audio I/O (e.g. \
155         aumi MIDI-effects) should return vec![BusLayout::new()] \
156         explicitly.",
157        type_name::<P>(),
158    );
159}
160
161/// Diagnostic for a plugin that declared more MIDI ports than the
162/// format can carry. The wrapper clamps to a single port and routes
163/// all traffic to port `0`; without this line the truncation would read
164/// as "multi-port supported." `declared` is the plugin's per-direction
165/// port count; nothing is logged for the single-port (or zero-port)
166/// case. `direction` is `"input"` / `"output"`.
167pub fn log_midi_ports_clamped(format: &str, direction: &str, declared: u8) {
168    if declared > 1 {
169        eprintln!(
170            "[truce {format}] plugin declares {declared} MIDI {direction} ports, but {format} \
171             carries one - routing all {direction} MIDI to port 0.",
172        );
173    }
174}
175
176/// Run a `register_*` body under [`std::panic::catch_unwind`].
177///
178/// Format wrappers' `register_*` entry points run during plugin
179/// registration - some from `extern "C" fn init` static
180/// initializers (`.init_array` / `__mod_init_func` / `.CRT$XCU`),
181/// others lazily on the first host query (AAX, to keep the Windows
182/// loader-lock window empty during Pro Tools' scan). A panic that
183/// escapes them crosses an `extern "C"`
184/// boundary and aborts the host process - a `panic = "abort"`
185/// configuration would do the same. Catching the unwind here turns
186/// any panic during registration into a logged diagnostic plus
187/// "host sees no plugin," which is the same outcome a plugin author
188/// would expect from a missing `bus_layouts` declaration.
189///
190/// `AssertUnwindSafe` is applied internally - the panic is treated
191/// as fatal-for-this-plugin, so leaving an `Arc` ref-count or
192/// `OnceLock` half-set is acceptable: the host won't load the
193/// plugin and the process will exit shortly after registration
194/// finishes anyway.
195pub fn run_register<P>(format: &str, body: impl FnOnce()) {
196    let result = catch_unwind(AssertUnwindSafe(body));
197    if let Err(payload) = result {
198        eprintln!(
199            "[truce {format}] panic during register for {}: {}",
200            type_name::<P>(),
201            extract_panic_msg(&payload),
202        );
203    }
204}
205
206/// Run a per-block audio-thread `body` under
207/// [`std::panic::catch_unwind`].
208///
209/// Format wrappers call this around the `cb_process` body so a panic
210/// from user `process()` can't unwind across the `extern "C"` FFI
211/// boundary into the host (UB on most toolchains; abort on others).
212/// Returns `true` on clean exit, `false` if the body panicked - the
213/// caller should zero output buffers on `false` so the host doesn't
214/// keep playing whatever happened to be in those slots.
215///
216/// Panic logging is one short `eprintln!` per occurrence; the audio
217/// thread should never panic, so the I/O is rare and acceptable.
218#[must_use]
219pub fn run_audio_block<P>(format: &str, body: impl FnOnce()) -> bool {
220    let result = catch_unwind(AssertUnwindSafe(body));
221    if let Err(payload) = result {
222        eprintln!(
223            "[truce {format}] panic in process() for {}: {}",
224            type_name::<P>(),
225            extract_panic_msg(&payload),
226        );
227        return false;
228    }
229    true
230}
231
232/// Like [`run_audio_block`] but for callbacks that return a status
233/// code. Returns `body`'s value on a clean exit, `fallback` if the
234/// body panicked. Used by the CLAP wrapper, whose process callback
235/// returns a `clap_process_status` `i32`.
236pub fn run_audio_block_with<P, R>(format: &str, fallback: R, body: impl FnOnce() -> R) -> R {
237    match catch_unwind(AssertUnwindSafe(body)) {
238        Ok(r) => r,
239        Err(payload) => {
240            eprintln!(
241                "[truce {format}] panic in process() for {}: {}",
242                type_name::<P>(),
243                extract_panic_msg(&payload),
244            );
245            fallback
246        }
247    }
248}
249
250/// Run a generic `extern "C"` callback body under
251/// [`std::panic::catch_unwind`]. Returns `body`'s value on a clean
252/// exit, `fallback` if the body panicked.
253///
254/// Same shape as [`run_audio_block_with`] but parameterized on
255/// `action` (e.g. `"save_state"`, `"load_state"`) so the panic log
256/// pinpoints which callback boundary fired. Use this for non-process
257/// FFI surfaces - state save / load, param formatting, anything the
258/// host calls through an `extern "C" fn` where a panic would unwind
259/// across an ABI that doesn't promise abort-on-unwind.
260///
261/// Audio-thread process bodies should keep using
262/// [`run_audio_block`] / [`run_audio_block_with`] - the hardcoded
263/// `"process()"` label there keeps existing log lines stable.
264pub fn run_extern_callback_with<P, R>(
265    format: &str,
266    action: &str,
267    fallback: R,
268    body: impl FnOnce() -> R,
269) -> R {
270    match catch_unwind(AssertUnwindSafe(body)) {
271        Ok(r) => r,
272        Err(payload) => {
273            eprintln!(
274                "[truce {format}] panic in {action} for {}: {}",
275                type_name::<P>(),
276                extract_panic_msg(&payload),
277            );
278            fallback
279        }
280    }
281}
282
283fn extract_panic_msg(payload: &Box<dyn std::any::Any + Send>) -> &str {
284    if let Some(s) = payload.downcast_ref::<&'static str>() {
285        s
286    } else if let Some(s) = payload.downcast_ref::<String>() {
287        s.as_str()
288    } else {
289        "<non-string panic payload>"
290    }
291}