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, 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 = "macos")]
50use crate::workers::coreaudio_worker::HwWorker;
51#[cfg(target_os = "openbsd")]
52use crate::workers::sndio_worker::HwWorker;
53#[cfg(target_os = "windows")]
54use crate::workers::wasapi_worker::HwWorker;
55use crate::{
56    history::{History, UndoEntry},
57    kind::Kind,
58    message::{Action, HwMidiEvent, Message, MidiControllerData, MidiNoteData},
59    midi::io::MidiEvent,
60    osc::OscServer,
61    state::{State, StateSlot},
62    workers::worker::NodeJobResult,
63};
64
65struct RtProducer<T>(rtrb::Producer<T>);
66
67// Safety: each producer is owned only by the engine task and is never shared
68// with the consumer thread. The `Sync` impl only permits the engine future to
69// remain `Send` across `.await`; no concurrent access is introduced.
70unsafe impl<T: Send> Send for RtProducer<T> {}
71unsafe impl<T: Send> Sync for RtProducer<T> {}
72
73impl<T> RtProducer<T> {
74    fn push(&mut self, value: T) -> Result<(), rtrb::PushError<T>> {
75        self.0.push(value)
76    }
77}
78
79struct RtConsumer<T>(rtrb::Consumer<T>);
80
81// Safety: each consumer is owned only by the engine task and is never shared
82// with the producer thread. See `RtProducer` for the `Sync` rationale.
83unsafe impl<T: Send> Send for RtConsumer<T> {}
84unsafe impl<T: Send> Sync for RtConsumer<T> {}
85
86impl<T> RtConsumer<T> {
87    fn pop(&mut self) -> Result<T, rtrb::PopError> {
88        self.0.pop()
89    }
90}
91
92struct WorkerData {
93    tx: Sender<Message>,
94    handle: Option<JoinHandle<()>>,
95    node_job_tx: Option<RtProducer<crate::executor::NodeJob>>,
96    node_result_rx: Option<RtConsumer<NodeJobResult>>,
97    node_thread: Option<std::thread::Thread>,
98    node_quit: Option<Arc<AtomicBool>>,
99}
100
101impl WorkerData {
102    pub fn new(tx: Sender<Message>, handle: JoinHandle<()>) -> Self {
103        Self {
104            tx,
105            handle: Some(handle),
106            node_job_tx: None,
107            node_result_rx: None,
108            node_thread: None,
109            node_quit: None,
110        }
111    }
112
113    pub fn with_node_mailbox(
114        tx: Sender<Message>,
115        handle: JoinHandle<()>,
116        node_job_tx: rtrb::Producer<crate::executor::NodeJob>,
117        node_result_rx: rtrb::Consumer<NodeJobResult>,
118        node_thread: std::thread::Thread,
119        node_quit: Arc<AtomicBool>,
120    ) -> Self {
121        Self {
122            tx,
123            handle: Some(handle),
124            node_job_tx: Some(RtProducer(node_job_tx)),
125            node_result_rx: Some(RtConsumer(node_result_rx)),
126            node_thread: Some(node_thread),
127            node_quit: Some(node_quit),
128        }
129    }
130}
131
132impl Drop for WorkerData {
133    fn drop(&mut self) {
134        if let Some(quit) = &self.node_quit {
135            quit.store(true, std::sync::atomic::Ordering::Release);
136        }
137        if let Some(thread) = &self.node_thread {
138            thread.unpark();
139        }
140    }
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub(crate) struct HwDriverInfo {
145    pub cycle_samples: usize,
146    pub sample_rate: i32,
147    pub input_channels: usize,
148    pub output_channels: usize,
149    pub sample_bits: i32,
150    pub frame_size_bytes: usize,
151}
152
153#[derive(Debug, Clone)]
154pub(crate) struct RecordingSession {
155    start_sample: usize,
156    samples: Vec<f32>,
157    channels: usize,
158    file_name: String,
159
160    stripe_peaks: Vec<Vec<[f32; 2]>>,
161
162    current_stripe_frames: usize,
163}
164
165const RECORDING_STRIPE_FRAMES: usize = 256;
166
167#[derive(Debug, Clone)]
168pub(crate) struct MidiRecordingSession {
169    start_sample: usize,
170    events: Vec<(u64, Vec<u8>)>,
171    file_name: String,
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Hash)]
175struct MidiHwInRoute {
176    device: String,
177    to_track: String,
178    to_port: usize,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Hash)]
182struct MidiHwOutRoute {
183    from_track: String,
184    from_port: usize,
185    device: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Hash)]
189struct MidiHwThruRoute {
190    from_device: String,
191    to_device: String,
192}
193
194struct OfflineBounceJob {
195    cancel: Arc<AtomicBool>,
196}
197
198#[cfg(unix)]
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200enum JackTransportPlaySync {
201    Start,
202    Stop,
203}
204
205#[derive(Clone, Copy)]
206#[cfg(unix)]
207pub(crate) struct AudioOpenRequest<'a> {
208    device: &'a str,
209    input_device: Option<&'a str>,
210    sample_rate_hz: i32,
211    bits: i32,
212    exclusive: bool,
213    period_frames: usize,
214    nperiods: usize,
215    sync_mode: bool,
216}
217
218pub(crate) struct ClipAddRequest<'a> {
219    clip_id: &'a str,
220    name: &'a str,
221    track_name: &'a str,
222    start: usize,
223    length: usize,
224    offset: usize,
225    input_channel: usize,
226    muted: bool,
227    peaks_file: Option<String>,
228    kind: Kind,
229    fade_enabled: bool,
230    fade_in_samples: usize,
231    fade_out_samples: usize,
232    source_name: Option<String>,
233    source_offset: Option<usize>,
234    source_length: Option<usize>,
235    preview_name: Option<String>,
236    pitch_correction_points: Vec<crate::message::PitchCorrectionPointData>,
237    pitch_correction_frame_likeness: Option<f32>,
238    pitch_correction_inertia_ms: Option<u16>,
239    pitch_correction_formant_compensation: Option<bool>,
240    plugin_graph_json: Option<serde_json::Value>,
241}
242
243#[cfg(unix)]
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245pub(crate) struct JackTransportSyncDecision {
246    play_sync: Option<JackTransportPlaySync>,
247    position_sync: Option<usize>,
248}
249
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub(crate) enum MidiLearnSlot {
252    Track(String, crate::message::TrackMidiLearnTarget),
253    Global(crate::message::GlobalMidiLearnTarget),
254    Session(crate::message::SessionMidiLearnTarget),
255}
256
257pub struct Engine {
258    clients: Vec<Sender<Message>>,
259    rx: Receiver<Message>,
260    state: Arc<State>,
261    state_snapshot: Arc<StateSlot>,
262    tx: Sender<Message>,
263    workers: Vec<WorkerData>,
264    hw_driver: Option<HwDriver>,
265    hw_driver_info: Option<HwDriverInfo>,
266    hw_input_ports: Vec<Arc<crate::audio::io::AudioIO>>,
267    hw_output_ports: Vec<Arc<crate::audio::io::AudioIO>>,
268    #[cfg(unix)]
269    jack_runtime: Option<JackRuntime>,
270    midi_hub: Option<MidiHub>,
271    hw_worker: Option<WorkerData>,
272    osc_server: Option<OscServer>,
273    osc_reply_socket: Option<UdpSocket>,
274    osc_reply_target: Option<SocketAddr>,
275    pending_hw_midi_events: Vec<MidiEvent>,
276    pending_hw_midi_events_by_device: HashMap<String, Vec<MidiEvent>>,
277    pending_hw_midi_out_events: Vec<MidiEvent>,
278    pending_hw_midi_out_events_by_device: Vec<HwMidiEvent>,
279    active_hw_notes_by_track: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
280    active_hw_notes_cycle_start: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
281    midi_hw_in_routes: Vec<MidiHwInRoute>,
282    midi_hw_out_routes: Vec<MidiHwOutRoute>,
283    midi_hw_thru_routes: Vec<MidiHwThruRoute>,
284    ready_workers: Vec<usize>,
285    pending_requests: VecDeque<Action>,
286    awaiting_hwfinished: bool,
287    handling_hwfinished: bool,
288    transport_panic_flush_pending: bool,
289    transport_restart_pending: bool,
290    notified_loop_wrap_sample: Option<usize>,
291    transport_sample: usize,
292
293    hw_input_latency_frames: usize,
294
295    hw_output_latency_frames: usize,
296    loop_enabled: bool,
297    loop_range_samples: Option<(usize, usize)>,
298    metronome_enabled: bool,
299    tempo_bpm: f64,
300    tsig_num: u16,
301    tsig_denom: u16,
302    tempo_points: Vec<crate::message::TempoPoint>,
303    time_signature_points: Vec<crate::message::TimeSignaturePoint>,
304    punch_enabled: bool,
305    punch_range_samples: Option<(usize, usize)>,
306    audio_recordings: std::collections::HashMap<String, RecordingSession>,
307    midi_recordings: std::collections::HashMap<String, MidiRecordingSession>,
308    completed_audio_recordings: Vec<(String, RecordingSession)>,
309    completed_midi_recordings: Vec<(String, MidiRecordingSession)>,
310    playing: bool,
311    transport_running: bool,
312    clip_playback_enabled: bool,
313    session_clip_playback_enabled: bool,
314    session_transport_sample: usize,
315    /// Scene queued via [`crate::message::SessionAction::QueueScene`] as
316    /// (scene_index, launch_at_sample); `None` when no scene is queued.
317    session_scene_queue: Option<(usize, usize)>,
318    session_scene_queue_length_samples: usize,
319    /// Scene whose launch most recently fired; reported in the session
320    /// runtime snapshot so clients can highlight it as the current scene.
321    session_current_scene: Option<usize>,
322    session_current_scene_previous_scene: Option<usize>,
323    session_current_scene_start_sample: usize,
324    session_current_scene_length_samples: usize,
325    session_completed_clip_passes: Vec<crate::meter::SessionCompletedClipPass>,
326    session_reported_clip_passes: std::collections::HashSet<(String, usize, String, usize, usize)>,
327    record_enabled: bool,
328    step_recording_enabled: bool,
329    session_dir: Option<PathBuf>,
330    hw_out_level_db: f32,
331    hw_out_balance: f32,
332    hw_out_muted: bool,
333    last_hw_out_meter_publish: Option<Instant>,
334    #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
335    last_hw_out_meter_linear: Vec<f32>,
336    hw_out_peak_hold_linear: Vec<f32>,
337    #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
338    hw_out_meter_publish_phase: bool,
339    last_track_meter_publish: Option<Instant>,
340    last_meter_snapshot_publish: Option<Instant>,
341    last_session_report_publish: Option<Instant>,
342    track_meter_linear_by_track: HashMap<String, Vec<f32>>,
343    meter_snapshot_producer:
344        crate::triple_buffer::TripleBufferProducer<crate::meter::MeterSnapshot>,
345    transport_snapshot_producer:
346        crate::triple_buffer::TripleBufferProducer<crate::meter::TransportSnapshot>,
347    session_runtime_snapshot_producer:
348        crate::triple_buffer::TripleBufferProducer<crate::meter::SessionRuntimeSnapshot>,
349    /// Phase 2 render-plan machinery (see `LOCKLESS.md`): the executor
350    /// drives per-cycle node dispatch, the builder thread recompiles and
351    /// publishes plans, `pending_node_jobs` buffers jobs when no worker is
352    /// free, and `hw_ports` is the snapshot the builder compiles against.
353    executor: crate::executor::CycleExecutor,
354    plan_slot: Arc<crate::render_plan::PlanSlot>,
355    hw_ports: Arc<arc_swap::ArcSwap<crate::plan_builder::HwPorts>>,
356    pending_node_jobs: VecDeque<crate::executor::NodeJob>,
357    plan_builder: crate::plan_builder::PlanBuilder,
358    latest_hw_out_meter_db: Arc<Vec<f32>>,
359    latest_track_meter_snapshot: Arc<Vec<(String, Vec<f32>)>>,
360    hw_out_loudness_meter: Option<crate::loudness::LoudnessMeter>,
361    latest_hw_out_lufs: Option<crate::loudness::LoudnessValues>,
362    history: History,
363    history_group: Option<UndoEntry>,
364    history_suspended: bool,
365    offline_bounce_jobs: HashMap<String, OfflineBounceJob>,
366    /// Bounce jobs registered while a plan cycle was still in flight; the
367    /// work is handed to the reserved worker when the cycle completes
368    /// (`on_all_tracks_finished`), so the bounce never races RT workers.
369    pending_bounce_starts: Vec<(usize, crate::message::OfflineBounceWork)>,
370    /// Worker index → track name for in-flight bounce jobs; the worker's
371    /// terminal `Ready(id)` removes the job even on error/cancel paths
372    /// whose `OfflineBounceFinished` payload carries no track name.
373    bounce_worker_tracks: HashMap<usize, String>,
374    pending_midi_learn: Option<(String, crate::message::TrackMidiLearnTarget, Option<String>)>,
375    pending_global_midi_learn: Option<crate::message::GlobalMidiLearnTarget>,
376    pending_session_midi_learn: Option<crate::message::SessionMidiLearnTarget>,
377    global_midi_learn_play_pause: Option<crate::message::MidiLearnBinding>,
378    global_midi_learn_stop: Option<crate::message::MidiLearnBinding>,
379    global_midi_learn_record_toggle: Option<crate::message::MidiLearnBinding>,
380    session_midi_learn_slots: HashMap<(String, usize), crate::message::MidiLearnBinding>,
381    session_midi_learn_scenes: HashMap<usize, crate::message::MidiLearnBinding>,
382    session_midi_learn_stop_track: HashMap<String, crate::message::MidiLearnBinding>,
383    session_midi_learn_stop_all: Option<crate::message::MidiLearnBinding>,
384    midi_cc_gate: HashMap<(String, u8, u8), bool>,
385    modulators: Vec<crate::modulator::Modulator>,
386    modulator_values: Option<Arc<std::collections::HashMap<usize, f32>>>,
387}
388
389type MidiEditParseResult = (
390    Vec<MidiNoteData>,
391    Vec<MidiControllerData>,
392    Vec<(u64, Vec<u8>)>,
393);
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::audio::clip::AudioClip;
399    use crate::message::PluginKind;
400    use crate::midi::clip::MIDIClip;
401    use crate::track::Track;
402    use std::path::Path;
403    use std::sync::atomic::Ordering;
404    use std::time::Duration;
405    use tokio::sync::mpsc::channel;
406    use tokio::time::{Duration as TokioDuration, timeout};
407
408    #[test]
409    #[cfg(unix)]
410    fn jack_transport_sync_decision_starts_and_syncs_position_on_external_play() {
411        let decision = Engine::jack_transport_sync_decision(false, 128, true, 256, 64);
412
413        assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Start));
414        assert_eq!(decision.position_sync, Some(256));
415    }
416
417    #[test]
418    #[cfg(unix)]
419    fn jack_transport_sync_decision_stops_and_syncs_position_on_external_stop() {
420        let decision = Engine::jack_transport_sync_decision(true, 512, false, 96, 64);
421
422        assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Stop));
423        assert_eq!(decision.position_sync, Some(96));
424    }
425
426    #[test]
427    #[cfg(unix)]
428    fn jack_transport_sync_decision_ignores_small_rolling_drift() {
429        let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1040, 64);
430
431        assert_eq!(decision.play_sync, None);
432        assert_eq!(decision.position_sync, None);
433    }
434
435    #[test]
436    #[cfg(unix)]
437    fn jack_transport_sync_decision_syncs_large_rolling_jump() {
438        let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1200, 64);
439
440        assert_eq!(decision.play_sync, None);
441        assert_eq!(decision.position_sync, Some(1200));
442    }
443
444    #[test]
445    #[cfg(unix)]
446    fn jack_transport_sync_decision_syncs_locate_while_stopped() {
447        let decision = Engine::jack_transport_sync_decision(false, 400, false, 900, 64);
448
449        assert_eq!(decision.play_sync, None);
450        assert_eq!(decision.position_sync, Some(900));
451    }
452
453    fn make_engine_with_client() -> (Engine, tokio::sync::mpsc::Receiver<Message>) {
454        let (engine_tx, engine_rx) = channel(16);
455        let mut engine = Engine::new(engine_rx, engine_tx);
456        let (client_tx, client_rx) = channel(16);
457        engine.clients.push(client_tx);
458        (engine, client_rx)
459    }
460
461    fn insert_track(engine: &mut Engine, track: Track) {
462        engine
463            .state
464            .lock()
465            .tracks
466            .insert(track.name.clone(), Arc::new(track));
467        engine.publish_state_snapshot();
468        engine.plan_builder.mark_dirty();
469    }
470
471    fn insert_track_for_modulator_test(engine: &mut Engine, track: Track) {
472        engine
473            .state
474            .lock()
475            .tracks
476            .insert(track.name.clone(), Arc::new(track));
477        engine.publish_state_snapshot();
478    }
479
480    fn osc_packet(address: &str) -> Vec<u8> {
481        fn push_padded_osc_string(packet: &mut Vec<u8>, value: &str) {
482            packet.extend_from_slice(value.as_bytes());
483            packet.push(0);
484            while !packet.len().is_multiple_of(4) {
485                packet.push(0);
486            }
487        }
488
489        let mut packet = Vec::new();
490        push_padded_osc_string(&mut packet, address);
491        push_padded_osc_string(&mut packet, ",");
492        packet
493    }
494
495    #[cfg_attr(
496        all(miri, target_os = "freebsd"),
497        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
498    )]
499    #[tokio::test]
500    async fn set_osc_enabled_starts_and_stops_server() {
501        let (mut engine, _client_rx) = make_engine_with_client();
502
503        engine
504            .set_osc_enabled_with(true, |tx| OscServer::start_on_addr(tx, "127.0.0.1:0"))
505            .expect("start osc server on ephemeral port");
506        assert!(engine.osc_server.is_some());
507
508        engine
509            .set_osc_enabled_with(false, OscServer::start)
510            .expect("stop osc server");
511        assert!(engine.osc_server.is_none());
512    }
513
514    #[cfg_attr(
515        all(miri, target_os = "freebsd"),
516        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
517    )]
518    #[tokio::test]
519    async fn set_tempo_map_is_recorded_and_undone() {
520        let (mut engine, _client_rx) = make_engine_with_client();
521        let original_tempo_points = engine.tempo_points.clone();
522        let original_time_signature_points = engine.time_signature_points.clone();
523
524        let new_tempo_points = vec![crate::message::TempoPoint {
525            sample: 0,
526            bpm: 140.0,
527        }];
528        let new_time_signature_points = vec![crate::message::TimeSignaturePoint {
529            sample: 0,
530            numerator: 3,
531            denominator: 4,
532        }];
533
534        engine
535            .handle_request(Action::SetTempoMap {
536                tempo_points: new_tempo_points.clone(),
537                time_signature_points: new_time_signature_points.clone(),
538            })
539            .await;
540
541        assert_eq!(engine.tempo_points, new_tempo_points);
542        assert_eq!(engine.time_signature_points, new_time_signature_points);
543        assert_eq!(engine.tempo_bpm, 140.0);
544        assert_eq!(engine.tsig_num, 3);
545        assert_eq!(engine.tsig_denom, 4);
546        assert!(engine.history.is_dirty());
547
548        engine.handle_request(Action::Undo).await;
549
550        assert_eq!(engine.tempo_points, original_tempo_points);
551        assert_eq!(engine.time_signature_points, original_time_signature_points);
552        assert_eq!(engine.tempo_bpm, 120.0);
553        assert_eq!(engine.tsig_num, 4);
554        assert_eq!(engine.tsig_denom, 4);
555    }
556
557    #[cfg_attr(
558        all(miri, target_os = "freebsd"),
559        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
560    )]
561    #[tokio::test]
562    async fn osc_server_forwards_transport_packets_to_engine_channel() {
563        let (tx, mut rx) = channel(4);
564        let mut server =
565            OscServer::start_on_addr(tx, "127.0.0.1:0").expect("start osc test server");
566        let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind sender socket");
567        let packet = osc_packet("/transport/play");
568        socket
569            .send_to(&packet, server.listen_addr())
570            .expect("send osc packet");
571
572        let message = timeout(TokioDuration::from_secs(1), rx.recv())
573            .await
574            .expect("packet delivery timeout")
575            .expect("osc message");
576        match message {
577            Message::OscRequest {
578                action: Action::Play,
579                ..
580            } => {}
581            other => panic!("unexpected osc message: {other:?}"),
582        }
583
584        server.stop();
585    }
586
587    #[cfg_attr(
588        all(miri, target_os = "freebsd"),
589        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
590    )]
591    #[tokio::test]
592    async fn track_offline_bounce_rejects_zero_length_requests() {
593        let (mut engine, mut client_rx) = make_engine_with_client();
594        insert_track(
595            &mut engine,
596            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
597        );
598
599        engine
600            .handle_request(Action::TrackOfflineBounce {
601                track_name: "track".to_string(),
602                output_path: "/tmp/out.wav".to_string(),
603                start_sample: 0,
604                length_samples: 0,
605                automation_lanes: vec![],
606                apply_fader: false,
607            })
608            .await;
609
610        match client_rx.recv().await.expect("response") {
611            Message::Response(Err(err)) => {
612                assert!(err.contains("has no renderable content for offline bounce"));
613            }
614            other => panic!("unexpected message: {other:?}"),
615        }
616    }
617
618    #[cfg_attr(
619        all(miri, target_os = "freebsd"),
620        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
621    )]
622    #[tokio::test]
623    async fn track_offline_bounce_rejects_when_same_track_is_active() {
624        let (mut engine, mut client_rx) = make_engine_with_client();
625        engine.offline_bounce_jobs.insert(
626            "other".to_string(),
627            OfflineBounceJob {
628                cancel: Arc::new(AtomicBool::new(false)),
629            },
630        );
631
632        engine
633            .handle_request(Action::TrackOfflineBounce {
634                track_name: "other".to_string(),
635                output_path: "/tmp/out.wav".to_string(),
636                start_sample: 0,
637                length_samples: 128,
638                automation_lanes: vec![],
639                apply_fader: false,
640            })
641            .await;
642
643        match client_rx.recv().await.expect("response") {
644            Message::Response(Err(err)) => {
645                assert!(err.contains("already in progress"));
646            }
647            other => panic!("unexpected message: {other:?}"),
648        }
649    }
650
651    #[cfg_attr(
652        all(miri, target_os = "freebsd"),
653        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
654    )]
655    #[tokio::test]
656    async fn track_offline_bounce_allows_different_track_concurrently() {
657        let (mut engine, _client_rx) = make_engine_with_client();
658        insert_track(
659            &mut engine,
660            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
661        );
662        engine.offline_bounce_jobs.insert(
663            "other".to_string(),
664            OfflineBounceJob {
665                cancel: Arc::new(AtomicBool::new(false)),
666            },
667        );
668
669        engine
670            .handle_request(Action::TrackOfflineBounce {
671                track_name: "track".to_string(),
672                output_path: "/tmp/out.wav".to_string(),
673                start_sample: 0,
674                length_samples: 128,
675                automation_lanes: vec![],
676                apply_fader: false,
677            })
678            .await;
679
680        assert!(engine.offline_bounce_jobs.contains_key("other"));
681        assert_eq!(engine.pending_requests.len(), 1);
682    }
683
684    #[cfg_attr(
685        all(miri, target_os = "freebsd"),
686        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
687    )]
688    #[tokio::test]
689    async fn reject_if_track_frozen_sends_error_and_blocks_operation() {
690        let (mut engine, mut client_rx) = make_engine_with_client();
691        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
692        track.set_frozen(true);
693        insert_track(&mut engine, track);
694
695        let rejected = engine
696            .reject_if_track_frozen("track", "arming/disarming")
697            .await;
698
699        assert!(rejected);
700        match client_rx.recv().await.expect("response") {
701            Message::Response(Err(err)) => {
702                assert_eq!(err, "Track 'track' is frozen; arming/disarming is blocked");
703            }
704            other => panic!("unexpected message: {other:?}"),
705        }
706    }
707
708    #[cfg_attr(
709        all(miri, target_os = "freebsd"),
710        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
711    )]
712    #[tokio::test]
713    async fn dispatcher_track_mutations_publish_state_snapshot() {
714        let (mut engine, _client_rx) = make_engine_with_client();
715        insert_track(
716            &mut engine,
717            Track::new("snap".to_string(), 1, 1, 0, 0, 64, 48_000.0),
718        );
719        engine.publish_state_snapshot();
720
721        let snapshot = engine.state_snapshot.load_full();
722        assert!(snapshot.tracks.contains_key("snap"));
723
724        engine
725            .handle_request(Action::TrackToggleArm("snap".to_string()))
726            .await;
727
728        let snapshot = engine.state_snapshot.load_full();
729        assert!(snapshot.tracks.get("snap").unwrap().lock().armed());
730
731        engine
732            .handle_request(Action::RemoveTrack("snap".to_string()))
733            .await;
734
735        let snapshot = engine.state_snapshot.load_full();
736        assert!(!snapshot.tracks.contains_key("snap"));
737    }
738
739    #[cfg_attr(
740        all(miri, target_os = "freebsd"),
741        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
742    )]
743    #[tokio::test]
744    async fn undo_restores_original_clip_bounds_after_stretch_style_group() {
745        let (mut engine, _client_rx) = make_engine_with_client();
746        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
747        let mut clip = AudioClip::new("audio/original.wav".to_string(), 100, 220);
748        clip.offset = 12;
749        clip.fade_in_samples = 20;
750        clip.fade_out_samples = 30;
751        track.audio.push_clip(clip);
752        insert_track(&mut engine, track);
753
754        engine.handle_request(Action::BeginHistoryGroup).await;
755        engine
756            .handle_request(Action::SetClipBounds {
757                track_name: "track".to_string(),
758                clip_index: 0,
759                kind: Kind::Audio,
760                start: 120,
761                length: 180,
762                offset: 0,
763            })
764            .await;
765        engine
766            .handle_request(Action::SetClipSourceName {
767                track_name: "track".to_string(),
768                clip_index: 0,
769                kind: Kind::Audio,
770                name: "audio/stretched.wav".to_string(),
771            })
772            .await;
773        engine
774            .handle_request(Action::SetClipFade {
775                track_name: "track".to_string(),
776                clip_index: 0,
777                kind: Kind::Audio,
778                fade_enabled: true,
779                fade_in_samples: 12,
780                fade_out_samples: 12,
781            })
782            .await;
783        engine.handle_request(Action::EndHistoryGroup).await;
784
785        engine.handle_request(Action::Undo).await;
786
787        let state = engine.state.lock();
788        let track = state.tracks.get("track").expect("track exists").lock();
789        let clips = track.audio.clips();
790        let clip = clips.first().expect("clip exists");
791        assert_eq!(clip.name, "audio/original.wav");
792        assert_eq!(clip.start, 100);
793        assert_eq!(clip.end, 220);
794        assert_eq!(clip.end.saturating_sub(clip.start), 120);
795        assert_eq!(clip.offset, 12);
796    }
797
798    #[cfg_attr(
799        all(miri, target_os = "freebsd"),
800        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
801    )]
802    #[tokio::test]
803    async fn move_clip_to_unused_stores_clip_and_undo_restores_it() {
804        let (mut engine, _client_rx) = make_engine_with_client();
805        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
806        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
807        clip.id = "clip-1".to_string();
808        track.audio.push_clip(clip);
809        insert_track(&mut engine, track);
810
811        engine
812            .handle_request(Action::MoveClipToUnused {
813                track_name: "track".to_string(),
814                kind: Kind::Audio,
815                clip_indices: vec![0],
816            })
817            .await;
818
819        {
820            let state = engine.state.lock();
821            let track = state.tracks.get("track").expect("track exists").lock();
822            assert!(track.audio.clips().is_empty());
823            assert_eq!(state.unused_audio_clips.len(), 1);
824            assert_eq!(state.unused_audio_clips[0].id, "clip-1");
825            assert_eq!(state.unused_audio_clips[0].name, "audio/clip.wav");
826        }
827
828        engine.handle_request(Action::Undo).await;
829
830        let state = engine.state.lock();
831        let track = state.tracks.get("track").expect("track exists").lock();
832        let clips = track.audio.clips();
833        let clip = clips.first().expect("clip restored");
834        assert_eq!(clip.id, "clip-1");
835        assert_eq!(clip.name, "audio/clip.wav");
836        assert!(state.unused_audio_clips.is_empty());
837    }
838
839    #[cfg_attr(
840        all(miri, target_os = "freebsd"),
841        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
842    )]
843    #[tokio::test]
844    async fn move_clip_to_unused_keeps_slot_referenced_clip_in_session_pool() {
845        let (mut engine, _client_rx) = make_engine_with_client();
846        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
847        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
848        clip.id = "clip-1".to_string();
849        track.audio.push_clip(clip);
850        insert_track(&mut engine, track);
851
852        engine
853            .handle_request(Action::TrackSetSessionSlot {
854                track_name: "track".to_string(),
855                scene_index: 0,
856                clip_id: Some("clip-1".to_string()),
857            })
858            .await;
859        engine
860            .handle_request(Action::MoveClipToUnused {
861                track_name: "track".to_string(),
862                kind: Kind::Audio,
863                clip_indices: vec![0],
864            })
865            .await;
866
867        {
868            let state = engine.state.lock();
869            let track = state.tracks.get("track").expect("track exists").lock();
870            assert!(track.audio.clips().is_empty());
871            assert_eq!(state.unused_audio_clips.len(), 1);
872            // Still referenced by a session slot: playback fallback keeps it.
873            assert_eq!(track.rt.session_clip_pool_audio.len(), 1);
874            assert_eq!(track.rt.session_clip_pool_audio[0].id, "clip-1");
875        }
876
877        engine
878            .handle_request(Action::TrackSetSessionSlot {
879                track_name: "track".to_string(),
880                scene_index: 0,
881                clip_id: None,
882            })
883            .await;
884
885        let state = engine.state.lock();
886        let track = state.tracks.get("track").expect("track exists").lock();
887        assert!(track.rt.session_clip_pool_audio.is_empty());
888        assert_eq!(state.unused_audio_clips.len(), 1);
889    }
890
891    #[cfg_attr(
892        all(miri, target_os = "freebsd"),
893        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
894    )]
895    #[tokio::test]
896    async fn launch_clip_accepts_clip_from_session_pool() {
897        let (mut engine, _client_rx) = make_engine_with_client();
898        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
899        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
900        clip.id = "clip-1".to_string();
901        track.audio.push_clip(clip);
902        insert_track(&mut engine, track);
903
904        engine
905            .handle_request(Action::TrackSetSessionSlot {
906                track_name: "track".to_string(),
907                scene_index: 0,
908                clip_id: Some("clip-1".to_string()),
909            })
910            .await;
911        engine
912            .handle_request(Action::MoveClipToUnused {
913                track_name: "track".to_string(),
914                kind: Kind::Audio,
915                clip_indices: vec![0],
916            })
917            .await;
918
919        engine
920            .handle_request(Action::Session(crate::message::SessionAction::LaunchClip {
921                track_name: "track".to_string(),
922                scene_index: 0,
923                clip_id: "clip-1".to_string(),
924                launch_quantization: crate::message::LaunchQuantization::Bar,
925                loop_enabled: true,
926                loop_start_samples: 0,
927                loop_end_samples: 0,
928            }))
929            .await;
930
931        let state = engine.state.lock();
932        let track = state.tracks.get("track").expect("track exists").lock();
933        assert!(track.audio.clips().is_empty());
934        assert_eq!(track.rt.pending_session_launches.len(), 1);
935        assert_eq!(track.rt.pending_session_launches[0].clip_id, "clip-1");
936    }
937
938    #[cfg_attr(
939        all(miri, target_os = "freebsd"),
940        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
941    )]
942    #[tokio::test]
943    async fn launch_scene_accepts_clip_from_session_pool() {
944        let (mut engine, _client_rx) = make_engine_with_client();
945        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
946        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
947        clip.id = "clip-1".to_string();
948        track.audio.push_clip(clip);
949        insert_track(&mut engine, track);
950
951        engine
952            .handle_request(Action::TrackSetSessionSlot {
953                track_name: "track".to_string(),
954                scene_index: 0,
955                clip_id: Some("clip-1".to_string()),
956            })
957            .await;
958        engine
959            .handle_request(Action::MoveClipToUnused {
960                track_name: "track".to_string(),
961                kind: Kind::Audio,
962                clip_indices: vec![0],
963            })
964            .await;
965
966        engine
967            .handle_request(Action::Session(
968                crate::message::SessionAction::LaunchScene {
969                    scene_index: 0,
970                    launch_quantization: crate::message::LaunchQuantization::Bar,
971                },
972            ))
973            .await;
974
975        let state = engine.state.lock();
976        let track = state.tracks.get("track").expect("track exists").lock();
977        assert!(track.audio.clips().is_empty());
978        assert_eq!(track.rt.pending_session_launches.len(), 1);
979        assert_eq!(track.rt.pending_session_launches[0].clip_id, "clip-1");
980    }
981
982    #[cfg_attr(
983        all(miri, target_os = "freebsd"),
984        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
985    )]
986    #[tokio::test]
987    async fn stop_clears_playing_session_clips_and_pending_launches() {
988        let (mut engine, _client_rx) = make_engine_with_client();
989        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
990        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
991        clip.id = "clip-1".to_string();
992        track.audio.push_clip(clip);
993        insert_track(&mut engine, track);
994
995        engine
996            .handle_request(Action::Session(crate::message::SessionAction::LaunchClip {
997                track_name: "track".to_string(),
998                scene_index: 0,
999                clip_id: "clip-1".to_string(),
1000                launch_quantization: crate::message::LaunchQuantization::Bar,
1001                loop_enabled: true,
1002                loop_start_samples: 0,
1003                loop_end_samples: 0,
1004            }))
1005            .await;
1006        {
1007            let state = engine.state.lock();
1008            let mut track = state.tracks.get("track").expect("track exists").lock();
1009            track
1010                .rt
1011                .playing_session_clips
1012                .push(crate::track::PlayingSessionClip {
1013                    scene_index: 0,
1014                    clip_id: "clip-1".to_string(),
1015                    kind: Kind::Audio,
1016                    play_position_samples: 4,
1017                    elapsed_samples: 4,
1018                    loop_enabled: true,
1019                    loop_start_samples: 0,
1020                    loop_end_samples: 0,
1021                    stop_at_sample: None,
1022                    active_midi_notes: std::collections::HashSet::new(),
1023                });
1024        }
1025
1026        engine.handle_request(Action::Stop).await;
1027
1028        let state = engine.state.lock();
1029        let track = state.tracks.get("track").expect("track exists").lock();
1030        assert!(track.rt.playing_session_clips.is_empty());
1031        assert!(track.rt.pending_session_launches.is_empty());
1032    }
1033
1034    /// Shared setup for QueueScene tests: one track with a 200-sample clip
1035    /// playing in scene 0 (at position 150) and a 100-sample clip slotted in
1036    /// scene 1, session transport at 1_000.
1037    async fn engine_with_playing_and_slotted_clip() -> Engine {
1038        let (mut engine, _client_rx) = make_engine_with_client();
1039        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1040        let mut playing_clip = AudioClip::new("audio/playing.wav".to_string(), 0, 200);
1041        playing_clip.id = "clip-playing".to_string();
1042        let mut queued_clip = AudioClip::new("audio/queued.wav".to_string(), 0, 100);
1043        queued_clip.id = "clip-queued".to_string();
1044        track.audio.push_clip(playing_clip);
1045        track.audio.push_clip(queued_clip);
1046        insert_track(&mut engine, track);
1047
1048        for (scene_index, clip_id) in [(0, "clip-playing"), (1, "clip-queued")] {
1049            engine
1050                .handle_request(Action::TrackSetSessionSlot {
1051                    track_name: "track".to_string(),
1052                    scene_index,
1053                    clip_id: Some(clip_id.to_string()),
1054                })
1055                .await;
1056        }
1057
1058        engine.playing = true;
1059        engine.session_clip_playback_enabled = true;
1060        engine.session_transport_sample = 1_000;
1061        {
1062            let state = engine.state.lock();
1063            let mut track = state.tracks.get("track").expect("track exists").lock();
1064            track
1065                .rt
1066                .playing_session_clips
1067                .push(crate::track::PlayingSessionClip {
1068                    scene_index: 0,
1069                    clip_id: "clip-playing".to_string(),
1070                    kind: Kind::Audio,
1071                    play_position_samples: 150,
1072                    elapsed_samples: 150,
1073                    loop_enabled: true,
1074                    loop_start_samples: 0,
1075                    loop_end_samples: 0,
1076                    stop_at_sample: None,
1077                    active_midi_notes: std::collections::HashSet::new(),
1078                });
1079        }
1080        engine
1081    }
1082
1083    #[cfg_attr(
1084        all(miri, target_os = "freebsd"),
1085        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1086    )]
1087    #[tokio::test]
1088    async fn queue_scene_launches_when_longest_playing_clip_pass_ends() {
1089        let mut engine = engine_with_playing_and_slotted_clip().await;
1090
1091        engine
1092            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1093                scene_index: 1,
1094                launch_quantization: crate::message::LaunchQuantization::Bar,
1095            }))
1096            .await;
1097
1098        {
1099            let state = engine.state.lock();
1100            let track = state.tracks.get("track").expect("track exists").lock();
1101            // The 200-sample playing clip is at position 150, so the queued
1102            // scene fires 50 samples from now and the current clip stops
1103            // then.
1104            assert_eq!(track.rt.pending_session_launches.len(), 1);
1105            let launch = &track.rt.pending_session_launches[0];
1106            assert_eq!(launch.scene_index, 1);
1107            assert_eq!(launch.clip_id, "clip-queued");
1108            assert_eq!(launch.launch_at_sample, 1_050);
1109            assert_eq!(
1110                track.rt.playing_session_clips[0].stop_at_sample,
1111                Some(1_050)
1112            );
1113        }
1114        assert_eq!(engine.session_scene_queue, Some((1, 1_050)));
1115    }
1116
1117    #[cfg_attr(
1118        all(miri, target_os = "freebsd"),
1119        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1120    )]
1121    #[tokio::test]
1122    async fn queue_scene_unmarked_slot_keeps_previous_clip_when_inheriting_play() {
1123        let mut engine = engine_with_playing_and_slotted_clip().await;
1124        // Scene 1 slot is unmarked (neither play nor stop) but has its own
1125        // clip; scene 0 slot keeps its default play mark. Inheriting play
1126        // plays the previous scene's clip — the slot's own clip is not
1127        // launched and the playing clip is not stopped.
1128        engine
1129            .handle_request(Action::TrackSetSessionSlotPlayEnabled {
1130                track_name: "track".to_string(),
1131                scene_index: 1,
1132                enabled: false,
1133            })
1134            .await;
1135
1136        engine
1137            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1138                scene_index: 1,
1139                launch_quantization: crate::message::LaunchQuantization::Bar,
1140            }))
1141            .await;
1142
1143        let state = engine.state.lock();
1144        let track = state.tracks.get("track").expect("track exists").lock();
1145        assert!(track.rt.pending_session_launches.is_empty());
1146        assert_eq!(track.rt.playing_session_clips[0].stop_at_sample, None);
1147    }
1148
1149    #[cfg_attr(
1150        all(miri, target_os = "freebsd"),
1151        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1152    )]
1153    #[tokio::test]
1154    async fn queue_scene_inherited_play_starts_previous_clip_when_track_silent() {
1155        let mut engine = engine_with_playing_and_slotted_clip().await;
1156        // The track is silent but scene 0 was the last current scene and
1157        // its slot is play-marked; scene 1's slot is unmarked and clipless.
1158        // Inheriting play starts the previous scene's clip.
1159        {
1160            let state = engine.state.lock();
1161            state
1162                .tracks
1163                .get("track")
1164                .expect("track exists")
1165                .lock()
1166                .rt
1167                .playing_session_clips
1168                .clear();
1169        }
1170        engine.session_current_scene = Some(0);
1171        engine
1172            .handle_request(Action::TrackSetSessionSlot {
1173                track_name: "track".to_string(),
1174                scene_index: 1,
1175                clip_id: None,
1176            })
1177            .await;
1178
1179        engine
1180            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1181                scene_index: 1,
1182                launch_quantization: crate::message::LaunchQuantization::Bar,
1183            }))
1184            .await;
1185
1186        let state = engine.state.lock();
1187        let track = state.tracks.get("track").expect("track exists").lock();
1188        assert_eq!(track.rt.pending_session_launches.len(), 1);
1189        let launch = &track.rt.pending_session_launches[0];
1190        assert_eq!(launch.clip_id, "clip-playing");
1191        assert_eq!(launch.scene_index, 0);
1192        // A current scene marker exists, so the queued scene waits until
1193        // the current scene boundary even though the track is silent.
1194        assert_eq!(launch.launch_at_sample, 96_000);
1195    }
1196
1197    #[cfg_attr(
1198        all(miri, target_os = "freebsd"),
1199        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1200    )]
1201    #[tokio::test]
1202    async fn queue_scene_unmarked_slot_inherits_stop_from_playing_scene() {
1203        let mut engine = engine_with_playing_and_slotted_clip().await;
1204        // Scene 0 slot is marked stop; scene 1 slot is unmarked.
1205        engine
1206            .handle_request(Action::TrackSetSessionSlotPlayEnabled {
1207                track_name: "track".to_string(),
1208                scene_index: 0,
1209                enabled: false,
1210            })
1211            .await;
1212        engine
1213            .handle_request(Action::TrackSetSessionSlotStopEnabled {
1214                track_name: "track".to_string(),
1215                scene_index: 0,
1216                enabled: true,
1217            })
1218            .await;
1219        engine
1220            .handle_request(Action::TrackSetSessionSlotPlayEnabled {
1221                track_name: "track".to_string(),
1222                scene_index: 1,
1223                enabled: false,
1224            })
1225            .await;
1226
1227        engine
1228            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1229                scene_index: 1,
1230                launch_quantization: crate::message::LaunchQuantization::Bar,
1231            }))
1232            .await;
1233
1234        let state = engine.state.lock();
1235        let track = state.tracks.get("track").expect("track exists").lock();
1236        assert!(track.rt.pending_session_launches.is_empty());
1237        assert_eq!(
1238            track.rt.playing_session_clips[0].stop_at_sample,
1239            Some(1_050)
1240        );
1241    }
1242
1243    #[cfg_attr(
1244        all(miri, target_os = "freebsd"),
1245        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1246    )]
1247    #[tokio::test]
1248    async fn queue_scene_unmarked_slot_continues_when_previous_scene_also_unmarked() {
1249        let mut engine = engine_with_playing_and_slotted_clip().await;
1250        // Both scenes' slots are unmarked: the track keeps playing whatever
1251        // it was playing through the scene switch.
1252        for scene_index in [0, 1] {
1253            engine
1254                .handle_request(Action::TrackSetSessionSlotPlayEnabled {
1255                    track_name: "track".to_string(),
1256                    scene_index,
1257                    enabled: false,
1258                })
1259                .await;
1260        }
1261
1262        engine
1263            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1264                scene_index: 1,
1265                launch_quantization: crate::message::LaunchQuantization::Bar,
1266            }))
1267            .await;
1268
1269        let state = engine.state.lock();
1270        let track = state.tracks.get("track").expect("track exists").lock();
1271        assert!(track.rt.pending_session_launches.is_empty());
1272        assert_eq!(track.rt.playing_session_clips[0].stop_at_sample, None);
1273    }
1274
1275    #[cfg_attr(
1276        all(miri, target_os = "freebsd"),
1277        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1278    )]
1279    #[tokio::test]
1280    async fn queue_scene_inherited_play_without_clip_keeps_current_clip() {
1281        let mut engine = engine_with_playing_and_slotted_clip().await;
1282        // Scene 1's slot is unmarked and has no clip (removed entirely).
1283        // The previous scene's slot is play-marked, so the inherited
1284        // behavior is "play" — with no clip of its own, the currently
1285        // playing clip keeps going instead of the track going silent.
1286        engine
1287            .handle_request(Action::TrackSetSessionSlot {
1288                track_name: "track".to_string(),
1289                scene_index: 1,
1290                clip_id: None,
1291            })
1292            .await;
1293
1294        engine
1295            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1296                scene_index: 1,
1297                launch_quantization: crate::message::LaunchQuantization::Bar,
1298            }))
1299            .await;
1300
1301        let state = engine.state.lock();
1302        let track = state.tracks.get("track").expect("track exists").lock();
1303        assert!(track.rt.pending_session_launches.is_empty());
1304        assert_eq!(track.rt.playing_session_clips[0].stop_at_sample, None);
1305    }
1306
1307    #[cfg_attr(
1308        all(miri, target_os = "freebsd"),
1309        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1310    )]
1311    #[tokio::test]
1312    async fn queue_scene_replaces_previous_queue() {
1313        let mut engine = engine_with_playing_and_slotted_clip().await;
1314        for scene_index in [1, 0] {
1315            engine
1316                .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1317                    scene_index,
1318                    launch_quantization: crate::message::LaunchQuantization::Bar,
1319                }))
1320                .await;
1321        }
1322
1323        {
1324            let state = engine.state.lock();
1325            let track = state.tracks.get("track").expect("track exists").lock();
1326            // Only the replacement scene's launch remains pending.
1327            assert_eq!(track.rt.pending_session_launches.len(), 1);
1328            let launch = &track.rt.pending_session_launches[0];
1329            assert_eq!(launch.scene_index, 0);
1330            assert_eq!(launch.launch_at_sample, 1_050);
1331            assert_eq!(
1332                track.rt.playing_session_clips[0].stop_at_sample,
1333                Some(1_050)
1334            );
1335        }
1336        assert_eq!(engine.session_scene_queue, Some((0, 1_050)));
1337    }
1338
1339    #[cfg_attr(
1340        all(miri, target_os = "freebsd"),
1341        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1342    )]
1343    #[tokio::test]
1344    async fn queue_scene_fire_marks_scene_as_current() {
1345        let mut engine = engine_with_playing_and_slotted_clip().await;
1346        engine
1347            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1348                scene_index: 1,
1349                launch_quantization: crate::message::LaunchQuantization::Bar,
1350            }))
1351            .await;
1352        assert_eq!(engine.session_current_scene, None);
1353
1354        // Simulate the fire: the pending launch is consumed and the
1355        // scheduled stop has happened, so nothing references the queue's
1356        // launch time anymore.
1357        {
1358            let state = engine.state_snapshot.load_full();
1359            let mut track = state.tracks.get("track").expect("track exists").lock();
1360            track.rt.pending_session_launches.clear();
1361            track.rt.playing_session_clips[0].stop_at_sample = None;
1362        }
1363        engine.session_transport_sample = 1_050;
1364        engine.publish_session_runtime_reports().await;
1365
1366        assert_eq!(engine.session_scene_queue, None);
1367        assert_eq!(engine.session_current_scene, Some(1));
1368    }
1369
1370    #[cfg_attr(
1371        all(miri, target_os = "freebsd"),
1372        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1373    )]
1374    #[tokio::test]
1375    async fn queue_scene_clipless_stop_marked_slot_stops_track() {
1376        let mut engine = engine_with_playing_and_slotted_clip().await;
1377        // Scene 1's slot is clipless but stop-marked. The mark action must
1378        // create the slot so the stop survives without a clip.
1379        engine
1380            .handle_request(Action::TrackSetSessionSlot {
1381                track_name: "track".to_string(),
1382                scene_index: 1,
1383                clip_id: None,
1384            })
1385            .await;
1386        engine
1387            .handle_request(Action::TrackSetSessionSlotStopEnabled {
1388                track_name: "track".to_string(),
1389                scene_index: 1,
1390                enabled: true,
1391            })
1392            .await;
1393
1394        engine
1395            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1396                scene_index: 1,
1397                launch_quantization: crate::message::LaunchQuantization::Bar,
1398            }))
1399            .await;
1400
1401        let state = engine.state.lock();
1402        let track = state.tracks.get("track").expect("track exists").lock();
1403        assert!(track.rt.pending_session_launches.is_empty());
1404        assert_eq!(
1405            track.rt.playing_session_clips[0].stop_at_sample,
1406            Some(1_050)
1407        );
1408    }
1409
1410    #[cfg_attr(
1411        all(miri, target_os = "freebsd"),
1412        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1413    )]
1414    #[tokio::test]
1415    async fn queue_scene_marker_holds_until_launch_time_when_nothing_scheduled() {
1416        let mut engine = engine_with_playing_and_slotted_clip().await;
1417        // Scene 1's slot is unmarked and clipless; the playing scene's slot
1418        // is play-marked, so the track continues and nothing is scheduled.
1419        engine
1420            .handle_request(Action::TrackSetSessionSlot {
1421                track_name: "track".to_string(),
1422                scene_index: 1,
1423                clip_id: None,
1424            })
1425            .await;
1426        engine
1427            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1428                scene_index: 1,
1429                launch_quantization: crate::message::LaunchQuantization::Bar,
1430            }))
1431            .await;
1432        assert_eq!(engine.session_scene_queue, Some((1, 1_050)));
1433
1434        // The session transport (1_000) has not reached the launch time:
1435        // the marker holds and the scene is not current yet.
1436        engine.publish_session_runtime_reports().await;
1437        assert_eq!(engine.session_scene_queue, Some((1, 1_050)));
1438        assert_eq!(engine.session_current_scene, None);
1439
1440        // Once the transport passes the launch time, the scene becomes
1441        // current even though nothing was scheduled.
1442        engine.session_transport_sample = 1_050;
1443        engine.last_session_report_publish = None;
1444        engine.publish_session_runtime_reports().await;
1445        assert_eq!(engine.session_scene_queue, None);
1446        assert_eq!(engine.session_current_scene, Some(1));
1447    }
1448
1449    #[cfg_attr(
1450        all(miri, target_os = "freebsd"),
1451        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1452    )]
1453    #[tokio::test]
1454    async fn queue_scene_with_nothing_playing_launches_immediately() {
1455        let (mut engine, _client_rx) = make_engine_with_client();
1456        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1457        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
1458        clip.id = "clip-1".to_string();
1459        track.audio.push_clip(clip);
1460        insert_track(&mut engine, track);
1461        engine
1462            .handle_request(Action::TrackSetSessionSlot {
1463                track_name: "track".to_string(),
1464                scene_index: 0,
1465                clip_id: Some("clip-1".to_string()),
1466            })
1467            .await;
1468        engine.playing = true;
1469        engine.session_clip_playback_enabled = true;
1470        engine.session_transport_sample = 500;
1471
1472        engine
1473            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1474                scene_index: 0,
1475                launch_quantization: crate::message::LaunchQuantization::Bar,
1476            }))
1477            .await;
1478
1479        let state = engine.state.lock();
1480        let track = state.tracks.get("track").expect("track exists").lock();
1481        assert_eq!(track.rt.pending_session_launches.len(), 1);
1482        assert_eq!(track.rt.pending_session_launches[0].launch_at_sample, 500);
1483    }
1484
1485    #[cfg_attr(
1486        all(miri, target_os = "freebsd"),
1487        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1488    )]
1489    #[tokio::test]
1490    async fn queue_scene_after_clipless_current_scene_waits_for_snap_length() {
1491        let (mut engine, _client_rx) = make_engine_with_client();
1492        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1493        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
1494        clip.id = "clip-1".to_string();
1495        track.audio.push_clip(clip);
1496        insert_track(&mut engine, track);
1497        engine
1498            .handle_request(Action::TrackSetSessionSlot {
1499                track_name: "track".to_string(),
1500                scene_index: 1,
1501                clip_id: Some("clip-1".to_string()),
1502            })
1503            .await;
1504
1505        engine.playing = true;
1506        engine.session_clip_playback_enabled = true;
1507        engine.session_transport_sample = 600;
1508        engine.session_current_scene = Some(0);
1509        engine.session_current_scene_start_sample = 500;
1510
1511        engine
1512            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1513                scene_index: 1,
1514                launch_quantization: crate::message::LaunchQuantization::Beat,
1515            }))
1516            .await;
1517
1518        let expected_launch = 500 + 24_000;
1519        let state = engine.state.lock();
1520        let track = state.tracks.get("track").expect("track exists").lock();
1521        assert_eq!(track.rt.pending_session_launches.len(), 1);
1522        assert_eq!(
1523            track.rt.pending_session_launches[0].launch_at_sample,
1524            expected_launch
1525        );
1526        assert_eq!(
1527            engine.session_scene_queue,
1528            Some((1, expected_launch)),
1529            "clipless current scene should hold for the selected snap length"
1530        );
1531    }
1532
1533    #[cfg_attr(
1534        all(miri, target_os = "freebsd"),
1535        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1536    )]
1537    #[tokio::test]
1538    async fn queue_scene_current_scene_length_uses_longest_clip_when_longer_than_snap() {
1539        let (mut engine, _client_rx) = make_engine_with_client();
1540        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1541        let mut current_clip = AudioClip::new("audio/current.wav".to_string(), 0, 36_000);
1542        current_clip.id = "clip-current".to_string();
1543        let mut queued_clip = AudioClip::new("audio/queued.wav".to_string(), 0, 12_000);
1544        queued_clip.id = "clip-queued".to_string();
1545        track.audio.push_clip(current_clip);
1546        track.audio.push_clip(queued_clip);
1547        insert_track(&mut engine, track);
1548        for (scene_index, clip_id) in [(0, "clip-current"), (1, "clip-queued")] {
1549            engine
1550                .handle_request(Action::TrackSetSessionSlot {
1551                    track_name: "track".to_string(),
1552                    scene_index,
1553                    clip_id: Some(clip_id.to_string()),
1554                })
1555                .await;
1556        }
1557
1558        engine.playing = true;
1559        engine.session_clip_playback_enabled = true;
1560        engine.session_transport_sample = 10_000;
1561        engine.session_current_scene = Some(0);
1562        engine.session_current_scene_start_sample = 1_000;
1563
1564        engine
1565            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1566                scene_index: 1,
1567                launch_quantization: crate::message::LaunchQuantization::Beat,
1568            }))
1569            .await;
1570
1571        let expected_launch = 1_000 + 36_000;
1572        let state = engine.state.lock();
1573        let track = state.tracks.get("track").expect("track exists").lock();
1574        assert_eq!(track.rt.pending_session_launches.len(), 1);
1575        assert_eq!(
1576            track.rt.pending_session_launches[0].launch_at_sample,
1577            expected_launch
1578        );
1579        assert_eq!(engine.session_scene_queue, Some((1, expected_launch)));
1580    }
1581
1582    #[cfg_attr(
1583        all(miri, target_os = "freebsd"),
1584        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1585    )]
1586    #[tokio::test]
1587    async fn queued_scene_length_includes_inherited_clip_when_longer_than_scene_clip() {
1588        let (mut engine, _client_rx) = make_engine_with_client();
1589        let synth = Track::new("Synth".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1590        let mut audio_clip = AudioClip::new("audio/synth.wav".to_string(), 0, 48_000);
1591        audio_clip.id = "audio-clip".to_string();
1592        synth.audio.push_clip(audio_clip);
1593        insert_track(&mut engine, synth);
1594
1595        let midi_track = Track::new("sdfv".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1596        let midi_clip = MIDIClip {
1597            id: "midi-clip".to_string(),
1598            name: "midi/sdfv.mid".to_string(),
1599            start: 0,
1600            end: 12_000,
1601            ..MIDIClip::default()
1602        };
1603        midi_track.midi.push_clip(midi_clip);
1604        insert_track(&mut engine, midi_track);
1605
1606        engine
1607            .handle_request(Action::TrackSetSessionSlot {
1608                track_name: "Synth".to_string(),
1609                scene_index: 0,
1610                clip_id: Some("audio-clip".to_string()),
1611            })
1612            .await;
1613        engine
1614            .handle_request(Action::TrackSetSessionSlot {
1615                track_name: "sdfv".to_string(),
1616                scene_index: 1,
1617                clip_id: Some("midi-clip".to_string()),
1618            })
1619            .await;
1620
1621        engine.playing = true;
1622        engine.session_clip_playback_enabled = true;
1623        engine.session_transport_sample = 10_000;
1624        engine.session_current_scene = Some(0);
1625        engine.session_current_scene_start_sample = 0;
1626        engine.session_current_scene_length_samples = 48_000;
1627
1628        engine
1629            .handle_request(Action::Session(crate::message::SessionAction::QueueScene {
1630                scene_index: 1,
1631                launch_quantization: crate::message::LaunchQuantization::Beat,
1632            }))
1633            .await;
1634
1635        assert_eq!(engine.session_scene_queue_length_samples, 48_000);
1636    }
1637
1638    #[cfg_attr(
1639        all(miri, target_os = "freebsd"),
1640        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1641    )]
1642    #[tokio::test]
1643    async fn session_runtime_reports_completed_scene_span_not_each_short_clip_loop() {
1644        let (mut engine, _client_rx) = make_engine_with_client();
1645        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1646        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 0, 1_000);
1647        clip.id = "clip-1".to_string();
1648        track.audio.push_clip(clip);
1649        insert_track(&mut engine, track);
1650        engine
1651            .handle_request(Action::TrackSetSessionSlot {
1652                track_name: "track".to_string(),
1653                scene_index: 0,
1654                clip_id: Some("clip-1".to_string()),
1655            })
1656            .await;
1657        engine.session_transport_sample = 1_000;
1658        engine.session_current_scene = Some(0);
1659        engine.session_current_scene_start_sample = 0;
1660        engine.session_current_scene_length_samples = 1_000;
1661        {
1662            let state = engine.state.lock();
1663            let mut track = state.tracks.get("track").expect("track exists").lock();
1664            track
1665                .rt
1666                .playing_session_clips
1667                .push(crate::track::PlayingSessionClip {
1668                    scene_index: 0,
1669                    clip_id: "clip-1".to_string(),
1670                    kind: Kind::Audio,
1671                    play_position_samples: 0,
1672                    elapsed_samples: 1_000,
1673                    loop_enabled: true,
1674                    loop_start_samples: 0,
1675                    loop_end_samples: 0,
1676                    stop_at_sample: None,
1677                    active_midi_notes: std::collections::HashSet::new(),
1678                });
1679        }
1680
1681        engine.publish_session_runtime_reports().await;
1682
1683        assert_eq!(engine.session_completed_clip_passes.len(), 1);
1684        let pass = &engine.session_completed_clip_passes[0];
1685        assert_eq!(pass.scene_index, 0);
1686        assert_eq!(pass.clip_id, "clip-1");
1687        assert_eq!(pass.start_sample, 0);
1688        assert_eq!(pass.length_samples, 1_000);
1689    }
1690
1691    #[cfg_attr(
1692        all(miri, target_os = "freebsd"),
1693        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1694    )]
1695    #[tokio::test]
1696    async fn session_runtime_reports_repeated_scene_occurrences_as_separate_passes() {
1697        let (mut engine, _client_rx) = make_engine_with_client();
1698        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1699        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 0, 1_000);
1700        clip.id = "clip-1".to_string();
1701        track.audio.push_clip(clip);
1702        insert_track(&mut engine, track);
1703        engine
1704            .handle_request(Action::TrackSetSessionSlot {
1705                track_name: "track".to_string(),
1706                scene_index: 0,
1707                clip_id: Some("clip-1".to_string()),
1708            })
1709            .await;
1710        {
1711            let state = engine.state.lock();
1712            let mut track = state.tracks.get("track").expect("track exists").lock();
1713            track
1714                .rt
1715                .playing_session_clips
1716                .push(crate::track::PlayingSessionClip {
1717                    scene_index: 0,
1718                    clip_id: "clip-1".to_string(),
1719                    kind: Kind::Audio,
1720                    play_position_samples: 0,
1721                    elapsed_samples: 1_000,
1722                    loop_enabled: true,
1723                    loop_start_samples: 0,
1724                    loop_end_samples: 0,
1725                    stop_at_sample: None,
1726                    active_midi_notes: std::collections::HashSet::new(),
1727                });
1728        }
1729
1730        engine.session_current_scene = Some(0);
1731        engine.session_current_scene_start_sample = 0;
1732        engine.session_current_scene_length_samples = 1_000;
1733        engine.session_transport_sample = 1_000;
1734        engine.publish_session_runtime_reports().await;
1735
1736        engine.session_current_scene = Some(0);
1737        engine.session_current_scene_start_sample = 2_000;
1738        engine.session_current_scene_length_samples = 1_000;
1739        engine.session_transport_sample = 3_000;
1740        engine.publish_session_runtime_reports().await;
1741
1742        let starts: Vec<_> = engine
1743            .session_completed_clip_passes
1744            .iter()
1745            .map(|pass| pass.start_sample)
1746            .collect();
1747        assert_eq!(starts, vec![0, 2_000]);
1748    }
1749
1750    #[cfg_attr(
1751        all(miri, target_os = "freebsd"),
1752        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1753    )]
1754    #[tokio::test]
1755    async fn session_runtime_reports_scene_that_ended_at_queued_launch_boundary() {
1756        let (mut engine, _client_rx) = make_engine_with_client();
1757        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1758        let mut scene_1_clip = AudioClip::new("audio/scene-1.wav".to_string(), 0, 1_000);
1759        scene_1_clip.id = "clip-scene-1".to_string();
1760        let mut scene_2_clip = AudioClip::new("audio/scene-2.wav".to_string(), 0, 1_000);
1761        scene_2_clip.id = "clip-scene-2".to_string();
1762        track.audio.push_clip(scene_1_clip);
1763        track.audio.push_clip(scene_2_clip);
1764        insert_track(&mut engine, track);
1765        for (scene_index, clip_id) in [(0, "clip-scene-1"), (1, "clip-scene-2")] {
1766            engine
1767                .handle_request(Action::TrackSetSessionSlot {
1768                    track_name: "track".to_string(),
1769                    scene_index,
1770                    clip_id: Some(clip_id.to_string()),
1771                })
1772                .await;
1773        }
1774
1775        engine.session_current_scene = Some(0);
1776        engine.session_current_scene_start_sample = 0;
1777        engine.session_current_scene_length_samples = 1_000;
1778        engine.session_scene_queue = Some((1, 1_000));
1779        engine.session_scene_queue_length_samples = 1_000;
1780        engine.session_transport_sample = 1_000;
1781
1782        engine.publish_session_runtime_reports().await;
1783
1784        assert_eq!(engine.session_current_scene, Some(1));
1785        assert_eq!(engine.session_completed_clip_passes.len(), 1);
1786        let pass = &engine.session_completed_clip_passes[0];
1787        assert_eq!(pass.scene_index, 0);
1788        assert_eq!(pass.clip_id, "clip-scene-1");
1789        assert_eq!(pass.start_sample, 0);
1790        assert_eq!(pass.length_samples, 1_000);
1791    }
1792
1793    #[cfg_attr(
1794        all(miri, target_os = "freebsd"),
1795        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1796    )]
1797    #[tokio::test]
1798    async fn session_runtime_reports_inherited_clip_and_own_length_midi_passes() {
1799        let (mut engine, _client_rx) = make_engine_with_client();
1800        let synth = Track::new("Synth".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1801        let mut audio_clip = AudioClip::new("audio/synth.wav".to_string(), 0, 48_000);
1802        audio_clip.id = "audio-clip".to_string();
1803        synth.audio.push_clip(audio_clip);
1804        insert_track(&mut engine, synth);
1805
1806        let midi_track = Track::new("sdfv".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1807        let midi_clip = MIDIClip {
1808            id: "midi-clip".to_string(),
1809            name: "midi/sdfv.mid".to_string(),
1810            start: 0,
1811            end: 24_000,
1812            ..MIDIClip::default()
1813        };
1814        midi_track.midi.push_clip(midi_clip);
1815        insert_track(&mut engine, midi_track);
1816
1817        engine
1818            .handle_request(Action::TrackSetSessionSlot {
1819                track_name: "Synth".to_string(),
1820                scene_index: 0,
1821                clip_id: Some("audio-clip".to_string()),
1822            })
1823            .await;
1824        engine
1825            .handle_request(Action::TrackSetSessionSlot {
1826                track_name: "sdfv".to_string(),
1827                scene_index: 1,
1828                clip_id: Some("midi-clip".to_string()),
1829            })
1830            .await;
1831
1832        engine.session_current_scene = Some(1);
1833        engine.session_current_scene_previous_scene = Some(0);
1834        engine.session_current_scene_start_sample = 48_000;
1835        engine.session_current_scene_length_samples = 48_000;
1836        engine.session_transport_sample = 96_000;
1837        engine.publish_session_runtime_reports().await;
1838
1839        let mut passes: Vec<_> = engine
1840            .session_completed_clip_passes
1841            .iter()
1842            .map(|pass| {
1843                (
1844                    pass.track_name.as_str(),
1845                    pass.clip_id.as_str(),
1846                    pass.start_sample,
1847                    pass.length_samples,
1848                )
1849            })
1850            .collect();
1851        passes.sort_unstable();
1852        assert_eq!(
1853            passes,
1854            vec![
1855                ("Synth", "audio-clip", 48_000, 48_000),
1856                ("sdfv", "midi-clip", 48_000, 24_000),
1857                ("sdfv", "midi-clip", 72_000, 24_000),
1858            ]
1859        );
1860    }
1861
1862    #[cfg_attr(
1863        all(miri, target_os = "freebsd"),
1864        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1865    )]
1866    #[tokio::test]
1867    async fn session_play_clears_stale_scene_state_before_recording() {
1868        let (mut engine, _client_rx) = make_engine_with_client();
1869        let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1870        let mut clip = AudioClip::new("audio/scene-1.wav".to_string(), 0, 1_000);
1871        clip.id = "clip-scene-1".to_string();
1872        track.audio.push_clip(clip);
1873        insert_track(&mut engine, track);
1874        engine
1875            .handle_request(Action::TrackSetSessionSlot {
1876                track_name: "track".to_string(),
1877                scene_index: 0,
1878                clip_id: Some("clip-scene-1".to_string()),
1879            })
1880            .await;
1881
1882        engine.session_current_scene = Some(1);
1883        engine.session_current_scene_previous_scene = Some(0);
1884        engine.session_current_scene_start_sample = 500;
1885        engine.session_current_scene_length_samples = 1_000;
1886        engine.session_scene_queue = Some((0, 1_000));
1887        engine.session_scene_queue_length_samples = 1_000;
1888        engine.session_transport_sample = 500;
1889        engine
1890            .session_completed_clip_passes
1891            .push(crate::meter::SessionCompletedClipPass {
1892                track_name: "track".to_string(),
1893                scene_index: 1,
1894                clip_id: "stale-clip".to_string(),
1895                pass_index: 0,
1896                start_sample: 0,
1897                length_samples: 1_000,
1898            });
1899        {
1900            let state = engine.state.lock();
1901            let mut track = state.tracks.get("track").expect("track exists").lock();
1902            track
1903                .rt
1904                .pending_session_launches
1905                .push(crate::track::PendingSessionLaunch {
1906                    scene_index: 1,
1907                    clip_id: "clip-scene-1".to_string(),
1908                    kind: Kind::Audio,
1909                    launch_at_sample: 1_000,
1910                    loop_enabled: true,
1911                    loop_start_samples: 0,
1912                    loop_end_samples: 0,
1913                });
1914            track
1915                .rt
1916                .playing_session_clips
1917                .push(crate::track::PlayingSessionClip {
1918                    scene_index: 1,
1919                    clip_id: "clip-scene-1".to_string(),
1920                    kind: Kind::Audio,
1921                    play_position_samples: 500,
1922                    elapsed_samples: 500,
1923                    loop_enabled: true,
1924                    loop_start_samples: 0,
1925                    loop_end_samples: 0,
1926                    stop_at_sample: None,
1927                    active_midi_notes: std::collections::HashSet::new(),
1928                });
1929        }
1930
1931        engine.handle_request(Action::SessionPlay).await;
1932
1933        assert_eq!(engine.session_current_scene, None);
1934        assert_eq!(engine.session_scene_queue, None);
1935        assert!(engine.session_completed_clip_passes.is_empty());
1936        {
1937            let state = engine.state.lock();
1938            let track = state.tracks.get("track").expect("track exists").lock();
1939            assert!(track.rt.pending_session_launches.is_empty());
1940            assert!(track.rt.playing_session_clips.is_empty());
1941        }
1942
1943        engine
1944            .handle_request(Action::Session(
1945                crate::message::SessionAction::LaunchScene {
1946                    scene_index: 0,
1947                    launch_quantization: crate::message::LaunchQuantization::None,
1948                },
1949            ))
1950            .await;
1951        engine.session_transport_sample = 1_000;
1952        engine.publish_session_runtime_reports().await;
1953
1954        assert_eq!(engine.session_completed_clip_passes.len(), 1);
1955        let pass = &engine.session_completed_clip_passes[0];
1956        assert_eq!(pass.scene_index, 0);
1957        assert_eq!(pass.clip_id, "clip-scene-1");
1958        assert_eq!(pass.start_sample, 0);
1959        assert_eq!(pass.length_samples, 1_000);
1960    }
1961
1962    #[cfg_attr(
1963        all(miri, target_os = "freebsd"),
1964        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1965    )]
1966    #[tokio::test]
1967    async fn set_unused_clips_populates_session_pool_for_slot_referenced_clips() {
1968        let (mut engine, _client_rx) = make_engine_with_client();
1969        insert_track(
1970            &mut engine,
1971            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
1972        );
1973
1974        // Session restore order: slots are assigned before the unused pool.
1975        engine
1976            .handle_request(Action::TrackSetSessionSlot {
1977                track_name: "track".to_string(),
1978                scene_index: 0,
1979                clip_id: Some("clip-1".to_string()),
1980            })
1981            .await;
1982        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
1983        clip.id = "clip-1".to_string();
1984        let data = crate::history::audio_clip_to_data(&clip);
1985        engine
1986            .handle_request(Action::SetUnusedClips {
1987                audio: vec![data],
1988                midi: vec![],
1989            })
1990            .await;
1991
1992        let state = engine.state.lock();
1993        let track = state.tracks.get("track").expect("track exists").lock();
1994        assert_eq!(track.rt.session_clip_pool_audio.len(), 1);
1995        assert_eq!(track.rt.session_clip_pool_audio[0].id, "clip-1");
1996        assert_eq!(track.rt.session_clip_pool_audio[0].name, "audio/clip.wav");
1997    }
1998
1999    #[cfg_attr(
2000        all(miri, target_os = "freebsd"),
2001        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2002    )]
2003    #[tokio::test]
2004    async fn track_offline_bounce_queues_when_no_worker_is_ready() {
2005        let (mut engine, _client_rx) = make_engine_with_client();
2006        insert_track(
2007            &mut engine,
2008            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
2009        );
2010
2011        engine
2012            .handle_request(Action::TrackOfflineBounce {
2013                track_name: "track".to_string(),
2014                output_path: "/tmp/out.wav".to_string(),
2015                start_sample: 0,
2016                length_samples: 128,
2017                automation_lanes: vec![],
2018                apply_fader: false,
2019            })
2020            .await;
2021
2022        assert!(engine.offline_bounce_jobs.is_empty());
2023        assert_eq!(engine.pending_requests.len(), 1);
2024        assert!(matches!(
2025            engine.pending_requests.front(),
2026            Some(Action::TrackOfflineBounce { track_name, length_samples, .. })
2027                if track_name == "track" && *length_samples == 128
2028        ));
2029    }
2030
2031    #[cfg_attr(
2032        all(miri, target_os = "freebsd"),
2033        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2034    )]
2035    #[tokio::test]
2036    async fn track_offline_bounce_returns_missing_track_error() {
2037        let (mut engine, mut client_rx) = make_engine_with_client();
2038
2039        engine
2040            .handle_request(Action::TrackOfflineBounce {
2041                track_name: "missing".to_string(),
2042                output_path: "/tmp/out.wav".to_string(),
2043                start_sample: 0,
2044                length_samples: 128,
2045                automation_lanes: vec![],
2046                apply_fader: false,
2047            })
2048            .await;
2049
2050        match client_rx.recv().await.expect("response") {
2051            Message::Response(Err(err)) => {
2052                assert_eq!(err, "Track not found: missing");
2053            }
2054            other => panic!("unexpected message: {other:?}"),
2055        }
2056    }
2057
2058    #[cfg_attr(
2059        all(miri, target_os = "freebsd"),
2060        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2061    )]
2062    #[tokio::test]
2063    async fn track_offline_bounce_clears_job_when_worker_send_fails() {
2064        let (mut engine, mut client_rx) = make_engine_with_client();
2065        insert_track(
2066            &mut engine,
2067            Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
2068        );
2069        let (worker_tx, worker_rx) = channel(1);
2070        drop(worker_rx);
2071        engine
2072            .workers
2073            .push(WorkerData::new(worker_tx, tokio::spawn(async {})));
2074        engine.ready_workers.push(0);
2075
2076        engine
2077            .handle_request(Action::TrackOfflineBounce {
2078                track_name: "track".to_string(),
2079                output_path: "/tmp/out.wav".to_string(),
2080                start_sample: 0,
2081                length_samples: 128,
2082                automation_lanes: vec![],
2083                apply_fader: false,
2084            })
2085            .await;
2086
2087        assert!(engine.offline_bounce_jobs.is_empty());
2088        match client_rx.recv().await.expect("response") {
2089            Message::Response(Err(err)) => {
2090                assert!(err.contains("Failed to schedule offline bounce"));
2091            }
2092            other => panic!("unexpected message: {other:?}"),
2093        }
2094    }
2095
2096    #[cfg_attr(
2097        all(miri, target_os = "freebsd"),
2098        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2099    )]
2100    #[tokio::test]
2101    async fn play_stop_play_keeps_clip_output_audible() {
2102        use crate::audio::clip::AudioClip;
2103        use crate::audio_codec::write_wav_f32;
2104
2105        let (engine_tx, engine_rx) = channel(16);
2106        let mut engine = Engine::new(engine_rx, engine_tx);
2107        let state = engine.state();
2108        let (client_tx, mut client_rx) = channel(16);
2109        engine.clients.push(client_tx);
2110        engine.init().await;
2111
2112        let tmp_dir = std::env::temp_dir().join("maolan_play_stop_play_test");
2113        let _ = std::fs::create_dir_all(&tmp_dir);
2114        let wav_path = tmp_dir.join("tone.wav");
2115        let sample_rate = 48_000u32;
2116        let clip_samples = sample_rate as usize;
2117        let mut samples = Vec::with_capacity(clip_samples);
2118        for i in 0..clip_samples {
2119            let phase = i as f32 / sample_rate as f32 * 2.0 * std::f32::consts::PI * 440.0;
2120            samples.push(phase.sin() * 0.5);
2121        }
2122        write_wav_f32(&wav_path, &samples, 1, sample_rate).expect("write wav");
2123
2124        let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 1024, sample_rate as f64);
2125        let mut clip = AudioClip::new(wav_path.to_string_lossy().to_string(), 0, clip_samples);
2126        clip.fade_enabled = false;
2127        track.audio.push_clip(clip);
2128        track.session_base_dir = Some(tmp_dir.clone());
2129        insert_track(&mut engine, track);
2130
2131        let tx = engine.tx.clone();
2132        let work_handle = tokio::spawn(async move {
2133            engine.work().await;
2134        });
2135
2136        // Wait for worker tasks to start up and send Ready messages.
2137        tokio::time::sleep(TokioDuration::from_millis(100)).await;
2138
2139        async fn drain_responses(
2140            client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
2141            count: usize,
2142        ) {
2143            for _ in 0..count {
2144                let _ = tokio::time::timeout(TokioDuration::from_secs(2), client_rx.recv()).await;
2145            }
2146        }
2147
2148        async fn wait_for_audible_track(
2149            client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
2150            state: &State,
2151        ) -> Option<f32> {
2152            let deadline = Instant::now() + Duration::from_secs(5);
2153            while Instant::now() < deadline {
2154                {
2155                    let state = state.lock();
2156                    let peak = state
2157                        .tracks
2158                        .get("track")
2159                        .map(|t| {
2160                            t.lock()
2161                                .output_meter_linear()
2162                                .into_iter()
2163                                .fold(0.0_f32, f32::max)
2164                        })
2165                        .unwrap_or(0.0);
2166                    if peak > 0.001 {
2167                        return Some(peak);
2168                    }
2169                }
2170                let _ =
2171                    tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await;
2172                tokio::time::sleep(TokioDuration::from_millis(10)).await;
2173            }
2174            None
2175        }
2176
2177        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
2178            .await
2179            .unwrap();
2180        tx.send(Message::Request(Action::Play)).await.unwrap();
2181        let first_peak = wait_for_audible_track(&mut client_rx, &state)
2182            .await
2183            .unwrap_or(0.0);
2184        assert!(
2185            first_peak > 0.001,
2186            "expected audible output on first play, got {first_peak}"
2187        );
2188
2189        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
2190            .await
2191            .unwrap();
2192        tx.send(Message::Request(Action::Stop)).await.unwrap();
2193        drain_responses(&mut client_rx, 2).await;
2194
2195        tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
2196            .await
2197            .unwrap();
2198        tx.send(Message::Request(Action::Play)).await.unwrap();
2199        let second_peak = wait_for_audible_track(&mut client_rx, &state)
2200            .await
2201            .unwrap_or(0.0);
2202        assert!(
2203            second_peak > 0.001,
2204            "expected audible output on second play after stop, got {second_peak}"
2205        );
2206
2207        let _ = tx.send(Message::Request(Action::Quit)).await;
2208        tokio::time::sleep(TokioDuration::from_millis(200)).await;
2209        work_handle.abort();
2210        let _ = std::fs::remove_dir_all(&tmp_dir);
2211    }
2212
2213    #[test]
2214    fn modulator_sets_track_volume() {
2215        let (mut engine, _client_rx) = make_engine_with_client();
2216        let track = Track::new("vol-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
2217        insert_track_for_modulator_test(&mut engine, track);
2218
2219        engine.modulators = vec![crate::modulator::Modulator {
2220            id: 1,
2221            name: "LFO".to_string(),
2222            shape: crate::modulator::ModulatorShape::Sine,
2223            rate: crate::modulator::ModulatorRate::Hz(1.0),
2224            phase: 0.0,
2225            enabled: true,
2226            targets: vec![crate::modulator::ModulatorTarget::TrackVolume {
2227                track_name: "vol-track".to_string(),
2228                min: -90.0,
2229                max: 20.0,
2230            }],
2231        }];
2232
2233        // At sample 12000 (1/4 period at 48kHz/1Hz), sine value maps to 1.0 -> max 20 dB.
2234        let echoes = engine.apply_modulators(12_000);
2235        let state_guard = engine.state.lock();
2236        let track = state_guard.tracks["vol-track"].lock();
2237        assert!(
2238            (track.level() - 20.0).abs() < 0.01,
2239            "expected 20 dB, got {}",
2240            track.level()
2241        );
2242        assert!(
2243            echoes
2244                .iter()
2245                .any(|a| matches!(a, Action::TrackAutomationLevel(name, _) if name == "vol-track"))
2246        );
2247    }
2248
2249    #[test]
2250    fn modulator_sets_track_balance() {
2251        let (mut engine, _client_rx) = make_engine_with_client();
2252        let track = Track::new("pan-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
2253        insert_track_for_modulator_test(&mut engine, track);
2254
2255        engine.modulators = vec![crate::modulator::Modulator {
2256            id: 1,
2257            name: "LFO".to_string(),
2258            shape: crate::modulator::ModulatorShape::Sine,
2259            rate: crate::modulator::ModulatorRate::Hz(1.0),
2260            phase: 0.0,
2261            enabled: true,
2262            targets: vec![crate::modulator::ModulatorTarget::TrackBalance {
2263                track_name: "pan-track".to_string(),
2264                min: -1.0,
2265                max: 1.0,
2266            }],
2267        }];
2268
2269        // At sample 12000 (1/4 period), sine value maps to 1.0 -> max balance 1.0.
2270        let echoes = engine.apply_modulators(12_000);
2271        let state_guard = engine.state.lock();
2272        let track = state_guard.tracks["pan-track"].lock();
2273        assert!(
2274            (track.balance() - 1.0).abs() < 0.01,
2275            "expected balance 1.0, got {}",
2276            track.balance()
2277        );
2278        assert!(
2279            echoes.iter().any(
2280                |a| matches!(a, Action::TrackAutomationBalance(name, _) if name == "pan-track")
2281            )
2282        );
2283    }
2284
2285    #[cfg_attr(
2286        all(miri, target_os = "freebsd"),
2287        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2288    )]
2289    #[tokio::test]
2290    async fn track_set_parent_wires_folder_input_to_child_input_and_child_output_to_folder_output()
2291    {
2292        let (mut engine, mut client_rx) = make_engine_with_client();
2293        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2294        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2295        insert_track(&mut engine, folder);
2296        insert_track(&mut engine, child);
2297
2298        engine
2299            .handle_request_inner(
2300                Action::TrackSetParent {
2301                    track_name: "child".to_string(),
2302                    parent_name: Some("folder".to_string()),
2303                },
2304                false,
2305            )
2306            .await;
2307
2308        // Drain client messages so the channel does not block later drops.
2309        while let Ok(Some(_)) =
2310            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2311        {}
2312
2313        let state = engine.state.lock();
2314        let folder = state.tracks.get("folder").unwrap().lock();
2315        let child = state.tracks.get("child").unwrap().lock();
2316
2317        assert!(folder.child_tracks.iter().any(|c| c.lock().name == "child"));
2318        assert_eq!(child.parent_track.as_deref(), Some("folder"));
2319
2320        // Folder input -> child input.
2321        for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
2322        {
2323            assert!(
2324                child_in
2325                    .connections()
2326                    .iter()
2327                    .any(|c| Arc::ptr_eq(c, parent_in)),
2328                "folder input {i} is not routed to child input {i}"
2329            );
2330            assert!(
2331                !parent_in
2332                    .connections()
2333                    .iter()
2334                    .any(|c| Arc::ptr_eq(c, child_in)),
2335                "folder input {i} should not read from child input {i}"
2336            );
2337        }
2338
2339        // Child output -> folder output.
2340        for (i, (child_out, parent_out)) in
2341            child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
2342        {
2343            assert!(
2344                parent_out
2345                    .connections()
2346                    .iter()
2347                    .any(|c| Arc::ptr_eq(c, child_out)),
2348                "child output {i} is not routed to folder output {i}"
2349            );
2350        }
2351
2352        // Child passthrough is restored so audio can flow through.
2353        for (i, child_out) in child.audio.outs.iter().enumerate() {
2354            assert!(
2355                child_out.connections().iter().any(|c| {
2356                    child
2357                        .audio
2358                        .ins
2359                        .get(i)
2360                        .is_some_and(|inp| Arc::ptr_eq(c, inp))
2361                }),
2362                "child output {i} is not connected to child input {i}"
2363            );
2364        }
2365    }
2366
2367    #[cfg_attr(
2368        all(miri, target_os = "freebsd"),
2369        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2370    )]
2371    #[tokio::test]
2372    async fn track_set_parent_to_none_restores_root_passthrough() {
2373        let (mut engine, mut client_rx) = make_engine_with_client();
2374        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2375        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2376        insert_track(&mut engine, folder);
2377        insert_track(&mut engine, child);
2378
2379        engine
2380            .handle_request_inner(
2381                Action::TrackSetParent {
2382                    track_name: "child".to_string(),
2383                    parent_name: Some("folder".to_string()),
2384                },
2385                false,
2386            )
2387            .await;
2388        engine
2389            .handle_request_inner(
2390                Action::TrackSetParent {
2391                    track_name: "child".to_string(),
2392                    parent_name: None,
2393                },
2394                false,
2395            )
2396            .await;
2397
2398        while let Ok(Some(_)) =
2399            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2400        {}
2401
2402        let state = engine.state.lock();
2403        let folder = state.tracks.get("folder").unwrap().lock();
2404        let child = state.tracks.get("child").unwrap().lock();
2405
2406        assert!(folder.child_tracks.is_empty());
2407        assert!(child.parent_track.is_none());
2408
2409        for (i, child_out) in child.audio.outs.iter().enumerate() {
2410            assert!(
2411                child_out.connections().iter().any(|c| {
2412                    child
2413                        .audio
2414                        .ins
2415                        .get(i)
2416                        .is_some_and(|inp| Arc::ptr_eq(c, inp))
2417                }),
2418                "child output {i} should be connected to child input {i} after moving to root"
2419            );
2420        }
2421    }
2422
2423    #[cfg_attr(
2424        all(miri, target_os = "freebsd"),
2425        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2426    )]
2427    #[tokio::test]
2428    async fn track_set_parent_wires_folder_midi_to_child_midi() {
2429        let (mut engine, mut client_rx) = make_engine_with_client();
2430        let folder = Track::new_folder("folder".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2431        let child = Track::new("child".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2432        insert_track(&mut engine, folder);
2433        insert_track(&mut engine, child);
2434
2435        engine
2436            .handle_request_inner(
2437                Action::TrackSetParent {
2438                    track_name: "child".to_string(),
2439                    parent_name: Some("folder".to_string()),
2440                },
2441                false,
2442            )
2443            .await;
2444
2445        while let Ok(Some(_)) =
2446            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2447        {}
2448
2449        let state = engine.state.lock();
2450        let folder = state.tracks.get("folder").unwrap().lock();
2451        let child = state.tracks.get("child").unwrap().lock();
2452
2453        let folder_midi_in = &folder.midi.ins[0];
2454        let child_midi_in = &child.midi.ins[0];
2455        assert!(
2456            child_midi_in
2457                .connections()
2458                .iter()
2459                .any(|c| Arc::ptr_eq(c, folder_midi_in)),
2460            "folder MIDI input should be routed to child MIDI input"
2461        );
2462
2463        let child_midi_out = &child.midi.outs[0];
2464        let folder_midi_out = &folder.midi.outs[0];
2465        assert!(
2466            child_midi_out
2467                .connections()
2468                .iter()
2469                .any(|c| Arc::ptr_eq(c, folder_midi_out)),
2470            "child MIDI output should be routed to folder MIDI output"
2471        );
2472    }
2473
2474    fn plan_task_node(
2475        plan: &crate::render_plan::RenderPlan,
2476        name: &str,
2477        want: fn(&crate::message::ProcessTask) -> bool,
2478    ) -> usize {
2479        use crate::message::ProcessTask;
2480        use crate::render_plan::Op;
2481        plan.nodes
2482            .iter()
2483            .enumerate()
2484            .find_map(|(i, op)| match op {
2485                Op::Task { task, .. } => {
2486                    let track = match task {
2487                        ProcessTask::Track(t)
2488                        | ProcessTask::FolderInput(t)
2489                        | ProcessTask::FolderOutput(t) => t,
2490                        ProcessTask::Plugin { track, .. } => track,
2491                    };
2492                    if track.lock().name == name && want(task) {
2493                        Some(i)
2494                    } else {
2495                        None
2496                    }
2497                }
2498                _ => None,
2499            })
2500            .expect("task node not found")
2501    }
2502
2503    fn plan_reachable(plan: &crate::render_plan::RenderPlan, from: usize, to: usize) -> bool {
2504        let mut seen = std::collections::HashSet::new();
2505        let mut queue = std::collections::VecDeque::from([from as u32]);
2506        while let Some(n) = queue.pop_front() {
2507            for &d in &plan.dependents[n as usize] {
2508                if d as usize == to {
2509                    return true;
2510                }
2511                if seen.insert(d) {
2512                    queue.push_back(d);
2513                }
2514            }
2515        }
2516        false
2517    }
2518
2519    #[test]
2520    fn nested_folder_expands_in_render_plan() {
2521        use crate::message::ProcessTask;
2522
2523        let state = crate::state::State::default();
2524        let outer = Arc::new(Track::new_folder(
2525            "outer".to_string(),
2526            2,
2527            2,
2528            0,
2529            0,
2530            64,
2531            48_000.0,
2532        ));
2533        let inner = Arc::new(Track::new_folder(
2534            "inner".to_string(),
2535            2,
2536            2,
2537            0,
2538            0,
2539            64,
2540            48_000.0,
2541        ));
2542        let leaf = Arc::new(Track::new("leaf".to_string(), 2, 2, 0, 0, 64, 48_000.0));
2543        outer.lock().child_tracks.push(inner.clone());
2544        inner.lock().child_tracks.push(leaf.clone());
2545        inner.lock().parent_track = Some("outer".to_string());
2546        leaf.lock().parent_track = Some("inner".to_string());
2547        {
2548            let mut state = state.lock();
2549            state.tracks.insert("outer".to_string(), outer);
2550            state.tracks.insert("inner".to_string(), inner);
2551            state.tracks.insert("leaf".to_string(), leaf);
2552        }
2553
2554        let plan = crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64);
2555        plan.verify().expect("plan invariants");
2556
2557        let is_fi = |t: &ProcessTask| matches!(t, ProcessTask::FolderInput(_));
2558        let is_fo = |t: &ProcessTask| matches!(t, ProcessTask::FolderOutput(_));
2559        let is_track = |t: &ProcessTask| matches!(t, ProcessTask::Track(_));
2560        let in_outer = plan_task_node(&plan, "outer", is_fi);
2561        let in_inner = plan_task_node(&plan, "inner", is_fi);
2562        let track_leaf = plan_task_node(&plan, "leaf", is_track);
2563        let out_inner = plan_task_node(&plan, "inner", is_fo);
2564        let out_outer = plan_task_node(&plan, "outer", is_fo);
2565
2566        assert!(
2567            in_outer < in_inner
2568                && in_inner < track_leaf
2569                && track_leaf < out_inner
2570                && out_inner < out_outer,
2571            "nested folder tasks should expand in topological order"
2572        );
2573        for (a, b) in [
2574            (in_outer, in_inner),
2575            (in_inner, track_leaf),
2576            (track_leaf, out_inner),
2577            (out_inner, out_outer),
2578        ] {
2579            assert!(plan_reachable(&plan, a, b), "{a} should reach {b}");
2580        }
2581        assert!(plan.forced.is_empty(), "no feedback cycle");
2582    }
2583
2584    #[cfg_attr(
2585        all(miri, target_os = "freebsd"),
2586        ignore = "uses filesystem metadata, which Miri isolation does not support on FreeBSD"
2587    )]
2588    #[test]
2589    fn child_to_plugin_to_folder_output_render_plan_has_no_cycle() {
2590        use crate::message::{ConnectableRef, ProcessTask};
2591
2592        let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR"))
2593            .parent()
2594            .unwrap()
2595            .join("daw")
2596            .join("plugin-host")
2597            .join("tests")
2598            .join("test_passthrough.clap");
2599        if !plugin_path.exists() {
2600            return;
2601        }
2602        if crate::plugins::ipc::find_plugin_host_binary().is_none() {
2603            return;
2604        }
2605
2606        let (mut engine, _client_rx) = make_engine_with_client();
2607        let mut folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2608        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2609
2610        folder
2611            .load_clap_plugin(
2612                &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
2613                None,
2614            )
2615            .expect("should load CLAP plugin on folder");
2616        folder.clap_plugins[0].processor.setup_audio_ports();
2617        let plugin_id = folder.clap_plugins[0].id;
2618
2619        insert_track(&mut engine, folder);
2620        insert_track(&mut engine, child);
2621
2622        {
2623            let state = engine.state.lock();
2624            let folder = state.tracks.get("folder").unwrap().clone();
2625            let child = state.tracks.get("child").unwrap().clone();
2626            folder.lock().child_tracks.push(child.clone());
2627            child.lock().parent_track = Some("folder".to_string());
2628
2629            folder
2630                .lock()
2631                .connect_audio_connectable(
2632                    ConnectableRef::ChildTrack("child".to_string()),
2633                    0,
2634                    ConnectableRef::ClapPlugin(plugin_id),
2635                    0,
2636                )
2637                .expect("connect child L to plugin L");
2638            folder
2639                .lock()
2640                .connect_audio_connectable(
2641                    ConnectableRef::ChildTrack("child".to_string()),
2642                    1,
2643                    ConnectableRef::ClapPlugin(plugin_id),
2644                    1,
2645                )
2646                .expect("connect child R to plugin R");
2647            folder
2648                .lock()
2649                .connect_audio_connectable(
2650                    ConnectableRef::ClapPlugin(plugin_id),
2651                    0,
2652                    ConnectableRef::TrackOutput,
2653                    0,
2654                )
2655                .expect("connect plugin L to folder output L");
2656            folder
2657                .lock()
2658                .connect_audio_connectable(
2659                    ConnectableRef::ClapPlugin(plugin_id),
2660                    1,
2661                    ConnectableRef::TrackOutput,
2662                    1,
2663                )
2664                .expect("connect plugin R to folder output R");
2665        }
2666
2667        let plan = {
2668            let state = engine.state.lock();
2669            crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64)
2670        };
2671        plan.verify().expect("plan invariants");
2672
2673        let folder_in = plan_task_node(&plan, "folder", |t| {
2674            matches!(t, ProcessTask::FolderInput(_))
2675        });
2676        let child_task = plan_task_node(&plan, "child", |t| matches!(t, ProcessTask::Track(_)));
2677        let plugin = plan_task_node(&plan, "folder", |t| {
2678            matches!(
2679                t,
2680                ProcessTask::Plugin {
2681                    kind: PluginKind::Clap,
2682                    index: 0,
2683                    ..
2684                }
2685            )
2686        });
2687        let folder_out = plan_task_node(&plan, "folder", |t| {
2688            matches!(t, ProcessTask::FolderOutput(_))
2689        });
2690
2691        assert!(
2692            plan_reachable(&plan, folder_in, child_task),
2693            "child task should depend on folder input"
2694        );
2695        assert!(
2696            plan_reachable(&plan, folder_in, plugin) && plan_reachable(&plan, child_task, plugin),
2697            "plugin task should depend on folder input and child"
2698        );
2699        assert!(
2700            plan_reachable(&plan, folder_in, folder_out)
2701                && plan_reachable(&plan, plugin, folder_out)
2702                && plan_reachable(&plan, child_task, folder_out),
2703            "folder output should depend on folder input, plugin, and child"
2704        );
2705        assert!(
2706            plan.forced.is_empty(),
2707            "render plan should not contain a cycle when a plugin reads from a child track"
2708        );
2709    }
2710
2711    #[cfg_attr(
2712        all(miri, target_os = "freebsd"),
2713        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2714    )]
2715    #[tokio::test]
2716    async fn track_set_parent_wires_child_io_to_folder_even_after_addtrack() {
2717        let (mut engine, mut client_rx) = make_engine_with_client();
2718        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2719        let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2720        insert_track(&mut engine, folder);
2721        insert_track(&mut engine, child);
2722
2723        engine
2724            .handle_request_inner(
2725                Action::TrackSetParent {
2726                    track_name: "child".to_string(),
2727                    parent_name: Some("folder".to_string()),
2728                },
2729                false,
2730            )
2731            .await;
2732
2733        while let Ok(Some(_)) =
2734            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2735        {}
2736
2737        let state = engine.state.lock();
2738        let folder = state.tracks.get("folder").unwrap().lock();
2739        let child = state.tracks.get("child").unwrap().lock();
2740
2741        // Folder input -> child input.
2742        for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
2743        {
2744            assert!(
2745                child_in
2746                    .connections()
2747                    .iter()
2748                    .any(|c| Arc::ptr_eq(c, parent_in)),
2749                "folder input {i} is not routed to child input {i}"
2750            );
2751        }
2752
2753        // Child output -> folder output.
2754        for (i, (child_out, parent_out)) in
2755            child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
2756        {
2757            assert!(
2758                parent_out
2759                    .connections()
2760                    .iter()
2761                    .any(|c| Arc::ptr_eq(c, child_out)),
2762                "child output {i} is not routed to folder output {i}"
2763            );
2764        }
2765    }
2766
2767    #[cfg_attr(
2768        all(miri, target_os = "freebsd"),
2769        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2770    )]
2771    #[tokio::test]
2772    async fn folder_child_audio_passes_through() {
2773        let (mut engine, mut client_rx) = make_engine_with_client();
2774        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2775        let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2776        insert_track(&mut engine, folder);
2777        insert_track(&mut engine, child);
2778
2779        engine
2780            .handle_request_inner(
2781                Action::TrackSetParent {
2782                    track_name: "child".to_string(),
2783                    parent_name: Some("folder".to_string()),
2784                },
2785                false,
2786            )
2787            .await;
2788        while let Ok(Some(_)) =
2789            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2790        {}
2791
2792        {
2793            let state = engine.state.lock();
2794            let folder = state.tracks.get("folder").unwrap().clone();
2795            let child = state.tracks.get("child").unwrap().clone();
2796
2797            folder.lock().set_input_monitor(vec![true]);
2798            child.lock().set_input_monitor(vec![true]);
2799
2800            // Feed a signal into the folder input from a fake hardware source
2801            // and execute the compiled render plan once.
2802            let source = Arc::new(crate::audio::io::AudioIO::new(64));
2803            crate::audio::io::AudioIO::connect(&source, &folder.lock().audio.ins[0]);
2804            let plan =
2805                crate::render_plan::RenderPlan::compile(&state.snapshot(), &[source], &[], 64);
2806            plan.verify().expect("plan invariants");
2807            let (_, hw_buf) = plan.hw_in_map[0];
2808            // Safety: test thread, no node is running yet; the fake driver owns
2809            // the hardware-input buffer before the cycle starts.
2810            unsafe { (&mut *plan.buffer_ptr(hw_buf)).fill(0.75) };
2811            let collector = basedrop::Collector::new();
2812            let shared = Arc::new(basedrop::Owned::new(&collector.handle(), plan));
2813            for node in 0..shared.nodes.len() {
2814                crate::workers::worker::Worker::process_node_job_result(
2815                    0,
2816                    crate::executor::NodeJob {
2817                        epoch: 0,
2818                        plan: shared.clone(),
2819                        node: node as u32,
2820                    },
2821                );
2822            }
2823
2824            let folder_lock = folder.lock();
2825            let output = folder_lock.last_audio_outputs()[0].clone();
2826            assert!(
2827                output.iter().any(|s| (*s - 0.75).abs() < 1e-5),
2828                "folder output should contain the child-processed folder input signal, got {:?}",
2829                output.iter().take(8).collect::<Vec<_>>()
2830            );
2831        }
2832    }
2833
2834    #[cfg_attr(
2835        all(miri, target_os = "freebsd"),
2836        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2837    )]
2838    #[tokio::test]
2839    async fn remove_folder_track_deletes_descendants_recursively() {
2840        let (mut engine, mut client_rx) = make_engine_with_client();
2841        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2842        let child = Track::new_folder("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2843        let grandchild = Track::new("grandchild".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2844        insert_track(&mut engine, folder);
2845        insert_track(&mut engine, child);
2846        insert_track(&mut engine, grandchild);
2847
2848        engine
2849            .handle_request(Action::TrackSetParent {
2850                track_name: "child".to_string(),
2851                parent_name: Some("folder".to_string()),
2852            })
2853            .await;
2854        engine
2855            .handle_request(Action::TrackSetParent {
2856                track_name: "grandchild".to_string(),
2857                parent_name: Some("child".to_string()),
2858            })
2859            .await;
2860
2861        // Drain TrackSetParent notifications so we can inspect the removal notifications.
2862        while let Ok(Some(_)) =
2863            tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
2864        {}
2865
2866        engine
2867            .handle_request(Action::RemoveTrack("folder".to_string()))
2868            .await;
2869
2870        {
2871            let state = engine.state.lock();
2872            assert!(
2873                !state.tracks.contains_key("folder"),
2874                "folder should have been removed"
2875            );
2876            assert!(
2877                !state.tracks.contains_key("child"),
2878                "child should have been removed"
2879            );
2880            assert!(
2881                !state.tracks.contains_key("grandchild"),
2882                "grandchild should have been removed"
2883            );
2884        }
2885
2886        let mut removed_names = Vec::new();
2887        for _ in 0..3 {
2888            let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
2889            if let Ok(Some(Message::Response(Ok(Action::RemoveTrack(name))))) = msg {
2890                removed_names.push(name);
2891            }
2892        }
2893        assert_eq!(
2894            removed_names,
2895            vec!["grandchild", "child", "folder"],
2896            "descendants should be removed before the folder and clients notified"
2897        );
2898    }
2899
2900    #[cfg_attr(
2901        all(miri, target_os = "freebsd"),
2902        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2903    )]
2904    #[tokio::test]
2905    async fn track_set_folder_rejects_master_track() {
2906        let (mut engine, mut client_rx) = make_engine_with_client();
2907        let track = Track::new("master".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2908        track.is_master.store(true, Ordering::Relaxed);
2909        insert_track(&mut engine, track);
2910
2911        engine
2912            .handle_request_inner(
2913                Action::TrackSetFolder {
2914                    track_name: "master".to_string(),
2915                    is_folder: true,
2916                },
2917                false,
2918            )
2919            .await;
2920
2921        {
2922            let state = engine.state.lock();
2923            assert!(!state.tracks.get("master").unwrap().lock().is_folder);
2924        }
2925
2926        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
2927        assert!(
2928            matches!(msg, Ok(Some(Message::Response(Err(_))))),
2929            "master track folder conversion should report an error"
2930        );
2931    }
2932
2933    #[cfg_attr(
2934        all(miri, target_os = "freebsd"),
2935        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2936    )]
2937    #[tokio::test]
2938    async fn track_toggle_master_ignored_for_folder_track() {
2939        let (mut engine, mut client_rx) = make_engine_with_client();
2940        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2941        insert_track(&mut engine, folder);
2942
2943        engine
2944            .handle_request_inner(Action::TrackToggleMaster("folder".to_string()), false)
2945            .await;
2946
2947        {
2948            let state = engine.state.lock();
2949            assert!(!state.tracks.get("folder").unwrap().lock().is_master());
2950        }
2951
2952        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
2953        assert!(
2954            matches!(
2955                msg,
2956                Ok(Some(Message::Response(Ok(Action::TrackToggleMaster(ref name)))))
2957                    if name == "folder"
2958            ),
2959            "folder track master toggle should still be echoed to clients: {msg:?}"
2960        );
2961    }
2962
2963    #[cfg_attr(
2964        all(miri, target_os = "freebsd"),
2965        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2966    )]
2967    #[tokio::test]
2968    async fn track_toggle_master_ignored_for_child_track() {
2969        let (mut engine, mut client_rx) = make_engine_with_client();
2970        let mut child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
2971        child.parent_track = Some("folder".to_string());
2972        insert_track(&mut engine, child);
2973
2974        engine
2975            .handle_request_inner(Action::TrackToggleMaster("child".to_string()), false)
2976            .await;
2977
2978        {
2979            let state = engine.state.lock();
2980            assert!(!state.tracks.get("child").unwrap().lock().is_master());
2981        }
2982
2983        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
2984        assert!(
2985            matches!(
2986                msg,
2987                Ok(Some(Message::Response(Ok(Action::TrackToggleMaster(ref name)))))
2988                    if name == "child"
2989            ),
2990            "child track master toggle should still be echoed to clients: {msg:?}"
2991        );
2992    }
2993
2994    #[cfg_attr(
2995        all(miri, target_os = "freebsd"),
2996        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
2997    )]
2998    #[tokio::test]
2999    async fn track_set_parent_rejects_master_track() {
3000        let (mut engine, mut client_rx) = make_engine_with_client();
3001        let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
3002        insert_track(&mut engine, folder);
3003        let track = Track::new("master".to_string(), 2, 2, 0, 0, 64, 48_000.0);
3004        track.is_master.store(true, Ordering::Relaxed);
3005        insert_track(&mut engine, track);
3006
3007        engine
3008            .handle_request_inner(
3009                Action::TrackSetParent {
3010                    track_name: "master".to_string(),
3011                    parent_name: Some("folder".to_string()),
3012                },
3013                false,
3014            )
3015            .await;
3016
3017        {
3018            let state = engine.state.lock();
3019            assert!(
3020                state
3021                    .tracks
3022                    .get("master")
3023                    .unwrap()
3024                    .lock()
3025                    .parent_track
3026                    .is_none()
3027            );
3028        }
3029
3030        let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
3031        assert!(
3032            matches!(msg, Ok(Some(Message::Response(Err(_))))),
3033            "setting master track as folder child should report an error"
3034        );
3035    }
3036}