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