Skip to main content

truce_lv2/
lib.rs

1//! LV2 format wrapper for the truce framework.
2//!
3//! Exports a `PluginExport` implementation as an LV2 plugin via the
4//! [`export_lv2!`] macro. LV2's C ABI is small and stable, so we
5//! hand-roll the bindings rather than pulling in a large `lv2-sys` crate.
6//!
7//! Port layout (default):
8//!   - `0..num_in` - audio input (one port per channel)
9//!   - `num_in..num_in+num_out` - audio output (one port per channel)
10//!   - next N - control input (one port per parameter, float)
11//!   - `atom_in_port` - single `AtomPort` for MIDI input (if plugin accepts MIDI)
12//!
13//! MIDI, State, and UI support live in sibling modules.
14
15#[doc(hidden)]
16pub mod __macro_deps {
17    pub use truce_core;
18}
19
20mod atom;
21mod state;
22mod types;
23mod ui;
24mod urid;
25
26pub use types::*;
27
28use std::ffi::{CStr, CString, c_char, c_void};
29use std::ptr;
30use std::sync::Arc;
31
32use truce_core::buffer::RawBufferScratch;
33use truce_core::cast::len_u32;
34use truce_core::chunked_process::{ChunkedProcess, process_chunked};
35use truce_core::events::{EVENT_LIST_PREALLOC, Event, EventBody, EventList, TransportInfo};
36use truce_core::export::PluginExport;
37use truce_core::info::{PluginCategory, PluginInfo};
38use truce_core::plugin::PluginRuntime;
39use truce_core::state::shared_plugin_state_hash;
40use truce_core::wrapper::run_audio_block;
41use truce_params::{ParamInfo, Params};
42
43use crate::atom::AtomSequenceReader;
44use crate::urid::{Urid, UridMap};
45
46// ---------------------------------------------------------------------------
47// Port layout
48// ---------------------------------------------------------------------------
49
50/// Describes where each logical port sits in the flat LV2 port-index space.
51/// Filled in once at `instantiate()` time.
52#[derive(Clone, Debug)]
53pub struct PortLayout {
54    pub num_audio_in: u32,
55    pub num_audio_out: u32,
56    pub num_params: u32,
57    pub num_meters: u32,
58    /// Whether the input atom port should additionally advertise
59    /// `midi:MidiEvent` support. The port itself always exists - hosts
60    /// deliver `time:Position` through it regardless of whether the
61    /// plugin consumes MIDI.
62    pub accepts_midi_in: bool,
63    pub has_midi_out: bool,
64}
65
66impl PortLayout {
67    #[must_use]
68    pub fn audio_in_start(&self) -> u32 {
69        0
70    }
71    #[must_use]
72    pub fn audio_out_start(&self) -> u32 {
73        self.num_audio_in
74    }
75    #[must_use]
76    pub fn control_start(&self) -> u32 {
77        self.num_audio_in + self.num_audio_out
78    }
79    #[must_use]
80    pub fn meter_start(&self) -> u32 {
81        self.control_start() + self.num_params
82    }
83    /// Index of the DSP input atom port. Always present: carries
84    /// `time:Position` (transport) for every plugin type and
85    /// additionally `midi:MidiEvent` for instruments / note effects.
86    #[must_use]
87    pub fn atom_in_port(&self) -> u32 {
88        self.meter_start() + self.num_meters
89    }
90    #[must_use]
91    pub fn midi_out_port(&self) -> Option<u32> {
92        if self.has_midi_out {
93            Some(self.atom_in_port() + 1)
94        } else {
95            None
96        }
97    }
98    /// Index of the DSP→UI notification atom port. Always present: the
99    /// DSP writes host transport (and any future plugin-defined notify
100    /// messages) here, and the UI listens via `ui:portNotification`.
101    #[must_use]
102    pub fn notify_out_port(&self) -> u32 {
103        self.atom_in_port() + 1 + u32::from(self.has_midi_out)
104    }
105    #[must_use]
106    pub fn total(&self) -> u32 {
107        self.notify_out_port() + 1
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Instance
113// ---------------------------------------------------------------------------
114
115/// Live instance of an LV2 plugin. Held as `LV2_Handle` for the host.
116pub struct Lv2Instance<P: PluginExport> {
117    plugin: P,
118    sample_rate: f64,
119    max_block_size: usize,
120    plugin_id_hash: u64,
121    param_infos: Vec<ParamInfo>,
122    layout: PortLayout,
123
124    // Port pointers populated by connect_port().
125    audio_inputs: Vec<*const f32>,
126    audio_outputs: Vec<*mut f32>,
127    control_ports: Vec<*const f32>,
128    /// Output control ports - one per `#[meter]` slot. We write the
129    /// latest meter reading here at the end of each `run()` so the host
130    /// forwards it to the UI via `port_event`.
131    meter_ports: Vec<*mut f32>,
132    /// Parameter/meter IDs for the meter slots, in port order.
133    meter_ids: Vec<u32>,
134    atom_in_port: *const AtomSequence,
135    midi_out_port: *mut AtomSequence,
136    notify_out_port: *mut AtomSequence,
137
138    /// Last observed value on each control port; used to emit
139    /// `ParamChange` events only when the host actually moved a knob.
140    /// `None` means "never read" - the first poll after instantiation
141    /// always emits, then subsequent polls only emit on diff.
142    last_control: Vec<Option<f32>>,
143
144    event_list: EventList,
145    output_events: EventList,
146    /// Per-sub-block scratch for `chunked_process::process_chunked`.
147    sub_event_scratch: EventList,
148    /// Cached `Arc<P::Params>` handed to the chunker as its
149    /// `&dyn Params` handle for `set_plain` calls. Pulled once at
150    /// instantiate.
151    params_arc: std::sync::Arc<P::Params>,
152    /// `min_subblock_samples` from `truce.toml`'s `[automation]`
153    /// table. Cached from `PluginInfo` at instantiate.
154    min_subblock_samples: u32,
155
156    urid_map: UridMap,
157    /// Per-parameter URID → param-id mapping for the LV2 1.18 patch
158    /// API. The host delivers parameter updates as `patch:Set` Objects
159    /// whose `patch:property` is the parameter's interned URI; we look
160    /// it up here to recover the truce `ParamInfo::id`. Built once at
161    /// `instantiate()` by interning `<plugin_uri>#p_<id>` for every
162    /// parameter - same string the TTL emits for the corresponding
163    /// `lv2:Parameter` block (see `truce-build/src/lv2.rs`). A 0 URID
164    /// (host didn't expose URID:map) leaves the table empty and the
165    /// `patch:Set` path stays inert; the legacy control-port path
166    /// still works.
167    param_urid_to_id: Vec<(Urid, u32)>,
168
169    /// Reused per-block scratch for `RawBufferScratch::build`. Lives
170    /// here so the slice / per-channel-copy storage survives across
171    /// `run()` invocations without re-allocating on the audio thread.
172    /// LV2 hosts may connect an input and an output port to the same
173    /// buffer (in-place processing); the scratch handles the
174    /// alias-then-copy fallback internally.
175    ///
176    /// Parameterised by `P::Sample` so plugins that picked `f64`
177    /// (via `prelude64`) get widening scratch transparently: the
178    /// host wire is always `f32`, and the scratch widens on input
179    /// then narrows on output around `plugin.process()`. Same-precision
180    /// (`f32`) plugins stay zero-copy.
181    scratch: RawBufferScratch<<P as PluginRuntime>::Sample>,
182
183    /// Shared transport slot - audio thread writes each block. LV2 UIs
184    /// are out-of-process so the UI side still reads `None`; this slot
185    /// exists so an in-process consumer (tests / DSP-side code) can
186    /// observe host transport.
187    transport_slot: Arc<truce_core::TransportSlot>,
188}
189
190// Raw pointers only - we never share an instance between threads. LV2 hosts
191// drive a single instance from one thread at a time (audio thread for
192// run(), main thread for everything else).
193unsafe impl<P: PluginExport> Send for Lv2Instance<P> {}
194
195// ---------------------------------------------------------------------------
196// LV2 lifecycle callbacks
197// ---------------------------------------------------------------------------
198
199/// Build a `PortLayout` from a plugin instance's declared bus layout + params.
200///
201/// Caller passes in `&P` so the layout extraction reuses the existing
202/// instance rather than constructing a fresh one. The TTL writer paths
203/// build their own plugin and the LV2 `instantiate` callback already
204/// owns one - both call this directly to skip a second `P::create()`.
205///
206/// # Panics
207///
208/// Panics if `P::bus_layouts()` is empty - same plugin-author
209/// contract as [`truce_core::wrapper::first_bus_layout`]; zero-bus
210/// plugins must return `vec![BusLayout::new()]` explicitly.
211pub fn derive_port_layout<P: PluginExport>(plugin: &P) -> PortLayout {
212    let layouts = P::bus_layouts();
213    let default_layout = layouts
214        .first()
215        .expect("Plugin must declare at least one bus layout");
216    let params = plugin.params();
217    let param_count = len_u32(params.param_infos().len());
218    let meter_count = len_u32(params.meter_ids().len());
219    let category = P::info().category;
220    let accepts_midi_in = matches!(
221        category,
222        PluginCategory::Instrument | PluginCategory::NoteEffect
223    );
224    let has_midi_out = matches!(category, PluginCategory::NoteEffect);
225    PortLayout {
226        num_audio_in: default_layout.total_input_channels(),
227        num_audio_out: default_layout.total_output_channels(),
228        num_params: param_count,
229        num_meters: meter_count,
230        accepts_midi_in,
231        has_midi_out,
232    }
233}
234
235/// # Safety
236/// Called by the LV2 host during plugin instantiation. `features` must be
237/// a null-terminated array of `LV2_Feature` pointers (or null if none).
238#[must_use]
239pub unsafe fn instantiate<P: PluginExport>(
240    sample_rate: f64,
241    _bundle_path: *const c_char,
242    features: *const *const LV2Feature,
243) -> *mut Lv2Instance<P> {
244    unsafe {
245        let plugin = P::create();
246        let layout = derive_port_layout::<P>(&plugin);
247        let info = P::info();
248        let param_infos = plugin.params().param_infos();
249        let params_arc = plugin.params_arc();
250        let min_subblock_samples = info.automation.min_subblock_samples;
251
252        let control_port_count = layout.num_params as usize;
253        let audio_in_count = layout.num_audio_in as usize;
254        let audio_out_count = layout.num_audio_out as usize;
255        let meter_ids = plugin.params().meter_ids();
256        let meter_count = meter_ids.len();
257
258        let urid_map = UridMap::from_features(features);
259
260        // Build the per-param URID lookup the patch:Set decoder uses.
261        // String must match the `<plugin_uri>#p_<id>` URI the TTL emits
262        // for each `lv2:Parameter` block (see truce-build/src/lv2.rs).
263        // Skipped when the host doesn't expose URID:map - the patch
264        // path then stays inert and only the legacy control-port path
265        // contributes parameter updates.
266        let plugin_uri = truce_build::lv2::plugin_uri(info.url, info.bundle_id);
267        let mut param_urid_to_id: Vec<(Urid, u32)> = Vec::with_capacity(param_infos.len());
268        if urid_map.has_map() {
269            for pi in &param_infos {
270                let uri = format!("{plugin_uri}#p_{}", pi.id);
271                let urid = urid_map.intern(&uri);
272                if urid != 0 {
273                    param_urid_to_id.push((urid, pi.id));
274                }
275            }
276        }
277
278        let instance = Box::new(Lv2Instance::<P> {
279            plugin,
280            sample_rate,
281            max_block_size: 0,
282            plugin_id_hash: shared_plugin_state_hash(&info),
283            param_infos,
284            layout,
285
286            audio_inputs: vec![ptr::null(); audio_in_count],
287            audio_outputs: vec![ptr::null_mut(); audio_out_count],
288            control_ports: vec![ptr::null(); control_port_count],
289            meter_ports: vec![ptr::null_mut(); meter_count],
290            meter_ids,
291            atom_in_port: ptr::null(),
292            midi_out_port: ptr::null_mut(),
293            notify_out_port: ptr::null_mut(),
294
295            last_control: vec![None; control_port_count],
296
297            event_list: EventList::with_capacity(EVENT_LIST_PREALLOC),
298            output_events: EventList::with_capacity(EVENT_LIST_PREALLOC),
299            sub_event_scratch: EventList::with_capacity(EVENT_LIST_PREALLOC),
300            params_arc,
301            min_subblock_samples,
302
303            urid_map,
304            param_urid_to_id,
305
306            scratch: RawBufferScratch::default(),
307
308            transport_slot: truce_core::TransportSlot::new(),
309        });
310        Box::into_raw(instance)
311    }
312}
313
314/// # Safety
315/// `handle` must be a valid `Lv2Instance<P>` pointer previously returned
316/// from `instantiate::<P>()`.
317pub unsafe fn connect_port<P: PluginExport>(
318    handle: *mut Lv2Instance<P>,
319    port: u32,
320    data: *mut c_void,
321) {
322    unsafe {
323        let inst = &mut *handle;
324        // Snapshot the port-range boundaries up-front (cheap copies of
325        // u32 start indices) so we can dispatch on `port` without
326        // holding a borrow of `inst.layout` while writing back to a
327        // sibling `inst.<port_array>` field. The alternative
328        // (`layout.clone()` per call) would allocate on every
329        // connect.
330        let audio_in_start = inst.layout.audio_in_start();
331        let audio_out_start = inst.layout.audio_out_start();
332        let control_start = inst.layout.control_start();
333        let meter_start = inst.layout.meter_start();
334        let num_meters = inst.layout.num_meters;
335        let atom_in_port = inst.layout.atom_in_port();
336        let midi_out_port = inst.layout.midi_out_port();
337        let notify_out_port = inst.layout.notify_out_port();
338
339        if port < audio_out_start {
340            inst.audio_inputs[(port - audio_in_start) as usize] = data as *const f32;
341        } else if port < control_start {
342            inst.audio_outputs[(port - audio_out_start) as usize] = data.cast::<f32>();
343        } else if port < meter_start {
344            inst.control_ports[(port - control_start) as usize] = data as *const f32;
345        } else if port < meter_start + num_meters {
346            inst.meter_ports[(port - meter_start) as usize] = data.cast::<f32>();
347        } else if port == atom_in_port {
348            inst.atom_in_port = data as *const AtomSequence;
349        } else if Some(port) == midi_out_port {
350            inst.midi_out_port = data.cast::<AtomSequence>();
351        } else if port == notify_out_port {
352            inst.notify_out_port = data.cast::<AtomSequence>();
353        }
354    }
355}
356
357/// LV2 has no `instantiate`-time max-block-length contract: the
358/// `bufsz:maxBlockLength` option is delivered through `lv2:options`,
359/// which few hosts implement. We pre-allocate scratch large enough to
360/// cover practical session sizes (Pro Tools tops out at 8192 H/W
361/// frames; jack/Carla and ardour have been observed up to ~16k).
362/// Anything beyond that falls into the realloc edge case in `run()`.
363const LV2_MAX_PREALLOC_BLOCK: usize = 16384;
364
365/// # Safety
366/// `handle` must be a valid `Lv2Instance<P>` pointer.
367pub unsafe fn activate<P: PluginExport>(handle: *mut Lv2Instance<P>) {
368    unsafe {
369        let inst = &mut *handle;
370        inst.max_block_size = LV2_MAX_PREALLOC_BLOCK;
371        inst.scratch.ensure_capacity(
372            inst.audio_inputs.len(),
373            inst.audio_outputs.len(),
374            LV2_MAX_PREALLOC_BLOCK,
375        );
376        inst.plugin.reset(inst.sample_rate, LV2_MAX_PREALLOC_BLOCK);
377        inst.plugin.params().set_sample_rate(inst.sample_rate);
378        inst.plugin.params().snap_smoothers();
379    }
380}
381
382/// # Safety
383/// `handle` must be a valid `Lv2Instance<P>` pointer with port connections
384/// established by prior calls to `connect_port()`. Audio and control port
385/// memory must be valid for `n_samples`.
386#[allow(clippy::too_many_lines)]
387pub unsafe fn run<P: PluginExport>(handle: *mut Lv2Instance<P>, n_samples: u32) {
388    let n = n_samples as usize;
389    let ok = run_audio_block::<P>("LV2", || unsafe {
390        let inst = &mut *handle;
391        if n == 0 {
392            return;
393        }
394        if n > inst.max_block_size {
395            // Host exceeded our pre-allocated ceiling. Calling
396            // `plugin.reset(sr, n)` would wipe filter delay lines /
397            // oscillator phase mid-stream - plugins assume `reset()`
398            // happens at quiescent points only. So we grow the input
399            // scratch in place (a one-time realloc per increase) and
400            // continue. The audio thread paying for `realloc` here is
401            // a known cost of LV2's missing block-size contract.
402            debug_assert!(
403                false,
404                "LV2 host delivered block of {n} samples, exceeding pre-allocated \
405                 {LV2_MAX_PREALLOC_BLOCK} - input scratch will realloc on the audio thread",
406            );
407            inst.scratch
408                .ensure_capacity(inst.audio_inputs.len(), inst.audio_outputs.len(), n);
409            inst.max_block_size = n;
410        }
411
412        inst.event_list.clear();
413        inst.output_events.clear();
414
415        // Emit ParamChange events for any control port that moved since last
416        // run. The event carries the PLAIN value - format wrappers agree on
417        // plain (see `HotShell::process`'s comment). Writing plain directly
418        // also lets the plugin see the value immediately via its params Arc;
419        // the event is only there so `PluginLogic`s that observe param
420        // changes via events (rather than reading atomics) pick the change up
421        // at the right sample offset.
422        for (i, &port_ptr) in inst.control_ports.iter().enumerate() {
423            if port_ptr.is_null() {
424                continue;
425            }
426            let v = *port_ptr;
427            if !v.is_finite() {
428                continue;
429            }
430            let changed = inst.last_control[i].is_none_or(|prev| (v - prev).abs() > f32::EPSILON);
431            if changed {
432                inst.last_control[i] = Some(v);
433                let pid = inst.param_infos[i].id;
434                let plain = f64::from(v);
435                // `set_plain` is deferred to the chunker's apply pass
436                // so smoothers see `set_target` at the event's sample.
437                // LV2 control-port reads land at sample 0 of the block
438                // so the chunker applies them on entry to the first
439                // sub-block, equivalent to the prior eager behaviour.
440                inst.event_list.push(Event {
441                    sample_offset: 0,
442                    body: EventBody::ParamChange {
443                        id: pid,
444                        value: plain,
445                    },
446                });
447            }
448        }
449
450        // Decode MIDI + time:Position + patch:Set from the input atom
451        // sequence port. The port is always declared so every plugin
452        // type (effects included) can receive host transport and
453        // sample-accurate parameter automation; MIDI events are only
454        // parsed when the plugin's category opts in.
455        let mut transport = TransportInfo::default();
456        if !inst.atom_in_port.is_null() {
457            let reader = AtomSequenceReader::new(inst.atom_in_port, &inst.urid_map);
458
459            // LV2 1.18+ host-→-plugin parameter automation. Each
460            // `patch:Set` Object's `patch:property` identifies the
461            // target parameter (looked up against the per-instance
462            // URID → param-id table built at instantiate); the
463            // event's `time_frames` becomes the within-block
464            // `sample_offset`. The chunker downstream splits the
465            // audio block at each emitted ParamChange.
466            //
467            // Coexists with the legacy control-port path below: if a
468            // host writes both (e.g. mirrors automation onto the
469            // control port at sample 0), the smoother sees two
470            // set_target calls for the same value - harmless.
471            if !inst.param_urid_to_id.is_empty() {
472                reader.for_each_patch_set(|sample_offset, property, value| {
473                    if let Some(&(_, pid)) =
474                        inst.param_urid_to_id.iter().find(|(u, _)| *u == property)
475                    {
476                        inst.event_list.push(Event {
477                            sample_offset,
478                            body: EventBody::ParamChange { id: pid, value },
479                        });
480                    }
481                });
482            }
483
484            if inst.layout.accepts_midi_in {
485                reader.for_each_midi(|sample_offset, bytes| {
486                    // SysEx is delivered as a single MIDI atom whose
487                    // payload starts with `0xF0` and ends with `0xF7`.
488                    // The framework's `EventBody::SysEx` carries only
489                    // the inner bytes - strip the framing here so
490                    // plug-in code never sees the start/end markers.
491                    // A pool-full push gets dropped silently; truncating
492                    // a `SysEx` makes it corrupt by definition, so the
493                    // event simply doesn't reach the plug-in.
494                    if let Some(0xF0) = bytes.first().copied() {
495                        let end = if bytes.last().copied() == Some(0xF7) {
496                            bytes.len() - 1
497                        } else {
498                            bytes.len()
499                        };
500                        let inner = &bytes[1..end];
501                        let _ = inst.event_list.push_sysex(sample_offset, inner);
502                        return;
503                    }
504                    if let Some(event) = atom::midi_bytes_to_event(sample_offset, bytes) {
505                        inst.event_list.push(event);
506                    }
507                });
508            }
509            reader.apply_time_position(&mut transport);
510        }
511
512        // Build AudioBuffer from port pointers via the shared
513        // `RawBufferScratch::build` helper. The helper owns the
514        // raw-pointer-to-slice conversion plus the alias-detection
515        // copy-into-scratch fallback (LV2 hosts may connect an input
516        // and an output port to the same buffer for in-place
517        // processing). Plugins that want pass-through must do
518        // `output.copy_from_slice(input)` themselves - `build` does
519        // not auto-copy because that would clobber the previous-block
520        // tail delay / reverb feedback paths read from the output.
521        //
522        // Reborrow `inst` through a raw pointer for the scratch +
523        // event-list arms so each can hold an independent `&mut`
524        // through the call. SAFETY: single-threaded LV2 instance
525        // (`run` is called on one thread at a time per host
526        // contract), so the simultaneous `&mut`s never alias an
527        // overlapping field - `scratch`, `output_events`, and the
528        // immutable reads of `audio_inputs` / `audio_outputs` /
529        // `event_list` / `sample_rate` are disjoint.
530        {
531            let inst_ptr: *mut Lv2Instance<P> = inst;
532            let s = &mut *inst_ptr;
533            let in_ptrs = s.audio_inputs.as_ptr();
534            let out_ptrs = s.audio_outputs.as_mut_ptr();
535            let num_in = u32::try_from(s.audio_inputs.len()).unwrap_or(u32::MAX);
536            let num_out = u32::try_from(s.audio_outputs.len()).unwrap_or(u32::MAX);
537            let mut audio = s.scratch.build(
538                in_ptrs,
539                out_ptrs,
540                num_in,
541                num_out,
542                n_samples,
543                P::supports_in_place(),
544            );
545            inst.transport_slot.write(&transport);
546            let mut transport_snap = transport;
547            let chunk_args = ChunkedProcess {
548                events: &inst.event_list,
549                sub_event_scratch: &mut inst.sub_event_scratch,
550                transport: &mut transport_snap,
551                sample_rate: inst.sample_rate,
552                output_events: &mut inst.output_events,
553                params_fn: None,
554                meters_fn: None,
555                param_infos: &inst.param_infos,
556                min_subblock_samples: inst.min_subblock_samples,
557            };
558            let _ = process_chunked(
559                &mut inst.plugin,
560                inst.params_arc.as_ref() as &dyn Params,
561                &mut audio,
562                chunk_args,
563            );
564            // End the `audio` borrow before reaching back into `scratch`.
565            let _ = audio;
566            // Narrow rendered output back to host f32 pointers when
567            // the plugin's `Sample = f64`. No-op for f32 plugins.
568            s.scratch.finish_widening_f32(out_ptrs, num_out, n_samples);
569        }
570
571        // Copy meter readings out to the host. The plugin's process() has
572        // already written the latest peaks into the HotShell via
573        // `ctx.set_meter`; reading them back via `plugin.get_meter` picks
574        // up those atomics. Hosts forward the updated port value to the UI
575        // through `port_event` so the editor's meter widget animates.
576        for (slot, &id) in inst.meter_ports.iter().zip(inst.meter_ids.iter()) {
577            if slot.is_null() {
578                continue;
579            }
580            let v = inst.plugin.get_meter(id);
581            **slot = v;
582        }
583
584        // Write MIDI output to the atom sequence port, if connected.
585        if !inst.midi_out_port.is_null() {
586            atom::write_midi_out_sequence(inst.midi_out_port, &inst.output_events, &inst.urid_map);
587        }
588
589        // Forward transport to the UI as a time:Position atom on the
590        // notify-out port. Hosts deliver this to the UI's port_event each
591        // block; the UI decodes it and updates its shared `TransportSlot`.
592        if !inst.notify_out_port.is_null() {
593            atom::write_time_position_sequence(inst.notify_out_port, &transport, &inst.urid_map);
594        }
595    });
596    if !ok {
597        // Panic in plugin.process() - zero output port buffers so
598        // the host doesn't keep playing whatever stale samples were
599        // there when DSP died.
600        unsafe {
601            let inst = &mut *handle;
602            for &ptr in &inst.audio_outputs {
603                if !ptr.is_null() {
604                    std::ptr::write_bytes(ptr, 0, n);
605                }
606            }
607        }
608    }
609}
610
611/// # Safety
612/// `handle` must be a valid `Lv2Instance<P>` pointer.
613pub unsafe fn deactivate<P: PluginExport>(_handle: *mut Lv2Instance<P>) {
614    // No-op: LV2 activate/deactivate bracketing is advisory. We keep the
615    // plugin ready to go; another activate() will reset again.
616}
617
618/// # Safety
619/// `handle` must be a valid `Lv2Instance<P>` pointer. After this call the
620/// pointer is dangling and must not be used.
621pub unsafe fn cleanup<P: PluginExport>(handle: *mut Lv2Instance<P>) {
622    unsafe {
623        if !handle.is_null() {
624            drop(Box::from_raw(handle));
625        }
626    }
627}
628
629/// # Safety
630/// `uri` must be a valid null-terminated C string or null.
631#[must_use]
632pub unsafe fn extension_data<P: PluginExport>(uri: *const c_char) -> *const c_void {
633    unsafe {
634        if uri.is_null() {
635            return ptr::null();
636        }
637        let Ok(uri) = CStr::from_ptr(uri).to_str() else {
638            return ptr::null();
639        };
640        if uri == state::LV2_STATE__INTERFACE_URI {
641            return ptr::from_ref(state::state_interface::<P>()).cast::<c_void>();
642        }
643        ptr::null()
644    }
645}
646
647// ---------------------------------------------------------------------------
648// Plugin URI
649// ---------------------------------------------------------------------------
650
651/// Derive the plugin's LV2 URI from its `PluginInfo`. Thin wrapper
652/// around [`truce_build::lv2::plugin_uri`] - the single source of
653/// truth shared with the manifest writer in `truce-derive::lv2_emit`.
654/// Both paths MUST produce the same string, or hosts will discover
655/// the plugin under one URI then fail to look up the saved project's
656/// stored URI.
657#[must_use]
658pub fn plugin_uri(info: &PluginInfo) -> String {
659    truce_build::lv2::plugin_uri(info.url, info.bundle_id)
660}
661
662// ---------------------------------------------------------------------------
663// Descriptor holder
664// ---------------------------------------------------------------------------
665
666/// Holds the static LV2 descriptor plus its owned URI string. One per
667/// plugin type per process.
668pub struct DescriptorHolder {
669    pub descriptor: LV2Descriptor,
670    _uri: CString,
671}
672
673unsafe impl Send for DescriptorHolder {}
674unsafe impl Sync for DescriptorHolder {}
675
676impl DescriptorHolder {
677    #[allow(clippy::too_many_arguments)]
678    pub fn new(
679        info: &PluginInfo,
680        instantiate: InstantiateFn,
681        connect_port: ConnectPortFn,
682        activate: LifecycleFn,
683        run: RunFn,
684        deactivate: LifecycleFn,
685        cleanup: LifecycleFn,
686        extension_data: ExtensionDataFn,
687    ) -> Self {
688        let uri = CString::new(plugin_uri(info)).unwrap_or_default();
689        let descriptor = LV2Descriptor {
690            uri: uri.as_ptr(),
691            instantiate,
692            connect_port,
693            activate: Some(activate),
694            run,
695            deactivate: Some(deactivate),
696            cleanup,
697            extension_data,
698        };
699        Self {
700            descriptor,
701            _uri: uri,
702        }
703    }
704}
705
706// ---------------------------------------------------------------------------
707// Export macro
708// ---------------------------------------------------------------------------
709
710/// Export a plugin as LV2.
711///
712/// ```ignore
713/// truce_lv2::export_lv2!(MyPlugin);
714/// ```
715#[macro_export]
716macro_rules! export_lv2 {
717    ($plugin_type:ty) => {
718        mod _lv2_entry {
719            use super::*;
720            use std::ffi::{c_char, c_void};
721            use std::sync::OnceLock;
722
723            use ::truce_lv2::__macro_deps::truce_core::plugin::PluginRuntime;
724            use ::truce_lv2::{DescriptorHolder, LV2Descriptor, LV2Feature, Lv2Instance};
725
726            static DESCRIPTOR: OnceLock<DescriptorHolder> = OnceLock::new();
727
728            fn get_descriptor() -> &'static LV2Descriptor {
729                let holder = DESCRIPTOR.get_or_init(|| {
730                    let info = <$plugin_type as PluginRuntime>::info();
731                    DescriptorHolder::new(
732                        &info,
733                        instantiate,
734                        connect_port,
735                        activate,
736                        run,
737                        deactivate,
738                        cleanup,
739                        extension_data,
740                    )
741                });
742                &holder.descriptor
743            }
744
745            unsafe extern "C" fn instantiate(
746                _descriptor: *const LV2Descriptor,
747                sample_rate: f64,
748                bundle_path: *const c_char,
749                features: *const *const LV2Feature,
750            ) -> *mut c_void {
751                ::truce_lv2::instantiate::<$plugin_type>(sample_rate, bundle_path, features)
752                    as *mut c_void
753            }
754
755            unsafe extern "C" fn connect_port(handle: *mut c_void, port: u32, data: *mut c_void) {
756                ::truce_lv2::connect_port::<$plugin_type>(
757                    handle as *mut Lv2Instance<$plugin_type>,
758                    port,
759                    data,
760                );
761            }
762
763            unsafe extern "C" fn activate(handle: *mut c_void) {
764                ::truce_lv2::activate::<$plugin_type>(handle as *mut Lv2Instance<$plugin_type>);
765            }
766
767            unsafe extern "C" fn run(handle: *mut c_void, n_samples: u32) {
768                ::truce_lv2::run::<$plugin_type>(
769                    handle as *mut Lv2Instance<$plugin_type>,
770                    n_samples,
771                );
772            }
773
774            unsafe extern "C" fn deactivate(handle: *mut c_void) {
775                ::truce_lv2::deactivate::<$plugin_type>(handle as *mut Lv2Instance<$plugin_type>);
776            }
777
778            unsafe extern "C" fn cleanup(handle: *mut c_void) {
779                ::truce_lv2::cleanup::<$plugin_type>(handle as *mut Lv2Instance<$plugin_type>);
780            }
781
782            unsafe extern "C" fn extension_data(uri: *const c_char) -> *const c_void {
783                ::truce_lv2::extension_data::<$plugin_type>(uri)
784            }
785
786            #[unsafe(no_mangle)]
787            pub extern "C" fn lv2_descriptor(index: u32) -> *const LV2Descriptor {
788                if index == 0 {
789                    get_descriptor() as *const LV2Descriptor
790                } else {
791                    std::ptr::null()
792                }
793            }
794
795            // --- UI descriptor ----------------------------------------------
796            use ::truce_lv2::Lv2UiDescriptor;
797
798            static UI_URI: OnceLock<std::ffi::CString> = OnceLock::new();
799            static UI_DESCRIPTOR: OnceLock<Lv2UiDescriptor> = OnceLock::new();
800
801            fn get_ui_descriptor() -> &'static Lv2UiDescriptor {
802                UI_DESCRIPTOR.get_or_init(|| {
803                    let info = <$plugin_type as PluginRuntime>::info();
804                    let uri_str = ::truce_lv2::ui_uri(&info);
805                    let uri =
806                        UI_URI.get_or_init(|| std::ffi::CString::new(uri_str).unwrap_or_default());
807                    ::truce_lv2::ui_descriptor::<$plugin_type>(uri)
808                })
809            }
810
811            #[unsafe(no_mangle)]
812            pub extern "C" fn lv2ui_descriptor(index: u32) -> *const Lv2UiDescriptor {
813                if index == 0 {
814                    get_ui_descriptor() as *const Lv2UiDescriptor
815                } else {
816                    std::ptr::null()
817                }
818            }
819        }
820    };
821}
822
823// Re-export AtomSequence for port-wiring & callers.
824pub use atom::AtomSequence;
825
826// Re-export UI types for the export_lv2 macro to use.
827pub use ui::{Lv2UiDescriptor, ui_descriptor};
828
829/// Derive the plugin's LV2 UI URI (plugin URI + "#ui"). Thin wrapper
830/// around [`truce_build::lv2::ui_uri`] - same single-source-of-truth
831/// posture as [`plugin_uri`].
832#[must_use]
833pub fn ui_uri(info: &PluginInfo) -> String {
834    truce_build::lv2::ui_uri(info.url, info.bundle_id)
835}
836
837#[cfg(test)]
838mod uri_consistency_tests {
839    //! Pins the LV2 URI agreement: the manifest writer
840    //! (`truce-derive::lv2_emit`) and this crate's runtime
841    //! `plugin_uri` MUST produce the same string for the same
842    //! `(vendor_url, bundle_id)`. Both now delegate to
843    //! `truce_build::lv2::plugin_uri`, so this test guarantees the
844    //! manifest-vs-runtime contract by checking the runtime call
845    //! against the same `truce_build` function the manifest writer
846    //! uses - any drift on either side breaks this test.
847    use super::{plugin_uri, ui_uri};
848    use truce_core::info::{PluginCategory, PluginInfo};
849
850    fn info_with(url: &'static str, bundle_id: &'static str) -> PluginInfo {
851        PluginInfo {
852            name: "Test",
853            vendor: "Vendor",
854            url,
855            version: "0.0.0",
856            category: PluginCategory::Effect,
857            bundle_id,
858            vst3_id: "",
859            clap_id: "",
860            fourcc: *b"Test",
861            au_type: *b"aufx",
862            au_manufacturer: *b"Vend",
863            aax_id: None,
864            aax_category: None,
865            vst3_subcategory: None,
866            vst3_name: None,
867            clap_name: None,
868            vst2_name: None,
869            au_name: None,
870            au3_name: None,
871            aax_name: None,
872            lv2_name: None,
873            preset_user_dir: None,
874            mute_preview_output: false,
875            automation: truce_core::info::AutomationConfig::DEFAULT,
876        }
877    }
878
879    #[test]
880    fn runtime_uri_matches_manifest_uri_with_vendor_url() {
881        let info = info_with("https://example.com", "my-gain");
882        assert_eq!(
883            plugin_uri(&info),
884            truce_build::lv2::plugin_uri("https://example.com", "my-gain"),
885        );
886    }
887
888    #[test]
889    fn runtime_uri_matches_manifest_uri_with_trailing_slash() {
890        let info = info_with("https://example.com/", "my-gain");
891        assert_eq!(
892            plugin_uri(&info),
893            truce_build::lv2::plugin_uri("https://example.com/", "my-gain"),
894        );
895    }
896
897    #[test]
898    fn runtime_uri_matches_manifest_uri_empty_url() {
899        let info = info_with("", "my-gain");
900        assert_eq!(
901            plugin_uri(&info),
902            truce_build::lv2::plugin_uri("", "my-gain"),
903        );
904    }
905
906    #[test]
907    fn runtime_ui_uri_matches_manifest_ui_uri() {
908        let info = info_with("https://example.com", "my-gain");
909        assert_eq!(
910            ui_uri(&info),
911            truce_build::lv2::ui_uri("https://example.com", "my-gain"),
912        );
913    }
914}