Skip to main content

maolan_engine/workers/
worker.rs

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, &param) };
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}