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