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