Skip to main content

ove/
audio.rs

1// Copyright (C) 2026 Kamil Lulko <kamil.lulko@gmail.com>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4//
5// This file is part of oveRTOS.
6
7//! Audio graph engine for oveRTOS.
8//!
9//! Provides safe wrappers around the C graph API: build a DAG of audio nodes
10//! (sources, processors, sinks), validate formats, and execute in topological
11//! order.
12
13use crate::bindings;
14use crate::error::{Error, Result};
15use core::ffi::c_void;
16
17// SAFETY (module-wide contract for the `unsafe { bindings::ove_*(...) }` FFI
18// calls below): any handle passed to the C API is non-null and refers to a
19// live RTOS object — wrapper constructors establish validity via
20// `Error::from_code`, and `Drop` (or an explicit `deinit`) is the only place
21// a handle is released. Pointer and slice arguments reference caller-owned
22// memory valid for the duration of the call; the C side copies whatever it
23// retains and does not alias them past return (verified against the
24// signatures in `include/ove/*.h`). Blocks that deviate — `transmute`, raw
25// pointer casts from user data, slice reconstruction via `from_raw_parts`,
26// or storing a callback across the FFI boundary — carry their own
27// `// SAFETY:` comment.
28
29// ---------------------------------------------------------------------------
30// Pin tracker (debug-only) — mirrors `bindings/zig/ove/src/pin.zig::Tracker`.
31//
32// `Graph::build()` causes the C-side `ove_audio_graph` to record self-pointers
33// from `g->buffers[i].fmt` to `&g->nodes[i].out_fmt` (see
34// `backends/common/ove_audio_graph.c:273`).  After that, moving the Graph in
35// Rust (e.g. `let g2 = g;`, returning by value out of an inner scope,
36// `Box::new(g)` after build, storing in a relocating container, etc.) leaves
37// those interior pointers dangling and silently corrupts subsequent audio-
38// thread reads.
39//
40// Rust can't easily enforce no-move at compile time without converting every
41// method to `Pin<&mut Self>` and breaking the documented
42// `let mut g = Graph::new()?;` ergonomics.  Zig settled on a runtime address
43// tracker that panics in `Debug` builds and compiles away in release — we
44// mirror that here so the two bindings have matching safety stories.
45//
46// Lifecycle:
47//   1. `Graph::new()` constructs the tracker with no recorded address — moves
48//      before `build()` are still safe and silent (no self-pointers yet).
49//   2. `Graph::build()` calls `tracker.record(self as *const _)`, capturing
50//      the address at the moment self-pointers are established.
51//   3. `start` / `stop` / `process` call `tracker.assert_same(self as *const _)`
52//      and panic on mismatch with a message naming the offender.
53//   4. `Drop` skips the assertion — `ove_audio_graph_deinit` only walks
54//      `nodes[].ctx` and frees `buf_storage`, never the self-pointers, so
55//      deinit at a moved address is benign.
56// ---------------------------------------------------------------------------
57
58/// Debug-build address tracker.  Zero-sized in release; emits a `dmb`-cheap
59/// pointer compare in debug.
60#[cfg(debug_assertions)]
61#[derive(Default)]
62struct Tracker {
63    /// `Some(addr)` after `record()`; `None` between `new()` and `build()`.
64    /// Stored as a thin pointer (we only ever compare for equality —
65    /// never dereference — so `*const ()` is fine).
66    addr: Option<*const ()>,
67}
68
69#[cfg(debug_assertions)]
70impl Tracker {
71    /// Capture the current address of the wrapper.  Called once from
72    /// `Graph::build()`; subsequent calls overwrite the recorded address
73    /// (harmless — a successful re-build by definition runs at a fresh
74    /// stable address).
75    #[inline]
76    fn record(&mut self, p: *const ()) {
77        self.addr = Some(p);
78    }
79
80    /// Assert that `p` matches the address recorded by `record()`.
81    /// No-op when nothing has been recorded yet (pre-build calls, e.g. a
82    /// caller that constructs a Graph but never builds it before drop).
83    ///
84    /// `type_name` is just a label baked into the panic message so the
85    /// failure says "audio::Graph" rather than "Graph (in some module)".
86    #[inline]
87    #[track_caller]
88    fn assert_same(&self, p: *const (), type_name: &str) {
89        if let Some(orig) = self.addr {
90            assert!(
91                orig == p,
92                "{type_name}: wrapper moved after build() — \
93                 inited at {orig:p}, used at {p:p}.  The C graph stores \
94                 interior pointers (buffers[i].fmt → &nodes[i].out_fmt) \
95                 during build(); moving the wrapper afterwards invalidates \
96                 them and silently corrupts audio-thread reads.  Pin the \
97                 Graph in an InitCell / Box::leak / &'static mut before \
98                 calling build()."
99            );
100        }
101    }
102}
103
104/// Release-build stub.  Zero-sized — `core::mem::size_of::<Tracker>() == 0`
105/// — and every method inlines to nothing.  `sizeof(Graph)` is identical
106/// between debug and release after `#[repr]` placement.
107#[cfg(not(debug_assertions))]
108#[derive(Default)]
109struct Tracker;
110
111#[cfg(not(debug_assertions))]
112impl Tracker {
113    #[inline(always)]
114    fn record(&mut self, _: *const ()) {}
115    #[inline(always)]
116    fn assert_same(&self, _: *const (), _: &str) {}
117}
118
119// SAFETY: the only field is an `Option<*const ()>` used purely as an
120// identity token — never dereferenced, never read for its target.  The
121// surrounding `Graph` already declares `unsafe impl Send/Sync` (audio
122// thread takes the same address the control thread holds), and the
123// tracker's address-equality semantics are correct under Send/Sync.
124#[cfg(debug_assertions)]
125unsafe impl Send for Tracker {}
126#[cfg(debug_assertions)]
127unsafe impl Sync for Tracker {}
128
129/// Audio sample format.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum SampleFmt {
132    S16,
133    S32,
134    F32,
135}
136
137impl SampleFmt {
138    /// Convert to the raw C enum value (`ove_audio_sample_fmt`).
139    pub fn to_raw(self) -> u32 {
140        match self {
141            SampleFmt::S16 => 0,
142            SampleFmt::S32 => 1,
143            SampleFmt::F32 => 2,
144        }
145    }
146}
147
148/// Audio format descriptor.
149pub struct AudioFmt {
150    pub sample_rate: u32,
151    pub channels: u32,
152    pub sample_fmt: SampleFmt,
153}
154
155/// Initialize the audio graph.
156///
157/// # Errors
158/// Returns an error if `frames_per_period` is zero.
159pub fn graph_init(graph: &mut bindings::ove_audio_graph, frames_per_period: u32) -> Result<()> {
160    let rc = unsafe { bindings::ove_audio_graph_init(graph, frames_per_period) };
161    Error::from_code(rc)
162}
163
164/// Tear down the graph and release all resources.
165pub fn graph_deinit(graph: &mut bindings::ove_audio_graph) {
166    unsafe { bindings::ove_audio_graph_deinit(graph) };
167}
168
169/// Add a node to the graph. Returns the node index.
170///
171/// # Errors
172/// Returns an error if the graph is full or not in IDLE state.
173pub fn graph_add_node(
174    graph: &mut bindings::ove_audio_graph,
175    ops: &bindings::ove_audio_node_ops,
176    ctx: *mut c_void,
177    name: &core::ffi::CStr,
178    node_type: bindings::ove_audio_node_type,
179) -> core::result::Result<i32, Error> {
180    let rc =
181        unsafe { bindings::ove_audio_graph_add_node(graph, ops, ctx, name.as_ptr(), node_type) };
182    if rc < 0 {
183        Err(Error::from_code(rc).unwrap_err())
184    } else {
185        Ok(rc)
186    }
187}
188
189/// Connect two nodes. `from` feeds into `to`.
190///
191/// # Errors
192/// Returns an error on invalid indices or type violations.
193pub fn graph_connect(graph: &mut bindings::ove_audio_graph, from: u32, to: u32) -> Result<()> {
194    let rc = unsafe { bindings::ove_audio_graph_connect(graph, from, to) };
195    Error::from_code(rc)
196}
197
198/// Validate formats, resolve execution order, allocate buffers.
199///
200/// # Errors
201/// Returns an error on format mismatch, cycles, or OOM.
202pub fn graph_build(graph: &mut bindings::ove_audio_graph) -> Result<()> {
203    let rc = unsafe { bindings::ove_audio_graph_build(graph) };
204    Error::from_code(rc)
205}
206
207/// Start the graph (sink-driven mode).
208///
209/// # Errors
210/// Returns an error if graph is not in READY state.
211pub fn graph_start(graph: &mut bindings::ove_audio_graph) -> Result<()> {
212    let rc = unsafe { bindings::ove_audio_graph_start(graph) };
213    Error::from_code(rc)
214}
215
216/// Stop the graph.
217///
218/// # Errors
219/// Returns an error if graph is not in RUNNING state.
220pub fn graph_stop(graph: &mut bindings::ove_audio_graph) -> Result<()> {
221    let rc = unsafe { bindings::ove_audio_graph_stop(graph) };
222    Error::from_code(rc)
223}
224
225/// Process one cycle (app-driven mode).
226///
227/// # Errors
228/// Returns an error if graph is not built.
229pub fn graph_process(graph: &mut bindings::ove_audio_graph) -> Result<()> {
230    let rc = unsafe { bindings::ove_audio_graph_process(graph) };
231    Error::from_code(rc)
232}
233
234// ---------------------------------------------------------------------------
235// Audio device configuration builder
236// ---------------------------------------------------------------------------
237
238/// Build an `ove_audio_device_cfg` for I2S transport.
239///
240/// Constructs the C struct safely, including the transport-specific union
241/// fields, without requiring `unsafe` in application code.
242pub fn device_cfg_i2s(
243    sample_rate: u32,
244    channels: u32,
245    input_device: u32,
246) -> bindings::ove_audio_device_cfg {
247    let mut cfg: bindings::ove_audio_device_cfg = unsafe { core::mem::zeroed() };
248    cfg.transport = bindings::OVE_AUDIO_TRANSPORT_I2S;
249    cfg.fmt.sample_rate = sample_rate;
250    cfg.fmt.channels = channels;
251    cfg.fmt.sample_fmt = bindings::OVE_AUDIO_FMT_S16;
252    // Write i2s.input_device into the union via raw pointer
253    unsafe {
254        let union_ptr = &mut cfg as *mut _ as *mut u8;
255        let i2s_offset = core::mem::offset_of!(bindings::ove_audio_device_cfg, __bindgen_anon_1);
256        let i2s_ptr = union_ptr.add(i2s_offset) as *mut u32;
257        *i2s_ptr = input_device;
258    }
259    cfg
260}
261
262// ---------------------------------------------------------------------------
263// Safe Graph wrapper
264// ---------------------------------------------------------------------------
265
266/// Owned audio graph session.
267///
268/// Wraps the C `ove_audio_graph` in a safe API.  All `unsafe` FFI calls
269/// are encapsulated — application code uses only safe methods.
270///
271/// # Example
272///
273/// ```ignore
274/// let mut g = Graph::new(512)?;
275/// let dev = device_cfg_i2s(16000, 1, 1);
276/// let src  = g.device_source(&dev, b"mic\0")?;
277/// let proc = g.add_processor(PROC.get_mut(), b"dsp\0")?;
278/// let sink = g.device_sink(&dev, b"spk\0")?;
279/// g.connect(src, proc)?;
280/// g.connect(proc, sink)?;
281/// g.build()?;
282/// g.start()?;
283/// ```
284pub struct Graph {
285    inner: bindings::ove_audio_graph,
286    /// Debug-only address tracker; zero-sized in release.  Records the
287    /// wrapper's address at `build()` time and panics if a later method
288    /// is called from a different address.  See the `Tracker` block
289    /// above for the full rationale and lifecycle.
290    tracker: Tracker,
291}
292
293impl Graph {
294    /// Create and initialize a new audio graph.
295    ///
296    /// **Move policy:** moves are safe until [`Graph::build`] is called.
297    /// After build, the C graph stores self-pointers and the wrapper
298    /// must stay at a stable address — see [`Graph::build`] for details.
299    pub fn new(frames_per_period: u32) -> Result<Self> {
300        let mut inner: bindings::ove_audio_graph = unsafe { core::mem::zeroed() };
301        let rc = unsafe { bindings::ove_audio_graph_init(&mut inner, frames_per_period) };
302        Error::from_code(rc)?;
303        Ok(Self {
304            inner,
305            tracker: Tracker::default(),
306        })
307    }
308
309    /// Create and initialize a graph, attaching caller-owned buffer storage.
310    /// Required for `CONFIG_OVE_ZERO_HEAP` builds where `build()` cannot
311    /// `calloc` inter-node buffers.  In heap-mode builds the storage is
312    /// unused but harmless.  Prefer the [`crate::audio_graph!`] macro,
313    /// which emits the backing array automatically.
314    ///
315    /// Same move policy as [`Graph::new`].
316    pub fn new_with_storage(frames_per_period: u32, storage: &'static mut [u8]) -> Result<Self> {
317        let mut inner: bindings::ove_audio_graph = unsafe { core::mem::zeroed() };
318        let rc = unsafe { bindings::ove_audio_graph_init(&mut inner, frames_per_period) };
319        Error::from_code(rc)?;
320        let rc = unsafe {
321            bindings::ove_audio_graph_set_buf_storage(
322                &mut inner,
323                storage.as_mut_ptr() as *mut _,
324                storage.len(),
325            )
326        };
327        Error::from_code(rc)?;
328        Ok(Self {
329            inner,
330            tracker: Tracker::default(),
331        })
332    }
333
334    /// Add a hardware audio source node.  Returns the node index.
335    pub fn device_source(
336        &mut self,
337        cfg: &bindings::ove_audio_device_cfg,
338        name: &[u8],
339    ) -> core::result::Result<u32, Error> {
340        let rc = unsafe {
341            bindings::ove_audio_device_source(&mut self.inner, cfg, name.as_ptr() as *const _)
342        };
343        if rc < 0 {
344            Err(Error::from_code(rc).unwrap_err())
345        } else {
346            Ok(rc as u32)
347        }
348    }
349
350    /// Add a hardware audio sink node.  Returns the node index.
351    pub fn device_sink(
352        &mut self,
353        cfg: &bindings::ove_audio_device_cfg,
354        name: &[u8],
355    ) -> core::result::Result<u32, Error> {
356        let rc = unsafe {
357            bindings::ove_audio_device_sink(&mut self.inner, cfg, name.as_ptr() as *const _)
358        };
359        if rc < 0 {
360            Err(Error::from_code(rc).unwrap_err())
361        } else {
362            Ok(rc as u32)
363        }
364    }
365
366    /// Register a custom processor node.  Returns the node index.
367    ///
368    /// Thin sugar over [`graph_add_processor`] — see that function's
369    /// rustdoc for the full lifetime / aliasing / drop-semantics
370    /// contract.  The `&'static mut T` bound is load-bearing and must
371    /// not be satisfied by lifetime-laundering through `unsafe`.
372    pub fn add_processor<T: AudioProcessor>(
373        &mut self,
374        processor: &'static mut T,
375        name: &[u8],
376    ) -> core::result::Result<u32, Error> {
377        graph_add_processor(&mut self.inner, processor, name).map(|i| i as u32)
378    }
379
380    /// Connect two nodes.  `from` feeds into `to`.
381    pub fn connect(&mut self, from: u32, to: u32) -> Result<()> {
382        graph_connect(&mut self.inner, from, to)
383    }
384
385    /// Validate formats, resolve execution order, allocate buffers.
386    ///
387    /// **Pinning:** this call records the wrapper's current address in
388    /// the debug-only tracker.  After build, the C side stores
389    /// self-pointers (`buffers[i].fmt -> &nodes[i].out_fmt`) which
390    /// reference *this* address.  Moving the Graph after build will be
391    /// caught by [`Graph::start`] / [`Graph::stop`] / [`Graph::process`]
392    /// with a panic in debug builds; in release the move silently
393    /// dangles those pointers, so callers must keep the wrapper in a
394    /// stable location (e.g. an `InitCell`, `Box::leak`, or
395    /// `&'static mut`).
396    pub fn build(&mut self) -> Result<()> {
397        // Record FIRST so a successful build but an out-of-place caller
398        // still gets caught on the next start/stop/process.  The C call
399        // does not depend on the tracker being populated.
400        self.tracker.record(self as *const Self as *const ());
401        graph_build(&mut self.inner)
402    }
403
404    /// Start the graph (sink-driven mode).
405    pub fn start(&mut self) -> Result<()> {
406        self.tracker
407            .assert_same(self as *const Self as *const (), "audio::Graph");
408        graph_start(&mut self.inner)
409    }
410
411    /// Stop the graph.
412    pub fn stop(&mut self) -> Result<()> {
413        self.tracker
414            .assert_same(self as *const Self as *const (), "audio::Graph");
415        graph_stop(&mut self.inner)
416    }
417
418    /// Process one cycle (app-driven mode).
419    pub fn process(&mut self) -> Result<()> {
420        self.tracker
421            .assert_same(self as *const Self as *const (), "audio::Graph");
422        graph_process(&mut self.inner)
423    }
424}
425
426impl Drop for Graph {
427    fn drop(&mut self) {
428        graph_deinit(&mut self.inner);
429    }
430}
431
432// SAFETY: the C-side graph object is safe to hand off between threads once
433// built — audio-thread callbacks only read immutable node state, and control
434// APIs (`connect`, `start`, `stop`) are serialised by the caller.  Required
435// so `Graph` can live inside `InitCell` / `InitMut` for FreeRTOS main-stack
436// survival (see the hiroic apps' `GRAPH` static).
437unsafe impl Send for Graph {}
438unsafe impl Sync for Graph {}
439
440// ---------------------------------------------------------------------------
441// Safe audio processor trait
442// ---------------------------------------------------------------------------
443
444/// Audio buffer descriptor (borrowed from the C layer).
445pub struct AudioBuf {
446    raw: *const bindings::ove_audio_buf,
447}
448
449impl AudioBuf {
450    /// Get a slice of interleaved S16 samples.
451    pub fn data_s16(&self) -> &[i16] {
452        // SAFETY: `self.raw` points to a live `ove_audio_buf` supplied by the
453        // graph for the duration of the processing callback; `data` is a
454        // `frames * channels`-element S16 buffer the C side keeps valid while
455        // the callback runs.
456        unsafe {
457            let buf = &*self.raw;
458            let count = buf.frames as usize * (*buf.fmt).channels as usize;
459            core::slice::from_raw_parts(buf.data as *const i16, count)
460        }
461    }
462
463    /// Get a mutable slice of interleaved S16 samples.
464    ///
465    /// `&self` is intentional: the underlying buffer is C-owned and the
466    /// AudioProcessor trait passes the output buf as `&AudioBuf` (so a
467    /// processor can read in / write out through one reference each).
468    /// Aliasing safety is the C side's responsibility.
469    #[allow(clippy::mut_from_ref)]
470    pub fn data_s16_mut(&self) -> &mut [i16] {
471        // SAFETY: as `data_s16`, but mutable — the graph hands the output
472        // buffer to the processor as `&AudioBuf` and the C side owns aliasing
473        // (read-in / write-out through separate references each cycle).
474        unsafe {
475            let buf = &*self.raw;
476            let count = buf.frames as usize * (*buf.fmt).channels as usize;
477            core::slice::from_raw_parts_mut(buf.data as *mut i16, count)
478        }
479    }
480
481    /// Number of frames in this buffer.
482    pub fn frames(&self) -> u32 {
483        unsafe { (*self.raw).frames }
484    }
485
486    /// Number of interleaved channels in this buffer.
487    pub fn channels(&self) -> u32 {
488        unsafe { (*(*self.raw).fmt).channels }
489    }
490}
491
492/// Trait for implementing custom audio processing nodes.
493///
494/// All FFI bridging is handled by the binding layer — implement this
495/// trait on a plain Rust struct with no `unsafe` or `extern "C"`.
496///
497/// # Example
498/// ```ignore
499/// struct MyProcessor { ... }
500/// impl ove::audio::AudioProcessor for MyProcessor {
501///     fn process(&mut self, input: &AudioBuf, output: &AudioBuf) {
502///         let src = input.data_s16();
503///         let dst = output.data_s16_mut();
504///         dst.copy_from_slice(src); // passthrough
505///     }
506/// }
507/// ```
508pub trait AudioProcessor {
509    /// Process one audio period. Called from the audio thread.
510    fn process(&mut self, input: &AudioBuf, output: &AudioBuf);
511}
512
513/// Register a custom processor node on the graph.
514///
515/// All FFI trampolines are generated internally — no `unsafe` or
516/// `extern "C"` needed in application code.  The processor's `process`
517/// method is called from the audio thread for every period.
518///
519/// # Lifetime contract — `&'static mut T`
520///
521/// The bound on `processor` is `&'static mut T`, *not* `&mut T` with an
522/// inferred shorter lifetime.  This is load-bearing:
523///
524/// - The audio thread holds a raw pointer to `T` (the registered
525///   context) and dereferences it as `&mut T` on every period.  That
526///   pointer must remain valid until the [`Graph`] is dropped.
527/// - [`Graph::drop`] does **not** call into `T::drop` or release the
528///   processor's storage.  The destroy slot in the C ops vtable is
529///   left `None` for processor nodes; `ove_audio_graph_deinit` simply
530///   walks past it.  Lifetime management of `T` is entirely the
531///   caller's responsibility.
532///
533/// ## Recommended patterns
534///
535/// ```ignore
536/// // Preferred — works in both heap and zero-heap modes:
537/// static MY_PROC: InitMut<MyProc> = InitMut::new();
538/// MY_PROC.init(MyProc::new());
539/// // SAFETY: single owner — `MY_PROC` is only ever handed to the audio
540/// // graph; no other code touches it.
541/// let p: &'static mut MyProc = unsafe { MY_PROC.get_mut() };
542/// graph.add_processor(p, b"my\0")?;
543///
544/// // Heap-only:
545/// let p: &'static mut MyProc = Box::leak(Box::new(MyProc::new()));
546/// graph.add_processor(p, b"my\0")?;
547///
548/// // Older style, no allocator dependency:
549/// static mut PROC: MyProc = MyProc::new();
550/// graph.add_processor(unsafe { &mut PROC }, b"my\0")?;
551/// ```
552///
553/// ## Anti-patterns (compile but break the contract)
554///
555/// - **Lifetime laundering.** Do *not* `transmute` a shorter lifetime
556///   into `'static` to satisfy the bound:
557///
558///   ```ignore
559///   fn install(g: &mut Graph, p: &mut MyProc) -> Result<u32, Error> {
560///       // WRONG — `p` likely dies before the audio thread next runs:
561///       g.add_processor(unsafe { core::mem::transmute(p) }, b"p\0")
562///   }
563///   let mut local = MyProc::new();
564///   install(&mut g, &mut local)?;          // `local` drops at scope end
565///   g.build()?; g.start()?;                // audio thread → dangling pointer
566///   ```
567///
568/// - **Re-initialising an `InitMut` while registered.** The cell's
569///   storage outlives the program, but `InitMut::init()` constructs
570///   a fresh `T` on top of the old bytes.  The audio thread's
571///   `&mut T` still points at the same address, now aliasing a
572///   freshly-initialised value — instant UB under Rust's `&mut`
573///   exclusivity rule.  Unregister (drop the [`Graph`]) before
574///   re-initialising the cell.
575///
576/// ## Aliasing rule
577///
578/// While the processor is registered, the audio thread effectively
579/// holds an exclusive `&mut T` borrow for every period.  The caller
580/// MUST NOT construct a second `&mut T` to the same processor (via
581/// `unsafe { &mut *STATIC.as_ptr() }`, transmute, or any other route)
582/// until the [`Graph`] has been dropped.  Multiple `&` shared borrows
583/// across threads are also UB — `process` takes `&mut self`.
584///
585/// # Errors
586///
587/// Returns an error if the graph is full or not in `IDLE` state
588/// (i.e. [`Graph::build`] has already been called).
589pub fn graph_add_processor<T: AudioProcessor>(
590    graph: &mut bindings::ove_audio_graph,
591    processor: &'static mut T,
592    name: &[u8],
593) -> core::result::Result<i32, Error> {
594    unsafe extern "C" fn configure_trampoline(
595        _ctx: *mut c_void,
596        in_fmt: *const bindings::ove_audio_fmt,
597        out_fmt: *mut bindings::ove_audio_fmt,
598    ) -> core::ffi::c_int {
599        if !in_fmt.is_null() && !out_fmt.is_null() {
600            unsafe { *out_fmt = *in_fmt };
601        }
602        0
603    }
604
605    unsafe extern "C" fn process_trampoline<T: AudioProcessor>(
606        ctx: *mut c_void,
607        in_buf: *const bindings::ove_audio_buf,
608        out_buf: *mut bindings::ove_audio_buf,
609    ) -> core::ffi::c_int {
610        let proc_: &mut T = unsafe { &mut *(ctx as *mut T) };
611        let input = AudioBuf { raw: in_buf };
612        let output = AudioBuf { raw: out_buf };
613        proc_.process(&input, &output);
614        0
615    }
616
617    // Build the ops vtable as a helper struct with static lifetime.
618    // Function pointers are compile-time constants so this is safe.
619    struct OpsHolder<U: AudioProcessor>(core::marker::PhantomData<U>);
620    impl<U: AudioProcessor> OpsHolder<U> {
621        // SAFETY: All fields are Option<fn ptr> or None — this is a valid
622        // zero-initialization that we then patch with the real pointers.
623        // The struct is never mutated after creation.
624        #[allow(invalid_value)]
625        const OPS: bindings::ove_audio_node_ops = {
626            let mut ops: bindings::ove_audio_node_ops = unsafe { core::mem::zeroed() };
627            ops.configure = Some(configure_trampoline);
628            ops.process = Some(process_trampoline::<U>);
629            ops
630        };
631    }
632
633    let ctx = processor as *mut T as *mut c_void;
634    let rc = unsafe {
635        bindings::ove_audio_graph_add_node(
636            graph,
637            &OpsHolder::<T>::OPS,
638            ctx,
639            name.as_ptr() as *const _,
640            bindings::OVE_AUDIO_NODE_PROCESSOR,
641        )
642    };
643    if rc < 0 {
644        Err(Error::from_code(rc).unwrap_err())
645    } else {
646        Ok(rc)
647    }
648}
649
650/// A static wrapper for an [`AudioProcessor`] that provides safe
651/// `&'static mut` access for [`graph_add_processor`].
652///
653/// # Example
654///
655/// ```ignore
656/// static PROC: StaticProcessor<MyProc> = StaticProcessor::new(MyProc);
657/// let idx = graph_add_processor(&mut graph, PROC.get_mut(), b"my-proc\0")?;
658/// ```
659pub struct StaticProcessor<T> {
660    inner: core::cell::UnsafeCell<T>,
661}
662
663// SAFETY: `StaticProcessor<T>` is `UnsafeCell<T>` accessed only from the
664// audio thread (single-writer/single-reader by construction — the C side
665// owns the call site).  The `T: Send` bound ensures `T` can cross from
666// the construction thread to the audio thread.
667unsafe impl<T: Send> Send for StaticProcessor<T> {}
668unsafe impl<T: Send> Sync for StaticProcessor<T> {}
669
670impl<T> StaticProcessor<T> {
671    /// Create a new static processor wrapper.
672    pub const fn new(val: T) -> Self {
673        Self {
674            inner: core::cell::UnsafeCell::new(val),
675        }
676    }
677
678    /// Obtain a `&'static mut` reference to the inner processor.
679    ///
680    /// Call once during graph setup before the audio thread starts.
681    #[allow(clippy::mut_from_ref)]
682    pub fn get_mut(&self) -> &mut T {
683        unsafe { &mut *self.inner.get() }
684    }
685}