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