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 ¶m_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}