Skip to main content

maolan_engine/engine/
mod.rs

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