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