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