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