Skip to main content

maolan_engine/engine/
mod.rs

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