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