Skip to main content

truce_rack_lv2/
lib.rs

1//! LV2 host implementation for the truce-rack framework.
2//!
3//! Built on `lilv-sys` — the Rust FFI for **lilv**, the standard
4//! LV2 host library. lilv handles URI resolution, TTL parsing, and
5//! the Turtle world load that LV2 demands; truce-rack-lv2 is the layer
6//! that turns those C-API queries into [`PluginInfo`] entries and
7//! drives `lilv_plugin_instantiate` / `lilv_instance_run` from the
8//! truce-rack-core trait surface.
9//!
10//! # Status
11//!
12//! - **Scan** via `lilv_world_load_all`.
13//! - **Load** instantiates the plugin into a `LilvInstance` with
14//!   the LV2 `urid#map` feature. Each plugin owns its own world
15//!   (instance pointers reference world-owned data).
16//! - **Process** connects audio / control / atom-sequence ports
17//!   per block and calls `lilv_instance_run`. Audio ports point
18//!   straight at the host's planes (zero-copy); control ports
19//!   carry their declared default value; the MIDI input atom port
20//!   is rebuilt each block from the truce-rack [`EventList`].
21//! - **MIDI** in via atom sequence ports tagged `midi:MidiEvent`.
22//!   MIDI out is not yet drained back to the truce-rack host.
23//!
24//! # Build dependency
25//!
26//! `lilv-sys` links against the system `lilv-0` library. On macOS,
27//! `brew install lilv`. On Debian/Ubuntu, `apt install liblilv-dev`.
28//! Without those, `cargo build -p truce-rack-lv2` will fail at link
29//! time.
30
31#![allow(
32    // Atom struct sizes are well under u32::MAX; the casts are for
33    // FFI fields that are themselves u32.
34    clippy::cast_possible_truncation,
35    // Vec<u8> allocations come from the global allocator with at
36    // least 8-byte alignment; safe to cast to atom-header pointers.
37    clippy::cast_ptr_alignment,
38    // `&x as *const _` reads cleaner here than `&raw const x`.
39    clippy::borrow_as_ptr,
40    clippy::ref_as_ptr,
41    clippy::ptr_as_ptr
42)]
43
44use truce_rack_core::buffer::AudioBuffer;
45use truce_rack_core::bus::{BusLayout, ChannelConfig};
46use truce_rack_core::editor::{PluginEditor, WindowHandle};
47use truce_rack_core::error::{Error, Result};
48use truce_rack_core::events::{Event, EventBody, EventList, MidiData};
49use truce_rack_core::info::{ParameterInfo, PluginCategory, PluginInfo, PresetInfo};
50use truce_rack_core::plugin::{Plugin, PluginCore, ProcessContext, ProcessStatus};
51use truce_rack_core::scanner::PluginScanner;
52use truce_rack_core::transport::TransportInfo;
53
54use lv2_raw::atom::{
55    LV2Atom, LV2AtomEvent, LV2AtomObjectBody, LV2AtomPropertyBody, LV2AtomSequence,
56    LV2AtomSequenceBody,
57};
58use lv2_raw::core::LV2Feature;
59use lv2_raw::ui::{LV2UIControllerRaw, LV2UIDescriptorRaw, LV2UIHandle, LV2UIWidget};
60use lv2_raw::urid::{LV2Urid, LV2UridMap, LV2UridMapHandle};
61
62use std::collections::HashMap;
63use std::ffi::{CStr, CString, c_char, c_void};
64use std::path::{Path, PathBuf};
65use std::sync::Mutex;
66
67/// Format identifier used on returned [`PluginInfo`].
68pub const FORMAT: &str = "lv2";
69
70const LV2_URID_MAP_URI: &[u8] = b"http://lv2plug.in/ns/ext/urid#map\0";
71const LV2_ATOM_SEQUENCE_URI: &[u8] = b"http://lv2plug.in/ns/ext/atom#Sequence\0";
72const LV2_UI_PARENT_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#parent\0";
73const LV2_UI_RESIZE_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#resize\0";
74const LV2_UI_IDLE_INTERFACE_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#idleInterface\0";
75const LV2_UI_RESIZE_INTERFACE_URI: &[u8] = LV2_UI_RESIZE_URI;
76
77#[cfg(target_os = "macos")]
78const NATIVE_UI_CLASS_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#CocoaUI\0";
79#[cfg(target_os = "windows")]
80const NATIVE_UI_CLASS_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#WindowsUI\0";
81#[cfg(all(unix, not(target_os = "macos")))]
82const NATIVE_UI_CLASS_URI: &[u8] = b"http://lv2plug.in/ns/extensions/ui#X11UI\0";
83
84/// LV2 scanner.
85#[derive(Debug, Default)]
86pub struct Lv2Scanner;
87
88impl Lv2Scanner {
89    /// Construct a default scanner.
90    #[must_use]
91    pub fn new() -> Self {
92        Self
93    }
94}
95
96impl PluginScanner for Lv2Scanner {
97    type Plugin = Lv2Plugin;
98
99    fn scan(&self) -> Result<Vec<PluginInfo>> {
100        let world = unsafe { World::new() }
101            .ok_or_else(|| Error::Other("lilv_world_new returned NULL".into()))?;
102        unsafe { lilv_sys::lilv_world_load_all(world.ptr) };
103        Ok(unsafe { world.collect_plugin_infos() })
104    }
105
106    fn scan_path(&self, _path: &Path) -> Result<Vec<PluginInfo>> {
107        // LV2's discovery model is URI-based, not path-based — the
108        // host calls `lilv_world_load_all` and lilv consults
109        // LV2_PATH / standard locations. A path-bounded scan would
110        // need `lilv_world_load_bundle` against each subdirectory;
111        // tracked as a follow-on.
112        Err(Error::Other(
113            "truce-rack-lv2 path-bounded scan not yet implemented".into(),
114        ))
115    }
116
117    fn load(&self, info: &PluginInfo) -> Result<Self::Plugin> {
118        Lv2Plugin::load_from(info)
119    }
120}
121
122// ---------------------------------------------------------------------------
123// World wrapper
124// ---------------------------------------------------------------------------
125
126/// RAII wrapper around `*mut LilvWorld`. Holds the world for the
127/// lifetime of either a scan or a loaded plugin (instance pointers
128/// reference world-owned data, so the world must outlive them).
129struct World {
130    ptr: *mut lilv_sys::LilvWorld,
131}
132
133impl World {
134    unsafe fn new() -> Option<Self> {
135        let ptr = unsafe { lilv_sys::lilv_world_new() };
136        if ptr.is_null() {
137            None
138        } else {
139            Some(Self { ptr })
140        }
141    }
142
143    unsafe fn collect_plugin_infos(&self) -> Vec<PluginInfo> {
144        // Stash this world for the per-plugin classifiers to reach
145        // (lilv exposes no `plugin → world` accessor). Cleared
146        // before we return so the TLS pointer doesn't outlive
147        // the borrow.
148        SCAN_WORLD.with(|w| w.set(self.ptr));
149        let mut out = Vec::new();
150        let plugins = unsafe { lilv_sys::lilv_world_get_all_plugins(self.ptr) };
151        if plugins.is_null() {
152            SCAN_WORLD.with(|w| w.set(std::ptr::null_mut()));
153            return out;
154        }
155        let audio_uri = unsafe { self.new_uri(lilv_sys::LILV_URI_AUDIO_PORT.as_ptr().cast()) };
156        let input_uri = unsafe { self.new_uri(lilv_sys::LILV_URI_INPUT_PORT.as_ptr().cast()) };
157        let output_uri = unsafe { self.new_uri(lilv_sys::LILV_URI_OUTPUT_PORT.as_ptr().cast()) };
158        let atom_uri = unsafe { self.new_uri(lilv_sys::LILV_URI_ATOM_PORT.as_ptr().cast()) };
159        let mut it = unsafe { lilv_sys::lilv_plugins_begin(plugins) };
160        loop {
161            if unsafe { lilv_sys::lilv_plugins_is_end(plugins, it) } {
162                break;
163            }
164            let plugin = unsafe { lilv_sys::lilv_plugins_get(plugins, it) };
165            if !plugin.is_null() {
166                out.push(unsafe {
167                    plugin_to_info(plugin, audio_uri, input_uri, output_uri, atom_uri)
168                });
169            }
170            it = unsafe { lilv_sys::lilv_plugins_next(plugins, it) };
171        }
172        unsafe {
173            lilv_sys::lilv_node_free(audio_uri);
174            lilv_sys::lilv_node_free(input_uri);
175            lilv_sys::lilv_node_free(output_uri);
176            lilv_sys::lilv_node_free(atom_uri);
177        }
178        SCAN_WORLD.with(|w| w.set(std::ptr::null_mut()));
179        out
180    }
181
182    unsafe fn new_uri(&self, uri: *const c_char) -> *mut lilv_sys::LilvNode {
183        unsafe { lilv_sys::lilv_new_uri(self.ptr, uri.cast()) }
184    }
185}
186
187impl Drop for World {
188    fn drop(&mut self) {
189        unsafe { lilv_sys::lilv_world_free(self.ptr) };
190    }
191}
192
193// SAFETY: lilv's world is meant to be single-threaded for mutation
194// but we hand the wrapper between activate/process on the same
195// thread the host owns. We never share it.
196unsafe impl Send for World {}
197
198unsafe fn plugin_to_info(
199    plugin: *const lilv_sys::LilvPlugin,
200    audio_uri: *mut lilv_sys::LilvNode,
201    input_uri: *mut lilv_sys::LilvNode,
202    output_uri: *mut lilv_sys::LilvNode,
203    atom_uri: *mut lilv_sys::LilvNode,
204) -> PluginInfo {
205    let uri_node = unsafe { lilv_sys::lilv_plugin_get_uri(plugin) };
206    let uri = unsafe { node_to_uri_string(uri_node) };
207
208    let name_node = unsafe { lilv_sys::lilv_plugin_get_name(plugin) };
209    let name = unsafe { node_to_string_owned(name_node) };
210    unsafe { lilv_sys::lilv_node_free(name_node) };
211
212    let author_node = unsafe { lilv_sys::lilv_plugin_get_author_name(plugin) };
213    let vendor = if author_node.is_null() {
214        String::new()
215    } else {
216        let v = unsafe { node_to_string_owned(author_node) };
217        unsafe { lilv_sys::lilv_node_free(author_node) };
218        v
219    };
220
221    let mut audio_in = 0u32;
222    let mut audio_out = 0u32;
223    let mut accepts_midi = false;
224    let count = unsafe { lilv_sys::lilv_plugin_get_num_ports(plugin) };
225    for idx in 0..count {
226        let port = unsafe { lilv_sys::lilv_plugin_get_port_by_index(plugin, idx) };
227        if port.is_null() {
228            continue;
229        }
230        let is_input = unsafe { lilv_sys::lilv_port_is_a(plugin, port, input_uri) };
231        if unsafe { lilv_sys::lilv_port_is_a(plugin, port, audio_uri) } {
232            if is_input {
233                audio_in += 1;
234            } else if unsafe { lilv_sys::lilv_port_is_a(plugin, port, output_uri) } {
235                audio_out += 1;
236            }
237        } else if is_input && unsafe { lilv_sys::lilv_port_is_a(plugin, port, atom_uri) } {
238            // Best-effort: any atom-port input is treated as a MIDI
239            // sink for the catalog. The actual MIDI-vs-not check
240            // requires walking the port's `atom:supports` triples,
241            // which we do at load time.
242            accepts_midi = true;
243        }
244    }
245    let category = if audio_in == 0 && audio_out > 0 {
246        PluginCategory::Instrument
247    } else {
248        PluginCategory::Effect
249    };
250
251    let has_editor = unsafe { has_native_ui(plugin) };
252
253    PluginInfo {
254        name,
255        vendor,
256        version: 0,
257        category,
258        path: std::path::PathBuf::new(),
259        unique_id: uri,
260        format: FORMAT,
261        has_editor,
262        accepts_midi,
263    }
264}
265
266/// True if the plugin advertises a UI of this platform's native class
267/// (`CocoaUI` / `WindowsUI` / `X11UI`).
268unsafe fn has_native_ui(plugin: *const lilv_sys::LilvPlugin) -> bool {
269    let uis = unsafe { lilv_sys::lilv_plugin_get_uis(plugin) };
270    if uis.is_null() {
271        return false;
272    }
273    let world_ptr = unsafe { lilv_plugin_world(plugin) };
274    if world_ptr.is_null() {
275        unsafe { lilv_sys::lilv_uis_free(uis) };
276        return false;
277    }
278    let class_node =
279        unsafe { lilv_sys::lilv_new_uri(world_ptr, NATIVE_UI_CLASS_URI.as_ptr().cast()) };
280    let mut found = false;
281    let mut it = unsafe { lilv_sys::lilv_uis_begin(uis) };
282    while !unsafe { lilv_sys::lilv_uis_is_end(uis, it) } {
283        let ui = unsafe { lilv_sys::lilv_uis_get(uis, it) };
284        if !ui.is_null() && unsafe { lilv_sys::lilv_ui_is_a(ui, class_node) } {
285            found = true;
286            break;
287        }
288        it = unsafe { lilv_sys::lilv_uis_next(uis, it) };
289    }
290    unsafe {
291        lilv_sys::lilv_node_free(class_node);
292        lilv_sys::lilv_uis_free(uis);
293    }
294    found
295}
296
297/// Recover the world pointer from a plugin pointer. lilv stores it
298/// internally but doesn't expose it directly; we cheat by stashing
299/// it into a thread-local during `collect_plugin_infos`.
300unsafe fn lilv_plugin_world(_plugin: *const lilv_sys::LilvPlugin) -> *mut lilv_sys::LilvWorld {
301    SCAN_WORLD.with(std::cell::Cell::get)
302}
303
304thread_local! {
305    /// Set by `collect_plugin_infos` for the duration of one scan
306    /// pass so the per-plugin classifiers can `lilv_new_uri`
307    /// against the same world. Cleared on Drop of the World guard.
308    static SCAN_WORLD: std::cell::Cell<*mut lilv_sys::LilvWorld> =
309        const { std::cell::Cell::new(std::ptr::null_mut()) };
310}
311
312unsafe fn node_to_uri_string(node: *const lilv_sys::LilvNode) -> String {
313    if node.is_null() {
314        return String::new();
315    }
316    let p = unsafe { lilv_sys::lilv_node_as_uri(node) };
317    if p.is_null() {
318        return String::new();
319    }
320    unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
321}
322
323unsafe fn node_to_string_owned(node: *mut lilv_sys::LilvNode) -> String {
324    if node.is_null() {
325        return String::new();
326    }
327    let p = unsafe { lilv_sys::lilv_node_as_string(node) };
328    if p.is_null() {
329        return String::new();
330    }
331    unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned()
332}
333
334// ---------------------------------------------------------------------------
335// URID map + features
336// ---------------------------------------------------------------------------
337
338/// Bidirectional URI ↔ u32 map handed to LV2 plugins as the
339/// standard `urid#map` feature.
340struct UridMap {
341    entries: Mutex<HashMap<CString, LV2Urid>>,
342    next_id: Mutex<LV2Urid>,
343}
344
345impl UridMap {
346    fn new() -> Self {
347        Self {
348            entries: Mutex::new(HashMap::new()),
349            // Reserve 0 as the LV2-spec "couldn't map" sentinel.
350            next_id: Mutex::new(1),
351        }
352    }
353
354    fn map_uri(&self, uri: &CStr) -> LV2Urid {
355        let mut entries = self.entries.lock().expect("urid map mutex");
356        if let Some(&id) = entries.get(uri) {
357            return id;
358        }
359        let mut next = self.next_id.lock().expect("urid next mutex");
360        let id = *next;
361        *next += 1;
362        entries.insert(uri.to_owned(), id);
363        id
364    }
365}
366
367extern "C" fn map_callback(handle: LV2UridMapHandle, uri: *const c_char) -> LV2Urid {
368    if handle.is_null() || uri.is_null() {
369        return 0;
370    }
371    // SAFETY: `handle` is the `&UridMap` we set into the feature
372    // struct at instantiate time; lilv passes it back unchanged.
373    let map = unsafe { &*(handle as *const UridMap) };
374    let cstr = unsafe { CStr::from_ptr(uri) };
375    map.map_uri(cstr)
376}
377
378/// Self-contained LV2 feature list. Heap-allocated so the pointers
379/// fed to `lilv_plugin_instantiate` stay valid for the instance's
380/// lifetime even if `Lv2Plugin` is moved.
381struct Features {
382    urid_map: UridMap,
383    // `LV2UridMap.handle` points at `urid_map` — the box's pinned
384    // address. `LV2UridMap.map` is the static callback. Held inline
385    // so its address can be taken for `feature.data`.
386    map_struct: LV2UridMap,
387    // The features array proper. Each `data` field points at a
388    // sibling field of the same `Features` allocation.
389    feature_storage: Vec<LV2Feature>,
390    // Null-terminated pointer array — what `lilv_plugin_instantiate`
391    // actually consumes.
392    feature_ptrs: Vec<*const LV2Feature>,
393}
394
395impl Features {
396    fn new() -> Box<Self> {
397        // Build in two passes so the `Box`'s heap address is stable
398        // before we record interior pointers.
399        let mut boxed = Box::new(Features {
400            urid_map: UridMap::new(),
401            map_struct: LV2UridMap {
402                handle: std::ptr::null_mut(),
403                map: map_callback,
404            },
405            feature_storage: Vec::with_capacity(1),
406            feature_ptrs: Vec::with_capacity(2),
407        });
408        let urid_map_handle: *mut UridMap = &mut boxed.urid_map as *mut UridMap;
409        boxed.map_struct.handle = urid_map_handle.cast::<c_void>();
410        boxed.feature_storage.push(LV2Feature {
411            uri: LV2_URID_MAP_URI.as_ptr().cast::<c_char>(),
412            data: (&boxed.map_struct) as *const LV2UridMap as *mut c_void,
413        });
414        boxed.feature_ptrs.push(&boxed.feature_storage[0]);
415        boxed.feature_ptrs.push(std::ptr::null());
416        boxed
417    }
418}
419
420// ---------------------------------------------------------------------------
421// UI plumbing
422// ---------------------------------------------------------------------------
423
424/// Metadata about a plugin's native UI bundle, captured at load
425/// time. `binary_path` is the absolute filesystem path to the
426/// `.so`/`.dylib`/`.dll` that exports `lv2ui_descriptor`.
427#[derive(Debug, Clone)]
428struct UiBundle {
429    ui_uri: CString,
430    bundle_path: PathBuf,
431    binary_path: PathBuf,
432}
433
434/// Live editor state. Created on `PluginEditor::open`; torn down on
435/// `close` or `Drop`. Kept on `Lv2Plugin` so the dropped instance's
436/// `lv2ui_descriptor.cleanup` runs before the audio instance is
437/// freed.
438struct EditorState {
439    /// The dlopen'd UI bundle. Held so the descriptor / function
440    /// pointers stay valid; dropped after `cleanup` returns.
441    _library: libloading::Library,
442    descriptor: *const LV2UIDescriptorRaw,
443    handle: LV2UIHandle,
444    widget: LV2UIWidget,
445    /// Self-contained features array passed to `instantiate`.
446    /// Boxed so the pointers we recorded into the LV2 plugin stay
447    /// valid even if `Lv2Plugin` itself is moved. Held for ownership
448    /// only — the UI side reaches into it through the raw feature
449    /// pointers it was given at instantiate time.
450    #[allow(dead_code)]
451    ui_features: Box<UiFeatureStorage>,
452    /// Optional `LV2_UI__idleInterface` — non-null if the UI exports
453    /// it via `extension_data`. Driven by `on_idle` once per host
454    /// frame.
455    idle_iface: *const lv2_raw::ui::LV2UIIdleInterface,
456    /// Optional `LV2_UI__resize` interface — non-null if the UI
457    /// exports it via `extension_data`. Used by `set_size` to push
458    /// host-driven resizes to the UI.
459    resize_iface: *const Lv2UiResizeInterface,
460    /// Snapshot of `control_values` taken at open time and refreshed
461    /// every `on_idle` so we can fire `port_event` for any value the
462    /// audio thread (or another UI write) has changed since the last
463    /// idle tick.
464    last_pushed_values: Vec<f32>,
465}
466
467/// LV2 host-side `ui:resize` interface — the same shape both the
468/// host implements (passed via the feature) and the UI implements
469/// (returned from `extension_data`). Non-zero `ui_resize` returns
470/// indicate failure.
471#[repr(C)]
472struct Lv2UiResizeInterface {
473    /// Opaque pointer the UI passes back. For host → UI, this is
474    /// the UI's own handle.
475    handle: *mut c_void,
476    ui_resize: extern "C" fn(handle: *mut c_void, width: i32, height: i32) -> i32,
477}
478
479/// Heap-stable storage for the LV2 UI feature array. Mirrors the
480/// shape of [`Features`] for the audio side.
481struct UiFeatureStorage {
482    /// Same URID map the audio instance uses — the UI talks to the
483    /// host through the same map.
484    map_struct: LV2UridMap,
485    /// `ui#parent` feature. `data` is a raw pointer to the parent
486    /// widget (`NSView`* / HWND / X11 Window).
487    parent_feature_data: *mut c_void,
488    /// Host-side `ui:resize` callback. The UI calls
489    /// `resize_struct.ui_resize(resize_struct.handle, w, h)` to ask
490    /// the host to resize. The handle is a pointer to a
491    /// `HostResizeNotifier` heap-allocated alongside us; the host
492    /// reads the latest requested size out of it via `take_request`.
493    resize_struct: Lv2UiResizeInterface,
494    resize_notifier: Box<HostResizeNotifier>,
495    feature_storage: Vec<LV2Feature>,
496    feature_ptrs: Vec<*const LV2Feature>,
497}
498
499/// Heap-stable cell the UI's `ui_resize` callback writes its
500/// requested dimensions into. The standalone polls this every
501/// `on_idle` and applies any pending request.
502struct HostResizeNotifier {
503    /// `(width, height)` requested by the UI; `None` means no
504    /// pending request. Written by the UI thread, read by the host
505    /// — both run on the same main thread, but the field is
506    /// touched from C code outside Rust's borrow tracking, so we
507    /// use `Cell` to make the interior mutability explicit.
508    pending: std::cell::Cell<Option<(i32, i32)>>,
509}
510
511extern "C" fn host_resize_callback(handle: *mut c_void, width: i32, height: i32) -> i32 {
512    if handle.is_null() {
513        return 1;
514    }
515    // SAFETY: `handle` is the boxed `HostResizeNotifier` we stored
516    // in the resize_struct at construction. lilv passes it back
517    // unchanged.
518    let notifier = unsafe { &*(handle as *const HostResizeNotifier) };
519    notifier.pending.set(Some((width, height)));
520    0
521}
522
523/// Heap-allocated controller passed back to the UI as the opaque
524/// `controller` argument of `instantiate` and `write_function`. The
525/// UI hands the same pointer back unchanged; we cast it to
526/// `&Controller` to route the write into our `control_values` vec.
527// Field names share the `control_` prefix because they all describe
528// the control-port shuttle — the prefix is meaningful, not noise.
529#[allow(clippy::struct_field_names)]
530struct Controller {
531    /// Pointer to the head of `Lv2Plugin::control_values`. Stable
532    /// for the lifetime of the plugin (we never reallocate after
533    /// `load_from`). The audio thread reads through the same
534    /// pointer via `lilv_instance_connect_port` — a benign data
535    /// race on aligned f32 stores, accepted by every LV2 host in
536    /// the wild.
537    control_values_base: *mut f32,
538    control_values_len: usize,
539    /// Map from LV2 port index to the `control_values` offset, or
540    /// `usize::MAX` if the port isn't a control port.
541    control_value_offset: Vec<usize>,
542}
543
544extern "C" fn ui_write_callback(
545    controller: LV2UIControllerRaw,
546    port_index: libc::c_uint,
547    buffer_size: libc::c_uint,
548    port_protocol: libc::c_uint,
549    buffer: *const c_void,
550) {
551    if controller.is_null() || buffer.is_null() {
552        return;
553    }
554    // Only the implicit float protocol (port_protocol == 0) updates
555    // a control port; richer protocols (atom event transfer, etc.)
556    // are not yet plumbed back into the audio side.
557    if port_protocol != 0 || buffer_size != 4 {
558        return;
559    }
560    // SAFETY: `controller` is the boxed `Controller` we set on
561    // `instantiate`. lilv passes it back unchanged. Const-cast is
562    // safe because we hold the only writer.
563    let ctrl = unsafe { &*(controller as *const Controller) };
564    let Some(&off) = ctrl.control_value_offset.get(port_index as usize) else {
565        return;
566    };
567    if off == usize::MAX || off >= ctrl.control_values_len {
568        return;
569    }
570    let value = unsafe { *(buffer as *const f32) };
571    // SAFETY: writing to a u32-sized aligned slot inside the
572    // control_values Vec. See doc comment on `control_values_base`.
573    unsafe {
574        *ctrl.control_values_base.add(off) = value;
575    }
576}
577
578impl UiFeatureStorage {
579    // The `Box` return is load-bearing: the caller stores raw
580    // pointers into this struct's interior fields, so the heap
581    // allocation must outlive `Lv2Plugin` moves.
582    #[allow(clippy::unnecessary_box_returns)]
583    fn new(map_handle: *mut UridMap, parent: *mut c_void) -> Box<Self> {
584        let mut boxed = Box::new(UiFeatureStorage {
585            map_struct: LV2UridMap {
586                handle: std::ptr::null_mut(),
587                map: map_callback,
588            },
589            parent_feature_data: parent,
590            resize_struct: Lv2UiResizeInterface {
591                handle: std::ptr::null_mut(),
592                ui_resize: host_resize_callback,
593            },
594            resize_notifier: Box::new(HostResizeNotifier {
595                pending: std::cell::Cell::new(None),
596            }),
597            feature_storage: Vec::with_capacity(3),
598            feature_ptrs: Vec::with_capacity(4),
599        });
600        boxed.map_struct.handle = map_handle.cast::<c_void>();
601        boxed.resize_struct.handle =
602            (&*boxed.resize_notifier) as *const HostResizeNotifier as *mut c_void;
603        boxed.feature_storage.push(LV2Feature {
604            uri: LV2_URID_MAP_URI.as_ptr().cast::<c_char>(),
605            data: (&boxed.map_struct) as *const LV2UridMap as *mut c_void,
606        });
607        boxed.feature_storage.push(LV2Feature {
608            uri: LV2_UI_PARENT_URI.as_ptr().cast::<c_char>(),
609            data: boxed.parent_feature_data,
610        });
611        boxed.feature_storage.push(LV2Feature {
612            uri: LV2_UI_RESIZE_URI.as_ptr().cast::<c_char>(),
613            data: (&boxed.resize_struct) as *const Lv2UiResizeInterface as *mut c_void,
614        });
615        for i in 0..boxed.feature_storage.len() {
616            boxed.feature_ptrs.push(&boxed.feature_storage[i]);
617        }
618        boxed.feature_ptrs.push(std::ptr::null());
619        boxed
620    }
621}
622
623/// Walk `plugin`'s UIs for one matching the host's native UI class.
624/// Returns the first match's metadata or `None`.
625unsafe fn discover_ui(
626    world: *mut lilv_sys::LilvWorld,
627    plugin: *const lilv_sys::LilvPlugin,
628) -> Option<UiBundle> {
629    let uis = unsafe { lilv_sys::lilv_plugin_get_uis(plugin) };
630    if uis.is_null() {
631        return None;
632    }
633    let class_node = unsafe { lilv_sys::lilv_new_uri(world, NATIVE_UI_CLASS_URI.as_ptr().cast()) };
634    let mut chosen: Option<UiBundle> = None;
635    let mut it = unsafe { lilv_sys::lilv_uis_begin(uis) };
636    while !unsafe { lilv_sys::lilv_uis_is_end(uis, it) } {
637        let ui = unsafe { lilv_sys::lilv_uis_get(uis, it) };
638        if !ui.is_null() && unsafe { lilv_sys::lilv_ui_is_a(ui, class_node) } {
639            let uri_str = unsafe {
640                let n = lilv_sys::lilv_ui_get_uri(ui);
641                node_to_uri_string(n)
642            };
643            let bundle_path = unsafe { node_uri_to_path(lilv_sys::lilv_ui_get_bundle_uri(ui)) };
644            let binary_path = unsafe { node_uri_to_path(lilv_sys::lilv_ui_get_binary_uri(ui)) };
645            if let (Ok(uri_c), Some(bundle), Some(binary)) =
646                (CString::new(uri_str), bundle_path, binary_path)
647            {
648                chosen = Some(UiBundle {
649                    ui_uri: uri_c,
650                    bundle_path: bundle,
651                    binary_path: binary,
652                });
653                break;
654            }
655        }
656        it = unsafe { lilv_sys::lilv_uis_next(uis, it) };
657    }
658    unsafe {
659        lilv_sys::lilv_node_free(class_node);
660        lilv_sys::lilv_uis_free(uis);
661    }
662    chosen
663}
664
665unsafe fn node_uri_to_path(node: *const lilv_sys::LilvNode) -> Option<PathBuf> {
666    if node.is_null() {
667        return None;
668    }
669    let uri = unsafe { lilv_sys::lilv_node_as_uri(node) };
670    if uri.is_null() {
671        return None;
672    }
673    let raw = unsafe { lilv_sys::lilv_file_uri_parse(uri, std::ptr::null_mut()) };
674    if raw.is_null() {
675        return None;
676    }
677    let s = unsafe { CStr::from_ptr(raw) }
678        .to_string_lossy()
679        .into_owned();
680    unsafe { lilv_sys::lilv_free(raw.cast()) };
681    Some(PathBuf::from(s))
682}
683
684// ---------------------------------------------------------------------------
685// Lv2Plugin
686// ---------------------------------------------------------------------------
687
688/// Cached metadata about one LV2 port.
689#[derive(Debug, Clone, Copy)]
690struct PortInfo {
691    index: u32,
692    kind: PortKind,
693    is_input: bool,
694}
695
696#[derive(Debug, Clone, Copy)]
697enum PortKind {
698    Audio,
699    Control { default: f32 },
700    AtomSequence,
701    Other,
702}
703
704/// URIDs needed to build a `time:Position` object atom, mapped once
705/// at load. The `atom_*` entries are the value types each property
706/// carries; the rest are the `time:` properties themselves.
707#[derive(Clone, Copy)]
708struct TimeUrids {
709    position: LV2Urid,
710    frame: LV2Urid,
711    speed: LV2Urid,
712    bar: LV2Urid,
713    bar_beat: LV2Urid,
714    beats_per_bar: LV2Urid,
715    beat_unit: LV2Urid,
716    bpm: LV2Urid,
717    atom_long: LV2Urid,
718    atom_float: LV2Urid,
719    atom_int: LV2Urid,
720    atom_object: LV2Urid,
721}
722
723/// One loaded LV2 plugin.
724///
725/// Owns its `World` (lilv data is referenced by pointer from the
726/// instance), its features, its per-port metadata, and the per-port
727/// scratch buffers connected on every block.
728pub struct Lv2Plugin {
729    info: PluginInfo,
730    layouts: Vec<BusLayout>,
731    active_layout: Option<BusLayout>,
732
733    /// Held for ownership: the `LilvPlugin` pointer and the instance
734    /// both reference world-owned data, so the world must outlive
735    /// them. Read directly only at load.
736    #[allow(dead_code)]
737    world: Box<World>,
738    plugin: *const lilv_sys::LilvPlugin,
739    instance: *mut lilv_sys::LilvInstance,
740
741    sample_rate: f64,
742
743    ports: Vec<PortInfo>,
744    /// Backing storage for control ports. `connect_port` points the
745    /// plugin at one f32 per control port; the index here matches
746    /// the position in `ports` filtered to controls.
747    control_values: Vec<f32>,
748    /// Map from `ports` index to the offset in `control_values`.
749    control_value_offset: Vec<usize>,
750
751    /// Backing storage for the (single) MIDI input atom-sequence
752    /// port, if the plugin has one. Rebuilt each block from the
753    /// truce-rack `EventList`.
754    midi_in_buf: Vec<u8>,
755    /// Backing storage for the MIDI output atom-sequence port. We
756    /// connect a chunk-typed buffer so the plugin has somewhere to
757    /// write; we don't currently drain it back into the host.
758    midi_out_buf: Vec<u8>,
759
760    features: Box<Features>,
761    midi_event_urid: LV2Urid,
762    atom_sequence_urid: LV2Urid,
763    time_urids: TimeUrids,
764
765    /// UI metadata captured at load if the plugin advertises a
766    /// native UI for this platform. `None` means no editor — the
767    /// `PluginEditor` impl will refuse `open`.
768    ui_bundle: Option<UiBundle>,
769    /// Live editor — `Some` between `open` and `close`.
770    editor: Option<EditorState>,
771    /// Heap-stable controller passed to the LV2 UI as the opaque
772    /// callback context. Built lazily on first `open`. Boxed so its
773    /// address survives moves of `Lv2Plugin`.
774    controller: Option<Box<Controller>>,
775}
776
777// SAFETY: We hand the plugin between the audio and main threads
778// behind an `Arc<Mutex<_>>` exactly like every other truce-rack format.
779unsafe impl Send for Lv2Plugin {}
780
781impl Lv2Plugin {
782    fn load_from(info: &PluginInfo) -> Result<Self> {
783        let world = Box::new(unsafe { World::new() }.ok_or_else(|| Error::LoadFailed {
784            path: info.path.clone(),
785            reason: "lilv_world_new returned NULL".into(),
786        })?);
787        unsafe { lilv_sys::lilv_world_load_all(world.ptr) };
788
789        let uri = CString::new(info.unique_id.clone()).map_err(|_| Error::LoadFailed {
790            path: info.path.clone(),
791            reason: format!("lv2 unique_id contains NUL: {:?}", info.unique_id),
792        })?;
793        let uri_node = unsafe { lilv_sys::lilv_new_uri(world.ptr, uri.as_ptr()) };
794        if uri_node.is_null() {
795            return Err(Error::LoadFailed {
796                path: info.path.clone(),
797                reason: format!("lilv_new_uri failed for {:?}", info.unique_id),
798            });
799        }
800        let plugins = unsafe { lilv_sys::lilv_world_get_all_plugins(world.ptr) };
801        let plugin = unsafe { lilv_sys::lilv_plugins_get_by_uri(plugins, uri_node) };
802        unsafe { lilv_sys::lilv_node_free(uri_node) };
803        if plugin.is_null() {
804            return Err(Error::LoadFailed {
805                path: info.path.clone(),
806                reason: format!("no LV2 plugin matching URI {:?}", info.unique_id),
807            });
808        }
809
810        // Walk the ports once, classify each, and stash
811        // direction + control defaults.
812        let (ports, audio_in_count, audio_out_count, control_value_offset, control_values) =
813            unsafe { classify_ports(world.ptr, plugin) };
814
815        // Pre-map the URIs we'll need at process time.
816        let features = Features::new();
817        let midi_event_urid = features
818            .urid_map
819            .map_uri(unsafe { CStr::from_ptr(lilv_sys::LILV_URI_MIDI_EVENT.as_ptr().cast()) });
820        let atom_sequence_urid = features
821            .urid_map
822            .map_uri(unsafe { CStr::from_ptr(LV2_ATOM_SEQUENCE_URI.as_ptr().cast()) });
823
824        // URIDs for the host transport (`time:Position`) atom we
825        // inject each block. Mapped once here so process() stays
826        // allocation-free.
827        let map_uri =
828            |uri: &[u8]| features.urid_map.map_uri(unsafe { CStr::from_ptr(uri.as_ptr().cast()) });
829        let time_urids = TimeUrids {
830            position: map_uri(lv2_raw::time::LV2_TIME__POSITION),
831            frame: map_uri(lv2_raw::time::LV2_TIME__FRAME),
832            speed: map_uri(lv2_raw::time::LV2_TIME__SPEED),
833            bar: map_uri(lv2_raw::time::LV2_TIME__BAR),
834            bar_beat: map_uri(lv2_raw::time::LV2_TIME__BARBEAT),
835            beats_per_bar: map_uri(lv2_raw::time::LV2_TIME__BEATSPERBAR),
836            beat_unit: map_uri(lv2_raw::time::LV2_TIME__BEATUNIT),
837            bpm: map_uri(lv2_raw::time::LV2_TIME__BEATSPERMINUTE),
838            atom_long: map_uri(lv2_raw::atom::LV2_ATOM__LONG),
839            atom_float: map_uri(lv2_raw::atom::LV2_ATOM__FLOAT),
840            atom_int: map_uri(lv2_raw::atom::LV2_ATOM__INT),
841            atom_object: map_uri(lv2_raw::atom::LV2_ATOM__OBJECT),
842        };
843
844        let layout = audio_layout(audio_in_count, audio_out_count);
845
846        let ui_bundle = unsafe { discover_ui(world.ptr, plugin) };
847        let mut info = info.clone();
848        info.has_editor = ui_bundle.is_some();
849
850        Ok(Self {
851            info,
852            layouts: vec![layout],
853            active_layout: None,
854            world,
855            plugin,
856            instance: std::ptr::null_mut(),
857            sample_rate: 0.0,
858            ports,
859            control_values,
860            control_value_offset,
861            midi_in_buf: Vec::new(),
862            midi_out_buf: Vec::new(),
863            features,
864            midi_event_urid,
865            atom_sequence_urid,
866            time_urids,
867            ui_bundle,
868            editor: None,
869            controller: None,
870        })
871    }
872
873    /// Build (or rebuild) the heap-stable Controller that the UI
874    /// uses as its callback context. Must be called only when
875    /// `control_values` is no longer going to be reallocated —
876    /// after `load_from` completes that's true for the plugin's
877    /// lifetime.
878    fn ensure_controller(&mut self) {
879        if self.controller.is_some() {
880            return;
881        }
882        self.controller = Some(Box::new(Controller {
883            control_values_base: self.control_values.as_mut_ptr(),
884            control_values_len: self.control_values.len(),
885            control_value_offset: self.control_value_offset.clone(),
886        }));
887    }
888
889    fn build_midi_sequence(&mut self, events: &EventList, transport: Option<TransportInfo>) {
890        let header_size = std::mem::size_of::<LV2AtomSequence>();
891        let cap = self.midi_in_buf.len();
892        if cap < header_size {
893            return;
894        }
895        let buf = self.midi_in_buf.as_mut_ptr();
896
897        // Header: capacity-bounded sequence whose `atom.size` only
898        // counts the body + events (the LV2 atom convention).
899        let body_size = std::mem::size_of::<LV2AtomSequenceBody>();
900        let seq = unsafe { &mut *buf.cast::<LV2AtomSequence>() };
901        seq.atom = LV2Atom {
902            size: body_size as u32,
903            mytype: self.atom_sequence_urid,
904        };
905        seq.body = LV2AtomSequenceBody { unit: 0, pad: 0 };
906
907        let mut write_off = header_size;
908
909        // Inject a `time:Position` object as the first event (frame
910        // 0) so transport-aware plugins see tempo / grid before any
911        // MIDI. MIDI events follow, also at frame >= 0.
912        if let Some(t) = transport {
913            write_off = unsafe { write_time_position(&self.time_urids, buf, write_off, cap, &t) };
914        }
915
916        let event_header = std::mem::size_of::<LV2AtomEvent>();
917        for ev in events {
918            let Some((status, d1, d2, len)) = midi_bytes(&ev.body) else {
919                continue;
920            };
921            let total = event_header + len;
922            let padded = (total + 7) & !7; // 8-byte alignment per atom spec
923            if write_off + padded > cap {
924                break;
925            }
926            let event_ptr = unsafe { buf.add(write_off) }.cast::<LV2AtomEvent>();
927            unsafe {
928                (*event_ptr).time_in_frames = i64::from(ev.sample_offset);
929                (*event_ptr).body = LV2Atom {
930                    size: len as u32,
931                    mytype: self.midi_event_urid,
932                };
933                let data_ptr = (event_ptr as *mut u8).add(event_header);
934                if len >= 1 {
935                    *data_ptr = status;
936                }
937                if len >= 2 {
938                    *data_ptr.add(1) = d1;
939                }
940                if len >= 3 {
941                    *data_ptr.add(2) = d2;
942                }
943            }
944            write_off += padded;
945        }
946        // sequence body size is everything past the atom header
947        seq.atom.size = (write_off - std::mem::size_of::<LV2Atom>()) as u32;
948    }
949
950    fn prep_midi_out(&mut self) {
951        // Reset to an empty Chunk-typed atom so the plugin has a
952        // defined buffer to write into. We don't drain output yet.
953        if self.midi_out_buf.len() < std::mem::size_of::<LV2AtomSequence>() {
954            return;
955        }
956        let buf = self.midi_out_buf.as_mut_ptr();
957        let seq = unsafe { &mut *buf.cast::<LV2AtomSequence>() };
958        seq.atom = LV2Atom {
959            size: std::mem::size_of::<LV2AtomSequenceBody>() as u32,
960            mytype: self.atom_sequence_urid,
961        };
962        seq.body = LV2AtomSequenceBody { unit: 0, pad: 0 };
963    }
964}
965
966/// Upper bound on the bytes one `time:Position` event consumes:
967/// event + object header plus eight 8-byte-aligned properties. Used
968/// to bounds-check once instead of per-property.
969const TIME_POSITION_MAX_BYTES: usize =
970    std::mem::size_of::<LV2AtomEvent>() + std::mem::size_of::<LV2AtomObjectBody>() + 8 * 32;
971
972/// Write one `time:` property (key + typed scalar value) into an
973/// object body at `off`, returning the next 8-byte-aligned offset.
974///
975/// # Safety
976/// `buf + off` must have room for the property header plus `value`
977/// rounded up to 8 bytes; callers guarantee this via a single
978/// up-front [`TIME_POSITION_MAX_BYTES`] check.
979unsafe fn write_property(buf: *mut u8, off: usize, key: LV2Urid, value_type: LV2Urid, value: &[u8]) -> usize {
980    let prop = unsafe { &mut *buf.add(off).cast::<LV2AtomPropertyBody>() };
981    prop.key = key;
982    prop.context = 0;
983    prop.value = LV2Atom {
984        size: u32::try_from(value.len()).unwrap_or(0),
985        mytype: value_type,
986    };
987    let data = unsafe { buf.add(off + std::mem::size_of::<LV2AtomPropertyBody>()) };
988    unsafe { std::ptr::copy_nonoverlapping(value.as_ptr(), data, value.len()) };
989    let total = std::mem::size_of::<LV2AtomPropertyBody>() + value.len();
990    off + ((total + 7) & !7)
991}
992
993/// Write a `time:Position` object event (at frame 0) into the atom
994/// sequence buffer starting at `base`, returning the offset just
995/// past it. Returns `base` unchanged if there isn't room.
996///
997/// # Safety
998/// `buf` must point at a buffer of at least `cap` bytes that is
999/// valid for writes in `[base, cap)` and aligned for atom structs.
1000#[allow(
1001    clippy::cast_possible_truncation,
1002    clippy::cast_possible_wrap,
1003    clippy::cast_precision_loss
1004)]
1005unsafe fn write_time_position(
1006    urids: &TimeUrids,
1007    buf: *mut u8,
1008    base: usize,
1009    cap: usize,
1010    t: &TransportInfo,
1011) -> usize {
1012    if base + TIME_POSITION_MAX_BYTES > cap {
1013        return base;
1014    }
1015    let event_header = std::mem::size_of::<LV2AtomEvent>();
1016    let obj_body_off = base + event_header;
1017
1018    // Object body header (id = 0 blank, otype = time:Position).
1019    let obj_body = unsafe { &mut *buf.add(obj_body_off).cast::<LV2AtomObjectBody>() };
1020    obj_body.id = 0;
1021    obj_body.otype = urids.position;
1022
1023    let mut off = obj_body_off + std::mem::size_of::<LV2AtomObjectBody>();
1024
1025    // speed: 1.0 while rolling, 0.0 when stopped.
1026    let speed: f32 = if t.playing { 1.0 } else { 0.0 };
1027    off = unsafe { write_property(buf, off, urids.speed, urids.atom_float, &speed.to_ne_bytes()) };
1028
1029    if let Some(samples) = t.song_position_samples {
1030        off = unsafe { write_property(buf, off, urids.frame, urids.atom_long, &samples.to_ne_bytes()) };
1031    }
1032    if let Some(tempo) = t.tempo_bpm {
1033        let bpm = tempo as f32;
1034        off = unsafe { write_property(buf, off, urids.bpm, urids.atom_float, &bpm.to_ne_bytes()) };
1035    }
1036    if let Some((num, den)) = t.time_signature {
1037        let beats_per_bar = num as f32;
1038        off = unsafe {
1039            write_property(buf, off, urids.beats_per_bar, urids.atom_float, &beats_per_bar.to_ne_bytes())
1040        };
1041        let beat_unit = den as i32;
1042        off = unsafe {
1043            write_property(buf, off, urids.beat_unit, urids.atom_int, &beat_unit.to_ne_bytes())
1044        };
1045
1046        // Bar / barBeat need the musical position too.
1047        let beats_per_bar_qn = f64::from(num) * 4.0 / f64::from(den.max(1));
1048        if let Some(bar_start) = t.bar_start_beats {
1049            let bar = (bar_start / beats_per_bar_qn.max(f64::EPSILON)).round() as i64;
1050            off = unsafe { write_property(buf, off, urids.bar, urids.atom_long, &bar.to_ne_bytes()) };
1051
1052            if let Some(beats) = t.song_position_beats {
1053                // Quarter notes since the bar, expressed in this
1054                // signature's beats (beatUnit per whole note).
1055                let bar_beat = ((beats - bar_start) * f64::from(den) / 4.0) as f32;
1056                off = unsafe {
1057                    write_property(buf, off, urids.bar_beat, urids.atom_float, &bar_beat.to_ne_bytes())
1058                };
1059            }
1060        }
1061    }
1062
1063    // Back-patch the event header now that the object size is known.
1064    let object_body_size = off - obj_body_off;
1065    let event = unsafe { &mut *buf.add(base).cast::<LV2AtomEvent>() };
1066    event.time_in_frames = 0;
1067    event.body = LV2Atom {
1068        size: object_body_size as u32,
1069        mytype: urids.atom_object,
1070    };
1071
1072    // Pad the whole event to the sequence's 8-byte event alignment.
1073    (off + 7) & !7
1074}
1075
1076fn audio_layout(in_count: usize, out_count: usize) -> BusLayout {
1077    let mut layout = BusLayout::new();
1078    if in_count > 0 {
1079        layout.inputs.push(truce_rack_core::bus::Bus::main(
1080            "Input",
1081            channel_config(in_count),
1082        ));
1083    }
1084    if out_count > 0 {
1085        layout.outputs.push(truce_rack_core::bus::Bus::main(
1086            "Output",
1087            channel_config(out_count),
1088        ));
1089    }
1090    layout
1091}
1092
1093fn channel_config(n: usize) -> ChannelConfig {
1094    match n {
1095        1 => ChannelConfig::Mono,
1096        2 => ChannelConfig::Stereo,
1097        6 => ChannelConfig::Surround5_1,
1098        8 => ChannelConfig::Surround7_1,
1099        n => ChannelConfig::Discrete(u32::try_from(n).unwrap_or(0)),
1100    }
1101}
1102
1103unsafe fn classify_ports(
1104    world: *mut lilv_sys::LilvWorld,
1105    plugin: *const lilv_sys::LilvPlugin,
1106) -> (Vec<PortInfo>, usize, usize, Vec<usize>, Vec<f32>) {
1107    let count = unsafe { lilv_sys::lilv_plugin_get_num_ports(plugin) };
1108    let audio_uri =
1109        unsafe { lilv_sys::lilv_new_uri(world, lilv_sys::LILV_URI_AUDIO_PORT.as_ptr().cast()) };
1110    let control_uri =
1111        unsafe { lilv_sys::lilv_new_uri(world, lilv_sys::LILV_URI_CONTROL_PORT.as_ptr().cast()) };
1112    let atom_uri =
1113        unsafe { lilv_sys::lilv_new_uri(world, lilv_sys::LILV_URI_ATOM_PORT.as_ptr().cast()) };
1114    let input_uri =
1115        unsafe { lilv_sys::lilv_new_uri(world, lilv_sys::LILV_URI_INPUT_PORT.as_ptr().cast()) };
1116    let output_uri =
1117        unsafe { lilv_sys::lilv_new_uri(world, lilv_sys::LILV_URI_OUTPUT_PORT.as_ptr().cast()) };
1118
1119    let mut ports = Vec::with_capacity(count as usize);
1120    let mut audio_in_count: usize = 0;
1121    let mut audio_out_count: usize = 0;
1122    let mut control_value_offset = Vec::with_capacity(count as usize);
1123    let mut control_values = Vec::new();
1124
1125    let mut defaults: Vec<f32> = vec![0.0; count as usize];
1126    unsafe {
1127        // Pull every control port's declared default in one call.
1128        let defaults_ptr = defaults.as_mut_ptr();
1129        lilv_sys::lilv_plugin_get_port_ranges_float(
1130            plugin,
1131            std::ptr::null_mut(), // mins (don't care)
1132            std::ptr::null_mut(), // maxes (don't care)
1133            defaults_ptr,
1134        );
1135    }
1136
1137    for idx in 0..count {
1138        let port = unsafe { lilv_sys::lilv_plugin_get_port_by_index(plugin, idx) };
1139        if port.is_null() {
1140            ports.push(PortInfo {
1141                index: idx,
1142                kind: PortKind::Other,
1143                is_input: false,
1144            });
1145            control_value_offset.push(usize::MAX);
1146            continue;
1147        }
1148        let is_input = unsafe { lilv_sys::lilv_port_is_a(plugin, port, input_uri) };
1149        let is_output = unsafe { lilv_sys::lilv_port_is_a(plugin, port, output_uri) };
1150        let kind = if unsafe { lilv_sys::lilv_port_is_a(plugin, port, audio_uri) } {
1151            if is_input {
1152                audio_in_count += 1;
1153            } else if is_output {
1154                audio_out_count += 1;
1155            }
1156            PortKind::Audio
1157        } else if unsafe { lilv_sys::lilv_port_is_a(plugin, port, control_uri) } {
1158            let default = if defaults[idx as usize].is_finite() {
1159                defaults[idx as usize]
1160            } else {
1161                0.0
1162            };
1163            PortKind::Control { default }
1164        } else if unsafe { lilv_sys::lilv_port_is_a(plugin, port, atom_uri) } {
1165            // We don't currently introspect the port's
1166            // `atom:supports` triples; assume any atom-port can
1167            // carry MIDI. Plugins with non-MIDI atom expectations
1168            // will see an unrecognised event type and ignore it,
1169            // which is the LV2 spec's required behaviour.
1170            PortKind::AtomSequence
1171        } else {
1172            PortKind::Other
1173        };
1174        if let PortKind::Control { default } = kind {
1175            control_value_offset.push(control_values.len());
1176            control_values.push(default);
1177        } else {
1178            control_value_offset.push(usize::MAX);
1179        }
1180        ports.push(PortInfo {
1181            index: idx,
1182            kind,
1183            is_input,
1184        });
1185    }
1186
1187    unsafe {
1188        lilv_sys::lilv_node_free(audio_uri);
1189        lilv_sys::lilv_node_free(control_uri);
1190        lilv_sys::lilv_node_free(atom_uri);
1191        lilv_sys::lilv_node_free(input_uri);
1192        lilv_sys::lilv_node_free(output_uri);
1193    }
1194
1195    (
1196        ports,
1197        audio_in_count,
1198        audio_out_count,
1199        control_value_offset,
1200        control_values,
1201    )
1202}
1203
1204/// Encode one truce-rack MIDI event into the (status, d1, d2, len)
1205/// triple LV2 atom MIDI events expect. Returns `None` for
1206/// non-MIDI events or unsupported MIDI variants.
1207fn midi_bytes(body: &EventBody) -> Option<(u8, u8, u8, usize)> {
1208    match body {
1209        EventBody::Midi(MidiData::NoteOn {
1210            channel,
1211            note,
1212            velocity,
1213        }) => Some((0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F, 3)),
1214        EventBody::Midi(MidiData::NoteOff {
1215            channel,
1216            note,
1217            velocity,
1218        }) => Some((0x80 | (channel & 0x0F), note & 0x7F, velocity & 0x7F, 3)),
1219        EventBody::Midi(MidiData::PolyAftertouch {
1220            channel,
1221            note,
1222            pressure,
1223        }) => Some((0xA0 | (channel & 0x0F), note & 0x7F, pressure & 0x7F, 3)),
1224        EventBody::Midi(MidiData::ControlChange {
1225            channel,
1226            controller,
1227            value,
1228        }) => Some((0xB0 | (channel & 0x0F), controller & 0x7F, value & 0x7F, 3)),
1229        EventBody::Midi(MidiData::ProgramChange { channel, program }) => {
1230            Some((0xC0 | (channel & 0x0F), program & 0x7F, 0, 2))
1231        }
1232        EventBody::Midi(MidiData::ChannelAftertouch { channel, pressure }) => {
1233            Some((0xD0 | (channel & 0x0F), pressure & 0x7F, 0, 2))
1234        }
1235        EventBody::Midi(MidiData::PitchBend { channel, value }) => Some((
1236            0xE0 | (channel & 0x0F),
1237            (value & 0x7F) as u8,
1238            ((value >> 7) & 0x7F) as u8,
1239            3,
1240        )),
1241        EventBody::Midi(MidiData::Raw { len, data }) if *len >= 1 && *len <= 3 => Some((
1242            data[0],
1243            *data.get(1).unwrap_or(&0),
1244            *data.get(2).unwrap_or(&0),
1245            *len as usize,
1246        )),
1247        _ => None,
1248    }
1249}
1250
1251/// Suppress unused-import warning for `Event`.
1252#[allow(dead_code)]
1253fn _event_marker(_: &Event) {}
1254
1255impl Drop for Lv2Plugin {
1256    fn drop(&mut self) {
1257        // Tear down the editor first so the UI's `cleanup` runs
1258        // while the audio instance is still alive (some UIs talk
1259        // to the instance via the `instance-access` extension).
1260        self.close_editor();
1261        if !self.instance.is_null() {
1262            unsafe {
1263                lilv_sys::lilv_instance_deactivate(self.instance);
1264                lilv_sys::lilv_instance_free(self.instance);
1265            }
1266            self.instance = std::ptr::null_mut();
1267        }
1268        // `world` and `features` drop in struct order after this.
1269    }
1270}
1271
1272impl Lv2Plugin {
1273    /// Free the live UI state (descriptor cleanup + libloading
1274    /// drop). Safe to call when no editor is open.
1275    fn close_editor(&mut self) {
1276        if let Some(state) = self.editor.take() {
1277            unsafe {
1278                ((*state.descriptor).cleanup)(state.handle);
1279            }
1280            // Library drops here, which closes the dlopen handle.
1281            drop(state);
1282        }
1283    }
1284}
1285
1286impl PluginCore for Lv2Plugin {
1287    fn info(&self) -> &PluginInfo {
1288        &self.info
1289    }
1290    fn active_layout(&self) -> Option<&BusLayout> {
1291        self.active_layout.as_ref()
1292    }
1293    fn supported_layouts(&self) -> &[BusLayout] {
1294        &self.layouts
1295    }
1296    fn parameter_count(&self) -> usize {
1297        // Control ports are LV2's parameter analogue, but exposing
1298        // them through the param API requires names + ranges, which
1299        // we don't yet read. Hosts can still read /set the value via
1300        // the trait once we add ParameterInfo enumeration.
1301        0
1302    }
1303    fn parameter_info(&self, index: usize) -> Result<ParameterInfo> {
1304        Err(Error::InvalidParameter(index))
1305    }
1306    fn parameter_value(&self, index: usize) -> Result<f64> {
1307        Err(Error::InvalidParameter(index))
1308    }
1309    fn parameter_value_string(&self, index: usize, _value: f64) -> Result<String> {
1310        Err(Error::InvalidParameter(index))
1311    }
1312    fn set_parameter(&mut self, index: usize, _value: f64) -> Result<()> {
1313        Err(Error::InvalidParameter(index))
1314    }
1315    fn preset_count(&self) -> usize {
1316        0
1317    }
1318    fn preset_info(&self, index: usize) -> Result<PresetInfo> {
1319        Err(Error::InvalidParameter(index))
1320    }
1321    fn load_preset(&mut self, _preset_number: i32) -> Result<()> {
1322        Err(Error::Other("lv2 preset loading not yet wired".into()))
1323    }
1324    fn save_state(&self) -> Result<Vec<u8>> {
1325        Err(Error::Other("lv2 state save not yet wired".into()))
1326    }
1327    fn load_state(&mut self, _bytes: &[u8]) -> Result<()> {
1328        Err(Error::Other("lv2 state load not yet wired".into()))
1329    }
1330
1331    fn activate(
1332        &mut self,
1333        layout: BusLayout,
1334        sample_rate: f64,
1335        max_block_size: usize,
1336    ) -> Result<()> {
1337        // (Re)instantiate if this is the first activate or the
1338        // sample rate has changed — LV2 bakes the rate into the
1339        // instance at `lilv_plugin_instantiate` time.
1340        let needs_reinstantiate =
1341            self.instance.is_null() || (self.sample_rate - sample_rate).abs() > f64::EPSILON;
1342        if needs_reinstantiate {
1343            if !self.instance.is_null() {
1344                unsafe {
1345                    lilv_sys::lilv_instance_deactivate(self.instance);
1346                    lilv_sys::lilv_instance_free(self.instance);
1347                }
1348                self.instance = std::ptr::null_mut();
1349            }
1350            let inst = unsafe {
1351                lilv_sys::lilv_plugin_instantiate(
1352                    self.plugin,
1353                    sample_rate,
1354                    self.features.feature_ptrs.as_ptr(),
1355                )
1356            };
1357            if inst.is_null() {
1358                return Err(Error::Other("lilv_plugin_instantiate returned NULL".into()));
1359            }
1360            self.instance = inst;
1361            self.sample_rate = sample_rate;
1362        }
1363
1364        // Rough sizing: room for a sequence header plus one max-len
1365        // MIDI event per frame. Plenty for any sane block.
1366        let event_slot = std::mem::size_of::<LV2AtomEvent>() + 8;
1367        let cap = std::mem::size_of::<LV2AtomSequence>()
1368            + TIME_POSITION_MAX_BYTES
1369            + max_block_size * event_slot;
1370        self.midi_in_buf.resize(cap.max(64), 0);
1371        self.midi_out_buf.resize(cap.max(64), 0);
1372
1373        unsafe { lilv_sys::lilv_instance_activate(self.instance) };
1374        self.active_layout = Some(layout);
1375        Ok(())
1376    }
1377    fn deactivate(&mut self) {
1378        if !self.instance.is_null() {
1379            unsafe { lilv_sys::lilv_instance_deactivate(self.instance) };
1380        }
1381        self.active_layout = None;
1382    }
1383    fn is_active(&self) -> bool {
1384        self.active_layout.is_some()
1385    }
1386
1387    fn editor(&mut self) -> Option<&mut dyn PluginEditor> {
1388        if self.ui_bundle.is_some() {
1389            Some(self)
1390        } else {
1391            None
1392        }
1393    }
1394}
1395
1396impl PluginEditor for Lv2Plugin {
1397    // `type DescFn = …` lives in the function body next to its single
1398    // call site so the LV2 UI signature is one read away — hoisting it
1399    // would orphan the comment from the use.
1400    #[allow(clippy::items_after_statements)]
1401    fn open(&mut self, parent: WindowHandle, _scale: f64) -> Result<()> {
1402        let bundle = self
1403            .ui_bundle
1404            .as_ref()
1405            .ok_or_else(|| Error::Other("lv2 plugin has no UI".into()))?
1406            .clone();
1407        if self.editor.is_some() {
1408            return Ok(());
1409        }
1410
1411        let parent_ptr: *mut c_void = match parent {
1412            WindowHandle::NSView(p) | WindowHandle::HWND(p) => p,
1413            // X11 hands us a u64 XID; LV2 X11UI expects it cast
1414            // to a pointer (lv2 spec: "an X11 Window with the
1415            // proper visual"). usize cast is identity on 64-bit
1416            // and zero-extends on 32-bit, both fine.
1417            WindowHandle::X11(xid) => xid as usize as *mut c_void,
1418        };
1419
1420        // dlopen the UI binary. libloading retains an OS handle
1421        // we keep in EditorState for the lifetime of the editor.
1422        let lib = unsafe { libloading::Library::new(&bundle.binary_path) }.map_err(|e| {
1423            Error::Other(format!(
1424                "lv2 ui dlopen {}: {}",
1425                bundle.binary_path.display(),
1426                e
1427            ))
1428        })?;
1429        type DescFn = unsafe extern "C" fn(u32) -> *const LV2UIDescriptorRaw;
1430        let descriptor = unsafe {
1431            let sym: libloading::Symbol<DescFn> = lib
1432                .get(b"lv2ui_descriptor")
1433                .map_err(|e| Error::Other(format!("lv2 ui_descriptor symbol: {e}")))?;
1434            // Walk indices until we find the descriptor whose URI
1435            // matches our chosen UI's URI, or the function returns
1436            // NULL.
1437            let mut chosen: *const LV2UIDescriptorRaw = std::ptr::null();
1438            for idx in 0u32..64 {
1439                let d = sym(idx);
1440                if d.is_null() {
1441                    break;
1442                }
1443                let uri = (*d).uri;
1444                if !uri.is_null() && CStr::from_ptr(uri) == bundle.ui_uri.as_c_str() {
1445                    chosen = d;
1446                    break;
1447                }
1448            }
1449            chosen
1450        };
1451        if descriptor.is_null() {
1452            return Err(Error::Other(format!(
1453                "no lv2ui_descriptor matching {:?}",
1454                bundle.ui_uri
1455            )));
1456        }
1457
1458        // Build the UI feature list. URID map shares the same
1459        // backing UridMap as the audio side so URIs interned by
1460        // either path stay consistent.
1461        let map_handle: *mut UridMap = (&mut self.features.urid_map) as *mut UridMap;
1462        let ui_features = UiFeatureStorage::new(map_handle, parent_ptr);
1463
1464        // Heap-stable controller for write_function callbacks. Built
1465        // lazily because `control_values` only stops moving after
1466        // load completes.
1467        self.ensure_controller();
1468        let controller_ptr: *const Controller = self
1469            .controller
1470            .as_deref()
1471            .map_or(std::ptr::null(), |c| c as *const Controller);
1472
1473        let plugin_uri = CString::new(self.info.unique_id.clone())
1474            .map_err(|_| Error::Other("lv2 plugin uri contains NUL".into()))?;
1475        let bundle_path_c = path_to_cstring_with_trailing_sep(&bundle.bundle_path)?;
1476
1477        let mut widget: LV2UIWidget = std::ptr::null_mut();
1478        let handle = unsafe {
1479            ((*descriptor).instantiate_raw)(
1480                descriptor,
1481                plugin_uri.as_ptr(),
1482                bundle_path_c.as_ptr(),
1483                Some(ui_write_callback),
1484                controller_ptr.cast::<c_void>(),
1485                &raw mut widget,
1486                ui_features.feature_ptrs.as_ptr(),
1487            )
1488        };
1489        if handle.is_null() {
1490            return Err(Error::Other("lv2 ui instantiate returned NULL".into()));
1491        }
1492
1493        // For embedded UI types (CocoaUI / WindowsUI / X11UI) the
1494        // "widget" is the toolkit-native parent-of-the-UI handle
1495        // (NSView / HWND / X11 Window). The host's parent already
1496        // contains it as a child after instantiate; we just keep
1497        // the pointer for size queries.
1498
1499        // Look up the optional idle / resize extension interfaces
1500        // the UI may have published. `extension_data` returns a
1501        // pointer to a shared static struct of function pointers.
1502        let idle_iface = unsafe {
1503            if let Some(ext) = (*descriptor).extension_data {
1504                ext(LV2_UI_IDLE_INTERFACE_URI.as_ptr().cast::<c_char>())
1505                    as *const lv2_raw::ui::LV2UIIdleInterface
1506            } else {
1507                std::ptr::null()
1508            }
1509        };
1510        let resize_iface = unsafe {
1511            if let Some(ext) = (*descriptor).extension_data {
1512                ext(LV2_UI_RESIZE_INTERFACE_URI.as_ptr().cast::<c_char>())
1513                    as *const Lv2UiResizeInterface
1514            } else {
1515                std::ptr::null()
1516            }
1517        };
1518
1519        let last_pushed_values = self.control_values.clone();
1520
1521        self.editor = Some(EditorState {
1522            _library: lib,
1523            descriptor,
1524            handle,
1525            widget,
1526            ui_features,
1527            idle_iface,
1528            resize_iface,
1529            last_pushed_values,
1530        });
1531        Ok(())
1532    }
1533
1534    fn close(&mut self) {
1535        self.close_editor();
1536    }
1537
1538    fn is_open(&self) -> bool {
1539        self.editor.is_some()
1540    }
1541
1542    fn size(&self) -> Option<(u32, u32)> {
1543        let state = self.editor.as_ref()?;
1544        native_widget_size(state.widget)
1545    }
1546
1547    fn is_resizable(&self) -> bool {
1548        // Resizable iff the UI exported a `ui:resize` extension —
1549        // that's the only way for the host to push a new size.
1550        self.editor
1551            .as_ref()
1552            .is_some_and(|e| !e.resize_iface.is_null())
1553    }
1554
1555    fn set_size(&mut self, width: u32, height: u32) -> Option<(u32, u32)> {
1556        let state = self.editor.as_ref()?;
1557        if state.resize_iface.is_null() {
1558            return None;
1559        }
1560        // SAFETY: `resize_iface` was returned by the UI's
1561        // `extension_data(ui:resize)` and is a static struct of
1562        // function pointers owned by the UI bundle (still loaded
1563        // because `EditorState._library` is alive).
1564        let r = unsafe {
1565            ((*state.resize_iface).ui_resize)(
1566                (*state.resize_iface).handle,
1567                i32::try_from(width).unwrap_or(i32::MAX),
1568                i32::try_from(height).unwrap_or(i32::MAX),
1569            )
1570        };
1571        if r == 0 { Some((width, height)) } else { None }
1572    }
1573
1574    fn show(&mut self) {
1575        // Embedded LV2 UIs are visible immediately after
1576        // instantiate; nothing to do.
1577    }
1578
1579    fn hide(&mut self) {
1580        // No-op for embedded UIs — closing the editor or the host
1581        // window is the visibility primitive.
1582    }
1583
1584    fn on_idle(&mut self) {
1585        let Some(state) = self.editor.as_mut() else {
1586            return;
1587        };
1588        // 1. Drive the optional `ui:idleInterface` first — animations
1589        //    and the UI's own event pump live here. Non-zero return
1590        //    means the UI closed itself; we bail and let the next
1591        //    on_idle skip cleanly.
1592        if !state.idle_iface.is_null() {
1593            let rc = unsafe { ((*state.idle_iface).idle)(state.handle) };
1594            if rc != 0 {
1595                // UI asked to be torn down. close_editor walks the
1596                // descriptor's cleanup and drops the library.
1597                self.close_editor();
1598                return;
1599            }
1600        }
1601
1602        // 2. Push host-side parameter changes to the UI via
1603        //    `port_event`. Compare current control_values against
1604        //    the snapshot we took last tick; for any port whose value
1605        //    differs, fire a float-protocol port_event so the UI
1606        //    redraws. Snapshot is then refreshed.
1607        let Some(state) = self.editor.as_mut() else {
1608            return;
1609        };
1610        // SAFETY: `descriptor` is the same one we instantiated; its
1611        // `port_event` field is non-null per the LV2 spec.
1612        let port_event_fn = unsafe { (*state.descriptor).port_event };
1613        for (port_index, &offset) in self.control_value_offset.iter().enumerate() {
1614            if offset == usize::MAX || offset >= self.control_values.len() {
1615                continue;
1616            }
1617            let cur = self.control_values[offset];
1618            // First idle tick after a re-open may have a shorter
1619            // snapshot than control_values — guard via .get.
1620            let prev = state
1621                .last_pushed_values
1622                .get(offset)
1623                .copied()
1624                .unwrap_or(cur + 1.0);
1625            // f32 inequality is fine here — we only push when the
1626            // bit pattern actually changed, NaNs included (NaN != NaN
1627            // is the right answer; both sides will have NaN if the
1628            // plugin keeps writing NaN, no spurious push).
1629            #[allow(clippy::float_cmp)]
1630            let changed = cur != prev;
1631            if changed {
1632                let value = cur;
1633                let port_index_u32 = u32::try_from(port_index).unwrap_or(u32::MAX);
1634                port_event_fn(
1635                    state.handle,
1636                    port_index_u32,
1637                    4,
1638                    0, // float protocol
1639                    (&raw const value).cast::<c_void>(),
1640                );
1641                if offset < state.last_pushed_values.len() {
1642                    state.last_pushed_values[offset] = cur;
1643                }
1644            }
1645        }
1646        // suppress unused-warning if no port matched
1647        let _ = port_event_fn;
1648
1649        // 3. Apply any UI-requested resize. The host-side ui:resize
1650        //    callback writes into the notifier; on_idle is the host's
1651        //    chance to act on it. We can't actually resize the
1652        //    baseview window from inside this trait method (no window
1653        //    handle), so we just stash the request — windowed.rs polls
1654        //    `size()` next frame and resizes accordingly.
1655        // Currently no-op past the notifier write; future host-driven
1656        // window resize would consume `notifier.pending.take()` here.
1657    }
1658}
1659
1660fn path_to_cstring_with_trailing_sep(p: &Path) -> Result<CString> {
1661    let mut s = p.to_string_lossy().into_owned();
1662    if !s.ends_with(std::path::MAIN_SEPARATOR) {
1663        s.push(std::path::MAIN_SEPARATOR);
1664    }
1665    CString::new(s).map_err(|_| Error::Other("lv2 ui bundle path contains NUL".into()))
1666}
1667
1668#[cfg(target_os = "macos")]
1669fn native_widget_size(widget: LV2UIWidget) -> Option<(u32, u32)> {
1670    use objc2::msg_send;
1671    use objc2_foundation::NSRect;
1672    if widget.is_null() {
1673        return None;
1674    }
1675    // SAFETY: For CocoaUI the widget is an NSView*. Reading
1676    // -frame is always safe on a live NSView; the LV2 spec
1677    // promises the widget stays valid until cleanup.
1678    let view = widget as *mut objc2::runtime::AnyObject;
1679    let frame: NSRect = unsafe { msg_send![view, frame] };
1680    // `.max(0.0)` clamps the negative branch the sign-loss lint
1681    // worries about; window dimensions can't reasonably overflow u32.
1682    #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1683    Some((
1684        frame.size.width.max(0.0) as u32,
1685        frame.size.height.max(0.0) as u32,
1686    ))
1687}
1688
1689#[cfg(not(target_os = "macos"))]
1690fn native_widget_size(_widget: LV2UIWidget) -> Option<(u32, u32)> {
1691    // X11 / Win32 paths would query XGetWindowAttributes /
1692    // GetClientRect respectively; truce-rack-standalone falls back to
1693    // its INITIAL_WINDOW until that's wired.
1694    None
1695}
1696
1697impl Plugin<f32> for Lv2Plugin {
1698    fn process(
1699        &mut self,
1700        buffer: &mut AudioBuffer<'_, f32>,
1701        events: &EventList,
1702        context: &mut ProcessContext<'_>,
1703    ) -> Result<ProcessStatus> {
1704        if !self.is_active() {
1705            return Err(Error::NotActivated);
1706        }
1707        let frames = buffer.num_frames();
1708
1709        // Build the input atom sequence (host transport + MIDI) and
1710        // reset the output.
1711        self.build_midi_sequence(events, context.transport);
1712        self.prep_midi_out();
1713
1714        // Take raw pointers for every audio plane up front so the
1715        // immutable + mutable borrows on `buffer` don't overlap
1716        // when we iterate ports below.
1717        let in_ptrs: Vec<*mut f32> = buffer
1718            .main_inputs()
1719            .iter()
1720            .map(|s| s.as_ptr().cast_mut())
1721            .collect();
1722        let out_ptrs: Vec<*mut f32> = buffer
1723            .main_outputs()
1724            .iter_mut()
1725            .map(|s| s.as_mut_ptr())
1726            .collect();
1727        let control_base = self.control_values.as_mut_ptr();
1728        let midi_in_ptr = self.midi_in_buf.as_mut_ptr().cast::<c_void>();
1729        let midi_out_ptr = self.midi_out_buf.as_mut_ptr().cast::<c_void>();
1730
1731        // Connect each port. Audio + atom port directions are already
1732        // classified at load; we just count audio ports as we go to
1733        // pick the right channel of the host buffer.
1734        let mut next_audio_in = 0usize;
1735        let mut next_audio_out = 0usize;
1736        for port in &self.ports {
1737            let data: *mut c_void = match port.kind {
1738                PortKind::Audio => {
1739                    if port.is_input {
1740                        let plane = in_ptrs
1741                            .get(next_audio_in)
1742                            .copied()
1743                            .unwrap_or(std::ptr::null_mut());
1744                        next_audio_in += 1;
1745                        plane.cast::<c_void>()
1746                    } else {
1747                        let plane = out_ptrs
1748                            .get(next_audio_out)
1749                            .copied()
1750                            .unwrap_or(std::ptr::null_mut());
1751                        next_audio_out += 1;
1752                        plane.cast::<c_void>()
1753                    }
1754                }
1755                PortKind::Control { .. } => {
1756                    let off = self.control_value_offset[port.index as usize];
1757                    if off == usize::MAX {
1758                        std::ptr::null_mut()
1759                    } else {
1760                        // SAFETY: `control_base` is the head of the
1761                        // `control_values` Vec; `off` was computed
1762                        // from its push order at load time.
1763                        unsafe { control_base.add(off).cast::<c_void>() }
1764                    }
1765                }
1766                PortKind::AtomSequence => {
1767                    if port.is_input {
1768                        midi_in_ptr
1769                    } else {
1770                        midi_out_ptr
1771                    }
1772                }
1773                PortKind::Other => std::ptr::null_mut(),
1774            };
1775            unsafe {
1776                lilv_sys::lilv_instance_connect_port(self.instance, port.index, data);
1777            }
1778        }
1779
1780        unsafe {
1781            lilv_sys::lilv_instance_run(self.instance, u32::try_from(frames).unwrap_or(u32::MAX));
1782        }
1783
1784        // Drain MIDI from the output atom-sequence port (if the
1785        // plugin connected one) into context.output_events.
1786        self.drain_midi_out(context);
1787
1788        Ok(ProcessStatus::Continue)
1789    }
1790}
1791
1792impl Lv2Plugin {
1793    /// Walk the MIDI output atom-sequence buffer the plugin just
1794    /// wrote and translate every `MidiEvent`-typed event back into
1795    /// rack2-core `EventList` events on `context.output_events`.
1796    fn drain_midi_out(&mut self, context: &mut ProcessContext<'_>) {
1797        let header_size = std::mem::size_of::<LV2AtomSequence>();
1798        if self.midi_out_buf.len() < header_size {
1799            return;
1800        }
1801        let buf = self.midi_out_buf.as_ptr();
1802        // SAFETY: midi_out_buf is sized for an LV2AtomSequence header
1803        // plus events at activate. Vec<u8> is heap-aligned to >= 8.
1804        let seq = unsafe { &*buf.cast::<LV2AtomSequence>() };
1805        // Sequence body size (atom.size) excludes the LV2Atom header
1806        // itself; the body's events run from sequence_begin to
1807        // sequence_end.
1808        let body_size = seq.atom.size;
1809        let event_header = std::mem::size_of::<LV2AtomEvent>();
1810        let body_offset = std::mem::size_of::<LV2Atom>();
1811        let mut cursor = body_offset + std::mem::size_of::<LV2AtomSequenceBody>();
1812        let body_end = body_offset + body_size as usize;
1813        while cursor + event_header <= body_end && cursor + event_header <= self.midi_out_buf.len()
1814        {
1815            let ev_ptr = unsafe { buf.add(cursor) }.cast::<LV2AtomEvent>();
1816            // SAFETY: cursor + event_header <= midi_out_buf.len().
1817            let ev = unsafe { &*ev_ptr };
1818            let payload_size = ev.body.size as usize;
1819            let payload_total = event_header + payload_size;
1820            if cursor + payload_total > body_end {
1821                break;
1822            }
1823            if ev.body.mytype == self.midi_event_urid && (1..=8).contains(&payload_size) {
1824                let data_ptr = unsafe { (ev_ptr as *const u8).add(event_header) };
1825                // SAFETY: payload_size bytes immediately follow the
1826                // event header within the bounds we just checked.
1827                let bytes = unsafe { std::slice::from_raw_parts(data_ptr, payload_size) };
1828                if let Some(body) = decode_midi_atom(bytes) {
1829                    let offset = u32::try_from(ev.time_in_frames.max(0)).unwrap_or(0);
1830                    context.output_events.push(Event {
1831                        sample_offset: offset,
1832                        body,
1833                    });
1834                }
1835            }
1836            // Advance cursor past this event, padded to 8 bytes per
1837            // the atom alignment rule.
1838            cursor += (payload_total + 7) & !7;
1839        }
1840    }
1841}
1842
1843/// Inverse of `midi_bytes` — turn 1-3 bytes of LV2 MIDI atom payload
1844/// into a typed `EventBody`. Anything longer (sysex etc.) is wrapped
1845/// in `MidiData::Raw` up to the 8-byte cap.
1846fn decode_midi_atom(bytes: &[u8]) -> Option<EventBody> {
1847    if bytes.is_empty() {
1848        return None;
1849    }
1850    let status = bytes[0];
1851    let channel = status & 0x0F;
1852    let kind = status & 0xF0;
1853    let body = match (kind, bytes) {
1854        (0x80, [_, note, vel]) => MidiData::NoteOff {
1855            channel,
1856            note: *note,
1857            velocity: *vel,
1858        },
1859        (0x90, [_, note, 0]) => MidiData::NoteOff {
1860            channel,
1861            note: *note,
1862            velocity: 0,
1863        },
1864        (0x90, [_, note, vel]) => MidiData::NoteOn {
1865            channel,
1866            note: *note,
1867            velocity: *vel,
1868        },
1869        (0xA0, [_, note, pressure]) => MidiData::PolyAftertouch {
1870            channel,
1871            note: *note,
1872            pressure: *pressure,
1873        },
1874        (0xB0, [_, controller, value]) => MidiData::ControlChange {
1875            channel,
1876            controller: *controller,
1877            value: *value,
1878        },
1879        (0xC0, [_, program]) => MidiData::ProgramChange {
1880            channel,
1881            program: *program,
1882        },
1883        (0xD0, [_, pressure]) => MidiData::ChannelAftertouch {
1884            channel,
1885            pressure: *pressure,
1886        },
1887        (0xE0, [_, lsb, msb]) => MidiData::PitchBend {
1888            channel,
1889            value: u16::from(*msb) << 7 | u16::from(*lsb),
1890        },
1891        _ if bytes.len() <= 8 => {
1892            let mut data = [0u8; 8];
1893            data[..bytes.len()].copy_from_slice(bytes);
1894            #[allow(clippy::cast_possible_truncation)]
1895            MidiData::Raw {
1896                len: bytes.len() as u8,
1897                data,
1898            }
1899        }
1900        _ => return None,
1901    };
1902    Some(EventBody::Midi(body))
1903}