Skip to main content

maolan_engine/plugins/
clap_proc.rs

1//! Out-of-process CLAP processor using `maolan-plugin-host` IPC.
2
3use crate::audio::io::AudioIO;
4use crate::midi::io::MidiEvent;
5use crate::mutex::UnsafeMutex;
6use crate::plugins::ipc;
7use crate::plugins::types::{
8    ClapMidiOutputEvent, ClapParamUpdate, ClapParameterInfo, ClapTransportInfo,
9};
10use maolan_plugin_protocol::events::EventPair;
11use maolan_plugin_protocol::protocol::*;
12use maolan_plugin_protocol::ringbuf::RingBuffer;
13use maolan_plugin_protocol::shm::ShmMapping;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::process::Child;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, atomic::AtomicU32};
19use std::time::{Duration, Instant};
20
21/// Shared state for an out-of-process CLAP plugin instance.
22pub struct ClapProcessor {
23    path: String,
24    plugin_id: String,
25    name: String,
26    audio_inputs: Vec<Arc<AudioIO>>,
27    audio_outputs: Vec<Arc<AudioIO>>,
28    main_audio_inputs: usize,
29    main_audio_outputs: usize,
30    midi_inputs: usize,
31    midi_outputs: usize,
32    param_infos: Vec<ClapParameterInfo>,
33    param_values: UnsafeMutex<HashMap<u32, f64>>,
34    bypassed: Arc<AtomicBool>,
35    // IPC state
36    child: UnsafeMutex<Option<Child>>,
37    mapping: Option<ShmMapping>,
38    events: Option<EventPair>,
39    shm_name: String,
40    // Crash recovery
41    crash_count: AtomicU32,
42    last_process_time: UnsafeMutex<Instant>,
43}
44
45pub type SharedClapProcessor = Arc<UnsafeMutex<ClapProcessor>>;
46
47impl ClapProcessor {
48    pub fn new(
49        _sample_rate: f64,
50        buffer_size: usize,
51        plugin_spec: &str,
52        input_count: usize,
53        output_count: usize,
54        host_binary: PathBuf,
55    ) -> Result<Self, String> {
56        let (plugin_path, plugin_id) = split_plugin_spec(plugin_spec);
57
58        // Spawn the host immediately so we can query params.
59        let instance_id = ipc::unique_instance_id("clap");
60        let plugin_spec = if plugin_id.is_empty() {
61            plugin_path.to_string()
62        } else {
63            format!("{plugin_path}::{plugin_id}")
64        };
65        let (mut child, mapping, events, shm_name) = ipc::spawn_host(ipc::HostSpawnArgs {
66            host_binary: &host_binary,
67            format: "clap",
68            plugin_spec: &plugin_spec,
69            instance_id: &instance_id,
70            extra_args: &[],
71        })?;
72
73        let header = unsafe { header_ref(mapping.as_ptr()) };
74        if !ipc::wait_for_ready(header, Duration::from_secs(10)) {
75            let _ = child.kill();
76            return Err("host did not signal ready".to_string());
77        }
78
79        let name = unsafe {
80            let mut name = None;
81            for _ in 0..50 {
82                name = maolan_plugin_protocol::protocol::read_plugin_name_from_scratch(
83                    mapping.as_ptr(),
84                );
85                if name.is_some() {
86                    break;
87                }
88                std::thread::sleep(std::time::Duration::from_millis(10));
89            }
90            name.unwrap_or_else(|| plugin_id.to_string())
91        };
92
93        // Read port counts written by the host (with fallback to constructor params).
94        let (actual_audio_in, actual_audio_out, actual_midi_in, actual_midi_out) = unsafe {
95            let mut counts = None;
96            for _ in 0..50 {
97                counts = maolan_plugin_protocol::protocol::read_port_counts_from_scratch(
98                    mapping.as_ptr(),
99                );
100                if counts.is_some() {
101                    break;
102                }
103                std::thread::sleep(std::time::Duration::from_millis(10));
104            }
105            let result = counts.map_or_else(
106                || (input_count as u32, output_count as u32, 0, 0),
107                |(a_in, a_out, m_in, m_out)| (a_in, a_out, m_in, m_out),
108            );
109            tracing::info!(
110                plugin = %plugin_spec,
111                audio_in = result.0,
112                audio_out = result.1,
113                midi_in = result.2,
114                midi_out = result.3,
115                from_host = counts.is_some(),
116                "CLAP processor port counts"
117            );
118            result
119        };
120
121        let audio_inputs = (0..actual_audio_in as usize)
122            .map(|_| Arc::new(AudioIO::new(buffer_size)))
123            .collect::<Vec<_>>();
124        let audio_outputs = (0..actual_audio_out as usize)
125            .map(|_| Arc::new(AudioIO::new(buffer_size)))
126            .collect::<Vec<_>>();
127
128        // Query parameter count from host via a simple param ring echo.
129        // For now, we use a minimal stub param list.
130        let param_infos = Vec::new();
131
132        Ok(Self {
133            path: plugin_spec.to_string(),
134            plugin_id: plugin_id.to_string(),
135            name,
136            audio_inputs,
137            audio_outputs,
138            main_audio_inputs: actual_audio_in as usize,
139            main_audio_outputs: actual_audio_out as usize,
140            midi_inputs: actual_midi_in as usize,
141            midi_outputs: actual_midi_out as usize,
142            param_infos,
143            param_values: UnsafeMutex::new(HashMap::new()),
144            bypassed: Arc::new(AtomicBool::new(false)),
145            child: UnsafeMutex::new(Some(child)),
146            mapping: Some(mapping),
147            events: Some(events),
148            shm_name,
149            crash_count: AtomicU32::new(0),
150            last_process_time: UnsafeMutex::new(Instant::now()),
151        })
152    }
153
154    pub fn setup_audio_ports(&self) {
155        for port in &self.audio_inputs {
156            port.setup();
157        }
158        for port in &self.audio_outputs {
159            port.setup();
160        }
161    }
162
163    pub fn audio_inputs(&self) -> &[Arc<AudioIO>] {
164        &self.audio_inputs
165    }
166
167    pub fn audio_outputs(&self) -> &[Arc<AudioIO>] {
168        &self.audio_outputs
169    }
170
171    pub fn main_audio_input_count(&self) -> usize {
172        self.main_audio_inputs
173    }
174
175    pub fn main_audio_output_count(&self) -> usize {
176        self.main_audio_outputs
177    }
178
179    pub fn midi_input_count(&self) -> usize {
180        self.midi_inputs
181    }
182
183    pub fn midi_output_count(&self) -> usize {
184        self.midi_outputs
185    }
186
187    pub fn set_bypassed(&self, bypassed: bool) {
188        self.bypassed.store(bypassed, Ordering::Relaxed);
189    }
190
191    pub fn is_bypassed(&self) -> bool {
192        self.bypassed.load(Ordering::Relaxed)
193    }
194
195    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
196        self.param_infos.clone()
197    }
198
199    pub fn parameter_values(&self) -> HashMap<u32, f64> {
200        self.param_values.lock().clone()
201    }
202
203    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
204        self.set_parameter_at(param_id, value, 0)
205    }
206
207    pub fn set_parameter_at(&self, param_id: u32, value: f64, _frame: u32) -> Result<(), String> {
208        self.param_values.lock().insert(param_id, value);
209        // Write to param ring buffer if host is alive.
210        if let Some(ref mapping) = self.mapping {
211            let ring = unsafe {
212                let buf = param_ring_ptr(mapping.as_ptr());
213                let (w, r) = param_indices(mapping.as_ptr());
214                RingBuffer::new(buf, w, r, RING_CAPACITY)
215            };
216            let ev = ParameterEvent {
217                param_index: param_id,
218                value: value as f32,
219                sample_offset: 0,
220                event_kind: maolan_plugin_protocol::PARAM_EVENT_VALUE,
221            };
222            if !ring.push(ev) {
223                tracing::warn!("param ring full, dropping parameter event");
224            }
225        }
226        Ok(())
227    }
228
229    pub fn begin_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
230        Ok(())
231    }
232
233    pub fn end_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
234        Ok(())
235    }
236
237    pub fn is_parameter_edit_active(&self, _param_id: u32) -> bool {
238        false
239    }
240
241    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
242        Err("state snapshot not yet implemented".to_string())
243    }
244
245    pub fn restore_state(
246        &self,
247        _state: &crate::plugins::types::ClapPluginState,
248    ) -> Result<(), String> {
249        Err("state restore not yet implemented".to_string())
250    }
251
252    pub fn process_with_audio_io(&self, frames: usize) {
253        let _ = self.process_with_midi(frames, &[], ClapTransportInfo::default());
254    }
255
256    pub fn process_with_midi(
257        &self,
258        frames: usize,
259        _midi_in: &[MidiEvent],
260        _transport: ClapTransportInfo,
261    ) -> Vec<ClapMidiOutputEvent> {
262        if self.bypassed.load(Ordering::Relaxed) {
263            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
264            return Vec::new();
265        }
266
267        {
268            let child = self.child.lock();
269            if let Some(ref mut c) = child.as_mut() {
270                match c.try_wait() {
271                    Ok(Some(status)) if !status.success() => {
272                        tracing::error!("plugin host crashed for '{}' ({})", self.name, self.path);
273                        self.crash_count.fetch_add(1, Ordering::Relaxed);
274                        ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
275                        return Vec::new();
276                    }
277                    _ => {}
278                }
279            }
280        }
281
282        let started = Instant::now();
283
284        let (mapping, events) = match (&self.mapping, &self.events) {
285            (Some(m), Some(e)) => (m, e),
286            _ => {
287                ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
288                return Vec::new();
289            }
290        };
291
292        let ptr = mapping.as_ptr();
293        unsafe {
294            ipc::configure_shm_header(
295                ptr,
296                frames,
297                self.audio_inputs.len(),
298                self.audio_outputs.len(),
299            );
300            ipc::copy_inputs_to_shm(&self.audio_inputs, ptr, frames);
301        }
302
303        if let Err(e) = events.signal_host() {
304            tracing::error!("Failed to signal host: {e}");
305            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
306            return Vec::new();
307        }
308
309        let timeout = Duration::from_millis(100);
310        if let Err(e) = events.wait_host(timeout) {
311            tracing::error!(
312                "host did not respond for '{}' ({}): {e}",
313                self.name,
314                self.path
315            );
316            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
317            return Vec::new();
318        }
319
320        unsafe {
321            ipc::copy_outputs_from_shm(&self.audio_outputs, ptr, frames);
322        }
323
324        let elapsed = started.elapsed();
325        if elapsed > Duration::from_millis(20) {
326            tracing::warn!(
327                "Slow process '{}' ({}) took {:.3} ms for {} frames",
328                self.name,
329                self.path,
330                elapsed.as_secs_f64() * 1000.0,
331                frames
332            );
333        }
334
335        *self.last_process_time.lock() = Instant::now();
336        Vec::new()
337    }
338
339    pub fn path(&self) -> &str {
340        &self.path
341    }
342
343    pub fn plugin_id(&self) -> &str {
344        &self.plugin_id
345    }
346
347    pub fn name(&self) -> &str {
348        &self.name
349    }
350
351    pub fn begin_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
352        Ok(())
353    }
354
355    pub fn end_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
356        Ok(())
357    }
358
359    pub fn run_host_callbacks_main_thread(&self) {}
360
361    pub fn reconfigure_ports_if_needed(&self) -> Result<bool, String> {
362        Ok(false)
363    }
364
365    pub fn ui_begin_session(&self) {}
366    pub fn ui_end_session(&self) {}
367    pub fn ui_should_close(&self) -> bool {
368        false
369    }
370    pub fn ui_take_due_timers(&self) -> Vec<u32> {
371        Vec::new()
372    }
373    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
374        Vec::new()
375    }
376    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
377        None
378    }
379
380    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
381        Err("GUI not yet supported for CLAP plugins".to_string())
382    }
383
384    pub fn gui_create(&self, _api: &str, _is_floating: bool) -> Result<(), String> {
385        Err("GUI not yet supported for CLAP plugins".to_string())
386    }
387
388    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
389        Err("GUI not yet supported for CLAP plugins".to_string())
390    }
391
392    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
393        if let Some(ref mapping) = self.mapping {
394            let header = unsafe { header_mut(mapping.as_ptr()) };
395            header.set_parent_window(window);
396            return Ok(());
397        }
398        Err("No active host to set parent window".to_string())
399    }
400
401    pub fn gui_show(&self) -> Result<(), String> {
402        if let Some(ref mapping) = self.mapping
403            && let Some(ref events) = self.events
404        {
405            let header = unsafe { header_mut(mapping.as_ptr()) };
406            header.request_type.store(3, Ordering::Release);
407            let _ = events.signal_host();
408            return Ok(());
409        }
410        Err("No active host to show GUI".to_string())
411    }
412
413    pub fn gui_hide(&self) {
414        if let Some(ref mapping) = self.mapping
415            && let Some(ref events) = self.events
416        {
417            let header = unsafe { header_mut(mapping.as_ptr()) };
418            header.request_type.store(4, Ordering::Release);
419            let _ = events.signal_host();
420        }
421    }
422
423    pub fn gui_destroy(&self) {}
424
425    pub fn gui_on_main_thread(&self) {}
426
427    pub fn gui_on_timer(&self, _timer_id: u32) {}
428
429    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
430        std::collections::HashMap::new()
431    }
432
433    pub fn drain_echoed_parameters(&self) -> Vec<ParameterEvent> {
434        let mut result = Vec::new();
435        if let Some(ref mapping) = self.mapping {
436            let ring = unsafe {
437                let buf = echo_ring_ptr(mapping.as_ptr());
438                let (w, r) = echo_indices(mapping.as_ptr());
439                RingBuffer::new(buf, w, r, RING_CAPACITY)
440            };
441            while let Some(ev) = ring.pop() {
442                result.push(ev);
443            }
444        }
445        result
446    }
447
448    pub fn drain_midi_outputs(&self) -> Vec<crate::midi::io::MidiEvent> {
449        let mut result = Vec::new();
450        if let Some(ref mapping) = self.mapping {
451            let ring = unsafe {
452                let buf = midi_out_ring_ptr(mapping.as_ptr());
453                let (w, r) = midi_out_indices(mapping.as_ptr());
454                RingBuffer::new(buf, w, r, RING_CAPACITY)
455            };
456            while let Some(ev) = ring.pop() {
457                result.push(crate::midi::io::MidiEvent {
458                    frame: ev.sample_offset,
459                    data: ev.data.to_vec(),
460                });
461            }
462        }
463        result
464    }
465}
466
467impl Drop for ClapProcessor {
468    fn drop(&mut self) {
469        ipc::drop_host(&self.mapping, &self.events, &self.child, &self.shm_name);
470    }
471}
472
473crate::impl_ipc_processor_wrapper!(ClapProcessor);
474
475impl UnsafeMutex<ClapProcessor> {
476    pub fn process_with_midi(
477        &self,
478        frames: usize,
479        midi_events: &[MidiEvent],
480        transport: ClapTransportInfo,
481    ) -> Vec<ClapMidiOutputEvent> {
482        self.lock()
483            .process_with_midi(frames, midi_events, transport)
484    }
485
486    pub fn is_bypassed(&self) -> bool {
487        self.lock().is_bypassed()
488    }
489
490    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
491        self.lock().parameter_infos()
492    }
493
494    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
495        self.lock().set_parameter(param_id, value)
496    }
497
498    pub fn set_parameter_at(&self, param_id: u32, value: f64, frame: u32) -> Result<(), String> {
499        self.lock().set_parameter_at(param_id, value, frame)
500    }
501
502    pub fn begin_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
503        self.lock().begin_parameter_edit_at(param_id, frame)
504    }
505
506    pub fn end_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
507        self.lock().end_parameter_edit_at(param_id, frame)
508    }
509
510    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
511        self.lock().snapshot_state()
512    }
513
514    pub fn restore_state(
515        &self,
516        state: &crate::plugins::types::ClapPluginState,
517    ) -> Result<(), String> {
518        self.lock().restore_state(state)
519    }
520
521    pub fn path(&self) -> String {
522        self.lock().path().to_string()
523    }
524
525    pub fn plugin_id(&self) -> String {
526        self.lock().plugin_id().to_string()
527    }
528
529    pub fn ui_begin_session(&self) {
530        self.lock().ui_begin_session();
531    }
532
533    pub fn ui_end_session(&self) {
534        self.lock().ui_end_session();
535    }
536
537    pub fn ui_should_close(&self) -> bool {
538        self.lock().ui_should_close()
539    }
540
541    pub fn ui_take_due_timers(&self) -> Vec<u32> {
542        self.lock().ui_take_due_timers()
543    }
544
545    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
546        self.lock().ui_take_param_updates()
547    }
548
549    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
550        self.lock().ui_take_state_update()
551    }
552
553    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
554        self.lock().gui_info()
555    }
556
557    pub fn gui_create(&self, api: &str, is_floating: bool) -> Result<(), String> {
558        self.lock().gui_create(api, is_floating)
559    }
560
561    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
562        self.lock().gui_get_size()
563    }
564
565    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
566        self.lock().gui_set_parent_x11(window)
567    }
568
569    pub fn gui_show(&self) -> Result<(), String> {
570        self.lock().gui_show()
571    }
572
573    pub fn gui_hide(&self) {
574        self.lock().gui_hide();
575    }
576
577    pub fn gui_destroy(&self) {
578        self.lock().gui_destroy();
579    }
580
581    pub fn gui_on_main_thread(&self) {
582        self.lock().gui_on_main_thread();
583    }
584
585    pub fn gui_on_timer(&self, timer_id: u32) {
586        self.lock().gui_on_timer(timer_id);
587    }
588
589    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
590        self.lock().note_names()
591    }
592}
593
594/// Locate the `maolan-plugin-host` binary at runtime.
595///
596/// Search order:
597/// 1. Same directory as the current executable.
598/// 2. Workspace `target/debug` or `target/release` (development).
599/// 3. `PATH` environment variable.
600fn split_plugin_spec(spec: &str) -> (&str, &str) {
601    // CLAP scanner uses "path::id"; host protocol uses "path#id".
602    if let Some(pos) = spec.rfind("::") {
603        (&spec[..pos], &spec[pos + 2..])
604    } else if let Some(pos) = spec.rfind('#') {
605        (&spec[..pos], &spec[pos + 1..])
606    } else {
607        (spec, "")
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn find_host_binary() -> PathBuf {
616        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
617        let workspace_root = std::path::Path::new(&manifest)
618            .parent()
619            .unwrap()
620            .join("daw");
621        workspace_root
622            .join("target")
623            .join("debug")
624            .join("maolan-plugin-host")
625    }
626
627    #[test]
628    fn clap_processor_processes_audio() {
629        let host_bin = find_host_binary();
630        if !host_bin.exists() {
631            eprintln!(
632                "Skipping test: host binary not found at {}",
633                host_bin.display()
634            );
635            return;
636        }
637
638        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
639            .parent()
640            .unwrap()
641            .join("daw")
642            .join("plugin-host")
643            .join("tests")
644            .join("test_passthrough.clap");
645
646        if !plugin_path.exists() {
647            eprintln!(
648                "Skipping test: plugin not found at {}",
649                plugin_path.display()
650            );
651            return;
652        }
653
654        let processor = ClapProcessor::new(
655            48000.0,
656            256,
657            &format!("{}#com.maolan.test.passthrough", plugin_path.display()),
658            2,
659            2,
660            host_bin,
661        )
662        .expect("should create processor");
663
664        processor.setup_audio_ports();
665
666        // Fill input buffers with a ramp.
667        for (i, input) in processor.audio_inputs().iter().enumerate() {
668            let buf = input.buffer.lock();
669            for (j, sample) in buf.iter_mut().enumerate() {
670                *sample = (i * 1000 + j) as f32;
671            }
672            *input.finished.lock() = true;
673        }
674
675        // Process one block.
676        processor.process_with_audio_io(256);
677
678        // Verify output buffers were written (non-zero).
679        for output in processor.audio_outputs().iter() {
680            let buf = output.buffer.lock();
681            assert!(
682                buf.iter().any(|&s| s != 0.0),
683                "output buffer should contain non-zero samples"
684            );
685        }
686
687        // Processor is dropped here, which should gracefully shut down the host.
688    }
689
690    #[test]
691    fn clap_processor_crash_bypass() {
692        let host_bin = find_host_binary();
693        if !host_bin.exists() {
694            eprintln!("Skipping crash test: host binary not found");
695            return;
696        }
697
698        // Use the crash test mode.
699        let processor = ClapProcessor::new(48000.0, 256, "__crash__", 1, 1, host_bin)
700            .expect("should create processor for crash test");
701
702        processor.setup_audio_ports();
703
704        // Fill input buffer.
705        {
706            let buf = processor.audio_inputs()[0].buffer.lock();
707            buf.fill(1.0);
708            *processor.audio_inputs()[0].finished.lock() = true;
709        }
710
711        // First process should trigger the crash; subsequent calls should bypass.
712        processor.process_with_audio_io(256);
713
714        // After crash, output should be a copy of input (bypass).
715        let out_buf = processor.audio_outputs()[0].buffer.lock();
716        assert!(
717            out_buf.iter().all(|&s| s == 1.0),
718            "after crash, output should be bypass copy of input"
719        );
720    }
721
722    #[test]
723    fn clap_track_integration() {
724        use crate::track::Track;
725
726        let host_bin = find_host_binary();
727        if !host_bin.exists() {
728            eprintln!("Skipping track integration test: host binary not found");
729            return;
730        }
731
732        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
733            .parent()
734            .unwrap()
735            .join("daw")
736            .join("plugin-host")
737            .join("tests")
738            .join("test_passthrough.clap");
739
740        if !plugin_path.exists() {
741            eprintln!(
742                "Skipping track integration test: plugin not found at {}",
743                plugin_path.display()
744            );
745            return;
746        }
747
748        let mut track = Track::new("test-track".to_string(), 2, 2, 0, 0, 256, 48000.0);
749
750        track
751            .load_clap_plugin(
752                &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
753                None,
754            )
755            .expect("should load CLAP plugin on track");
756
757        assert_eq!(track.clap_plugins.len(), 1);
758
759        // Process directly through the plugin processor to verify IPC works.
760        // Track-level routing requires explicit audio connections; this test
761        // verifies that a plugin loaded on a track can process audio correctly.
762        let processor = track.clap_plugins[0].processor.lock();
763        processor.setup_audio_ports();
764
765        for (i, input) in processor.audio_inputs().iter().enumerate() {
766            let buf = input.buffer.lock();
767            for (j, sample) in buf.iter_mut().enumerate() {
768                *sample = (i * 1000 + j) as f32;
769            }
770            *input.finished.lock() = true;
771        }
772
773        processor.process_with_audio_io(256);
774
775        for (ch, output) in processor.audio_outputs().iter().enumerate() {
776            let buf = output.buffer.lock();
777            assert!(
778                buf.iter().any(|&s| s != 0.0),
779                "plugin output ch={ch} should contain non-zero samples after CLAP processing"
780            );
781        }
782    }
783}