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