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