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