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, HwOptions, 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 = "linux")]
50use crate::workers::alsa_worker::HwWorker;
51#[cfg(target_os = "macos")]
52use crate::workers::coreaudio_worker::HwWorker;
53#[cfg(target_os = "openbsd")]
54use crate::workers::sndio_worker::HwWorker;
55#[cfg(target_os = "windows")]
56use crate::workers::wasapi_worker::HwWorker;
57use crate::{
58 history::{History, UndoEntry},
59 kind::Kind,
60 message::{Action, HwMidiEvent, Message, MidiControllerData, MidiNoteData},
61 midi::io::MidiEvent,
62 osc::OscServer,
63 state::{State, StateSlot},
64 workers::worker::NodeJobResult,
65};
66
67struct RtProducer<T>(rtrb::Producer<T>);
68
69unsafe impl<T: Send> Send for RtProducer<T> {}
73unsafe impl<T: Send> Sync for RtProducer<T> {}
74
75impl<T> RtProducer<T> {
76 fn push(&mut self, value: T) -> Result<(), rtrb::PushError<T>> {
77 self.0.push(value)
78 }
79}
80
81struct RtConsumer<T>(rtrb::Consumer<T>);
82
83unsafe impl<T: Send> Send for RtConsumer<T> {}
86unsafe impl<T: Send> Sync for RtConsumer<T> {}
87
88impl<T> RtConsumer<T> {
89 fn pop(&mut self) -> Result<T, rtrb::PopError> {
90 self.0.pop()
91 }
92}
93
94struct WorkerData {
95 tx: Sender<Message>,
96 handle: Option<JoinHandle<()>>,
97 node_job_tx: Option<RtProducer<crate::executor::NodeJob>>,
98 node_result_rx: Option<RtConsumer<NodeJobResult>>,
99 node_thread: Option<std::thread::Thread>,
100 node_quit: Option<Arc<AtomicBool>>,
101}
102
103impl WorkerData {
104 pub fn new(tx: Sender<Message>, handle: JoinHandle<()>) -> Self {
105 Self {
106 tx,
107 handle: Some(handle),
108 node_job_tx: None,
109 node_result_rx: None,
110 node_thread: None,
111 node_quit: None,
112 }
113 }
114
115 pub fn with_node_mailbox(
116 tx: Sender<Message>,
117 handle: JoinHandle<()>,
118 node_job_tx: rtrb::Producer<crate::executor::NodeJob>,
119 node_result_rx: rtrb::Consumer<NodeJobResult>,
120 node_thread: std::thread::Thread,
121 node_quit: Arc<AtomicBool>,
122 ) -> Self {
123 Self {
124 tx,
125 handle: Some(handle),
126 node_job_tx: Some(RtProducer(node_job_tx)),
127 node_result_rx: Some(RtConsumer(node_result_rx)),
128 node_thread: Some(node_thread),
129 node_quit: Some(node_quit),
130 }
131 }
132}
133
134impl Drop for WorkerData {
135 fn drop(&mut self) {
136 if let Some(quit) = &self.node_quit {
137 quit.store(true, std::sync::atomic::Ordering::Release);
138 }
139 if let Some(thread) = &self.node_thread {
140 thread.unpark();
141 }
142 }
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub(crate) struct HwDriverInfo {
147 pub cycle_samples: usize,
148 pub sample_rate: i32,
149 pub input_channels: usize,
150 pub output_channels: usize,
151 pub sample_bits: i32,
152 pub frame_size_bytes: usize,
153}
154
155#[derive(Debug, Clone)]
156pub(crate) struct RecordingSession {
157 start_sample: usize,
158 samples: Vec<f32>,
159 channels: usize,
160 file_name: String,
161
162 stripe_peaks: Vec<Vec<[f32; 2]>>,
163
164 current_stripe_frames: usize,
165}
166
167const RECORDING_STRIPE_FRAMES: usize = 256;
168
169#[derive(Debug, Clone)]
170pub(crate) struct MidiRecordingSession {
171 start_sample: usize,
172 events: Vec<(u64, Vec<u8>)>,
173 file_name: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
177struct MidiHwInRoute {
178 device: String,
179 to_track: String,
180 to_port: usize,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Hash)]
184struct MidiHwOutRoute {
185 from_track: String,
186 from_port: usize,
187 device: String,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Hash)]
191struct MidiHwThruRoute {
192 from_device: String,
193 to_device: String,
194}
195
196struct OfflineBounceJob {
197 cancel: Arc<AtomicBool>,
198}
199
200#[cfg(unix)]
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202enum JackTransportPlaySync {
203 Start,
204 Stop,
205}
206
207#[derive(Clone, Copy)]
208#[cfg(unix)]
209pub(crate) struct AudioOpenRequest<'a> {
210 device: &'a str,
211 input_device: Option<&'a str>,
212 sample_rate_hz: i32,
213 bits: i32,
214 exclusive: bool,
215 period_frames: usize,
216 nperiods: usize,
217 sync_mode: bool,
218}
219
220pub(crate) struct ClipAddRequest<'a> {
221 clip_id: &'a str,
222 name: &'a str,
223 track_name: &'a str,
224 start: usize,
225 length: usize,
226 offset: usize,
227 input_channel: usize,
228 muted: bool,
229 peaks_file: Option<String>,
230 kind: Kind,
231 fade_enabled: bool,
232 fade_in_samples: usize,
233 fade_out_samples: usize,
234 source_name: Option<String>,
235 source_offset: Option<usize>,
236 source_length: Option<usize>,
237 preview_name: Option<String>,
238 pitch_correction_points: Vec<crate::message::PitchCorrectionPointData>,
239 pitch_correction_frame_likeness: Option<f32>,
240 pitch_correction_inertia_ms: Option<u16>,
241 pitch_correction_formant_compensation: Option<bool>,
242 plugin_graph_json: Option<serde_json::Value>,
243}
244
245#[cfg(unix)]
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub(crate) struct JackTransportSyncDecision {
248 play_sync: Option<JackTransportPlaySync>,
249 position_sync: Option<usize>,
250}
251
252#[derive(Clone, Debug, PartialEq, Eq)]
253pub(crate) enum MidiLearnSlot {
254 Track(String, crate::message::TrackMidiLearnTarget),
255 Global(crate::message::GlobalMidiLearnTarget),
256 Session(crate::message::SessionMidiLearnTarget),
257}
258
259pub struct Engine {
260 clients: Vec<Sender<Message>>,
261 rx: Receiver<Message>,
262 state: Arc<State>,
263 state_snapshot: Arc<StateSlot>,
264 tx: Sender<Message>,
265 workers: Vec<WorkerData>,
266 hw_driver: Option<HwDriver>,
267 hw_driver_info: Option<HwDriverInfo>,
268 hw_input_ports: Vec<Arc<crate::audio::io::AudioIO>>,
269 hw_output_ports: Vec<Arc<crate::audio::io::AudioIO>>,
270 #[cfg(unix)]
271 jack_runtime: Option<JackRuntime>,
272 midi_hub: Option<MidiHub>,
273 hw_worker: Option<WorkerData>,
274 osc_server: Option<OscServer>,
275 osc_reply_socket: Option<UdpSocket>,
276 osc_reply_target: Option<SocketAddr>,
277 pending_hw_midi_events: Vec<MidiEvent>,
278 pending_hw_midi_events_by_device: HashMap<String, Vec<MidiEvent>>,
279 pending_hw_midi_out_events: Vec<MidiEvent>,
280 pending_hw_midi_out_events_by_device: Vec<HwMidiEvent>,
281 active_hw_notes_by_track: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
282 active_hw_notes_cycle_start: HashMap<String, std::collections::HashSet<(String, u8, u8)>>,
283 midi_hw_in_routes: Vec<MidiHwInRoute>,
284 midi_hw_out_routes: Vec<MidiHwOutRoute>,
285 midi_hw_thru_routes: Vec<MidiHwThruRoute>,
286 ready_workers: Vec<usize>,
287 pending_requests: VecDeque<Action>,
288 awaiting_hwfinished: bool,
289 handling_hwfinished: bool,
290 transport_panic_flush_pending: bool,
291 transport_restart_pending: bool,
292 notified_loop_wrap_sample: Option<usize>,
293 transport_sample: usize,
294
295 hw_input_latency_frames: usize,
296
297 hw_output_latency_frames: usize,
298 loop_enabled: bool,
299 loop_range_samples: Option<(usize, usize)>,
300 metronome_enabled: bool,
301 tempo_bpm: f64,
302 tsig_num: u16,
303 tsig_denom: u16,
304 tempo_points: Vec<crate::message::TempoPoint>,
305 time_signature_points: Vec<crate::message::TimeSignaturePoint>,
306 punch_enabled: bool,
307 punch_range_samples: Option<(usize, usize)>,
308 audio_recordings: std::collections::HashMap<String, RecordingSession>,
309 midi_recordings: std::collections::HashMap<String, MidiRecordingSession>,
310 completed_audio_recordings: Vec<(String, RecordingSession)>,
311 completed_midi_recordings: Vec<(String, MidiRecordingSession)>,
312 playing: bool,
313 transport_running: bool,
314 clip_playback_enabled: bool,
315 session_clip_playback_enabled: bool,
316 session_transport_sample: usize,
317 record_enabled: bool,
318 step_recording_enabled: bool,
319 session_dir: Option<PathBuf>,
320 hw_out_level_db: f32,
321 hw_out_balance: f32,
322 hw_out_muted: bool,
323 last_hw_out_meter_publish: Option<Instant>,
324 #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
325 last_hw_out_meter_linear: Vec<f32>,
326 hw_out_peak_hold_linear: Vec<f32>,
327 #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "openbsd"))]
328 hw_out_meter_publish_phase: bool,
329 last_track_meter_publish: Option<Instant>,
330 last_meter_snapshot_publish: Option<Instant>,
331 last_session_report_publish: Option<Instant>,
332 track_meter_linear_by_track: HashMap<String, Vec<f32>>,
333 meter_snapshot_producer:
334 crate::triple_buffer::TripleBufferProducer<crate::meter::MeterSnapshot>,
335 transport_snapshot_producer:
336 crate::triple_buffer::TripleBufferProducer<crate::meter::TransportSnapshot>,
337 session_runtime_snapshot_producer:
338 crate::triple_buffer::TripleBufferProducer<crate::meter::SessionRuntimeSnapshot>,
339 executor: crate::executor::CycleExecutor,
344 plan_slot: Arc<crate::render_plan::PlanSlot>,
345 hw_ports: Arc<arc_swap::ArcSwap<crate::plan_builder::HwPorts>>,
346 pending_node_jobs: VecDeque<crate::executor::NodeJob>,
347 plan_builder: crate::plan_builder::PlanBuilder,
348 latest_hw_out_meter_db: Arc<Vec<f32>>,
349 latest_track_meter_snapshot: Arc<Vec<(String, Vec<f32>)>>,
350 history: History,
351 history_group: Option<UndoEntry>,
352 history_suspended: bool,
353 offline_bounce_jobs: HashMap<String, OfflineBounceJob>,
354 pending_bounce_starts: Vec<(usize, crate::message::OfflineBounceWork)>,
358 bounce_worker_tracks: HashMap<usize, String>,
362 pending_midi_learn: Option<(String, crate::message::TrackMidiLearnTarget, Option<String>)>,
363 pending_global_midi_learn: Option<crate::message::GlobalMidiLearnTarget>,
364 pending_session_midi_learn: Option<crate::message::SessionMidiLearnTarget>,
365 global_midi_learn_play_pause: Option<crate::message::MidiLearnBinding>,
366 global_midi_learn_stop: Option<crate::message::MidiLearnBinding>,
367 global_midi_learn_record_toggle: Option<crate::message::MidiLearnBinding>,
368 session_midi_learn_slots: HashMap<(String, usize), crate::message::MidiLearnBinding>,
369 session_midi_learn_scenes: HashMap<usize, crate::message::MidiLearnBinding>,
370 session_midi_learn_stop_track: HashMap<String, crate::message::MidiLearnBinding>,
371 session_midi_learn_stop_all: Option<crate::message::MidiLearnBinding>,
372 midi_cc_gate: HashMap<(String, u8, u8), bool>,
373 modulators: Vec<crate::modulator::Modulator>,
374 modulator_values: Option<Arc<std::collections::HashMap<usize, f32>>>,
375}
376
377type MidiEditParseResult = (
378 Vec<MidiNoteData>,
379 Vec<MidiControllerData>,
380 Vec<(u64, Vec<u8>)>,
381);
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::audio::clip::AudioClip;
387 use crate::message::PluginKind;
388 use crate::track::Track;
389 use std::path::Path;
390 use std::sync::atomic::Ordering;
391 use std::time::Duration;
392 use tokio::sync::mpsc::channel;
393 use tokio::time::{Duration as TokioDuration, timeout};
394
395 #[test]
396 #[cfg(unix)]
397 fn jack_transport_sync_decision_starts_and_syncs_position_on_external_play() {
398 let decision = Engine::jack_transport_sync_decision(false, 128, true, 256, 64);
399
400 assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Start));
401 assert_eq!(decision.position_sync, Some(256));
402 }
403
404 #[test]
405 #[cfg(unix)]
406 fn jack_transport_sync_decision_stops_and_syncs_position_on_external_stop() {
407 let decision = Engine::jack_transport_sync_decision(true, 512, false, 96, 64);
408
409 assert_eq!(decision.play_sync, Some(JackTransportPlaySync::Stop));
410 assert_eq!(decision.position_sync, Some(96));
411 }
412
413 #[test]
414 #[cfg(unix)]
415 fn jack_transport_sync_decision_ignores_small_rolling_drift() {
416 let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1040, 64);
417
418 assert_eq!(decision.play_sync, None);
419 assert_eq!(decision.position_sync, None);
420 }
421
422 #[test]
423 #[cfg(unix)]
424 fn jack_transport_sync_decision_syncs_large_rolling_jump() {
425 let decision = Engine::jack_transport_sync_decision(true, 1024, true, 1200, 64);
426
427 assert_eq!(decision.play_sync, None);
428 assert_eq!(decision.position_sync, Some(1200));
429 }
430
431 #[test]
432 #[cfg(unix)]
433 fn jack_transport_sync_decision_syncs_locate_while_stopped() {
434 let decision = Engine::jack_transport_sync_decision(false, 400, false, 900, 64);
435
436 assert_eq!(decision.play_sync, None);
437 assert_eq!(decision.position_sync, Some(900));
438 }
439
440 fn make_engine_with_client() -> (Engine, tokio::sync::mpsc::Receiver<Message>) {
441 let (engine_tx, engine_rx) = channel(16);
442 let mut engine = Engine::new(engine_rx, engine_tx);
443 let (client_tx, client_rx) = channel(16);
444 engine.clients.push(client_tx);
445 (engine, client_rx)
446 }
447
448 fn insert_track(engine: &mut Engine, track: Track) {
449 engine
450 .state
451 .lock()
452 .tracks
453 .insert(track.name.clone(), Arc::new(track));
454 engine.publish_state_snapshot();
455 engine.plan_builder.mark_dirty();
456 }
457
458 fn insert_track_for_modulator_test(engine: &mut Engine, track: Track) {
459 engine
460 .state
461 .lock()
462 .tracks
463 .insert(track.name.clone(), Arc::new(track));
464 engine.publish_state_snapshot();
465 }
466
467 fn osc_packet(address: &str) -> Vec<u8> {
468 fn push_padded_osc_string(packet: &mut Vec<u8>, value: &str) {
469 packet.extend_from_slice(value.as_bytes());
470 packet.push(0);
471 while !packet.len().is_multiple_of(4) {
472 packet.push(0);
473 }
474 }
475
476 let mut packet = Vec::new();
477 push_padded_osc_string(&mut packet, address);
478 push_padded_osc_string(&mut packet, ",");
479 packet
480 }
481
482 #[cfg_attr(
483 all(miri, target_os = "freebsd"),
484 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
485 )]
486 #[tokio::test]
487 async fn set_osc_enabled_starts_and_stops_server() {
488 let (mut engine, _client_rx) = make_engine_with_client();
489
490 engine
491 .set_osc_enabled_with(true, |tx| OscServer::start_on_addr(tx, "127.0.0.1:0"))
492 .expect("start osc server on ephemeral port");
493 assert!(engine.osc_server.is_some());
494
495 engine
496 .set_osc_enabled_with(false, OscServer::start)
497 .expect("stop osc server");
498 assert!(engine.osc_server.is_none());
499 }
500
501 #[cfg_attr(
502 all(miri, target_os = "freebsd"),
503 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
504 )]
505 #[tokio::test]
506 async fn set_tempo_map_is_recorded_and_undone() {
507 let (mut engine, _client_rx) = make_engine_with_client();
508 let original_tempo_points = engine.tempo_points.clone();
509 let original_time_signature_points = engine.time_signature_points.clone();
510
511 let new_tempo_points = vec![crate::message::TempoPoint {
512 sample: 0,
513 bpm: 140.0,
514 }];
515 let new_time_signature_points = vec![crate::message::TimeSignaturePoint {
516 sample: 0,
517 numerator: 3,
518 denominator: 4,
519 }];
520
521 engine
522 .handle_request(Action::SetTempoMap {
523 tempo_points: new_tempo_points.clone(),
524 time_signature_points: new_time_signature_points.clone(),
525 })
526 .await;
527
528 assert_eq!(engine.tempo_points, new_tempo_points);
529 assert_eq!(engine.time_signature_points, new_time_signature_points);
530 assert_eq!(engine.tempo_bpm, 140.0);
531 assert_eq!(engine.tsig_num, 3);
532 assert_eq!(engine.tsig_denom, 4);
533 assert!(engine.history.is_dirty());
534
535 engine.handle_request(Action::Undo).await;
536
537 assert_eq!(engine.tempo_points, original_tempo_points);
538 assert_eq!(engine.time_signature_points, original_time_signature_points);
539 assert_eq!(engine.tempo_bpm, 120.0);
540 assert_eq!(engine.tsig_num, 4);
541 assert_eq!(engine.tsig_denom, 4);
542 }
543
544 #[cfg_attr(
545 all(miri, target_os = "freebsd"),
546 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
547 )]
548 #[tokio::test]
549 async fn osc_server_forwards_transport_packets_to_engine_channel() {
550 let (tx, mut rx) = channel(4);
551 let mut server =
552 OscServer::start_on_addr(tx, "127.0.0.1:0").expect("start osc test server");
553 let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("bind sender socket");
554 let packet = osc_packet("/transport/play");
555 socket
556 .send_to(&packet, server.listen_addr())
557 .expect("send osc packet");
558
559 let message = timeout(TokioDuration::from_secs(1), rx.recv())
560 .await
561 .expect("packet delivery timeout")
562 .expect("osc message");
563 match message {
564 Message::OscRequest {
565 action: Action::Play,
566 ..
567 } => {}
568 other => panic!("unexpected osc message: {other:?}"),
569 }
570
571 server.stop();
572 }
573
574 #[cfg_attr(
575 all(miri, target_os = "freebsd"),
576 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
577 )]
578 #[tokio::test]
579 async fn track_offline_bounce_rejects_zero_length_requests() {
580 let (mut engine, mut client_rx) = make_engine_with_client();
581 insert_track(
582 &mut engine,
583 Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
584 );
585
586 engine
587 .handle_request(Action::TrackOfflineBounce {
588 track_name: "track".to_string(),
589 output_path: "/tmp/out.wav".to_string(),
590 start_sample: 0,
591 length_samples: 0,
592 automation_lanes: vec![],
593 apply_fader: false,
594 })
595 .await;
596
597 match client_rx.recv().await.expect("response") {
598 Message::Response(Err(err)) => {
599 assert!(err.contains("has no renderable content for offline bounce"));
600 }
601 other => panic!("unexpected message: {other:?}"),
602 }
603 }
604
605 #[cfg_attr(
606 all(miri, target_os = "freebsd"),
607 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
608 )]
609 #[tokio::test]
610 async fn track_offline_bounce_rejects_when_same_track_is_active() {
611 let (mut engine, mut client_rx) = make_engine_with_client();
612 engine.offline_bounce_jobs.insert(
613 "other".to_string(),
614 OfflineBounceJob {
615 cancel: Arc::new(AtomicBool::new(false)),
616 },
617 );
618
619 engine
620 .handle_request(Action::TrackOfflineBounce {
621 track_name: "other".to_string(),
622 output_path: "/tmp/out.wav".to_string(),
623 start_sample: 0,
624 length_samples: 128,
625 automation_lanes: vec![],
626 apply_fader: false,
627 })
628 .await;
629
630 match client_rx.recv().await.expect("response") {
631 Message::Response(Err(err)) => {
632 assert!(err.contains("already in progress"));
633 }
634 other => panic!("unexpected message: {other:?}"),
635 }
636 }
637
638 #[cfg_attr(
639 all(miri, target_os = "freebsd"),
640 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
641 )]
642 #[tokio::test]
643 async fn track_offline_bounce_allows_different_track_concurrently() {
644 let (mut engine, _client_rx) = make_engine_with_client();
645 insert_track(
646 &mut engine,
647 Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
648 );
649 engine.offline_bounce_jobs.insert(
650 "other".to_string(),
651 OfflineBounceJob {
652 cancel: Arc::new(AtomicBool::new(false)),
653 },
654 );
655
656 engine
657 .handle_request(Action::TrackOfflineBounce {
658 track_name: "track".to_string(),
659 output_path: "/tmp/out.wav".to_string(),
660 start_sample: 0,
661 length_samples: 128,
662 automation_lanes: vec![],
663 apply_fader: false,
664 })
665 .await;
666
667 assert!(engine.offline_bounce_jobs.contains_key("other"));
668 assert_eq!(engine.pending_requests.len(), 1);
669 }
670
671 #[cfg_attr(
672 all(miri, target_os = "freebsd"),
673 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
674 )]
675 #[tokio::test]
676 async fn reject_if_track_frozen_sends_error_and_blocks_operation() {
677 let (mut engine, mut client_rx) = make_engine_with_client();
678 let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
679 track.set_frozen(true);
680 insert_track(&mut engine, track);
681
682 let rejected = engine
683 .reject_if_track_frozen("track", "arming/disarming")
684 .await;
685
686 assert!(rejected);
687 match client_rx.recv().await.expect("response") {
688 Message::Response(Err(err)) => {
689 assert_eq!(err, "Track 'track' is frozen; arming/disarming is blocked");
690 }
691 other => panic!("unexpected message: {other:?}"),
692 }
693 }
694
695 #[cfg_attr(
696 all(miri, target_os = "freebsd"),
697 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
698 )]
699 #[tokio::test]
700 async fn dispatcher_track_mutations_publish_state_snapshot() {
701 let (mut engine, _client_rx) = make_engine_with_client();
702 insert_track(
703 &mut engine,
704 Track::new("snap".to_string(), 1, 1, 0, 0, 64, 48_000.0),
705 );
706 engine.publish_state_snapshot();
707
708 let snapshot = engine.state_snapshot.load_full();
709 assert!(snapshot.tracks.contains_key("snap"));
710
711 engine
712 .handle_request(Action::TrackToggleArm("snap".to_string()))
713 .await;
714
715 let snapshot = engine.state_snapshot.load_full();
716 assert!(snapshot.tracks.get("snap").unwrap().lock().armed());
717
718 engine
719 .handle_request(Action::RemoveTrack("snap".to_string()))
720 .await;
721
722 let snapshot = engine.state_snapshot.load_full();
723 assert!(!snapshot.tracks.contains_key("snap"));
724 }
725
726 #[cfg_attr(
727 all(miri, target_os = "freebsd"),
728 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
729 )]
730 #[tokio::test]
731 async fn undo_restores_original_clip_bounds_after_stretch_style_group() {
732 let (mut engine, _client_rx) = make_engine_with_client();
733 let track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
734 let mut clip = AudioClip::new("audio/original.wav".to_string(), 100, 220);
735 clip.offset = 12;
736 clip.fade_in_samples = 20;
737 clip.fade_out_samples = 30;
738 track.audio.push_clip(clip);
739 insert_track(&mut engine, track);
740
741 engine.handle_request(Action::BeginHistoryGroup).await;
742 engine
743 .handle_request(Action::SetClipBounds {
744 track_name: "track".to_string(),
745 clip_index: 0,
746 kind: Kind::Audio,
747 start: 120,
748 length: 180,
749 offset: 0,
750 })
751 .await;
752 engine
753 .handle_request(Action::SetClipSourceName {
754 track_name: "track".to_string(),
755 clip_index: 0,
756 kind: Kind::Audio,
757 name: "audio/stretched.wav".to_string(),
758 })
759 .await;
760 engine
761 .handle_request(Action::SetClipFade {
762 track_name: "track".to_string(),
763 clip_index: 0,
764 kind: Kind::Audio,
765 fade_enabled: true,
766 fade_in_samples: 12,
767 fade_out_samples: 12,
768 })
769 .await;
770 engine.handle_request(Action::EndHistoryGroup).await;
771
772 engine.handle_request(Action::Undo).await;
773
774 let state = engine.state.lock();
775 let track = state.tracks.get("track").expect("track exists").lock();
776 let clips = track.audio.clips();
777 let clip = clips.first().expect("clip exists");
778 assert_eq!(clip.name, "audio/original.wav");
779 assert_eq!(clip.start, 100);
780 assert_eq!(clip.end, 220);
781 assert_eq!(clip.end.saturating_sub(clip.start), 120);
782 assert_eq!(clip.offset, 12);
783 }
784
785 #[cfg_attr(
786 all(miri, target_os = "freebsd"),
787 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
788 )]
789 #[tokio::test]
790 async fn track_offline_bounce_queues_when_no_worker_is_ready() {
791 let (mut engine, _client_rx) = make_engine_with_client();
792 insert_track(
793 &mut engine,
794 Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
795 );
796
797 engine
798 .handle_request(Action::TrackOfflineBounce {
799 track_name: "track".to_string(),
800 output_path: "/tmp/out.wav".to_string(),
801 start_sample: 0,
802 length_samples: 128,
803 automation_lanes: vec![],
804 apply_fader: false,
805 })
806 .await;
807
808 assert!(engine.offline_bounce_jobs.is_empty());
809 assert_eq!(engine.pending_requests.len(), 1);
810 assert!(matches!(
811 engine.pending_requests.front(),
812 Some(Action::TrackOfflineBounce { track_name, length_samples, .. })
813 if track_name == "track" && *length_samples == 128
814 ));
815 }
816
817 #[cfg_attr(
818 all(miri, target_os = "freebsd"),
819 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
820 )]
821 #[tokio::test]
822 async fn track_offline_bounce_returns_missing_track_error() {
823 let (mut engine, mut client_rx) = make_engine_with_client();
824
825 engine
826 .handle_request(Action::TrackOfflineBounce {
827 track_name: "missing".to_string(),
828 output_path: "/tmp/out.wav".to_string(),
829 start_sample: 0,
830 length_samples: 128,
831 automation_lanes: vec![],
832 apply_fader: false,
833 })
834 .await;
835
836 match client_rx.recv().await.expect("response") {
837 Message::Response(Err(err)) => {
838 assert_eq!(err, "Track not found: missing");
839 }
840 other => panic!("unexpected message: {other:?}"),
841 }
842 }
843
844 #[cfg_attr(
845 all(miri, target_os = "freebsd"),
846 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
847 )]
848 #[tokio::test]
849 async fn track_offline_bounce_clears_job_when_worker_send_fails() {
850 let (mut engine, mut client_rx) = make_engine_with_client();
851 insert_track(
852 &mut engine,
853 Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0),
854 );
855 let (worker_tx, worker_rx) = channel(1);
856 drop(worker_rx);
857 engine
858 .workers
859 .push(WorkerData::new(worker_tx, tokio::spawn(async {})));
860 engine.ready_workers.push(0);
861
862 engine
863 .handle_request(Action::TrackOfflineBounce {
864 track_name: "track".to_string(),
865 output_path: "/tmp/out.wav".to_string(),
866 start_sample: 0,
867 length_samples: 128,
868 automation_lanes: vec![],
869 apply_fader: false,
870 })
871 .await;
872
873 assert!(engine.offline_bounce_jobs.is_empty());
874 match client_rx.recv().await.expect("response") {
875 Message::Response(Err(err)) => {
876 assert!(err.contains("Failed to schedule offline bounce"));
877 }
878 other => panic!("unexpected message: {other:?}"),
879 }
880 }
881
882 #[cfg_attr(
883 all(miri, target_os = "freebsd"),
884 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
885 )]
886 #[tokio::test]
887 async fn play_stop_play_keeps_clip_output_audible() {
888 use crate::audio::clip::AudioClip;
889 use crate::audio_codec::write_wav_f32;
890
891 let (engine_tx, engine_rx) = channel(16);
892 let mut engine = Engine::new(engine_rx, engine_tx);
893 let state = engine.state();
894 let (client_tx, mut client_rx) = channel(16);
895 engine.clients.push(client_tx);
896 engine.init().await;
897
898 let tmp_dir = std::env::temp_dir().join("maolan_play_stop_play_test");
899 let _ = std::fs::create_dir_all(&tmp_dir);
900 let wav_path = tmp_dir.join("tone.wav");
901 let sample_rate = 48_000u32;
902 let clip_samples = sample_rate as usize;
903 let mut samples = Vec::with_capacity(clip_samples);
904 for i in 0..clip_samples {
905 let phase = i as f32 / sample_rate as f32 * 2.0 * std::f32::consts::PI * 440.0;
906 samples.push(phase.sin() * 0.5);
907 }
908 write_wav_f32(&wav_path, &samples, 1, sample_rate).expect("write wav");
909
910 let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 1024, sample_rate as f64);
911 let mut clip = AudioClip::new(wav_path.to_string_lossy().to_string(), 0, clip_samples);
912 clip.fade_enabled = false;
913 track.audio.push_clip(clip);
914 track.session_base_dir = Some(tmp_dir.clone());
915 insert_track(&mut engine, track);
916
917 let tx = engine.tx.clone();
918 let work_handle = tokio::spawn(async move {
919 engine.work().await;
920 });
921
922 tokio::time::sleep(TokioDuration::from_millis(100)).await;
924
925 async fn drain_responses(
926 client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
927 count: usize,
928 ) {
929 for _ in 0..count {
930 let _ = tokio::time::timeout(TokioDuration::from_secs(2), client_rx.recv()).await;
931 }
932 }
933
934 async fn wait_for_audible_track(
935 client_rx: &mut tokio::sync::mpsc::Receiver<Message>,
936 state: &State,
937 ) -> Option<f32> {
938 let deadline = Instant::now() + Duration::from_secs(5);
939 while Instant::now() < deadline {
940 {
941 let state = state.lock();
942 let peak = state
943 .tracks
944 .get("track")
945 .map(|t| {
946 t.lock()
947 .output_meter_linear()
948 .into_iter()
949 .fold(0.0_f32, f32::max)
950 })
951 .unwrap_or(0.0);
952 if peak > 0.001 {
953 return Some(peak);
954 }
955 }
956 let _ =
957 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await;
958 tokio::time::sleep(TokioDuration::from_millis(10)).await;
959 }
960 None
961 }
962
963 tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
964 .await
965 .unwrap();
966 tx.send(Message::Request(Action::Play)).await.unwrap();
967 let first_peak = wait_for_audible_track(&mut client_rx, &state)
968 .await
969 .unwrap_or(0.0);
970 assert!(
971 first_peak > 0.001,
972 "expected audible output on first play, got {first_peak}"
973 );
974
975 tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
976 .await
977 .unwrap();
978 tx.send(Message::Request(Action::Stop)).await.unwrap();
979 drain_responses(&mut client_rx, 2).await;
980
981 tx.send(Message::Request(Action::SetClipPlaybackEnabled(true)))
982 .await
983 .unwrap();
984 tx.send(Message::Request(Action::Play)).await.unwrap();
985 let second_peak = wait_for_audible_track(&mut client_rx, &state)
986 .await
987 .unwrap_or(0.0);
988 assert!(
989 second_peak > 0.001,
990 "expected audible output on second play after stop, got {second_peak}"
991 );
992
993 let _ = tx.send(Message::Request(Action::Quit)).await;
994 tokio::time::sleep(TokioDuration::from_millis(200)).await;
995 work_handle.abort();
996 let _ = std::fs::remove_dir_all(&tmp_dir);
997 }
998
999 #[test]
1000 fn modulator_sets_track_volume() {
1001 let (mut engine, _client_rx) = make_engine_with_client();
1002 let track = Track::new("vol-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
1003 insert_track_for_modulator_test(&mut engine, track);
1004
1005 engine.modulators = vec![crate::modulator::Modulator {
1006 id: 1,
1007 name: "LFO".to_string(),
1008 shape: crate::modulator::ModulatorShape::Sine,
1009 rate: crate::modulator::ModulatorRate::Hz(1.0),
1010 phase: 0.0,
1011 enabled: true,
1012 targets: vec![crate::modulator::ModulatorTarget::TrackVolume {
1013 track_name: "vol-track".to_string(),
1014 min: -90.0,
1015 max: 20.0,
1016 }],
1017 }];
1018
1019 let echoes = engine.apply_modulators(12_000);
1021 let state_guard = engine.state.lock();
1022 let track = state_guard.tracks["vol-track"].lock();
1023 assert!(
1024 (track.level() - 20.0).abs() < 0.01,
1025 "expected 20 dB, got {}",
1026 track.level()
1027 );
1028 assert!(
1029 echoes
1030 .iter()
1031 .any(|a| matches!(a, Action::TrackAutomationLevel(name, _) if name == "vol-track"))
1032 );
1033 }
1034
1035 #[test]
1036 fn modulator_sets_track_balance() {
1037 let (mut engine, _client_rx) = make_engine_with_client();
1038 let track = Track::new("pan-track".to_string(), 0, 2, 0, 0, 128, 48_000.0);
1039 insert_track_for_modulator_test(&mut engine, track);
1040
1041 engine.modulators = vec![crate::modulator::Modulator {
1042 id: 1,
1043 name: "LFO".to_string(),
1044 shape: crate::modulator::ModulatorShape::Sine,
1045 rate: crate::modulator::ModulatorRate::Hz(1.0),
1046 phase: 0.0,
1047 enabled: true,
1048 targets: vec![crate::modulator::ModulatorTarget::TrackBalance {
1049 track_name: "pan-track".to_string(),
1050 min: -1.0,
1051 max: 1.0,
1052 }],
1053 }];
1054
1055 let echoes = engine.apply_modulators(12_000);
1057 let state_guard = engine.state.lock();
1058 let track = state_guard.tracks["pan-track"].lock();
1059 assert!(
1060 (track.balance() - 1.0).abs() < 0.01,
1061 "expected balance 1.0, got {}",
1062 track.balance()
1063 );
1064 assert!(
1065 echoes.iter().any(
1066 |a| matches!(a, Action::TrackAutomationBalance(name, _) if name == "pan-track")
1067 )
1068 );
1069 }
1070
1071 #[cfg_attr(
1072 all(miri, target_os = "freebsd"),
1073 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1074 )]
1075 #[tokio::test]
1076 async fn track_set_parent_wires_folder_input_to_child_input_and_child_output_to_folder_output()
1077 {
1078 let (mut engine, mut client_rx) = make_engine_with_client();
1079 let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1080 let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1081 insert_track(&mut engine, folder);
1082 insert_track(&mut engine, child);
1083
1084 engine
1085 .handle_request_inner(
1086 Action::TrackSetParent {
1087 track_name: "child".to_string(),
1088 parent_name: Some("folder".to_string()),
1089 },
1090 false,
1091 )
1092 .await;
1093
1094 while let Ok(Some(_)) =
1096 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1097 {}
1098
1099 let state = engine.state.lock();
1100 let folder = state.tracks.get("folder").unwrap().lock();
1101 let child = state.tracks.get("child").unwrap().lock();
1102
1103 assert!(folder.child_tracks.iter().any(|c| c.lock().name == "child"));
1104 assert_eq!(child.parent_track.as_deref(), Some("folder"));
1105
1106 for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
1108 {
1109 assert!(
1110 child_in
1111 .connections()
1112 .iter()
1113 .any(|c| Arc::ptr_eq(c, parent_in)),
1114 "folder input {i} is not routed to child input {i}"
1115 );
1116 assert!(
1117 !parent_in
1118 .connections()
1119 .iter()
1120 .any(|c| Arc::ptr_eq(c, child_in)),
1121 "folder input {i} should not read from child input {i}"
1122 );
1123 }
1124
1125 for (i, (child_out, parent_out)) in
1127 child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
1128 {
1129 assert!(
1130 parent_out
1131 .connections()
1132 .iter()
1133 .any(|c| Arc::ptr_eq(c, child_out)),
1134 "child output {i} is not routed to folder output {i}"
1135 );
1136 }
1137
1138 for (i, child_out) in child.audio.outs.iter().enumerate() {
1140 assert!(
1141 child_out.connections().iter().any(|c| {
1142 child
1143 .audio
1144 .ins
1145 .get(i)
1146 .is_some_and(|inp| Arc::ptr_eq(c, inp))
1147 }),
1148 "child output {i} is not connected to child input {i}"
1149 );
1150 }
1151 }
1152
1153 #[cfg_attr(
1154 all(miri, target_os = "freebsd"),
1155 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1156 )]
1157 #[tokio::test]
1158 async fn track_set_parent_to_none_restores_root_passthrough() {
1159 let (mut engine, mut client_rx) = make_engine_with_client();
1160 let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1161 let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1162 insert_track(&mut engine, folder);
1163 insert_track(&mut engine, child);
1164
1165 engine
1166 .handle_request_inner(
1167 Action::TrackSetParent {
1168 track_name: "child".to_string(),
1169 parent_name: Some("folder".to_string()),
1170 },
1171 false,
1172 )
1173 .await;
1174 engine
1175 .handle_request_inner(
1176 Action::TrackSetParent {
1177 track_name: "child".to_string(),
1178 parent_name: None,
1179 },
1180 false,
1181 )
1182 .await;
1183
1184 while let Ok(Some(_)) =
1185 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1186 {}
1187
1188 let state = engine.state.lock();
1189 let folder = state.tracks.get("folder").unwrap().lock();
1190 let child = state.tracks.get("child").unwrap().lock();
1191
1192 assert!(folder.child_tracks.is_empty());
1193 assert!(child.parent_track.is_none());
1194
1195 for (i, child_out) in child.audio.outs.iter().enumerate() {
1196 assert!(
1197 child_out.connections().iter().any(|c| {
1198 child
1199 .audio
1200 .ins
1201 .get(i)
1202 .is_some_and(|inp| Arc::ptr_eq(c, inp))
1203 }),
1204 "child output {i} should be connected to child input {i} after moving to root"
1205 );
1206 }
1207 }
1208
1209 #[cfg_attr(
1210 all(miri, target_os = "freebsd"),
1211 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1212 )]
1213 #[tokio::test]
1214 async fn track_set_parent_wires_folder_midi_to_child_midi() {
1215 let (mut engine, mut client_rx) = make_engine_with_client();
1216 let folder = Track::new_folder("folder".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1217 let child = Track::new("child".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1218 insert_track(&mut engine, folder);
1219 insert_track(&mut engine, child);
1220
1221 engine
1222 .handle_request_inner(
1223 Action::TrackSetParent {
1224 track_name: "child".to_string(),
1225 parent_name: Some("folder".to_string()),
1226 },
1227 false,
1228 )
1229 .await;
1230
1231 while let Ok(Some(_)) =
1232 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1233 {}
1234
1235 let state = engine.state.lock();
1236 let folder = state.tracks.get("folder").unwrap().lock();
1237 let child = state.tracks.get("child").unwrap().lock();
1238
1239 let folder_midi_in = &folder.midi.ins[0];
1240 let child_midi_in = &child.midi.ins[0];
1241 assert!(
1242 child_midi_in
1243 .connections()
1244 .iter()
1245 .any(|c| Arc::ptr_eq(c, folder_midi_in)),
1246 "folder MIDI input should be routed to child MIDI input"
1247 );
1248
1249 let child_midi_out = &child.midi.outs[0];
1250 let folder_midi_out = &folder.midi.outs[0];
1251 assert!(
1252 child_midi_out
1253 .connections()
1254 .iter()
1255 .any(|c| Arc::ptr_eq(c, folder_midi_out)),
1256 "child MIDI output should be routed to folder MIDI output"
1257 );
1258 }
1259
1260 fn plan_task_node(
1261 plan: &crate::render_plan::RenderPlan,
1262 name: &str,
1263 want: fn(&crate::message::ProcessTask) -> bool,
1264 ) -> usize {
1265 use crate::message::ProcessTask;
1266 use crate::render_plan::Op;
1267 plan.nodes
1268 .iter()
1269 .enumerate()
1270 .find_map(|(i, op)| match op {
1271 Op::Task { task, .. } => {
1272 let track = match task {
1273 ProcessTask::Track(t)
1274 | ProcessTask::FolderInput(t)
1275 | ProcessTask::FolderOutput(t) => t,
1276 ProcessTask::Plugin { track, .. } => track,
1277 };
1278 if track.lock().name == name && want(task) {
1279 Some(i)
1280 } else {
1281 None
1282 }
1283 }
1284 _ => None,
1285 })
1286 .expect("task node not found")
1287 }
1288
1289 fn plan_reachable(plan: &crate::render_plan::RenderPlan, from: usize, to: usize) -> bool {
1290 let mut seen = std::collections::HashSet::new();
1291 let mut queue = std::collections::VecDeque::from([from as u32]);
1292 while let Some(n) = queue.pop_front() {
1293 for &d in &plan.dependents[n as usize] {
1294 if d as usize == to {
1295 return true;
1296 }
1297 if seen.insert(d) {
1298 queue.push_back(d);
1299 }
1300 }
1301 }
1302 false
1303 }
1304
1305 #[test]
1306 fn nested_folder_expands_in_render_plan() {
1307 use crate::message::ProcessTask;
1308
1309 let state = crate::state::State::default();
1310 let outer = Arc::new(Track::new_folder(
1311 "outer".to_string(),
1312 2,
1313 2,
1314 0,
1315 0,
1316 64,
1317 48_000.0,
1318 ));
1319 let inner = Arc::new(Track::new_folder(
1320 "inner".to_string(),
1321 2,
1322 2,
1323 0,
1324 0,
1325 64,
1326 48_000.0,
1327 ));
1328 let leaf = Arc::new(Track::new("leaf".to_string(), 2, 2, 0, 0, 64, 48_000.0));
1329 outer.lock().child_tracks.push(inner.clone());
1330 inner.lock().child_tracks.push(leaf.clone());
1331 inner.lock().parent_track = Some("outer".to_string());
1332 leaf.lock().parent_track = Some("inner".to_string());
1333 {
1334 let mut state = state.lock();
1335 state.tracks.insert("outer".to_string(), outer);
1336 state.tracks.insert("inner".to_string(), inner);
1337 state.tracks.insert("leaf".to_string(), leaf);
1338 }
1339
1340 let plan = crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64);
1341 plan.verify().expect("plan invariants");
1342
1343 let is_fi = |t: &ProcessTask| matches!(t, ProcessTask::FolderInput(_));
1344 let is_fo = |t: &ProcessTask| matches!(t, ProcessTask::FolderOutput(_));
1345 let is_track = |t: &ProcessTask| matches!(t, ProcessTask::Track(_));
1346 let in_outer = plan_task_node(&plan, "outer", is_fi);
1347 let in_inner = plan_task_node(&plan, "inner", is_fi);
1348 let track_leaf = plan_task_node(&plan, "leaf", is_track);
1349 let out_inner = plan_task_node(&plan, "inner", is_fo);
1350 let out_outer = plan_task_node(&plan, "outer", is_fo);
1351
1352 assert!(
1353 in_outer < in_inner
1354 && in_inner < track_leaf
1355 && track_leaf < out_inner
1356 && out_inner < out_outer,
1357 "nested folder tasks should expand in topological order"
1358 );
1359 for (a, b) in [
1360 (in_outer, in_inner),
1361 (in_inner, track_leaf),
1362 (track_leaf, out_inner),
1363 (out_inner, out_outer),
1364 ] {
1365 assert!(plan_reachable(&plan, a, b), "{a} should reach {b}");
1366 }
1367 assert!(plan.forced.is_empty(), "no feedback cycle");
1368 }
1369
1370 #[cfg_attr(
1371 all(miri, target_os = "freebsd"),
1372 ignore = "uses filesystem metadata, which Miri isolation does not support on FreeBSD"
1373 )]
1374 #[test]
1375 fn child_to_plugin_to_folder_output_render_plan_has_no_cycle() {
1376 use crate::message::{ConnectableRef, ProcessTask};
1377
1378 let plugin_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1379 .parent()
1380 .unwrap()
1381 .join("daw")
1382 .join("plugin-host")
1383 .join("tests")
1384 .join("test_passthrough.clap");
1385 if !plugin_path.exists() {
1386 return;
1387 }
1388 if crate::plugins::ipc::find_plugin_host_binary().is_none() {
1389 return;
1390 }
1391
1392 let (mut engine, _client_rx) = make_engine_with_client();
1393 let mut folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1394 let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1395
1396 folder
1397 .load_clap_plugin(
1398 &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
1399 None,
1400 )
1401 .expect("should load CLAP plugin on folder");
1402 folder.clap_plugins[0].processor.setup_audio_ports();
1403 let plugin_id = folder.clap_plugins[0].id;
1404
1405 insert_track(&mut engine, folder);
1406 insert_track(&mut engine, child);
1407
1408 {
1409 let state = engine.state.lock();
1410 let folder = state.tracks.get("folder").unwrap().clone();
1411 let child = state.tracks.get("child").unwrap().clone();
1412 folder.lock().child_tracks.push(child.clone());
1413 child.lock().parent_track = Some("folder".to_string());
1414
1415 folder
1416 .lock()
1417 .connect_audio_connectable(
1418 ConnectableRef::ChildTrack("child".to_string()),
1419 0,
1420 ConnectableRef::ClapPlugin(plugin_id),
1421 0,
1422 )
1423 .expect("connect child L to plugin L");
1424 folder
1425 .lock()
1426 .connect_audio_connectable(
1427 ConnectableRef::ChildTrack("child".to_string()),
1428 1,
1429 ConnectableRef::ClapPlugin(plugin_id),
1430 1,
1431 )
1432 .expect("connect child R to plugin R");
1433 folder
1434 .lock()
1435 .connect_audio_connectable(
1436 ConnectableRef::ClapPlugin(plugin_id),
1437 0,
1438 ConnectableRef::TrackOutput,
1439 0,
1440 )
1441 .expect("connect plugin L to folder output L");
1442 folder
1443 .lock()
1444 .connect_audio_connectable(
1445 ConnectableRef::ClapPlugin(plugin_id),
1446 1,
1447 ConnectableRef::TrackOutput,
1448 1,
1449 )
1450 .expect("connect plugin R to folder output R");
1451 }
1452
1453 let plan = {
1454 let state = engine.state.lock();
1455 crate::render_plan::RenderPlan::compile(&state.snapshot(), &[], &[], 64)
1456 };
1457 plan.verify().expect("plan invariants");
1458
1459 let folder_in = plan_task_node(&plan, "folder", |t| {
1460 matches!(t, ProcessTask::FolderInput(_))
1461 });
1462 let child_task = plan_task_node(&plan, "child", |t| matches!(t, ProcessTask::Track(_)));
1463 let plugin = plan_task_node(&plan, "folder", |t| {
1464 matches!(
1465 t,
1466 ProcessTask::Plugin {
1467 kind: PluginKind::Clap,
1468 index: 0,
1469 ..
1470 }
1471 )
1472 });
1473 let folder_out = plan_task_node(&plan, "folder", |t| {
1474 matches!(t, ProcessTask::FolderOutput(_))
1475 });
1476
1477 assert!(
1478 plan_reachable(&plan, folder_in, child_task),
1479 "child task should depend on folder input"
1480 );
1481 assert!(
1482 plan_reachable(&plan, folder_in, plugin) && plan_reachable(&plan, child_task, plugin),
1483 "plugin task should depend on folder input and child"
1484 );
1485 assert!(
1486 plan_reachable(&plan, folder_in, folder_out)
1487 && plan_reachable(&plan, plugin, folder_out)
1488 && plan_reachable(&plan, child_task, folder_out),
1489 "folder output should depend on folder input, plugin, and child"
1490 );
1491 assert!(
1492 plan.forced.is_empty(),
1493 "render plan should not contain a cycle when a plugin reads from a child track"
1494 );
1495 }
1496
1497 #[cfg_attr(
1498 all(miri, target_os = "freebsd"),
1499 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1500 )]
1501 #[tokio::test]
1502 async fn track_set_parent_wires_child_io_to_folder_even_after_addtrack() {
1503 let (mut engine, mut client_rx) = make_engine_with_client();
1504 let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1505 let child = Track::new("child".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1506 insert_track(&mut engine, folder);
1507 insert_track(&mut engine, child);
1508
1509 engine
1510 .handle_request_inner(
1511 Action::TrackSetParent {
1512 track_name: "child".to_string(),
1513 parent_name: Some("folder".to_string()),
1514 },
1515 false,
1516 )
1517 .await;
1518
1519 while let Ok(Some(_)) =
1520 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1521 {}
1522
1523 let state = engine.state.lock();
1524 let folder = state.tracks.get("folder").unwrap().lock();
1525 let child = state.tracks.get("child").unwrap().lock();
1526
1527 for (i, (parent_in, child_in)) in folder.audio.ins.iter().zip(&child.audio.ins).enumerate()
1529 {
1530 assert!(
1531 child_in
1532 .connections()
1533 .iter()
1534 .any(|c| Arc::ptr_eq(c, parent_in)),
1535 "folder input {i} is not routed to child input {i}"
1536 );
1537 }
1538
1539 for (i, (child_out, parent_out)) in
1541 child.audio.outs.iter().zip(&folder.audio.outs).enumerate()
1542 {
1543 assert!(
1544 parent_out
1545 .connections()
1546 .iter()
1547 .any(|c| Arc::ptr_eq(c, child_out)),
1548 "child output {i} is not routed to folder output {i}"
1549 );
1550 }
1551 }
1552
1553 #[cfg_attr(
1554 all(miri, target_os = "freebsd"),
1555 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1556 )]
1557 #[tokio::test]
1558 async fn folder_child_audio_passes_through() {
1559 let (mut engine, mut client_rx) = make_engine_with_client();
1560 let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1561 let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1562 insert_track(&mut engine, folder);
1563 insert_track(&mut engine, child);
1564
1565 engine
1566 .handle_request_inner(
1567 Action::TrackSetParent {
1568 track_name: "child".to_string(),
1569 parent_name: Some("folder".to_string()),
1570 },
1571 false,
1572 )
1573 .await;
1574 while let Ok(Some(_)) =
1575 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1576 {}
1577
1578 {
1579 let state = engine.state.lock();
1580 let folder = state.tracks.get("folder").unwrap().clone();
1581 let child = state.tracks.get("child").unwrap().clone();
1582
1583 folder.lock().set_input_monitor(vec![true]);
1584 child.lock().set_input_monitor(vec![true]);
1585
1586 let source = Arc::new(crate::audio::io::AudioIO::new(64));
1589 crate::audio::io::AudioIO::connect(&source, &folder.lock().audio.ins[0]);
1590 let plan =
1591 crate::render_plan::RenderPlan::compile(&state.snapshot(), &[source], &[], 64);
1592 plan.verify().expect("plan invariants");
1593 let (_, hw_buf) = plan.hw_in_map[0];
1594 unsafe { (&mut *plan.buffer_ptr(hw_buf)).fill(0.75) };
1597 let collector = basedrop::Collector::new();
1598 let shared = Arc::new(basedrop::Owned::new(&collector.handle(), plan));
1599 for node in 0..shared.nodes.len() {
1600 crate::workers::worker::Worker::process_node_job_result(
1601 0,
1602 crate::executor::NodeJob {
1603 epoch: 0,
1604 plan: shared.clone(),
1605 node: node as u32,
1606 },
1607 );
1608 }
1609
1610 let folder_lock = folder.lock();
1611 let output = folder_lock.last_audio_outputs()[0].clone();
1612 assert!(
1613 output.iter().any(|s| (*s - 0.75).abs() < 1e-5),
1614 "folder output should contain the child-processed folder input signal, got {:?}",
1615 output.iter().take(8).collect::<Vec<_>>()
1616 );
1617 }
1618 }
1619
1620 #[cfg_attr(
1621 all(miri, target_os = "freebsd"),
1622 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1623 )]
1624 #[tokio::test]
1625 async fn remove_folder_track_deletes_descendants_recursively() {
1626 let (mut engine, mut client_rx) = make_engine_with_client();
1627 let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1628 let child = Track::new_folder("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1629 let grandchild = Track::new("grandchild".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1630 insert_track(&mut engine, folder);
1631 insert_track(&mut engine, child);
1632 insert_track(&mut engine, grandchild);
1633
1634 engine
1635 .handle_request(Action::TrackSetParent {
1636 track_name: "child".to_string(),
1637 parent_name: Some("folder".to_string()),
1638 })
1639 .await;
1640 engine
1641 .handle_request(Action::TrackSetParent {
1642 track_name: "grandchild".to_string(),
1643 parent_name: Some("child".to_string()),
1644 })
1645 .await;
1646
1647 while let Ok(Some(_)) =
1649 tokio::time::timeout(TokioDuration::from_millis(10), client_rx.recv()).await
1650 {}
1651
1652 engine
1653 .handle_request(Action::RemoveTrack("folder".to_string()))
1654 .await;
1655
1656 {
1657 let state = engine.state.lock();
1658 assert!(
1659 !state.tracks.contains_key("folder"),
1660 "folder should have been removed"
1661 );
1662 assert!(
1663 !state.tracks.contains_key("child"),
1664 "child should have been removed"
1665 );
1666 assert!(
1667 !state.tracks.contains_key("grandchild"),
1668 "grandchild should have been removed"
1669 );
1670 }
1671
1672 let mut removed_names = Vec::new();
1673 for _ in 0..3 {
1674 let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1675 if let Ok(Some(Message::Response(Ok(Action::RemoveTrack(name))))) = msg {
1676 removed_names.push(name);
1677 }
1678 }
1679 assert_eq!(
1680 removed_names,
1681 vec!["grandchild", "child", "folder"],
1682 "descendants should be removed before the folder and clients notified"
1683 );
1684 }
1685
1686 #[cfg_attr(
1687 all(miri, target_os = "freebsd"),
1688 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1689 )]
1690 #[tokio::test]
1691 async fn track_set_folder_rejects_master_track() {
1692 let (mut engine, mut client_rx) = make_engine_with_client();
1693 let track = Track::new("master".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1694 track.is_master.store(true, Ordering::Relaxed);
1695 insert_track(&mut engine, track);
1696
1697 engine
1698 .handle_request_inner(
1699 Action::TrackSetFolder {
1700 track_name: "master".to_string(),
1701 is_folder: true,
1702 },
1703 false,
1704 )
1705 .await;
1706
1707 {
1708 let state = engine.state.lock();
1709 assert!(!state.tracks.get("master").unwrap().lock().is_folder);
1710 }
1711
1712 let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1713 assert!(
1714 matches!(msg, Ok(Some(Message::Response(Err(_))))),
1715 "master track folder conversion should report an error"
1716 );
1717 }
1718
1719 #[cfg_attr(
1720 all(miri, target_os = "freebsd"),
1721 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1722 )]
1723 #[tokio::test]
1724 async fn track_toggle_master_ignored_for_folder_track() {
1725 let (mut engine, mut client_rx) = make_engine_with_client();
1726 let folder = Track::new_folder("folder".to_string(), 2, 2, 0, 0, 64, 48_000.0);
1727 insert_track(&mut engine, folder);
1728
1729 engine
1730 .handle_request_inner(Action::TrackToggleMaster("folder".to_string()), false)
1731 .await;
1732
1733 {
1734 let state = engine.state.lock();
1735 assert!(!state.tracks.get("folder").unwrap().lock().is_master());
1736 }
1737
1738 let msg = tokio::time::timeout(TokioDuration::from_millis(100), client_rx.recv()).await;
1739 assert!(
1740 matches!(
1741 msg,
1742 Ok(Some(Message::Response(Ok(Action::TrackToggleMaster(ref name)))))
1743 if name == "folder"
1744 ),
1745 "folder track master toggle should still be echoed to clients: {msg:?}"
1746 );
1747 }
1748}