Skip to main content

maolan_engine/engine/
mod.rs

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