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