Skip to main content

maolan_engine/engine/
mod.rs

1use std::{
2    collections::{HashMap, VecDeque},
3    net::{SocketAddr, UdpSocket},
4    path::PathBuf,
5    sync::{Arc, atomic::AtomicBool},
6    time::Instant,
7};
8use tokio::sync::mpsc::{Receiver, Sender};
9use tokio::task::JoinHandle;
10
11mod hardware;
12mod midi;
13mod plugins;
14mod runtime;
15mod topology;
16mod transport_record_bounce;
17
18type HwDeviceInfo = (usize, usize, usize, ((usize, usize), (usize, usize)));
19
20pub fn parse_automation_lanes(
21    value: &serde_json::Value,
22) -> Vec<crate::message::OfflineAutomationLane> {
23    serde_json::from_value(value.clone()).unwrap_or_else(|_| {
24        if let Some(array) = value.as_array() {
25            array
26                .iter()
27                .filter_map(|item| serde_json::from_value(item.clone()).ok())
28                .collect()
29        } else {
30            vec![]
31        }
32    })
33}
34
35#[cfg(target_os = "linux")]
36use crate::hw::alsa::{HwDriver, HwOptions, MidiHub};
37#[cfg(target_os = "macos")]
38use crate::hw::coreaudio::{HwDriver, HwOptions, MidiHub};
39#[cfg(unix)]
40use crate::hw::jack::JackRuntime;
41#[cfg(target_os = "windows")]
42use crate::hw::options::HwOptions;
43#[cfg(target_os = "freebsd")]
44use crate::hw::oss::{HwDriver, MidiHub};
45#[cfg(target_os = "openbsd")]
46use crate::hw::sndio::{HwDriver, HwOptions, MidiHub};
47#[cfg(target_os = "windows")]
48use crate::hw::wasapi::{self, HwDriver, MidiHub};
49#[cfg(target_os = "linux")]
50use crate::workers::alsa_worker::HwWorker;
51#[cfg(target_os = "macos")]
52use crate::workers::coreaudio_worker::HwWorker;
53#[cfg(target_os = "openbsd")]
54use crate::workers::sndio_worker::HwWorker;
55#[cfg(target_os = "windows")]
56use crate::workers::wasapi_worker::HwWorker;
57use crate::{
58    history::{History, UndoEntry},
59    kind::Kind,
60    message::{Action, HwMidiEvent, Message, MidiControllerData, MidiNoteData},
61    midi::io::MidiEvent,
62    osc::OscServer,
63    state::{State, StateSlot},
64    workers::worker::NodeJobResult,
65};
66
67struct RtProducer<T>(rtrb::Producer<T>);
68
69// Safety: each producer is owned only by the engine task and is never shared
70// with the consumer thread. The `Sync` impl only permits the engine future to
71// remain `Send` across `.await`; no concurrent access is introduced.
72unsafe impl<T: Send> Send for RtProducer<T> {}
73unsafe impl<T: Send> Sync for RtProducer<T> {}
74
75impl<T> RtProducer<T> {
76    fn push(&mut self, value: T) -> Result<(), rtrb::PushError<T>> {
77        self.0.push(value)
78    }
79}
80
81struct RtConsumer<T>(rtrb::Consumer<T>);
82
83// Safety: each consumer is owned only by the engine task and is never shared
84// with the producer thread. See `RtProducer` for the `Sync` rationale.
85unsafe impl<T: Send> Send for RtConsumer<T> {}
86unsafe impl<T: Send> Sync for RtConsumer<T> {}
87
88impl<T> RtConsumer<T> {
89    fn pop(&mut self) -> Result<T, rtrb::PopError> {
90        self.0.pop()
91    }
92}
93
94struct WorkerData {
95    tx: Sender<Message>,
96    handle: Option<JoinHandle<()>>,
97    node_job_tx: Option<RtProducer<crate::executor::NodeJob>>,
98    node_result_rx: Option<RtConsumer<NodeJobResult>>,
99    node_thread: Option<std::thread::Thread>,
100    node_quit: Option<Arc<AtomicBool>>,
101}
102
103impl WorkerData {
104    pub fn new(tx: Sender<Message>, handle: JoinHandle<()>) -> Self {
105        Self {
106            tx,
107            handle: Some(handle),
108            node_job_tx: None,
109            node_result_rx: None,
110            node_thread: None,
111            node_quit: None,
112        }
113    }
114
115    pub fn with_node_mailbox(
116        tx: Sender<Message>,
117        handle: JoinHandle<()>,
118        node_job_tx: rtrb::Producer<crate::executor::NodeJob>,
119        node_result_rx: rtrb::Consumer<NodeJobResult>,
120        node_thread: std::thread::Thread,
121        node_quit: Arc<AtomicBool>,
122    ) -> Self {
123        Self {
124            tx,
125            handle: Some(handle),
126            node_job_tx: Some(RtProducer(node_job_tx)),
127            node_result_rx: Some(RtConsumer(node_result_rx)),
128            node_thread: Some(node_thread),
129            node_quit: Some(node_quit),
130        }
131    }
132}
133
134impl Drop for WorkerData {
135    fn drop(&mut self) {
136        if let Some(quit) = &self.node_quit {
137            quit.store(true, std::sync::atomic::Ordering::Release);
138        }
139        if let Some(thread) = &self.node_thread {
140            thread.unpark();
141        }
142    }
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub(crate) struct HwDriverInfo {
147    pub cycle_samples: usize,
148    pub sample_rate: i32,
149    pub input_channels: usize,
150    pub output_channels: usize,
151    pub sample_bits: i32,
152    pub frame_size_bytes: usize,
153}
154
155#[derive(Debug, Clone)]
156pub(crate) struct RecordingSession {
157    start_sample: usize,
158    samples: Vec<f32>,
159    channels: usize,
160    file_name: String,
161
162    stripe_peaks: Vec<Vec<[f32; 2]>>,
163
164    current_stripe_frames: usize,
165}
166
167const RECORDING_STRIPE_FRAMES: usize = 256;
168
169#[derive(Debug, Clone)]
170pub(crate) struct MidiRecordingSession {
171    start_sample: usize,
172    events: Vec<(u64, Vec<u8>)>,
173    file_name: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177struct MidiHwInRoute {
178    device: String,
179    to_track: String,
180    to_port: usize,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Hash)]
184struct MidiHwOutRoute {
185    from_track: String,
186    from_port: usize,
187    device: String,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Hash)]
191struct MidiHwThruRoute {
192    from_device: String,
193    to_device: String,
194}
195
196struct OfflineBounceJob {
197    cancel: Arc<AtomicBool>,
198}
199
200#[cfg(unix)]
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202enum JackTransportPlaySync {
203    Start,
204    Stop,
205}
206
207#[derive(Clone, Copy)]
208#[cfg(unix)]
209pub(crate) struct AudioOpenRequest<'a> {
210    device: &'a str,
211    input_device: Option<&'a str>,
212    sample_rate_hz: i32,
213    bits: i32,
214    exclusive: bool,
215    period_frames: usize,
216    nperiods: usize,
217    sync_mode: bool,
218}
219
220pub(crate) struct ClipAddRequest<'a> {
221    clip_id: &'a str,
222    name: &'a str,
223    track_name: &'a str,
224    start: usize,
225    length: usize,
226    offset: usize,
227    input_channel: usize,
228    muted: bool,
229    peaks_file: Option<String>,
230    kind: Kind,
231    fade_enabled: bool,
232    fade_in_samples: usize,
233    fade_out_samples: usize,
234    source_name: Option<String>,
235    source_offset: Option<usize>,
236    source_length: Option<usize>,
237    preview_name: Option<String>,
238    pitch_correction_points: Vec<crate::message::PitchCorrectionPointData>,
239    pitch_correction_frame_likeness: Option<f32>,
240    pitch_correction_inertia_ms: Option<u16>,
241    pitch_correction_formant_compensation: Option<bool>,
242    plugin_graph_json: Option<serde_json::Value>,
243}
244
245#[cfg(unix)]
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub(crate) struct JackTransportSyncDecision {
248    play_sync: Option<JackTransportPlaySync>,
249    position_sync: Option<usize>,
250}
251
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub(crate) enum MidiLearnSlot {
254    Track(String, crate::message::TrackMidiLearnTarget),
255    Global(crate::message::GlobalMidiLearnTarget),
256    Session(crate::message::SessionMidiLearnTarget),
257}
258
259pub struct Engine {
260    clients: Vec<Sender<Message>>,
261    rx: Receiver<Message>,
262    state: Arc<State>,
263    state_snapshot: Arc<StateSlot>,
264    tx: Sender<Message>,
265    workers: Vec<WorkerData>,
266    hw_driver: Option<HwDriver>,
267    hw_driver_info: Option<HwDriverInfo>,
268    hw_input_ports: Vec<Arc<crate::audio::io::AudioIO>>,
269    hw_output_ports: Vec<Arc<crate::audio::io::AudioIO>>,
270    #[cfg(unix)]
271    jack_runtime: Option<JackRuntime>,
272    midi_hub: Option<MidiHub>,
273    hw_worker: Option<WorkerData>,
274    osc_server: Option<OscServer>,
275    osc_reply_socket: Option<UdpSocket>,
276    osc_reply_target: Option<SocketAddr>,
277    pending_hw_midi_events: Vec<MidiEvent>,
278    pending_hw_midi_events_by_device: HashMap<String, Vec<MidiEvent>>,
279    pending_hw_midi_out_events: Vec<MidiEvent>,
280    pending_hw_midi_out_events_by_device: Vec<HwMidiEvent>,
281    active_hw_notes_by_track: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
282    active_hw_notes_cycle_start: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
283    midi_hw_in_routes: Vec<MidiHwInRoute>,
284    midi_hw_out_routes: Vec<MidiHwOutRoute>,
285    midi_hw_thru_routes: Vec<MidiHwThruRoute>,
286    ready_workers: Vec<usize>,
287    pending_requests: VecDeque<Action>,
288    awaiting_hwfinished: bool,
289    handling_hwfinished: bool,
290    transport_panic_flush_pending: bool,
291    transport_restart_pending: bool,
292    notified_loop_wrap_sample: Option<usize>,
293    transport_sample: usize,
294
295    hw_input_latency_frames: usize,
296
297    hw_output_latency_frames: usize,
298    loop_enabled: bool,
299    loop_range_samples: Option<(usize, usize)>,
300    metronome_enabled: bool,
301    tempo_bpm: f64,
302    tsig_num: u16,
303    tsig_denom: u16,
304    tempo_points: Vec<crate::message::TempoPoint>,
305    time_signature_points: Vec<crate::message::TimeSignaturePoint>,
306    punch_enabled: bool,
307    punch_range_samples: Option<(usize, usize)>,
308    audio_recordings: std::collections::HashMap<String, RecordingSession>,
309    midi_recordings: std::collections::HashMap<String, MidiRecordingSession>,
310    completed_audio_recordings: Vec<(String, RecordingSession)>,
311    completed_midi_recordings: Vec<(String, MidiRecordingSession)>,
312    playing: bool,
313    transport_running: bool,
314    clip_playback_enabled: bool,
315    session_clip_playback_enabled: bool,
316    session_transport_sample: usize,
317    record_enabled: bool,
318    step_recording_enabled: bool,
319    session_dir: Option<PathBuf>,
320    hw_out_level_db: f32,
321    hw_out_balance: f32,
322    hw_out_muted: bool,
323    last_hw_out_meter_publish: Option<Instant>,
324    #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
325    last_hw_out_meter_linear: Vec<f32>,
326    hw_out_peak_hold_linear: Vec<f32>,
327    #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
328    hw_out_meter_publish_phase: bool,
329    last_track_meter_publish: Option<Instant>,
330    last_meter_snapshot_publish: Option<Instant>,
331    last_session_report_publish: Option<Instant>,
332    track_meter_linear_by_track: HashMap<String, Vec<f32>>,
333    meter_snapshot_producer:
334        crate::triple_buffer::TripleBufferProducer<crate::meter::MeterSnapshot>,
335    transport_snapshot_producer:
336        crate::triple_buffer::TripleBufferProducer<crate::meter::TransportSnapshot>,
337    session_runtime_snapshot_producer:
338        crate::triple_buffer::TripleBufferProducer<crate::meter::SessionRuntimeSnapshot>,
339    /// Phase 2 render-plan machinery (see `LOCKLESS.md`): the executor
340    /// drives per-cycle node dispatch, the builder thread recompiles and
341    /// publishes plans, `pending_node_jobs` buffers jobs when no worker is
342    /// free, and `hw_ports` is the snapshot the builder compiles against.
343    executor: crate::executor::CycleExecutor,
344    plan_slot: Arc<crate::render_plan::PlanSlot>,
345    hw_ports: Arc<arc_swap::ArcSwap<crate::plan_builder::HwPorts>>,
346    pending_node_jobs: VecDeque<crate::executor::NodeJob>,
347    plan_builder: crate::plan_builder::PlanBuilder,
348    latest_hw_out_meter_db: Arc<Vec<f32>>,
349    latest_track_meter_snapshot: Arc<Vec<(String, Vec<f32>)>>,
350    history: History,
351    history_group: Option<UndoEntry>,
352    history_suspended: bool,
353    offline_bounce_jobs: HashMap<String, OfflineBounceJob>,
354    /// Bounce jobs registered while a plan cycle was still in flight; the
355    /// work is handed to the reserved worker when the cycle completes
356    /// (`on_all_tracks_finished`), so the bounce never races RT workers.
357    pending_bounce_starts: Vec<(usize, crate::message::OfflineBounceWork)>,
358    /// Worker index → track name for in-flight bounce jobs; the worker's
359    /// terminal `Ready(id)` removes the job even on error/cancel paths
360    /// whose `OfflineBounceFinished` payload carries no track name.
361    bounce_worker_tracks: HashMap<usize, String>,
362    pending_midi_learn: Option<(String, crate::message::TrackMidiLearnTarget, Option<String>)>,
363    pending_global_midi_learn: Option<crate::message::GlobalMidiLearnTarget>,
364    pending_session_midi_learn: Option<crate::message::SessionMidiLearnTarget>,
365    global_midi_learn_play_pause: Option<crate::message::MidiLearnBinding>,
366    global_midi_learn_stop: Option<crate::message::MidiLearnBinding>,
367    global_midi_learn_record_toggle: Option<crate::message::MidiLearnBinding>,
368    session_midi_learn_slots: HashMap<(String, usize), crate::message::MidiLearnBinding>,
369    session_midi_learn_scenes: HashMap<usize, crate::message::MidiLearnBinding>,
370    session_midi_learn_stop_track: HashMap<String, crate::message::MidiLearnBinding>,
371    session_midi_learn_stop_all: Option<crate::message::MidiLearnBinding>,
372    midi_cc_gate: HashMap<(String, u8, u8), bool>,
373    modulators: Vec<crate::modulator::Modulator>,
374    modulator_values: Option<Arc<std::collections::HashMap<usize, f32>>>,
375}
376
377type MidiEditParseResult = (
378    Vec<MidiNoteData>,
379    Vec<MidiControllerData>,
380    Vec<(u64, Vec<u8>)>,
381);
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::audio::clip::AudioClip;
387    use crate::message::PluginKind;
388    use crate::track::Track;
389    use std::path::Path;
390    use std::sync::atomic::Ordering;
391    use std::time::Duration;
392    use tokio::sync::mpsc::channel;
393    use tokio::time::{Duration as TokioDuration, timeout};
394
395    #[test]
396    #[cfg(unix)]
397    fn jack_transport_sync_decision_starts_and_syncs_position_on_external_play() {
398        let decision = Engine::jack_transport_sync_decision(false, 128, true, 256, 64);
399
400        assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Start));
401        assert_eq!(decision.position_sync, Some(256));
402    }
403
404    #[test]
405    #[cfg(unix)]
406    fn jack_transport_sync_decision_stops_and_syncs_position_on_external_stop() {
407        let decision = Engine::jack_transport_sync_decision(true, 512, false, 96, 64);
408
409        assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Stop));
410        assert_eq!(decision.position_sync, Some(96));
411    }
412
413    #[test]
414    #[cfg(unix)]
415    fn jack_transport_sync_decision_ignores_small_rolling_drift() {
416        let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1040, 64);
417
418        assert_eq!(decision.play_sync, None);
419        assert_eq!(decision.position_sync, None);
420    }
421
422    #[test]
423    #[cfg(unix)]
424    fn jack_transport_sync_decision_syncs_large_rolling_jump() {
425        let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1200, 64);
426
427        assert_eq!(decision.play_sync, None);
428        assert_eq!(decision.position_sync, Some(1200));
429    }
430
431    #[test]
432    #[cfg(unix)]
433    fn jack_transport_sync_decision_syncs_locate_while_stopped() {
434        let decision = Engine::jack_transport_sync_decision(false, 400, false, 900, 64);
435
436        assert_eq!(decision.play_sync, None);
437        assert_eq!(decision.position_sync, Some(900));
438    }
439
440    fn make_engine_with_client() -> (Engine, tokio::sync::mpsc::Receiver<Message>) {
441        let (engine_tx, engine_rx) = channel(16);
442        let mut engine = Engine::new(engine_rx, engine_tx);
443        let (client_tx, client_rx) = channel(16);
444        engine.clients.push(client_tx);
445        (engine, client_rx)
446    }
447
448    fn insert_track(engine: &mut Engine, track: Track) {
449        engine
450            .state
451            .lock()
452            .tracks
453            .insert(track.name.clone(), Arc::new(track));
454        engine.publish_state_snapshot();
455        engine.plan_builder.mark_dirty();
456    }
457
458    fn insert_track_for_modulator_test(engine: &mut Engine, track: Track) {
459        engine
460            .state
461            .lock()
462            .tracks
463            .insert(track.name.clone(), Arc::new(track));
464        engine.publish_state_snapshot();
465    }
466
467    fn osc_packet(address: &str) -> Vec<u8> {
468        fn push_padded_osc_string(packet: &mut Vec<u8>, value: &str) {
469            packet.extend_from_slice(value.as_bytes());
470            packet.push(0);
471            while !packet.len().is_multiple_of(4) {
472                packet.push(0);
473            }
474        }
475
476        let mut packet = Vec::new();
477        push_padded_osc_string(&mut packet, address);
478        push_padded_osc_string(&mut packet, ",");
479        packet
480    }
481
482    #[cfg_attr(
483        all(miri, target_os = "freebsd"),
484        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
485    )]
486    #[tokio::test]
487    async fn set_osc_enabled_starts_and_stops_server() {
488        let (mut engine, _client_rx) = make_engine_with_client();
489
490        engine
491            .set_osc_enabled_with(true, |tx| OscServer::start_on_addr(tx, "127.0.0.1:0"))
492            .expect("start osc server on ephemeral port");
493        assert!(engine.osc_server.is_some());
494
495        engine
496            .set_osc_enabled_with(false, OscServer::start)
497            .expect("stop osc server");
498        assert!(engine.osc_server.is_none());
499    }
500
501    #[cfg_attr(
502        all(miri, target_os = "freebsd"),
503        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
504    )]
505    #[tokio::test]
506    async fn set_tempo_map_is_recorded_and_undone() {
507        let (mut engine, _client_rx) = make_engine_with_client();
508        let original_tempo_points = engine.tempo_points.clone();
509        let original_time_signature_points = engine.time_signature_points.clone();
510
511        let new_tempo_points = vec![crate::message::TempoPoint {
512            sample: 0,
513            bpm: 140.0,
514        }];
515        let new_time_signature_points = vec![crate::message::TimeSignaturePoint {
516            sample: 0,
517            numerator: 3,
518            denominator: 4,
519        }];
520
521        engine
522            .handle_request(Action::SetTempoMap {
523                tempo_points: new_tempo_points.clone(),
524                time_signature_points: new_time_signature_points.clone(),
525            })
526            .await;
527
528        assert_eq!(engine.tempo_points, new_tempo_points);
529        assert_eq!(engine.time_signature_points, new_time_signature_points);
530        assert_eq!(engine.tempo_bpm, 140.0);
531        assert_eq!(engine.tsig_num, 3);
532        assert_eq!(engine.tsig_denom, 4);
533        assert!(engine.history.is_dirty());
534
535        engine.handle_request(Action::Undo).await;
536
537        assert_eq!(engine.tempo_points, original_tempo_points);
538        assert_eq!(engine.time_signature_points, original_time_signature_points);
539        assert_eq!(engine.tempo_bpm, 120.0);
540        assert_eq!(engine.tsig_num, 4);
541        assert_eq!(engine.tsig_denom, 4);
542    }
543
544    #[cfg_attr(
545        all(miri, target_os = "freebsd"),
546        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
547    )]
548    #[tokio::test]
549    async fn osc_server_forwards_transport_packets_to_engine_channel() {
550        let (tx, mut rx) = channel(4);
551        let mut server =
552            OscServer::start_on_addr(tx, "127.0.0.1:0").expect("start osc test server");
553        let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind sender socket");
554        let packet = osc_packet("/transport/play");
555        socket
556            .send_to(&packet, server.listen_addr())
557            .expect("send osc packet");
558
559        let message = timeout(TokioDuration::from_secs(1), rx.recv())
560            .await
561            .expect("packet delivery timeout")
562            .expect("osc message");
563        match message {
564            Message::OscRequest {
565                action: Action::Play,
566                ..
567            } => {}
568            other => panic!("unexpected osc message: {other:?}"),
569        }
570
571        server.stop();
572    }
573
574    #[cfg_attr(
575        all(miri, target_os = "freebsd"),
576        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
577    )]
578    #[tokio::test]
579    async fn track_offline_bounce_rejects_zero_length_requests() {
580        let (mut engine, mut client_rx) = make_engine_with_client();
581        insert_track(
582            &mut engine,
583            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
584        );
585
586        engine
587            .handle_request(Action::TrackOfflineBounce {
588                track_name: "track".to_string(),
589                output_path: "/tmp/out.wav".to_string(),
590                start_sample: 0,
591                length_samples: 0,
592                automation_lanes: vec![],
593                apply_fader: false,
594            })
595            .await;
596
597        match client_rx.recv().await.expect("response") {
598            Message::Response(Err(err)) => {
599                assert!(err.contains("has no renderable content for offline bounce"));
600            }
601            other => panic!("unexpected message: {other:?}"),
602        }
603    }
604
605    #[cfg_attr(
606        all(miri, target_os = "freebsd"),
607        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
608    )]
609    #[tokio::test]
610    async fn track_offline_bounce_rejects_when_same_track_is_active() {
611        let (mut engine, mut client_rx) = make_engine_with_client();
612        engine.offline_bounce_jobs.insert(
613            "other".to_string(),
614            OfflineBounceJob {
615                cancel: Arc::new(AtomicBool::new(false)),
616            },
617        );
618
619        engine
620            .handle_request(Action::TrackOfflineBounce {
621                track_name: "other".to_string(),
622                output_path: "/tmp/out.wav".to_string(),
623                start_sample: 0,
624                length_samples: 128,
625                automation_lanes: vec![],
626                apply_fader: false,
627            })
628            .await;
629
630        match client_rx.recv().await.expect("response") {
631            Message::Response(Err(err)) => {
632                assert!(err.contains("already in progress"));
633            }
634            other => panic!("unexpected message: {other:?}"),
635        }
636    }
637
638    #[cfg_attr(
639        all(miri, target_os = "freebsd"),
640        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
641    )]
642    #[tokio::test]
643    async fn track_offline_bounce_allows_different_track_concurrently() {
644        let (mut engine, _client_rx) = make_engine_with_client();
645        insert_track(
646            &mut engine,
647            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
648        );
649        engine.offline_bounce_jobs.insert(
650            "other".to_string(),
651            OfflineBounceJob {
652                cancel: Arc::new(AtomicBool::new(false)),
653            },
654        );
655
656        engine
657            .handle_request(Action::TrackOfflineBounce {
658                track_name: "track".to_string(),
659                output_path: "/tmp/out.wav".to_string(),
660                start_sample: 0,
661                length_samples: 128,
662                automation_lanes: vec![],
663                apply_fader: false,
664            })
665            .await;
666
667        assert!(engine.offline_bounce_jobs.contains_key("other"));
668        assert_eq!(engine.pending_requests.len(), 1);
669    }
670
671    #[cfg_attr(
672        all(miri, target_os = "freebsd"),
673        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
674    )]
675    #[tokio::test]
676    async fn reject_if_track_frozen_sends_error_and_blocks_operation() {
677        let (mut engine, mut client_rx) = make_engine_with_client();
678        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
679        track.set_frozen(true);
680        insert_track(&mut engine, track);
681
682        let rejected = engine
683            .reject_if_track_frozen("track", "arming/disarming")
684            .await;
685
686        assert!(rejected);
687        match client_rx.recv().await.expect("response") {
688            Message::Response(Err(err)) => {
689                assert_eq!(err, "Track 'track' is frozen; arming/disarming is blocked");
690            }
691            other => panic!("unexpected message: {other:?}"),
692        }
693    }
694
695    #[cfg_attr(
696        all(miri, target_os = "freebsd"),
697        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
698    )]
699    #[tokio::test]
700    async fn dispatcher_track_mutations_publish_state_snapshot() {
701        let (mut engine, _client_rx) = make_engine_with_client();
702        insert_track(
703            &mut engine,
704            Track::new("snap".to_string(), 1, 1, 0, 0, 64, 48_000.0),
705        );
706        engine.publish_state_snapshot();
707
708        let snapshot = engine.state_snapshot.load_full();
709        assert!(snapshot.tracks.contains_key("snap"));
710
711        engine
712            .handle_request(Action::TrackToggleArm("snap".to_string()))
713            .await;
714
715        let snapshot = engine.state_snapshot.load_full();
716        assert!(snapshot.tracks.get("snap").unwrap().lock().armed());
717
718        engine
719            .handle_request(Action::RemoveTrack("snap".to_string()))
720            .await;
721
722        let snapshot = engine.state_snapshot.load_full();
723        assert!(!snapshot.tracks.contains_key("snap"));
724    }
725
726    #[cfg_attr(
727        all(miri, target_os = "freebsd"),
728        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
729    )]
730    #[tokio::test]
731    async fn undo_restores_original_clip_bounds_after_stretch_style_group() {
732        let (mut engine, _client_rx) = make_engine_with_client();
733        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
734        let mut clip = AudioClip::new("audio/original.wav".to_string(), 100, 220);
735        clip.offset = 12;
736        clip.fade_in_samples = 20;
737        clip.fade_out_samples = 30;
738        track.audio.push_clip(clip);
739        insert_track(&mut engine, track);
740
741        engine.handle_request(Action::BeginHistoryGroup).await;
742        engine
743            .handle_request(Action::SetClipBounds {
744                track_name: "track".to_string(),
745                clip_index: 0,
746                kind: Kind::Audio,
747                start: 120,
748                length: 180,
749                offset: 0,
750            })
751            .await;
752        engine
753            .handle_request(Action::SetClipSourceName {
754                track_name: "track".to_string(),
755                clip_index: 0,
756                kind: Kind::Audio,
757                name: "audio/stretched.wav".to_string(),
758            })
759            .await;
760        engine
761            .handle_request(Action::SetClipFade {
762                track_name: "track".to_string(),
763                clip_index: 0,
764                kind: Kind::Audio,
765                fade_enabled: true,
766                fade_in_samples: 12,
767                fade_out_samples: 12,
768            })
769            .await;
770        engine.handle_request(Action::EndHistoryGroup).await;
771
772        engine.handle_request(Action::Undo).await;
773
774        let state = engine.state.lock();
775        let track = state.tracks.get("track").expect("track exists").lock();
776        let clips = track.audio.clips();
777        let clip = clips.first().expect("clip exists");
778        assert_eq!(clip.name, "audio/original.wav");
779        assert_eq!(clip.start, 100);
780        assert_eq!(clip.end, 220);
781        assert_eq!(clip.end.saturating_sub(clip.start), 120);
782        assert_eq!(clip.offset, 12);
783    }
784
785    #[cfg_attr(
786        all(miri, target_os = "freebsd"),
787        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
788    )]
789    #[tokio::test]
790    async fn track_offline_bounce_queues_when_no_worker_is_ready() {
791        let (mut engine, _client_rx) = make_engine_with_client();
792        insert_track(
793            &mut engine,
794            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
795        );
796
797        engine
798            .handle_request(Action::TrackOfflineBounce {
799                track_name: "track".to_string(),
800                output_path: "/tmp/out.wav".to_string(),
801                start_sample: 0,
802                length_samples: 128,
803                automation_lanes: vec![],
804                apply_fader: false,
805            })
806            .await;
807
808        assert!(engine.offline_bounce_jobs.is_empty());
809        assert_eq!(engine.pending_requests.len(), 1);
810        assert!(matches!(
811            engine.pending_requests.front(),
812            Some(Action::TrackOfflineBounce { track_name, length_samples, .. })
813                if track_name == "track" && *length_samples == 128
814        ));
815    }
816
817    #[cfg_attr(
818        all(miri, target_os = "freebsd"),
819        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
820    )]
821    #[tokio::test]
822    async fn track_offline_bounce_returns_missing_track_error() {
823        let (mut engine, mut client_rx) = make_engine_with_client();
824
825        engine
826            .handle_request(Action::TrackOfflineBounce {
827                track_name: "missing".to_string(),
828                output_path: "/tmp/out.wav".to_string(),
829                start_sample: 0,
830                length_samples: 128,
831                automation_lanes: vec![],
832                apply_fader: false,
833            })
834            .await;
835
836        match client_rx.recv().await.expect("response") {
837            Message::Response(Err(err)) => {
838                assert_eq!(err, "Track not found: missing");
839            }
840            other => panic!("unexpected message: {other:?}"),
841        }
842    }
843
844    #[cfg_attr(
845        all(miri, target_os = "freebsd"),
846        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
847    )]
848    #[tokio::test]
849    async fn track_offline_bounce_clears_job_when_worker_send_fails() {
850        let (mut engine, mut client_rx) = make_engine_with_client();
851        insert_track(
852            &mut engine,
853            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
854        );
855        let (worker_tx, worker_rx) = channel(1);
856        drop(worker_rx);
857        engine
858            .workers
859            .push(WorkerData::new(worker_tx, tokio::spawn(async {})));
860        engine.ready_workers.push(0);
861
862        engine
863            .handle_request(Action::TrackOfflineBounce {
864                track_name: "track".to_string(),
865                output_path: "/tmp/out.wav".to_string(),
866                start_sample: 0,
867                length_samples: 128,
868                automation_lanes: vec![],
869                apply_fader: false,
870            })
871            .await;
872
873        assert!(engine.offline_bounce_jobs.is_empty());
874        match client_rx.recv().await.expect("response") {
875            Message::Response(Err(err)) => {
876                assert!(err.contains("Failed to schedule offline bounce"));
877            }
878            other => panic!("unexpected message: {other:?}"),
879        }
880    }
881
882    #[cfg_attr(
883        all(miri, target_os = "freebsd"),
884        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
885    )]
886    #[tokio::test]
887    async fn play_stop_play_keeps_clip_output_audible() {
888        use crate::audio::clip::AudioClip;
889        use crate::audio_codec::write_wav_f32;
890
891        let (engine_tx, engine_rx) = channel(16);
892        let mut engine = Engine::new(engine_rx, engine_tx);
893        let state = engine.state();
894        let (client_tx, mut client_rx) = channel(16);
895        engine.clients.push(client_tx);
896        engine.init().await;
897
898        let tmp_dir = std::env::temp_dir().join("maolan_play_stop_play_test");
899        let _ = std::fs::create_dir_all(&tmp_dir);
900        let wav_path = tmp_dir.join("tone.wav");
901        let sample_rate = 48_000u32;
902        let clip_samples = sample_rate as usize;
903        let mut samples = Vec::with_capacity(clip_samples);
904        for i in 0..clip_samples {
905            let phase = i as f32 / sample_rate as f32 * 2.0 * std::f32::consts::PI * 440.0;
906            samples.push(phase.sin() * 0.5);
907        }
908        write_wav_f32(&wav_path, &samples, 1, sample_rate).expect("write wav");
909
910        let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 1024, sample_rate as f64);
911        let mut clip = AudioClip::new(wav_path.to_string_lossy().to_string(), 0, clip_samples);
912        clip.fade_enabled = false;
913        track.audio.push_clip(clip);
914        track.session_base_dir = Some(tmp_dir.clone());
915        insert_track(&mut engine, track);
916
917        let tx = engine.tx.clone();
918        let work_handle = tokio::spawn(async move {
919            engine.work().await;
920        });
921
922        // Wait for worker tasks to start up and send Ready messages.
923        tokio::time::sleep(TokioDuration::from_millis(100)).await;
924
925        async fn drain_responses(
926            client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
927            count: usize,
928        ) {
929            for _ in 0..count {
930                let _ = tokio::time::timeout(TokioDuration::from_secs(2), client_rx.recv()).await;
931            }
932        }
933
934        async fn wait_for_audible_track(
935            client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
936            state: &State,
937        ) -> Option<f32> {
938            let deadline = Instant::now() + Duration::from_secs(5);
939            while Instant::now() < deadline {
940                {
941                    let state = state.lock();
942                    let peak = state
943                        .tracks
944                        .get("track")
945                        .map(|t| {
946                            t.lock()
947                                .output_meter_linear()
948                                .into_iter()
949                                .fold(0.0_f32, f32::max)
950                        })
951                        .unwrap_or(0.0);
952                    if peak > 0.001 {
953                        return Some(peak);
954                    }
955                }
956                let _ =
957                    tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await;
958                tokio::time::sleep(TokioDuration::from_millis(10)).await;
959            }
960            None
961        }
962
963        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
964            .await
965            .unwrap();
966        tx.send(Message::Request(Action::Play)).await.unwrap();
967        let first_peak = wait_for_audible_track(&mut client_rx, &state)
968            .await
969            .unwrap_or(0.0);
970        assert!(
971            first_peak > 0.001,
972            "expected audible output on first play, got {first_peak}"
973        );
974
975        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
976            .await
977            .unwrap();
978        tx.send(Message::Request(Action::Stop)).await.unwrap();
979        drain_responses(&mut client_rx, 2).await;
980
981        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
982            .await
983            .unwrap();
984        tx.send(Message::Request(Action::Play)).await.unwrap();
985        let second_peak = wait_for_audible_track(&mut client_rx, &state)
986            .await
987            .unwrap_or(0.0);
988        assert!(
989            second_peak > 0.001,
990            "expected audible output on second play after stop, got {second_peak}"
991        );
992
993        let _ = tx.send(Message::Request(Action::Quit)).await;
994        tokio::time::sleep(TokioDuration::from_millis(200)).await;
995        work_handle.abort();
996        let _ = std::fs::remove_dir_all(&tmp_dir);
997    }
998
999    #[test]
1000    fn modulator_sets_track_volume() {
1001        let (mut engine, _client_rx) = make_engine_with_client();
1002        let track = Track::new("vol-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
1003        insert_track_for_modulator_test(&mut engine, track);
1004
1005        engine.modulators = vec![crate::modulator::Modulator {
1006            id: 1,
1007            name: "LFO".to_string(),
1008            shape: crate::modulator::ModulatorShape::Sine,
1009            rate: crate::modulator::ModulatorRate::Hz(1.0),
1010            phase: 0.0,
1011            enabled: true,
1012            targets: vec![crate::modulator::ModulatorTarget::TrackVolume {
1013                track_name: "vol-track".to_string(),
1014                min: -90.0,
1015                max: 20.0,
1016            }],
1017        }];
1018
1019        // At sample 12000 (1/4 period at 48kHz/1Hz), sine value maps to 1.0 -> max 20 dB.
1020        let echoes = engine.apply_modulators(12_000);
1021        let state_guard = engine.state.lock();
1022        let track = state_guard.tracks["vol-track"].lock();
1023        assert!(
1024            (track.level() - 20.0).abs() < 0.01,
1025            "expected 20 dB, got {}",
1026            track.level()
1027        );
1028        assert!(
1029            echoes
1030                .iter()
1031                .any(|a| matches!(a, Action::TrackAutomationLevel(name, _) if name == "vol-track"))
1032        );
1033    }
1034
1035    #[test]
1036    fn modulator_sets_track_balance() {
1037        let (mut engine, _client_rx) = make_engine_with_client();
1038        let track = Track::new("pan-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
1039        insert_track_for_modulator_test(&mut engine, track);
1040
1041        engine.modulators = vec![crate::modulator::Modulator {
1042            id: 1,
1043            name: "LFO".to_string(),
1044            shape: crate::modulator::ModulatorShape::Sine,
1045            rate: crate::modulator::ModulatorRate::Hz(1.0),
1046            phase: 0.0,
1047            enabled: true,
1048            targets: vec![crate::modulator::ModulatorTarget::TrackBalance {
1049                track_name: "pan-track".to_string(),
1050                min: -1.0,
1051                max: 1.0,
1052            }],
1053        }];
1054
1055        // At sample 12000 (1/4 period), sine value maps to 1.0 -> max balance 1.0.
1056        let echoes = engine.apply_modulators(12_000);
1057        let state_guard = engine.state.lock();
1058        let track = state_guard.tracks["pan-track"].lock();
1059        assert!(
1060            (track.balance() - 1.0).abs() < 0.01,
1061            "expected balance 1.0, got {}",
1062            track.balance()
1063        );
1064        assert!(
1065            echoes.iter().any(
1066                |a| matches!(a, Action::TrackAutomationBalance(name, _) if name == "pan-track")
1067            )
1068        );
1069    }
1070
1071    #[cfg_attr(
1072        all(miri, target_os = "freebsd"),
1073        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1074    )]
1075    #[tokio::test]
1076    async fn track_set_parent_wires_folder_input_to_child_input_and_child_output_to_folder_output()
1077    {
1078        let (mut engine, mut client_rx) = make_engine_with_client();
1079        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1080        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1081        insert_track(&mut engine, folder);
1082        insert_track(&mut engine, child);
1083
1084        engine
1085            .handle_request_inner(
1086                Action::TrackSetParent {
1087                    track_name: "child".to_string(),
1088                    parent_name: Some("folder".to_string()),
1089                },
1090                false,
1091            )
1092            .await;
1093
1094        // Drain client messages so the channel does not block later drops.
1095        while let Ok(Some(_)) =
1096            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1097        {}
1098
1099        let state = engine.state.lock();
1100        let folder = state.tracks.get("folder").unwrap().lock();
1101        let child = state.tracks.get("child").unwrap().lock();
1102
1103        assert!(folder.child_tracks.iter().any(|c| c.lock().name == "child"));
1104        assert_eq!(child.parent_track.as_deref(), Some("folder"));
1105
1106        // Folder input -> child input.
1107        for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
1108        {
1109            assert!(
1110                child_in
1111                    .connections()
1112                    .iter()
1113                    .any(|c| Arc::ptr_eq(c, parent_in)),
1114                "folder input {i} is not routed to child input {i}"
1115            );
1116            assert!(
1117                !parent_in
1118                    .connections()
1119                    .iter()
1120                    .any(|c| Arc::ptr_eq(c, child_in)),
1121                "folder input {i} should not read from child input {i}"
1122            );
1123        }
1124
1125        // Child output -> folder output.
1126        for (i, (child_out, parent_out)) in
1127            child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
1128        {
1129            assert!(
1130                parent_out
1131                    .connections()
1132                    .iter()
1133                    .any(|c| Arc::ptr_eq(c, child_out)),
1134                "child output {i} is not routed to folder output {i}"
1135            );
1136        }
1137
1138        // Child passthrough is restored so audio can flow through.
1139        for (i, child_out) in child.audio.outs.iter().enumerate() {
1140            assert!(
1141                child_out.connections().iter().any(|c| {
1142                    child
1143                        .audio
1144                        .ins
1145                        .get(i)
1146                        .is_some_and(|inp| Arc::ptr_eq(c, inp))
1147                }),
1148                "child output {i} is not connected to child input {i}"
1149            );
1150        }
1151    }
1152
1153    #[cfg_attr(
1154        all(miri, target_os = "freebsd"),
1155        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1156    )]
1157    #[tokio::test]
1158    async fn track_set_parent_to_none_restores_root_passthrough() {
1159        let (mut engine, mut client_rx) = make_engine_with_client();
1160        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1161        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1162        insert_track(&mut engine, folder);
1163        insert_track(&mut engine, child);
1164
1165        engine
1166            .handle_request_inner(
1167                Action::TrackSetParent {
1168                    track_name: "child".to_string(),
1169                    parent_name: Some("folder".to_string()),
1170                },
1171                false,
1172            )
1173            .await;
1174        engine
1175            .handle_request_inner(
1176                Action::TrackSetParent {
1177                    track_name: "child".to_string(),
1178                    parent_name: None,
1179                },
1180                false,
1181            )
1182            .await;
1183
1184        while let Ok(Some(_)) =
1185            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1186        {}
1187
1188        let state = engine.state.lock();
1189        let folder = state.tracks.get("folder").unwrap().lock();
1190        let child = state.tracks.get("child").unwrap().lock();
1191
1192        assert!(folder.child_tracks.is_empty());
1193        assert!(child.parent_track.is_none());
1194
1195        for (i, child_out) in child.audio.outs.iter().enumerate() {
1196            assert!(
1197                child_out.connections().iter().any(|c| {
1198                    child
1199                        .audio
1200                        .ins
1201                        .get(i)
1202                        .is_some_and(|inp| Arc::ptr_eq(c, inp))
1203                }),
1204                "child output {i} should be connected to child input {i} after moving to root"
1205            );
1206        }
1207    }
1208
1209    #[cfg_attr(
1210        all(miri, target_os = "freebsd"),
1211        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1212    )]
1213    #[tokio::test]
1214    async fn track_set_parent_wires_folder_midi_to_child_midi() {
1215        let (mut engine, mut client_rx) = make_engine_with_client();
1216        let folder = Track::new_folder("folder".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1217        let child = Track::new("child".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1218        insert_track(&mut engine, folder);
1219        insert_track(&mut engine, child);
1220
1221        engine
1222            .handle_request_inner(
1223                Action::TrackSetParent {
1224                    track_name: "child".to_string(),
1225                    parent_name: Some("folder".to_string()),
1226                },
1227                false,
1228            )
1229            .await;
1230
1231        while let Ok(Some(_)) =
1232            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1233        {}
1234
1235        let state = engine.state.lock();
1236        let folder = state.tracks.get("folder").unwrap().lock();
1237        let child = state.tracks.get("child").unwrap().lock();
1238
1239        let folder_midi_in = &folder.midi.ins[0];
1240        let child_midi_in = &child.midi.ins[0];
1241        assert!(
1242            child_midi_in
1243                .connections()
1244                .iter()
1245                .any(|c| Arc::ptr_eq(c, folder_midi_in)),
1246            "folder MIDI input should be routed to child MIDI input"
1247        );
1248
1249        let child_midi_out = &child.midi.outs[0];
1250        let folder_midi_out = &folder.midi.outs[0];
1251        assert!(
1252            child_midi_out
1253                .connections()
1254                .iter()
1255                .any(|c| Arc::ptr_eq(c, folder_midi_out)),
1256            "child MIDI output should be routed to folder MIDI output"
1257        );
1258    }
1259
1260    fn plan_task_node(
1261        plan: &crate::render_plan::RenderPlan,
1262        name: &str,
1263        want: fn(&crate::message::ProcessTask) -> bool,
1264    ) -> usize {
1265        use crate::message::ProcessTask;
1266        use crate::render_plan::Op;
1267        plan.nodes
1268            .iter()
1269            .enumerate()
1270            .find_map(|(i, op)| match op {
1271                Op::Task { task, .. } => {
1272                    let track = match task {
1273                        ProcessTask::Track(t)
1274                        | ProcessTask::FolderInput(t)
1275                        | ProcessTask::FolderOutput(t) => t,
1276                        ProcessTask::Plugin { track, .. } => track,
1277                    };
1278                    if track.lock().name == name && want(task) {
1279                        Some(i)
1280                    } else {
1281                        None
1282                    }
1283                }
1284                _ => None,
1285            })
1286            .expect("task node not found")
1287    }
1288
1289    fn plan_reachable(plan: &crate::render_plan::RenderPlan, from: usize, to: usize) -> bool {
1290        let mut seen = std::collections::HashSet::new();
1291        let mut queue = std::collections::VecDeque::from([from as u32]);
1292        while let Some(n) = queue.pop_front() {
1293            for &d in &plan.dependents[n as usize] {
1294                if d as usize == to {
1295                    return true;
1296                }
1297                if seen.insert(d) {
1298                    queue.push_back(d);
1299                }
1300            }
1301        }
1302        false
1303    }
1304
1305    #[test]
1306    fn nested_folder_expands_in_render_plan() {
1307        use crate::message::ProcessTask;
1308
1309        let state = crate::state::State::default();
1310        let outer = Arc::new(Track::new_folder(
1311            "outer".to_string(),
1312            2,
1313            2,
1314            0,
1315            0,
1316            64,
1317            48_000.0,
1318        ));
1319        let inner = Arc::new(Track::new_folder(
1320            "inner".to_string(),
1321            2,
1322            2,
1323            0,
1324            0,
1325            64,
1326            48_000.0,
1327        ));
1328        let leaf = Arc::new(Track::new("leaf".to_string(), 2, 2, 0, 0, 64, 48_000.0));
1329        outer.lock().child_tracks.push(inner.clone());
1330        inner.lock().child_tracks.push(leaf.clone());
1331        inner.lock().parent_track = Some("outer".to_string());
1332        leaf.lock().parent_track = Some("inner".to_string());
1333        {
1334            let mut state = state.lock();
1335            state.tracks.insert("outer".to_string(), outer);
1336            state.tracks.insert("inner".to_string(), inner);
1337            state.tracks.insert("leaf".to_string(), leaf);
1338        }
1339
1340        let plan = crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64);
1341        plan.verify().expect("plan invariants");
1342
1343        let is_fi = |t: &ProcessTask| matches!(t, ProcessTask::FolderInput(_));
1344        let is_fo = |t: &ProcessTask| matches!(t, ProcessTask::FolderOutput(_));
1345        let is_track = |t: &ProcessTask| matches!(t, ProcessTask::Track(_));
1346        let in_outer = plan_task_node(&plan, "outer", is_fi);
1347        let in_inner = plan_task_node(&plan, "inner", is_fi);
1348        let track_leaf = plan_task_node(&plan, "leaf", is_track);
1349        let out_inner = plan_task_node(&plan, "inner", is_fo);
1350        let out_outer = plan_task_node(&plan, "outer", is_fo);
1351
1352        assert!(
1353            in_outer < in_inner
1354                && in_inner < track_leaf
1355                && track_leaf < out_inner
1356                && out_inner < out_outer,
1357            "nested folder tasks should expand in topological order"
1358        );
1359        for (a, b) in [
1360            (in_outer, in_inner),
1361            (in_inner, track_leaf),
1362            (track_leaf, out_inner),
1363            (out_inner, out_outer),
1364        ] {
1365            assert!(plan_reachable(&plan, a, b), "{a} should reach {b}");
1366        }
1367        assert!(plan.forced.is_empty(), "no feedback cycle");
1368    }
1369
1370    #[cfg_attr(
1371        all(miri, target_os = "freebsd"),
1372        ignore = "uses filesystem metadata, which Miri isolation does not support on FreeBSD"
1373    )]
1374    #[test]
1375    fn child_to_plugin_to_folder_output_render_plan_has_no_cycle() {
1376        use crate::message::{ConnectableRef, ProcessTask};
1377
1378        let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1379            .parent()
1380            .unwrap()
1381            .join("daw")
1382            .join("plugin-host")
1383            .join("tests")
1384            .join("test_passthrough.clap");
1385        if !plugin_path.exists() {
1386            return;
1387        }
1388        if crate::plugins::ipc::find_plugin_host_binary().is_none() {
1389            return;
1390        }
1391
1392        let (mut engine, _client_rx) = make_engine_with_client();
1393        let mut folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1394        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1395
1396        folder
1397            .load_clap_plugin(
1398                &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
1399                None,
1400            )
1401            .expect("should load CLAP plugin on folder");
1402        folder.clap_plugins[0].processor.setup_audio_ports();
1403        let plugin_id = folder.clap_plugins[0].id;
1404
1405        insert_track(&mut engine, folder);
1406        insert_track(&mut engine, child);
1407
1408        {
1409            let state = engine.state.lock();
1410            let folder = state.tracks.get("folder").unwrap().clone();
1411            let child = state.tracks.get("child").unwrap().clone();
1412            folder.lock().child_tracks.push(child.clone());
1413            child.lock().parent_track = Some("folder".to_string());
1414
1415            folder
1416                .lock()
1417                .connect_audio_connectable(
1418                    ConnectableRef::ChildTrack("child".to_string()),
1419                    0,
1420                    ConnectableRef::ClapPlugin(plugin_id),
1421                    0,
1422                )
1423                .expect("connect child L to plugin L");
1424            folder
1425                .lock()
1426                .connect_audio_connectable(
1427                    ConnectableRef::ChildTrack("child".to_string()),
1428                    1,
1429                    ConnectableRef::ClapPlugin(plugin_id),
1430                    1,
1431                )
1432                .expect("connect child R to plugin R");
1433            folder
1434                .lock()
1435                .connect_audio_connectable(
1436                    ConnectableRef::ClapPlugin(plugin_id),
1437                    0,
1438                    ConnectableRef::TrackOutput,
1439                    0,
1440                )
1441                .expect("connect plugin L to folder output L");
1442            folder
1443                .lock()
1444                .connect_audio_connectable(
1445                    ConnectableRef::ClapPlugin(plugin_id),
1446                    1,
1447                    ConnectableRef::TrackOutput,
1448                    1,
1449                )
1450                .expect("connect plugin R to folder output R");
1451        }
1452
1453        let plan = {
1454            let state = engine.state.lock();
1455            crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64)
1456        };
1457        plan.verify().expect("plan invariants");
1458
1459        let folder_in = plan_task_node(&plan, "folder", |t| {
1460            matches!(t, ProcessTask::FolderInput(_))
1461        });
1462        let child_task = plan_task_node(&plan, "child", |t| matches!(t, ProcessTask::Track(_)));
1463        let plugin = plan_task_node(&plan, "folder", |t| {
1464            matches!(
1465                t,
1466                ProcessTask::Plugin {
1467                    kind: PluginKind::Clap,
1468                    index: 0,
1469                    ..
1470                }
1471            )
1472        });
1473        let folder_out = plan_task_node(&plan, "folder", |t| {
1474            matches!(t, ProcessTask::FolderOutput(_))
1475        });
1476
1477        assert!(
1478            plan_reachable(&plan, folder_in, child_task),
1479            "child task should depend on folder input"
1480        );
1481        assert!(
1482            plan_reachable(&plan, folder_in, plugin) && plan_reachable(&plan, child_task, plugin),
1483            "plugin task should depend on folder input and child"
1484        );
1485        assert!(
1486            plan_reachable(&plan, folder_in, folder_out)
1487                && plan_reachable(&plan, plugin, folder_out)
1488                && plan_reachable(&plan, child_task, folder_out),
1489            "folder output should depend on folder input, plugin, and child"
1490        );
1491        assert!(
1492            plan.forced.is_empty(),
1493            "render plan should not contain a cycle when a plugin reads from a child track"
1494        );
1495    }
1496
1497    #[cfg_attr(
1498        all(miri, target_os = "freebsd"),
1499        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1500    )]
1501    #[tokio::test]
1502    async fn track_set_parent_wires_child_io_to_folder_even_after_addtrack() {
1503        let (mut engine, mut client_rx) = make_engine_with_client();
1504        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1505        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1506        insert_track(&mut engine, folder);
1507        insert_track(&mut engine, child);
1508
1509        engine
1510            .handle_request_inner(
1511                Action::TrackSetParent {
1512                    track_name: "child".to_string(),
1513                    parent_name: Some("folder".to_string()),
1514                },
1515                false,
1516            )
1517            .await;
1518
1519        while let Ok(Some(_)) =
1520            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1521        {}
1522
1523        let state = engine.state.lock();
1524        let folder = state.tracks.get("folder").unwrap().lock();
1525        let child = state.tracks.get("child").unwrap().lock();
1526
1527        // Folder input -> child input.
1528        for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
1529        {
1530            assert!(
1531                child_in
1532                    .connections()
1533                    .iter()
1534                    .any(|c| Arc::ptr_eq(c, parent_in)),
1535                "folder input {i} is not routed to child input {i}"
1536            );
1537        }
1538
1539        // Child output -> folder output.
1540        for (i, (child_out, parent_out)) in
1541            child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
1542        {
1543            assert!(
1544                parent_out
1545                    .connections()
1546                    .iter()
1547                    .any(|c| Arc::ptr_eq(c, child_out)),
1548                "child output {i} is not routed to folder output {i}"
1549            );
1550        }
1551    }
1552
1553    #[cfg_attr(
1554        all(miri, target_os = "freebsd"),
1555        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1556    )]
1557    #[tokio::test]
1558    async fn folder_child_audio_passes_through() {
1559        let (mut engine, mut client_rx) = make_engine_with_client();
1560        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1561        let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1562        insert_track(&mut engine, folder);
1563        insert_track(&mut engine, child);
1564
1565        engine
1566            .handle_request_inner(
1567                Action::TrackSetParent {
1568                    track_name: "child".to_string(),
1569                    parent_name: Some("folder".to_string()),
1570                },
1571                false,
1572            )
1573            .await;
1574        while let Ok(Some(_)) =
1575            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1576        {}
1577
1578        {
1579            let state = engine.state.lock();
1580            let folder = state.tracks.get("folder").unwrap().clone();
1581            let child = state.tracks.get("child").unwrap().clone();
1582
1583            folder.lock().set_input_monitor(vec![true]);
1584            child.lock().set_input_monitor(vec![true]);
1585
1586            // Feed a signal into the folder input from a fake hardware source
1587            // and execute the compiled render plan once.
1588            let source = Arc::new(crate::audio::io::AudioIO::new(64));
1589            crate::audio::io::AudioIO::connect(&source, &folder.lock().audio.ins[0]);
1590            let plan =
1591                crate::render_plan::RenderPlan::compile(&state.snapshot(), &[source], &[], 64);
1592            plan.verify().expect("plan invariants");
1593            let (_, hw_buf) = plan.hw_in_map[0];
1594            // Safety: test thread, no node is running yet; the fake driver owns
1595            // the hardware-input buffer before the cycle starts.
1596            unsafe { (&mut *plan.buffer_ptr(hw_buf)).fill(0.75) };
1597            let collector = basedrop::Collector::new();
1598            let shared = Arc::new(basedrop::Owned::new(&collector.handle(), plan));
1599            for node in 0..shared.nodes.len() {
1600                crate::workers::worker::Worker::process_node_job_result(
1601                    0,
1602                    crate::executor::NodeJob {
1603                        epoch: 0,
1604                        plan: shared.clone(),
1605                        node: node as u32,
1606                    },
1607                );
1608            }
1609
1610            let folder_lock = folder.lock();
1611            let output = folder_lock.last_audio_outputs()[0].clone();
1612            assert!(
1613                output.iter().any(|s| (*s - 0.75).abs() < 1e-5),
1614                "folder output should contain the child-processed folder input signal, got {:?}",
1615                output.iter().take(8).collect::<Vec<_>>()
1616            );
1617        }
1618    }
1619
1620    #[cfg_attr(
1621        all(miri, target_os = "freebsd"),
1622        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1623    )]
1624    #[tokio::test]
1625    async fn remove_folder_track_deletes_descendants_recursively() {
1626        let (mut engine, mut client_rx) = make_engine_with_client();
1627        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1628        let child = Track::new_folder("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1629        let grandchild = Track::new("grandchild".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1630        insert_track(&mut engine, folder);
1631        insert_track(&mut engine, child);
1632        insert_track(&mut engine, grandchild);
1633
1634        engine
1635            .handle_request(Action::TrackSetParent {
1636                track_name: "child".to_string(),
1637                parent_name: Some("folder".to_string()),
1638            })
1639            .await;
1640        engine
1641            .handle_request(Action::TrackSetParent {
1642                track_name: "grandchild".to_string(),
1643                parent_name: Some("child".to_string()),
1644            })
1645            .await;
1646
1647        // Drain TrackSetParent notifications so we can inspect the removal notifications.
1648        while let Ok(Some(_)) =
1649            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1650        {}
1651
1652        engine
1653            .handle_request(Action::RemoveTrack("folder".to_string()))
1654            .await;
1655
1656        {
1657            let state = engine.state.lock();
1658            assert!(
1659                !state.tracks.contains_key("folder"),
1660                "folder should have been removed"
1661            );
1662            assert!(
1663                !state.tracks.contains_key("child"),
1664                "child should have been removed"
1665            );
1666            assert!(
1667                !state.tracks.contains_key("grandchild"),
1668                "grandchild should have been removed"
1669            );
1670        }
1671
1672        let mut removed_names = Vec::new();
1673        for _ in 0..3 {
1674            let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1675            if let Ok(Some(Message::Response(Ok(Action::RemoveTrack(name))))) = msg {
1676                removed_names.push(name);
1677            }
1678        }
1679        assert_eq!(
1680            removed_names,
1681            vec!["grandchild", "child", "folder"],
1682            "descendants should be removed before the folder and clients notified"
1683        );
1684    }
1685
1686    #[cfg_attr(
1687        all(miri, target_os = "freebsd"),
1688        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1689    )]
1690    #[tokio::test]
1691    async fn track_set_folder_rejects_master_track() {
1692        let (mut engine, mut client_rx) = make_engine_with_client();
1693        let track = Track::new("master".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1694        track.is_master.store(true, Ordering::Relaxed);
1695        insert_track(&mut engine, track);
1696
1697        engine
1698            .handle_request_inner(
1699                Action::TrackSetFolder {
1700                    track_name: "master".to_string(),
1701                    is_folder: true,
1702                },
1703                false,
1704            )
1705            .await;
1706
1707        {
1708            let state = engine.state.lock();
1709            assert!(!state.tracks.get("master").unwrap().lock().is_folder);
1710        }
1711
1712        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1713        assert!(
1714            matches!(msg, Ok(Some(Message::Response(Err(_))))),
1715            "master track folder conversion should report an error"
1716        );
1717    }
1718
1719    #[cfg_attr(
1720        all(miri, target_os = "freebsd"),
1721        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1722    )]
1723    #[tokio::test]
1724    async fn track_toggle_master_ignored_for_folder_track() {
1725        let (mut engine, mut client_rx) = make_engine_with_client();
1726        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1727        insert_track(&mut engine, folder);
1728
1729        engine
1730            .handle_request_inner(Action::TrackToggleMaster("folder".to_string()), false)
1731            .await;
1732
1733        {
1734            let state = engine.state.lock();
1735            assert!(!state.tracks.get("folder").unwrap().lock().is_master());
1736        }
1737
1738        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1739        assert!(
1740            matches!(
1741                msg,
1742                Ok(Some(Message::Response(Ok(Action::TrackToggleMaster(ref name)))))
1743                    if name == "folder"
1744            ),
1745            "folder track master toggle should still be echoed to clients: {msg:?}"
1746        );
1747    }
1748}