1use crate::message::{
2 Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
3 OfflineBounceWork,
4};
5#[cfg(unix)]
6use nix::libc;
7use std::collections::HashSet;
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10use tokio::sync::mpsc::{Receiver, Sender};
11use tracing::{error, info};
12
13#[derive(Debug)]
14pub struct Worker {
15 id: usize,
16 rx: Receiver<Message>,
17 tx: Sender<Message>,
18}
19
20impl Worker {
21 fn automation_lane_value_at(points: &[OfflineAutomationPoint], sample: usize) -> Option<f32> {
22 if points.is_empty() {
23 return None;
24 }
25 if sample <= points[0].sample {
26 return Some(points[0].value.clamp(0.0, 1.0));
27 }
28 if sample >= points[points.len().saturating_sub(1)].sample {
29 return Some(points[points.len().saturating_sub(1)].value.clamp(0.0, 1.0));
30 }
31 for segment in points.windows(2) {
32 let left = &segment[0];
33 let right = &segment[1];
34 if sample < left.sample || sample > right.sample {
35 continue;
36 }
37 let span = right.sample.saturating_sub(left.sample).max(1) as f32;
38 let t = (sample.saturating_sub(left.sample) as f32 / span).clamp(0.0, 1.0);
39 return Some((left.value + (right.value - left.value) * t).clamp(0.0, 1.0));
40 }
41 None
42 }
43
44 fn apply_freeze_automation_at_sample(
45 track: &mut crate::track::Track,
46 sample: usize,
47 lanes: &[OfflineAutomationLane],
48 ) {
49 for lane in lanes {
50 if matches!(
51 lane.target,
52 OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance
53 ) {
54 continue;
55 }
56 let Some(value) = Self::automation_lane_value_at(&lane.points, sample) else {
57 continue;
58 };
59 match lane.target {
60 OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance => {}
61 OfflineAutomationTarget::Mute => {
62 track.set_muted(value >= 0.5);
63 }
64 #[cfg(all(unix, not(target_os = "macos")))]
65 OfflineAutomationTarget::Lv2Parameter {
66 instance_id,
67 index,
68 min,
69 max,
70 } => {
71 let lo = min.min(max);
72 let hi = max.max(min);
73 let param_value = (lo + value * (hi - lo)).clamp(lo, hi);
74 let _ = track.set_lv2_control_value(instance_id, index, param_value);
75 }
76 OfflineAutomationTarget::Vst3Parameter {
77 instance_id,
78 param_id,
79 } => {
80 let _ = track.set_vst3_parameter(instance_id, param_id, value.clamp(0.0, 1.0));
81 }
82 OfflineAutomationTarget::ClapParameter {
83 instance_id,
84 param_id,
85 min,
86 max,
87 } => {
88 let lo = min.min(max);
89 let hi = max.max(min);
90 let param_value = (lo + value as f64 * (hi - lo)).clamp(lo, hi);
91 let _ = track.set_clap_parameter_at(instance_id, param_id, param_value, 0);
92 }
93 }
94 }
95 }
96
97 fn prepare_track_for_freeze_render(track: &mut crate::track::Track) -> (f32, f32) {
98 let original_level = track.level();
99 let original_balance = track.balance;
100 track.set_level(0.0);
101 track.set_balance(0.0);
102 (original_level, original_balance)
103 }
104
105 fn restore_track_after_freeze_render(
106 track: &mut crate::track::Track,
107 original_level: f32,
108 original_balance: f32,
109 ) {
110 track.set_level(original_level);
111 track.set_balance(original_balance);
112 }
113
114 async fn process_offline_bounce(&self, job: OfflineBounceWork) {
115 let track_handle = job.state.lock().tracks.get(&job.track_name).cloned();
116 let Some(target_track) = track_handle else {
117 let _ = self
118 .tx
119 .send(Message::OfflineBounceFinished {
120 result: Err(format!("Track not found: {}", job.track_name)),
121 })
122 .await;
123 return;
124 };
125 let (channels, block_size, sample_rate) = {
126 let t = target_track.lock();
127 let block_size = t
128 .audio
129 .outs
130 .first()
131 .map(|io| io.buffer.lock().len())
132 .or_else(|| t.audio.ins.first().map(|io| io.buffer.lock().len()))
133 .unwrap_or(0)
134 .max(1);
135 (
136 t.audio.outs.len().max(1),
137 block_size,
138 t.sample_rate.round().max(1.0) as i32,
139 )
140 };
141 let freeze_state = if job.apply_fader {
142 None
143 } else {
144 let t = target_track.lock();
145 Some(Self::prepare_track_for_freeze_render(t))
146 };
147
148 let all_tracks: Vec<_> = job.state.lock().tracks.values().cloned().collect();
149 let mut output_to_track: std::collections::HashMap<usize, String> =
150 std::collections::HashMap::new();
151 for handle in &all_tracks {
152 let t = handle.lock();
153 for out in &t.audio.outs {
154 output_to_track.insert(Arc::as_ptr(out) as usize, t.name.clone());
155 }
156 }
157 let mut relevant_names = HashSet::new();
158 let mut queue = vec![job.track_name.clone()];
159 while let Some(name) = queue.pop() {
160 if !relevant_names.insert(name.clone()) {
161 continue;
162 }
163 if let Some(handle) = all_tracks.iter().find(|h| h.lock().name == name) {
164 let t = handle.lock();
165 for input in &t.audio.ins {
166 for conn in input.connections.lock().iter() {
167 if let Some(source_name) =
168 output_to_track.get(&(Arc::as_ptr(conn) as usize))
169 {
170 queue.push(source_name.clone());
171 }
172 }
173 }
174 }
175 }
176 let relevant_tracks: Vec<_> = all_tracks
177 .into_iter()
178 .filter(|h| relevant_names.contains(&h.lock().name))
179 .collect();
180
181 let spec = hound::WavSpec {
182 channels: channels as u16,
183 sample_rate: sample_rate as u32,
184 bits_per_sample: 32,
185 sample_format: hound::SampleFormat::Float,
186 };
187 let mut writer = match hound::WavWriter::create(&job.output_path, spec) {
188 Ok(w) => w,
189 Err(e) => {
190 if let Some((original_level, original_balance)) = freeze_state {
191 let t = target_track.lock();
192 Self::restore_track_after_freeze_render(t, original_level, original_balance);
193 }
194 let _ = self
195 .tx
196 .send(Message::OfflineBounceFinished {
197 result: Err(format!(
198 "Failed to create offline bounce '{}': {e}",
199 job.output_path
200 )),
201 })
202 .await;
203 let _ = self.tx.send(Message::Ready(self.id)).await;
204 return;
205 }
206 };
207
208 let mut cursor = 0usize;
209 let mut last_reported_progress = 0.0_f32;
210 let mut total_process_time = Duration::ZERO;
211 let mut total_write_time = Duration::ZERO;
212 let mut block_count = 0usize;
213 let bounce_start = Instant::now();
214 while cursor < job.length_samples {
215 if job.cancel.load(std::sync::atomic::Ordering::Relaxed) {
216 let _ = writer.finalize();
217 let _ = std::fs::remove_file(&job.output_path);
218 if let Some((original_level, original_balance)) = freeze_state {
219 let t = target_track.lock();
220 Self::restore_track_after_freeze_render(t, original_level, original_balance);
221 }
222 let _ = self
223 .tx
224 .send(Message::OfflineBounceFinished {
225 result: Ok(Action::TrackOfflineBounceCanceled {
226 track_name: job.track_name.clone(),
227 }),
228 })
229 .await;
230 let _ = self.tx.send(Message::Ready(self.id)).await;
231 return;
232 }
233
234 let step = (job.length_samples - cursor).min(block_size);
235 for handle in &relevant_tracks {
236 let t = handle.lock();
237 t.audio.finished = false;
238 t.audio.processing = false;
239 t.set_transport_sample(job.start_sample.saturating_add(cursor));
240 t.set_loop_config(false, None);
241 t.set_transport_timing(job.tempo_bpm, job.tsig_num, job.tsig_denom);
242 t.set_clip_playback_enabled(true);
243 t.set_record_tap_enabled(false);
244 }
245
246 let block_process_start = Instant::now();
247 loop {
248 let mut all_finished = true;
249 let mut progressed = false;
250 for handle in &relevant_tracks {
251 let t = handle.lock();
252 if t.audio.finished {
253 continue;
254 }
255 all_finished = false;
256 if !t.audio.processing && t.audio.ready() {
257 if t.name == job.track_name {
258 Self::apply_freeze_automation_at_sample(
259 t,
260 job.start_sample.saturating_add(cursor),
261 &job.automation_lanes,
262 );
263 }
264 t.audio.processing = true;
265 let p_start = Instant::now();
266 t.process();
267 total_process_time += p_start.elapsed();
268 t.audio.processing = false;
269 progressed = true;
270 }
271 }
272 if all_finished {
273 break;
274 }
275 if !progressed {
276 for handle in &relevant_tracks {
277 let t = handle.lock();
278 if t.audio.finished {
279 continue;
280 }
281 if t.name == job.track_name {
282 Self::apply_freeze_automation_at_sample(
283 t,
284 job.start_sample.saturating_add(cursor),
285 &job.automation_lanes,
286 );
287 }
288 t.audio.processing = true;
289 let p_start = Instant::now();
290 t.process();
291 total_process_time += p_start.elapsed();
292 t.audio.processing = false;
293 }
294 break;
295 }
296 }
297 let _block_process_elapsed = block_process_start.elapsed();
298
299 let write_start = Instant::now();
300 let write_result = {
301 let t = target_track.lock();
302 let outs: Vec<_> = (0..channels)
303 .map(|ch| t.audio.outs[ch].buffer.lock())
304 .collect();
305 (|| -> Result<(), hound::Error> {
306 for i in 0..step {
307 for out in outs.iter().take(channels) {
308 let sample = out.get(i).copied().unwrap_or(0.0);
309 writer.write_sample(sample)?;
310 }
311 }
312 Ok(())
313 })()
314 };
315 total_write_time += write_start.elapsed();
316 if let Err(e) = write_result {
317 let _ = writer.finalize();
318 let _ = std::fs::remove_file(&job.output_path);
319 if let Some((original_level, original_balance)) = freeze_state {
320 let t = target_track.lock();
321 Self::restore_track_after_freeze_render(t, original_level, original_balance);
322 }
323 let _ = self
324 .tx
325 .send(Message::OfflineBounceFinished {
326 result: Err(format!(
327 "Failed to write offline bounce '{}': {e}",
328 job.output_path
329 )),
330 })
331 .await;
332 let _ = self.tx.send(Message::Ready(self.id)).await;
333 return;
334 }
335
336 cursor = cursor.saturating_add(step);
337 block_count += 1;
338 let progress = (cursor as f32 / job.length_samples as f32).clamp(0.0, 1.0);
339
340 if progress - last_reported_progress >= 0.01 || cursor >= job.length_samples {
341 last_reported_progress = progress;
342 let _ = self
343 .tx
344 .send(Message::OfflineBounceFinished {
345 result: Ok(Action::TrackOfflineBounceProgress {
346 track_name: job.track_name.clone(),
347 progress,
348 operation: Some("Rendering freeze".to_string()),
349 }),
350 })
351 .await;
352 }
353 }
354 let bounce_elapsed = bounce_start.elapsed();
355 info!(
356 "Bounce '{}' — total: {:?}, blocks: {}, process: {:?}, write: {:?}",
357 job.track_name, bounce_elapsed, block_count, total_process_time, total_write_time
358 );
359
360 if let Err(e) = writer.finalize() {
361 let _ = std::fs::remove_file(&job.output_path);
362 if let Some((original_level, original_balance)) = freeze_state {
363 let t = target_track.lock();
364 Self::restore_track_after_freeze_render(t, original_level, original_balance);
365 }
366 let _ = self
367 .tx
368 .send(Message::OfflineBounceFinished {
369 result: Err(format!(
370 "Failed to finalize offline bounce '{}': {e}",
371 job.output_path
372 )),
373 })
374 .await;
375 let _ = self.tx.send(Message::Ready(self.id)).await;
376 return;
377 }
378
379 if let Some((original_level, original_balance)) = freeze_state {
380 let t = target_track.lock();
381 Self::restore_track_after_freeze_render(t, original_level, original_balance);
382 }
383
384 let _ = self
385 .tx
386 .send(Message::OfflineBounceFinished {
387 result: Ok(Action::TrackOfflineBounce {
388 track_name: job.track_name,
389 output_path: job.output_path,
390 start_sample: job.start_sample,
391 length_samples: job.length_samples,
392 automation_lanes: vec![],
393 apply_fader: job.apply_fader,
394 }),
395 })
396 .await;
397 let _ = self.tx.send(Message::Ready(self.id)).await;
398 }
399
400 #[cfg(unix)]
401 fn try_enable_realtime() -> Result<(), String> {
402 let thread = unsafe { libc::pthread_self() };
403 let policy = libc::SCHED_FIFO;
404 let param = unsafe {
405 let mut p = std::mem::zeroed::<libc::sched_param>();
406 p.sched_priority = 10;
407 p
408 };
409 let rc = unsafe { libc::pthread_setschedparam(thread, policy, ¶m) };
410 if rc == 0 {
411 Ok(())
412 } else {
413 Err(format!("pthread_setschedparam failed with errno {}", rc))
414 }
415 }
416
417 #[cfg(not(unix))]
418 fn try_enable_realtime() -> Result<(), String> {
419 Err("Realtime thread priority is not supported on this platform".to_string())
420 }
421
422 pub async fn new(id: usize, rx: Receiver<Message>, tx: Sender<Message>) -> Worker {
423 let worker = Worker { id, rx, tx };
424 worker.send(Message::Ready(id)).await;
425 worker
426 }
427
428 pub async fn send(&self, message: Message) {
429 self.tx
430 .send(message)
431 .await
432 .expect("Failed to send message from worker");
433 }
434
435 pub async fn work(&mut self) {
436 if let Err(e) = Self::try_enable_realtime() {
437 error!("Worker {} realtime priority not enabled: {}", self.id, e);
438 }
439 while let Some(message) = self.rx.recv().await {
440 match message {
441 Message::Request(Action::Quit) => {
442 return;
443 }
444 Message::ProcessTrack(t) => {
445 let (track_name, output_linear, process_epoch) = {
446 let track = t.lock();
447 let process_epoch = track.process_epoch;
448 let started = Instant::now();
449 track.process();
450 let elapsed = started.elapsed();
451 if elapsed.as_millis() > 20 {
452 tracing::warn!(
453 "Slow track process '{}' took {:.3} ms",
454 track.name,
455 elapsed.as_secs_f64() * 1000.0
456 );
457 }
458 track.audio.processing = false;
459 (
460 track.name.clone(),
461 track.output_meter_linear(),
462 process_epoch,
463 )
464 };
465 match self
466 .tx
467 .send(Message::Finished {
468 worker_id: self.id,
469 track_name,
470 output_linear,
471 process_epoch,
472 })
473 .await
474 {
475 Ok(_) => {}
476 Err(e) => {
477 error!("Error while sending Finished: {}", e);
478 }
479 }
480 }
481 Message::ProcessOfflineBounce(job) => {
482 self.process_offline_bounce(job).await;
483 }
484 _ => {}
485 }
486 }
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::Worker;
493 use crate::message::{
494 Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
495 OfflineBounceWork,
496 };
497 use crate::mutex::UnsafeMutex;
498 use crate::state::State;
499 use crate::track::Track;
500 use std::path::PathBuf;
501 use std::sync::{Arc, atomic::AtomicBool};
502 use std::time::{SystemTime, UNIX_EPOCH};
503 use tokio::sync::mpsc::channel;
504
505 fn make_state_with_track(track: Track) -> Arc<UnsafeMutex<State>> {
506 let mut state = State::default();
507 state.tracks.insert(
508 track.name.clone(),
509 Arc::new(UnsafeMutex::new(Box::new(track))),
510 );
511 Arc::new(UnsafeMutex::new(state))
512 }
513
514 fn unique_temp_wav(name: &str) -> PathBuf {
515 let nanos = SystemTime::now()
516 .duration_since(UNIX_EPOCH)
517 .expect("clock")
518 .as_nanos();
519 std::env::temp_dir().join(format!("maolan_{name}_{nanos}.wav"))
520 }
521
522 #[test]
523 fn prepare_track_for_freeze_render_neutralizes_level_and_balance() {
524 let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
525 track.set_level(-6.0);
526 track.set_balance(0.35);
527
528 let (level, balance) = Worker::prepare_track_for_freeze_render(&mut track);
529
530 assert_eq!(level, -6.0);
531 assert_eq!(balance, 0.35);
532 assert_eq!(track.level(), 0.0);
533 assert_eq!(track.balance, 0.0);
534
535 Worker::restore_track_after_freeze_render(&mut track, level, balance);
536 assert_eq!(track.level(), -6.0);
537 assert_eq!(track.balance, 0.35);
538 }
539
540 #[test]
541 fn freeze_automation_ignores_volume_and_balance_lanes() {
542 let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
543 let lanes = vec![
544 OfflineAutomationLane {
545 target: OfflineAutomationTarget::Volume,
546 points: vec![OfflineAutomationPoint {
547 sample: 0,
548 value: 0.0,
549 }],
550 },
551 OfflineAutomationLane {
552 target: OfflineAutomationTarget::Balance,
553 points: vec![OfflineAutomationPoint {
554 sample: 0,
555 value: 1.0,
556 }],
557 },
558 OfflineAutomationLane {
559 target: OfflineAutomationTarget::Mute,
560 points: vec![OfflineAutomationPoint {
561 sample: 0,
562 value: 1.0,
563 }],
564 },
565 ];
566
567 Worker::apply_freeze_automation_at_sample(&mut track, 0, &lanes);
568
569 assert_eq!(track.level(), 0.0);
570 assert_eq!(track.balance, 0.0);
571 assert!(track.muted);
572 }
573
574 #[test]
575 fn automation_lane_value_at_interpolates_between_points() {
576 let value = Worker::automation_lane_value_at(
577 &[
578 OfflineAutomationPoint {
579 sample: 10,
580 value: 0.25,
581 },
582 OfflineAutomationPoint {
583 sample: 20,
584 value: 0.75,
585 },
586 ],
587 15,
588 )
589 .expect("value");
590
591 assert!((value - 0.5).abs() < 1.0e-6);
592 }
593
594 #[test]
595 fn freeze_automation_applies_interpolated_mute_lane() {
596 let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
597 let lanes = vec![OfflineAutomationLane {
598 target: OfflineAutomationTarget::Mute,
599 points: vec![
600 OfflineAutomationPoint {
601 sample: 0,
602 value: 0.0,
603 },
604 OfflineAutomationPoint {
605 sample: 10,
606 value: 1.0,
607 },
608 ],
609 }];
610
611 Worker::apply_freeze_automation_at_sample(&mut track, 5, &lanes);
612 assert!(track.muted);
613
614 track.set_muted(false);
615 Worker::apply_freeze_automation_at_sample(&mut track, 2, &lanes);
616 assert!(!track.muted);
617 }
618
619 #[tokio::test]
620 async fn process_offline_bounce_errors_when_track_is_missing() {
621 let (_rx_unused_tx, rx_unused) = channel(1);
622 let (tx, mut out_rx) = channel(8);
623 let worker = Worker {
624 id: 7,
625 rx: rx_unused,
626 tx,
627 };
628 let job = OfflineBounceWork {
629 state: Arc::new(UnsafeMutex::new(State::default())),
630 track_name: "missing".to_string(),
631 output_path: unique_temp_wav("missing").to_string_lossy().to_string(),
632 start_sample: 0,
633 length_samples: 8,
634 tempo_bpm: 120.0,
635 tsig_num: 4,
636 tsig_denom: 4,
637 automation_lanes: vec![],
638 cancel: Arc::new(AtomicBool::new(false)),
639 apply_fader: false,
640 };
641
642 worker.process_offline_bounce(job).await;
643
644 match out_rx.recv().await.expect("message") {
645 Message::OfflineBounceFinished { result: Err(err) } => {
646 assert!(err.contains("Track not found: missing"));
647 }
648 other => panic!("unexpected message: {other:?}"),
649 }
650 }
651
652 #[tokio::test]
653 async fn process_offline_bounce_cancels_and_restores_track_state() {
654 let (_rx_unused_tx, rx_unused) = channel(1);
655 let (tx, mut out_rx) = channel(8);
656 let worker = Worker {
657 id: 5,
658 rx: rx_unused,
659 tx,
660 };
661 let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
662 track.set_level(-9.0);
663 track.set_balance(-0.3);
664 let state = make_state_with_track(track);
665 let job = OfflineBounceWork {
666 state: state.clone(),
667 track_name: "track".to_string(),
668 output_path: unique_temp_wav("cancel").to_string_lossy().to_string(),
669 start_sample: 0,
670 length_samples: 8,
671 tempo_bpm: 120.0,
672 tsig_num: 4,
673 tsig_denom: 4,
674 automation_lanes: vec![],
675 cancel: Arc::new(AtomicBool::new(true)),
676 apply_fader: false,
677 };
678
679 worker.process_offline_bounce(job).await;
680
681 match out_rx.recv().await.expect("message") {
682 Message::OfflineBounceFinished {
683 result: Ok(Action::TrackOfflineBounceCanceled { track_name }),
684 } => assert_eq!(track_name, "track"),
685 other => panic!("unexpected message: {other:?}"),
686 }
687 assert!(matches!(out_rx.recv().await, Some(Message::Ready(5))));
688 let track = state.lock().tracks.get("track").expect("track").lock();
689 assert_eq!(track.level(), -9.0);
690 assert_eq!(track.balance, -0.3);
691 }
692
693 #[tokio::test]
694 async fn process_offline_bounce_restores_track_state_on_write_failure() {
695 let (_rx_unused_tx, rx_unused) = channel(1);
696 let (tx, mut out_rx) = channel(8);
697 let worker = Worker {
698 id: 3,
699 rx: rx_unused,
700 tx,
701 };
702 let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
703 track.set_level(-4.0);
704 track.set_balance(0.25);
705 let state = make_state_with_track(track);
706 let output_path = std::env::temp_dir().to_string_lossy().to_string();
707 let job = OfflineBounceWork {
708 state: state.clone(),
709 track_name: "track".to_string(),
710 output_path,
711 start_sample: 0,
712 length_samples: 4,
713 tempo_bpm: 120.0,
714 tsig_num: 4,
715 tsig_denom: 4,
716 automation_lanes: vec![],
717 cancel: Arc::new(AtomicBool::new(false)),
718 apply_fader: false,
719 };
720
721 worker.process_offline_bounce(job).await;
722
723 let mut saw_error = false;
724 while let Some(message) = out_rx.recv().await {
725 match message {
726 Message::OfflineBounceFinished {
727 result: Ok(Action::TrackOfflineBounceProgress { .. }),
728 } => {}
729 Message::OfflineBounceFinished { result: Err(err) } => {
730 assert!(
731 err.contains("Failed to create offline bounce")
732 || err.contains("Failed to write offline bounce")
733 || err.contains("Failed to finalize offline bounce")
734 );
735 saw_error = true;
736 }
737 Message::Ready(3) => break,
738 other => panic!("unexpected message: {other:?}"),
739 }
740 }
741 assert!(saw_error);
742 let track = state.lock().tracks.get("track").expect("track").lock();
743 assert_eq!(track.level(), -4.0);
744 assert_eq!(track.balance, 0.25);
745 }
746
747 #[tokio::test]
748 async fn process_offline_bounce_emits_progress_and_completion() {
749 let (_rx_unused_tx, rx_unused) = channel(1);
750 let (tx, mut out_rx) = channel(16);
751 let worker = Worker {
752 id: 2,
753 rx: rx_unused,
754 tx,
755 };
756 let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 4, 48_000.0);
757 track.set_level(-3.0);
758 track.set_balance(0.4);
759 let state = make_state_with_track(track);
760 let output = unique_temp_wav("success");
761 let job = OfflineBounceWork {
762 state: state.clone(),
763 track_name: "track".to_string(),
764 output_path: output.to_string_lossy().to_string(),
765 start_sample: 0,
766 length_samples: 8,
767 tempo_bpm: 120.0,
768 tsig_num: 4,
769 tsig_denom: 4,
770 automation_lanes: vec![],
771 cancel: Arc::new(AtomicBool::new(false)),
772 apply_fader: false,
773 };
774
775 worker.process_offline_bounce(job).await;
776
777 let mut saw_progress = false;
778 let mut saw_complete = false;
779 let mut saw_ready = false;
780 while let Some(message) = out_rx.recv().await {
781 match message {
782 Message::OfflineBounceFinished {
783 result:
784 Ok(Action::TrackOfflineBounceProgress {
785 track_name,
786 progress,
787 ..
788 }),
789 } => {
790 assert_eq!(track_name, "track");
791 assert!(progress > 0.0);
792 saw_progress = true;
793 }
794 Message::OfflineBounceFinished {
795 result:
796 Ok(Action::TrackOfflineBounce {
797 track_name,
798 output_path,
799 ..
800 }),
801 } => {
802 assert_eq!(track_name, "track");
803 assert_eq!(output_path, output.to_string_lossy());
804 saw_complete = true;
805 }
806 Message::Ready(2) => {
807 saw_ready = true;
808 break;
809 }
810 other => panic!("unexpected message: {other:?}"),
811 }
812 }
813
814 assert!(saw_progress);
815 assert!(saw_complete);
816 assert!(saw_ready);
817 assert!(output.exists());
818 std::fs::remove_file(&output).expect("remove temp wav");
819 let track = state.lock().tracks.get("track").expect("track").lock();
820 assert_eq!(track.level(), -3.0);
821 assert_eq!(track.balance, 0.4);
822 assert!(!track.muted);
823 }
824}