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