1use crate::{
2 executor::NodeJob,
3 message::{
4 Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
5 OfflineBounceWork, ProcessTask,
6 },
7 midi::io::MidiEvent,
8 render_plan::Op,
9};
10#[cfg(unix)]
11use nix::libc;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tokio::sync::mpsc::{Receiver, Sender};
15
16pub(crate) struct NodeJobResult {
17 pub(crate) worker_id: usize,
18 pub(crate) epoch: u64,
19 pub(crate) node: u32,
20 pub(crate) output_linear: Vec<f32>,
21 pub(crate) parameter_updates: Vec<Action>,
22 pub(crate) latency_changed: bool,
23}
24
25#[derive(Debug)]
26pub struct Worker {
27 id: usize,
28 rx: Receiver<Message>,
29 tx: Sender<Message>,
30 realtime_priority: i32,
31}
32
33impl Worker {
34 fn automation_lane_value_at(points: &[OfflineAutomationPoint], sample: usize) -> Option<f32> {
35 if points.is_empty() {
36 return None;
37 }
38 if sample <= points[0].sample {
39 return Some(points[0].value.clamp(0.0, 1.0));
40 }
41 if sample >= points[points.len().saturating_sub(1)].sample {
42 return Some(points[points.len().saturating_sub(1)].value.clamp(0.0, 1.0));
43 }
44 for segment in points.windows(2) {
45 let left = &segment[0];
46 let right = &segment[1];
47 if sample < left.sample || sample > right.sample {
48 continue;
49 }
50 let span = right.sample.saturating_sub(left.sample).max(1) as f32;
51 let t = (sample.saturating_sub(left.sample) as f32 / span).clamp(0.0, 1.0);
52 return Some((left.value + (right.value - left.value) * t).clamp(0.0, 1.0));
53 }
54 None
55 }
56
57 fn apply_freeze_automation_at_sample(
58 track: &mut crate::track::TrackData,
59 sample: usize,
60 lanes: &[OfflineAutomationLane],
61 ) {
62 for lane in lanes {
63 if matches!(
64 lane.target,
65 OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance
66 ) {
67 continue;
68 }
69 let Some(value) = Self::automation_lane_value_at(&lane.points, sample) else {
70 continue;
71 };
72 match lane.target {
73 OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance => {}
74 OfflineAutomationTarget::MidiCc { channel, cc } => {
75 let cc_value = (value * 127.0).round() as u8;
76 track.rt.pending_automation_midi_events.push(MidiEvent::new(
77 0,
78 vec![0xB0 | channel.min(15), cc.min(127), cc_value],
79 ));
80 }
81 #[cfg(all(unix, not(target_os = "macos")))]
82 OfflineAutomationTarget::Lv2Parameter {
83 instance_id,
84 index,
85 min,
86 max,
87 } => {
88 let lo = min.min(max);
89 let hi = max.max(min);
90 let param_value = (lo + value * (hi - lo)).clamp(lo, hi);
91 let _ = track.set_lv2_control_value(
92 instance_id,
93 index as usize,
94 param_value as f64,
95 );
96 }
97 OfflineAutomationTarget::Vst3Parameter {
98 instance_id,
99 param_id,
100 } => {
101 let _ = track.set_vst3_parameter(instance_id, param_id, value.clamp(0.0, 1.0));
102 }
103 OfflineAutomationTarget::ClapParameter {
104 instance_id,
105 param_id,
106 min,
107 max,
108 } => {
109 let lo = min.min(max);
110 let hi = max.max(min);
111 let param_value = (lo + value as f64 * (hi - lo)).clamp(lo, hi);
112 let _ = track.set_clap_parameter_at(instance_id, param_id, param_value, 0);
113 }
114 }
115 }
116 }
117
118 fn prepare_track_for_freeze_render(track: &mut crate::track::TrackData) -> (f32, f32) {
119 let original_level = track.level();
120 let original_balance = track.balance();
121 track.set_level(0.0);
122 track.set_balance(0.0);
123 (original_level, original_balance)
124 }
125
126 fn restore_track_after_freeze_render(
127 track: &mut crate::track::TrackData,
128 original_level: f32,
129 original_balance: f32,
130 ) {
131 track.set_level(original_level);
132 track.set_balance(original_balance);
133 }
134
135 async fn process_offline_bounce(&self, job: OfflineBounceWork) {
136 let track_handle = job.state.tracks.get(&job.track_name).cloned();
137 let Some(target_track) = track_handle else {
138 let _ = self
139 .tx
140 .send(Message::OfflineBounceFinished {
141 result: Err(format!("Track not found: {}", job.track_name)),
142 })
143 .await;
144 let _ = self.tx.send(Message::Ready(self.id)).await;
145 return;
146 };
147 let (channels, block_size, sample_rate) = {
148 let t = target_track.lock();
149 let block_size = t
150 .audio
151 .outs
152 .first()
153 .map(|io| io.buffer_size())
154 .or_else(|| t.audio.ins.first().map(|io| io.buffer_size()))
155 .unwrap_or(0)
156 .max(1);
157 (
158 t.audio.outs.len().max(1),
159 block_size,
160 t.sample_rate.round().max(1.0) as i32,
161 )
162 };
163 let freeze_state = if job.apply_fader {
164 None
165 } else {
166 let mut t = target_track.lock();
167 Some(Self::prepare_track_for_freeze_render(&mut t))
168 };
169
170 let all_tracks: Vec<_> = job.state.tracks.values().cloned().collect();
171 let plan_collector = basedrop::Collector::new();
172 let render_plan = crate::render_plan::RenderPlan::compile(&job.state, &[], &[], block_size);
173 let render_plan = Arc::new(basedrop::Owned::new(&plan_collector.handle(), render_plan));
174
175 let mut output_samples =
176 Vec::<f32>::with_capacity(job.length_samples.saturating_mul(channels.max(1)));
177
178 let mut cursor = 0usize;
179 let mut last_reported_progress = 0.0_f32;
180 let mut total_process_time = Duration::ZERO;
181 let mut total_write_time = Duration::ZERO;
182 while cursor < job.length_samples {
183 if job.cancel.load(std::sync::atomic::Ordering::Relaxed) {
184 let _ = std::fs::remove_file(&job.output_path);
185 if let Some((original_level, original_balance)) = freeze_state {
186 let mut t = target_track.lock();
187 Self::restore_track_after_freeze_render(
188 &mut t,
189 original_level,
190 original_balance,
191 );
192 }
193 let _ = self
194 .tx
195 .send(Message::OfflineBounceFinished {
196 result: Ok(Action::TrackOfflineBounceCanceled {
197 track_name: job.track_name.clone(),
198 }),
199 })
200 .await;
201 let _ = self.tx.send(Message::Ready(self.id)).await;
202 return;
203 }
204
205 let step = (job.length_samples - cursor).min(block_size);
206 for handle in &all_tracks {
207 let mut t = handle.lock();
208 t.audio.set_finished(false);
209 t.audio.set_processing(false);
210 t.set_transport_sample(job.start_sample.saturating_add(cursor));
211 t.set_loop_config(false, None);
212 t.set_transport_timing(job.tempo_bpm, job.tsig_num, job.tsig_denom);
213 t.set_clip_playback_enabled(true);
214 t.set_record_tap_enabled(false);
215 }
216 {
217 let mut t = target_track.lock();
218 Self::apply_freeze_automation_at_sample(
219 &mut t,
220 job.start_sample.saturating_add(cursor),
221 &job.automation_lanes,
222 );
223 }
224
225 let p_start = Instant::now();
226 for node in 0..render_plan.nodes.len() as crate::render_plan::NodeId {
227 let _ = Self::process_node_job_result(
228 self.id,
229 NodeJob {
230 epoch: 0,
231 plan: render_plan.clone(),
232 node,
233 },
234 );
235 }
236 total_process_time += p_start.elapsed();
237
238 let write_start = Instant::now();
239 {
240 let t = target_track.lock();
241 let outs = t.last_audio_outputs();
242 for i in 0..step {
243 for ch in 0..channels {
244 let sample = outs
245 .get(ch)
246 .and_then(|out| out.get(i))
247 .copied()
248 .unwrap_or(0.0);
249 output_samples.push(sample);
250 }
251 }
252 }
253 total_write_time += write_start.elapsed();
254
255 cursor = cursor.saturating_add(step);
256 let progress = (cursor as f32 / job.length_samples as f32).clamp(0.0, 1.0);
257
258 if progress - last_reported_progress >= 0.01 || cursor >= job.length_samples {
259 last_reported_progress = progress;
260 let _ = self
261 .tx
262 .send(Message::OfflineBounceFinished {
263 result: Ok(Action::TrackOfflineBounceProgress {
264 track_name: job.track_name.clone(),
265 progress,
266 operation: Some("Rendering freeze".to_string()),
267 }),
268 })
269 .await;
270 }
271 }
272
273 if let Err(e) = crate::audio_codec::write_wav_f32(
274 std::path::Path::new(&job.output_path),
275 &output_samples,
276 channels,
277 sample_rate as u32,
278 ) {
279 let _ = std::fs::remove_file(&job.output_path);
280 if let Some((original_level, original_balance)) = freeze_state {
281 let mut t = target_track.lock();
282 Self::restore_track_after_freeze_render(&mut t, original_level, original_balance);
283 }
284 let _ = self
285 .tx
286 .send(Message::OfflineBounceFinished {
287 result: Err(format!(
288 "Failed to write offline bounce '{}': {e}",
289 job.output_path
290 )),
291 })
292 .await;
293 let _ = self.tx.send(Message::Ready(self.id)).await;
294 return;
295 }
296
297 if let Some((original_level, original_balance)) = freeze_state {
298 let mut t = target_track.lock();
299 Self::restore_track_after_freeze_render(&mut t, original_level, original_balance);
300 }
301
302 let _ = self
303 .tx
304 .send(Message::OfflineBounceFinished {
305 result: Ok(Action::TrackOfflineBounce {
306 track_name: job.track_name,
307 output_path: job.output_path,
308 start_sample: job.start_sample,
309 length_samples: job.length_samples,
310 automation_lanes: vec![],
311 apply_fader: job.apply_fader,
312 }),
313 })
314 .await;
315 let _ = self.tx.send(Message::Ready(self.id)).await;
316 }
317
318 #[cfg(unix)]
319 pub(crate) fn try_enable_realtime(priority: i32) -> Result<(), String> {
320 let thread = unsafe { libc::pthread_self() };
321 let policy = libc::SCHED_FIFO;
322 let param = unsafe {
323 let mut p = std::mem::zeroed::<libc::sched_param>();
324 p.sched_priority = priority;
325 p
326 };
327 let rc = unsafe { libc::pthread_setschedparam(thread, policy, ¶m) };
328 if rc == 0 {
329 Ok(())
330 } else {
331 Err(format!("pthread_setschedparam failed with errno {}", rc))
332 }
333 }
334
335 #[cfg(not(unix))]
336 pub(crate) fn try_enable_realtime(_priority: i32) -> Result<(), String> {
337 Err("Realtime thread priority is not supported on this platform".to_string())
338 }
339
340 pub async fn new(
341 id: usize,
342 rx: Receiver<Message>,
343 tx: Sender<Message>,
344 realtime_priority: i32,
345 ) -> Worker {
346 let worker = Worker {
347 id,
348 rx,
349 tx,
350 realtime_priority,
351 };
352 worker.send(Message::Ready(id)).await;
353 worker
354 }
355
356 pub async fn send(&self, message: Message) {
357 self.tx
358 .send(message)
359 .await
360 .expect("Failed to send message from worker");
361 }
362
363 fn arena_input_slices<'a>(
364 plan: &'a crate::render_plan::RenderPlan,
365 ins: &[crate::render_plan::BufferId],
366 ) -> Vec<&'a [f32]> {
367 ins.iter()
368 .map(|&buf| {
369 unsafe { plan.buffer(buf) }
372 })
373 .collect()
374 }
375
376 fn arena_source_slices<'a>(
377 plan: &'a crate::render_plan::RenderPlan,
378 writable: &[crate::render_plan::BufferId],
379 ) -> Vec<(usize, &'a [f32], usize)> {
380 plan.port_map
381 .iter()
382 .filter_map(|(&key, &buf)| {
383 if writable.contains(&buf) {
384 return None;
385 }
386 Some((key, unsafe { plan.buffer(buf) }, plan.buffer_latency(buf)))
391 })
392 .collect()
393 }
394
395 fn metronome_output_buffer(
396 plan: &crate::render_plan::RenderPlan,
397 t: &crate::track::TrackData,
398 outs: &[crate::render_plan::BufferId],
399 ) -> Option<crate::render_plan::BufferId> {
400 let source = t.metronome_source()?;
401 let key = Arc::as_ptr(&source) as usize;
402 let &buf = plan.port_map.get(&key)?;
403 outs.contains(&buf).then_some(buf)
404 }
405
406 pub(crate) fn process_node_job_result(worker_id: usize, job: NodeJob) -> NodeJobResult {
413 let NodeJob { epoch, plan, node } = job;
414 let (output_linear, parameter_updates, latency_changed) = match &plan.nodes[node as usize] {
415 Op::Zero { output } => {
416 unsafe { &mut *plan.buffer_ptr(*output) }.fill(0.0);
420 plan.set_buffer_latency(*output, 0);
421 (Vec::new(), Vec::new(), false)
422 }
423 Op::Sum {
424 inputs,
425 delays,
426 output,
427 } => {
428 let out = unsafe { &mut *plan.buffer_ptr(*output) };
431 out.fill(0.0);
432 let max_latency = inputs
433 .iter()
434 .map(|&input| plan.buffer_latency(input))
435 .max()
436 .unwrap_or(0);
437 for (idx, &input) in inputs.iter().enumerate() {
438 let src = unsafe { plan.buffer(input) };
439 let delay = max_latency.saturating_sub(plan.buffer_latency(input));
440 let line = unsafe { &mut *delays[idx].get() };
444 line.process(src, delay, out, idx != 0);
445 }
446 plan.set_buffer_latency(*output, max_latency);
447 (Vec::new(), Vec::new(), false)
448 }
449 Op::HwInput { output, .. } => {
450 plan.set_buffer_latency(*output, 0);
453 (Vec::new(), Vec::new(), false)
454 }
455 Op::Task { task, ins, outs } => {
456 let track = match task {
457 ProcessTask::Track(t)
458 | ProcessTask::FolderInput(t)
459 | ProcessTask::FolderOutput(t) => t,
460 ProcessTask::Plugin { track, .. } => track,
461 };
462 let mut t = track.lock();
463 match task {
464 ProcessTask::Track(_) => {
465 let audio_out_count = t.audio.outs.len();
466 let metronome_output = Self::metronome_output_buffer(&plan, &t, outs);
467 let input_ptrs = ins
468 .iter()
469 .map(|&buf| {
470 unsafe { plan.buffer_ptr(buf) }
473 })
474 .collect::<Vec<_>>();
475 let mut inputs = input_ptrs
476 .iter()
477 .map(|&ptr| {
478 unsafe { (&mut *ptr).as_mut_slice() }
481 })
482 .collect::<Vec<_>>();
483 let source_buffers = Self::arena_source_slices(&plan, outs);
484 let output_ptrs = outs
485 .iter()
486 .take(audio_out_count)
487 .map(|&buf| {
488 unsafe { plan.buffer_ptr(buf) }
491 })
492 .collect::<Vec<_>>();
493 let mut outputs = output_ptrs
494 .iter()
495 .map(|&ptr| {
496 unsafe { (&mut *ptr).as_mut_slice() }
499 })
500 .collect::<Vec<_>>();
501 let metronome_output_ptr = metronome_output.map(|buf| {
502 unsafe { plan.buffer_ptr(buf) }
505 });
506 let metronome_output = metronome_output_ptr.map(|ptr| {
507 unsafe { (&mut *ptr).as_mut_slice() }
510 });
511 t.process_render_block_with_audio_buffers_and_metronome(
512 &mut inputs,
513 &mut outputs,
514 &source_buffers,
515 metronome_output,
516 );
517 for &out in outs.iter().take(audio_out_count) {
518 plan.set_buffer_latency(out, t.plugin_graph_latency_samples());
519 }
520 }
521 ProcessTask::FolderInput(_) => {
522 let metronome_output = Self::metronome_output_buffer(&plan, &t, outs);
523 let input_ptrs = ins
524 .iter()
525 .map(|&buf| {
526 unsafe { plan.buffer_ptr(buf) }
529 })
530 .collect::<Vec<_>>();
531 let mut inputs = input_ptrs
532 .iter()
533 .map(|&ptr| {
534 unsafe { (&mut *ptr).as_mut_slice() }
537 })
538 .collect::<Vec<_>>();
539 let metronome_output_ptr = metronome_output.map(|buf| {
540 unsafe { plan.buffer_ptr(buf) }
543 });
544 let metronome_output = metronome_output_ptr.map(|ptr| {
545 unsafe { (&mut *ptr).as_mut_slice() }
548 });
549 t.process_folder_input_with_audio_buffers_and_metronome(
550 &mut inputs,
551 metronome_output,
552 );
553 for &input in ins {
554 plan.set_buffer_latency(input, 0);
555 }
556 }
557 ProcessTask::FolderOutput(_) => {
558 let source_buffers = Self::arena_source_slices(&plan, outs);
559 let output_ptrs = outs
560 .iter()
561 .map(|&buf| {
562 unsafe { plan.buffer_ptr(buf) }
565 })
566 .collect::<Vec<_>>();
567 let mut outputs = output_ptrs
568 .iter()
569 .map(|&ptr| {
570 unsafe { (&mut *ptr).as_mut_slice() }
573 })
574 .collect::<Vec<_>>();
575 t.process_folder_output_with_audio_buffers(&mut outputs, &source_buffers);
576 for &out in outs {
577 plan.set_buffer_latency(out, t.plugin_graph_latency_samples());
578 }
579 }
580 ProcessTask::Plugin { kind, index, .. } => {
581 let input_latency = ins
582 .iter()
583 .map(|&input| plan.buffer_latency(input))
584 .max()
585 .unwrap_or(0);
586 let inputs = Self::arena_input_slices(&plan, ins);
587 let output_ptrs = outs
588 .iter()
589 .map(|&buf| {
590 unsafe { plan.buffer_ptr(buf) }
593 })
594 .collect::<Vec<_>>();
595 let mut outputs = output_ptrs
596 .iter()
597 .map(|&ptr| {
598 unsafe { (&mut *ptr).as_mut_slice() }
601 })
602 .collect::<Vec<_>>();
603 t.process_plugin_with_audio_buffers(*kind, *index, &inputs, &mut outputs);
604 let latency =
605 input_latency.saturating_add(t.plugin_latency_samples(*kind, *index));
606 for &out in outs {
607 plan.set_buffer_latency(out, latency);
608 }
609 }
610 }
611 t.audio.set_processing(false);
612 let latency_changed = t.take_plugin_latency_changed();
613 let updates = std::mem::take(&mut t.rt.echoed_parameter_updates);
614 let meter = t.output_meter_linear();
615 (meter, updates, latency_changed)
616 }
617 };
618 NodeJobResult {
619 worker_id,
620 epoch,
621 node,
622 output_linear,
623 parameter_updates,
624 latency_changed,
625 }
626 }
627
628 async fn process_node_job(&self, job: NodeJob) {
629 let result = Self::process_node_job_result(self.id, job);
630 let _ = self.tx.send(result.into()).await;
631 }
632
633 pub async fn work(&mut self) {
634 crate::enable_flush_denormals_to_zero();
635 if let Err(e) = Self::try_enable_realtime(self.realtime_priority) {
636 tracing::warn!(
637 "Worker {} realtime priority {} not enabled: {}",
638 self.id,
639 self.realtime_priority,
640 e
641 );
642 }
643 while let Some(message) = self.rx.recv().await {
644 match message {
645 Message::Request(Action::Quit) => {
646 return;
647 }
648 Message::ProcessOfflineBounce(job) => {
649 self.process_offline_bounce(job).await;
650 }
651 Message::NodeJob(job) => {
652 self.process_node_job(job).await;
653 }
654 _ => {}
655 }
656 }
657 }
658}
659
660impl From<NodeJobResult> for Message {
661 fn from(result: NodeJobResult) -> Self {
662 Message::NodeDone {
663 worker_id: result.worker_id,
664 epoch: result.epoch,
665 node: result.node,
666 output_linear: result.output_linear,
667 parameter_updates: result.parameter_updates,
668 latency_changed: result.latency_changed,
669 }
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::Worker;
676 use crate::message::{
677 Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
678 OfflineBounceWork,
679 };
680 use crate::state::State;
681 use crate::track::Track;
682 use std::path::PathBuf;
683 use std::sync::{Arc, atomic::AtomicBool};
684 use std::time::{SystemTime, UNIX_EPOCH};
685 use tokio::sync::mpsc::channel;
686
687 fn make_state_with_track(track: Track) -> State {
688 let mut state = State::default();
689 state.tracks.insert(track.name.clone(), Arc::new(track));
690 state
691 }
692
693 fn unique_temp_wav(name: &str) -> PathBuf {
694 let nanos = SystemTime::now()
695 .duration_since(UNIX_EPOCH)
696 .expect("clock")
697 .as_nanos();
698 std::env::temp_dir().join(format!("maolan_{name}_{nanos}.wav"))
699 }
700
701 #[test]
702 fn prepare_track_for_freeze_render_neutralizes_level_and_balance() {
703 let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
704 track.set_level(-6.0);
705 track.set_balance(0.35);
706
707 let (level, balance) = Worker::prepare_track_for_freeze_render(&mut track);
708
709 assert_eq!(level, -6.0);
710 assert_eq!(balance, 0.35);
711 assert_eq!(track.level(), 0.0);
712 assert_eq!(track.balance(), 0.0);
713
714 Worker::restore_track_after_freeze_render(&mut track, level, balance);
715 assert_eq!(track.level(), -6.0);
716 assert_eq!(track.balance(), 0.35);
717 }
718
719 #[test]
720 fn freeze_automation_ignores_volume_and_balance_lanes() {
721 let mut track = Track::new("track".to_string(), 1, 2, 0, 1, 64, 48_000.0);
722 let lanes = vec![
723 OfflineAutomationLane {
724 target: OfflineAutomationTarget::Volume,
725 visible: true,
726 points: vec![OfflineAutomationPoint {
727 sample: 0,
728 value: 0.0,
729 }],
730 },
731 OfflineAutomationLane {
732 target: OfflineAutomationTarget::Balance,
733 visible: true,
734 points: vec![OfflineAutomationPoint {
735 sample: 0,
736 value: 1.0,
737 }],
738 },
739 OfflineAutomationLane {
740 target: OfflineAutomationTarget::MidiCc { channel: 0, cc: 7 },
741 visible: true,
742 points: vec![OfflineAutomationPoint {
743 sample: 0,
744 value: 1.0,
745 }],
746 },
747 ];
748
749 Worker::apply_freeze_automation_at_sample(&mut track, 0, &lanes);
750
751 assert_eq!(track.level(), 0.0);
752 assert_eq!(track.balance(), 0.0);
753 assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
754 assert_eq!(
755 track.rt.pending_automation_midi_events[0].data,
756 vec![0xB0, 7, 127]
757 );
758 }
759
760 #[test]
761 fn automation_lane_value_at_interpolates_between_points() {
762 let value = Worker::automation_lane_value_at(
763 &[
764 OfflineAutomationPoint {
765 sample: 10,
766 value: 0.25,
767 },
768 OfflineAutomationPoint {
769 sample: 20,
770 value: 0.75,
771 },
772 ],
773 15,
774 )
775 .expect("value");
776
777 assert!((value - 0.5).abs() < 1.0e-6);
778 }
779
780 #[test]
781 fn freeze_automation_applies_interpolated_midi_cc_lane() {
782 let mut track = Track::new("track".to_string(), 1, 1, 0, 1, 64, 48_000.0);
783 let lanes = vec![OfflineAutomationLane {
784 target: OfflineAutomationTarget::MidiCc { channel: 0, cc: 7 },
785 visible: true,
786 points: vec![
787 OfflineAutomationPoint {
788 sample: 0,
789 value: 0.0,
790 },
791 OfflineAutomationPoint {
792 sample: 10,
793 value: 1.0,
794 },
795 ],
796 }];
797
798 Worker::apply_freeze_automation_at_sample(&mut track, 5, &lanes);
799 assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
800 assert_eq!(track.rt.pending_automation_midi_events[0].data[2], 64);
801
802 track.rt.pending_automation_midi_events.clear();
803 Worker::apply_freeze_automation_at_sample(&mut track, 2, &lanes);
804 assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
805 assert_eq!(track.rt.pending_automation_midi_events[0].data[2], 25);
806 }
807
808 #[cfg_attr(
809 all(miri, target_os = "freebsd"),
810 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
811 )]
812 #[tokio::test]
813 async fn process_node_job_sums_arena_buffers() {
814 use crate::render_plan::{Op, RenderPlan};
815 use std::cell::UnsafeCell;
816 use std::collections::HashMap;
817
818 let (_rx_unused_tx, rx_unused) = channel(1);
819 let (tx, mut out_rx) = channel(8);
820 let worker = Worker {
821 id: 4,
822 rx: rx_unused,
823 tx,
824 realtime_priority: 0,
825 };
826 let collector = basedrop::Collector::new();
827 let plan = RenderPlan {
828 buffer_size: 4,
829 buffers: (0..3).map(|_| UnsafeCell::new(vec![0.0; 4])).collect(),
830 buffer_latencies: (0..3)
831 .map(|_| std::sync::atomic::AtomicUsize::new(0))
832 .collect(),
833 nodes: vec![Op::Sum {
834 inputs: vec![0, 1],
835 delays: vec![
836 UnsafeCell::new(crate::render_plan::DelayLine::new()),
837 UnsafeCell::new(crate::render_plan::DelayLine::new()),
838 ],
839 output: 2,
840 }],
841 indegree: vec![0],
842 dependents: vec![vec![]],
843 sources: vec![0],
844 hw_in_map: vec![],
845 hw_out_map: vec![],
846 port_map: HashMap::new(),
847 midi_edges: vec![],
848 forced: vec![],
849 };
850 unsafe {
852 (&mut *plan.buffer_ptr(0)).copy_from_slice(&[0.25, 0.5, 0.75, 1.0]);
853 (&mut *plan.buffer_ptr(1)).copy_from_slice(&[0.75, 0.5, 0.25, f32::NAN]);
854 }
855 let shared = std::sync::Arc::new(basedrop::Owned::new(&collector.handle(), plan));
856
857 worker
858 .process_node_job(crate::executor::NodeJob {
859 epoch: 1,
860 plan: shared.clone(),
861 node: 0,
862 })
863 .await;
864
865 unsafe {
868 assert_eq!(
869 &*shared.buffer_ptr(2),
870 &vec![1.0, 1.0, 1.0, 1.0],
871 "sanitized sum in the arena"
872 );
873 }
874 match out_rx.recv().await.expect("message") {
875 Message::NodeDone {
876 worker_id,
877 epoch,
878 node,
879 output_linear,
880 ..
881 } => {
882 assert_eq!(worker_id, 4);
883 assert_eq!(epoch, 1);
884 assert_eq!(node, 0);
885 assert!(output_linear.is_empty());
886 }
887 other => panic!("unexpected message: {other:?}"),
888 }
889 }
890
891 #[cfg_attr(
892 all(miri, target_os = "freebsd"),
893 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
894 )]
895 #[tokio::test]
896 async fn process_offline_bounce_errors_when_track_is_missing() {
897 let (_rx_unused_tx, rx_unused) = channel(1);
898 let (tx, mut out_rx) = channel(8);
899 let worker = Worker {
900 id: 7,
901 rx: rx_unused,
902 tx,
903 realtime_priority: 0,
904 };
905 let job = OfflineBounceWork {
906 state: Arc::new(State::default().snapshot()),
907 track_name: "missing".to_string(),
908 output_path: unique_temp_wav("missing").to_string_lossy().to_string(),
909 start_sample: 0,
910 length_samples: 8,
911 tempo_bpm: 120.0,
912 tsig_num: 4,
913 tsig_denom: 4,
914 automation_lanes: vec![],
915 cancel: Arc::new(AtomicBool::new(false)),
916 apply_fader: false,
917 };
918
919 worker.process_offline_bounce(job).await;
920
921 match out_rx.recv().await.expect("message") {
922 Message::OfflineBounceFinished { result: Err(err) } => {
923 assert!(err.contains("Track not found: missing"));
924 }
925 other => panic!("unexpected message: {other:?}"),
926 }
927 }
928
929 #[cfg_attr(
930 all(miri, target_os = "freebsd"),
931 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
932 )]
933 #[tokio::test]
934 async fn process_offline_bounce_cancels_and_restores_track_state() {
935 let (_rx_unused_tx, rx_unused) = channel(1);
936 let (tx, mut out_rx) = channel(8);
937 let worker = Worker {
938 id: 5,
939 rx: rx_unused,
940 tx,
941 realtime_priority: 0,
942 };
943 let track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
944 track.set_level(-9.0);
945 track.set_balance(-0.3);
946 let state = make_state_with_track(track);
947 let job = OfflineBounceWork {
948 state: Arc::new(state.lock().snapshot()),
949 track_name: "track".to_string(),
950 output_path: unique_temp_wav("cancel").to_string_lossy().to_string(),
951 start_sample: 0,
952 length_samples: 8,
953 tempo_bpm: 120.0,
954 tsig_num: 4,
955 tsig_denom: 4,
956 automation_lanes: vec![],
957 cancel: Arc::new(AtomicBool::new(true)),
958 apply_fader: false,
959 };
960
961 worker.process_offline_bounce(job).await;
962
963 match out_rx.recv().await.expect("message") {
964 Message::OfflineBounceFinished {
965 result: Ok(Action::TrackOfflineBounceCanceled { track_name }),
966 } => assert_eq!(track_name, "track"),
967 other => panic!("unexpected message: {other:?}"),
968 }
969 assert!(matches!(out_rx.recv().await, Some(Message::Ready(5))));
970 let state_guard = state.lock();
971 let track = state_guard.tracks.get("track").expect("track").lock();
972 assert_eq!(track.level(), -9.0);
973 assert_eq!(track.balance(), -0.3);
974 }
975
976 #[cfg_attr(
977 all(miri, target_os = "freebsd"),
978 ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
979 )]
980 #[tokio::test]
981 async fn process_offline_bounce_restores_track_state_on_write_failure() {
982 let (_rx_unused_tx, rx_unused) = channel(1);
983 let (tx, mut out_rx) = channel(8);
984 let worker = Worker {
985 id: 3,
986 rx: rx_unused,
987 tx,
988 realtime_priority: 0,
989 };
990 let track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
991 track.set_level(-4.0);
992 track.set_balance(0.25);
993 let state = make_state_with_track(track);
994 let output_path = std::env::temp_dir().to_string_lossy().to_string();
995 let job = OfflineBounceWork {
996 state: Arc::new(state.lock().snapshot()),
997 track_name: "track".to_string(),
998 output_path,
999 start_sample: 0,
1000 length_samples: 4,
1001 tempo_bpm: 120.0,
1002 tsig_num: 4,
1003 tsig_denom: 4,
1004 automation_lanes: vec![],
1005 cancel: Arc::new(AtomicBool::new(false)),
1006 apply_fader: false,
1007 };
1008
1009 worker.process_offline_bounce(job).await;
1010
1011 let mut saw_error = false;
1012 while let Some(message) = out_rx.recv().await {
1013 match message {
1014 Message::OfflineBounceFinished {
1015 result: Ok(Action::TrackOfflineBounceProgress { .. }),
1016 } => {}
1017 Message::OfflineBounceFinished { result: Err(err) } => {
1018 assert!(
1019 err.contains("Failed to create offline bounce")
1020 || err.contains("Failed to write offline bounce")
1021 || err.contains("Failed to finalize offline bounce")
1022 );
1023 saw_error = true;
1024 }
1025 Message::Ready(3) => break,
1026 other => panic!("unexpected message: {other:?}"),
1027 }
1028 }
1029 assert!(saw_error);
1030 let state_guard = state.lock();
1031 let track = state_guard.tracks.get("track").expect("track").lock();
1032 assert_eq!(track.level(), -4.0);
1033 assert_eq!(track.balance(), 0.25);
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 process_offline_bounce_emits_progress_and_completion() {
1042 let (_rx_unused_tx, rx_unused) = channel(1);
1043 let (tx, mut out_rx) = channel(16);
1044 let worker = Worker {
1045 id: 2,
1046 rx: rx_unused,
1047 tx,
1048 realtime_priority: 0,
1049 };
1050 let track = Track::new("track".to_string(), 1, 1, 0, 0, 4, 48_000.0);
1051 track.set_level(-3.0);
1052 track.set_balance(0.4);
1053 let state = make_state_with_track(track);
1054 let output = unique_temp_wav("success");
1055 let job = OfflineBounceWork {
1056 state: Arc::new(state.lock().snapshot()),
1057 track_name: "track".to_string(),
1058 output_path: output.to_string_lossy().to_string(),
1059 start_sample: 0,
1060 length_samples: 8,
1061 tempo_bpm: 120.0,
1062 tsig_num: 4,
1063 tsig_denom: 4,
1064 automation_lanes: vec![],
1065 cancel: Arc::new(AtomicBool::new(false)),
1066 apply_fader: false,
1067 };
1068
1069 worker.process_offline_bounce(job).await;
1070
1071 let mut saw_progress = false;
1072 let mut saw_complete = false;
1073 let mut saw_ready = false;
1074 while let Some(message) = out_rx.recv().await {
1075 match message {
1076 Message::OfflineBounceFinished {
1077 result:
1078 Ok(Action::TrackOfflineBounceProgress {
1079 track_name,
1080 progress,
1081 ..
1082 }),
1083 } => {
1084 assert_eq!(track_name, "track");
1085 assert!(progress > 0.0);
1086 saw_progress = true;
1087 }
1088 Message::OfflineBounceFinished {
1089 result:
1090 Ok(Action::TrackOfflineBounce {
1091 track_name,
1092 output_path,
1093 ..
1094 }),
1095 } => {
1096 assert_eq!(track_name, "track");
1097 assert_eq!(output_path, output.to_string_lossy());
1098 saw_complete = true;
1099 }
1100 Message::Ready(2) => {
1101 saw_ready = true;
1102 break;
1103 }
1104 other => panic!("unexpected message: {other:?}"),
1105 }
1106 }
1107
1108 assert!(saw_progress);
1109 assert!(saw_complete);
1110 assert!(saw_ready);
1111 assert!(output.exists());
1112 std::fs::remove_file(&output).expect("remove temp wav");
1113 let state_guard = state.lock();
1114 let track = state_guard.tracks.get("track").expect("track").lock();
1115 assert_eq!(track.level(), -3.0);
1116 assert_eq!(track.balance(), 0.4);
1117 assert!(!track.muted());
1118 }
1119}