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