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