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