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