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};
11
12#[derive(Debug)]
13pub struct Worker {
14    id: usize,
15    rx: Receiver<Message>,
16    tx: Sender<Message>,
17    realtime_priority: i32,
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 mut output_samples =
186            Vec::<f32>::with_capacity(job.length_samples.saturating_mul(channels.max(1)));
187
188        let mut cursor = 0usize;
189        let mut last_reported_progress = 0.0_f32;
190        let mut total_process_time = Duration::ZERO;
191        let mut total_write_time = Duration::ZERO;
192        while cursor < job.length_samples {
193            if job.cancel.load(std::sync::atomic::Ordering::Relaxed) {
194                let _ = std::fs::remove_file(&job.output_path);
195                if let Some((original_level, original_balance)) = freeze_state {
196                    let t = target_track.lock();
197                    Self::restore_track_after_freeze_render(t, original_level, original_balance);
198                }
199                let _ = self
200                    .tx
201                    .send(Message::OfflineBounceFinished {
202                        result: Ok(Action::TrackOfflineBounceCanceled {
203                            track_name: job.track_name.clone(),
204                        }),
205                    })
206                    .await;
207                let _ = self.tx.send(Message::Ready(self.id)).await;
208                return;
209            }
210
211            let step = (job.length_samples - cursor).min(block_size);
212            for handle in &relevant_tracks {
213                let t = handle.lock();
214                t.audio.finished = false;
215                t.audio.processing = false;
216                t.set_transport_sample(job.start_sample.saturating_add(cursor));
217                t.set_loop_config(false, None);
218                t.set_transport_timing(job.tempo_bpm, job.tsig_num, job.tsig_denom);
219                t.set_clip_playback_enabled(true);
220                t.set_record_tap_enabled(false);
221            }
222
223            loop {
224                let mut all_finished = true;
225                let mut progressed = false;
226                for handle in &relevant_tracks {
227                    let t = handle.lock();
228                    if t.audio.finished {
229                        continue;
230                    }
231                    all_finished = false;
232                    if !t.audio.processing && t.audio.ready() {
233                        if t.name == job.track_name {
234                            Self::apply_freeze_automation_at_sample(
235                                t,
236                                job.start_sample.saturating_add(cursor),
237                                &job.automation_lanes,
238                            );
239                        }
240                        t.audio.processing = true;
241                        let p_start = Instant::now();
242                        t.process();
243                        total_process_time += p_start.elapsed();
244                        t.audio.processing = false;
245                        progressed = true;
246                    }
247                }
248                if all_finished {
249                    break;
250                }
251                if !progressed {
252                    for handle in &relevant_tracks {
253                        let t = handle.lock();
254                        if t.audio.finished {
255                            continue;
256                        }
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                    }
270                    break;
271                }
272            }
273
274            let write_start = Instant::now();
275            {
276                let t = target_track.lock();
277                let outs: Vec<_> = (0..channels)
278                    .map(|ch| t.audio.outs[ch].buffer.lock())
279                    .collect();
280                for i in 0..step {
281                    for out in outs.iter().take(channels) {
282                        let sample = out.get(i).copied().unwrap_or(0.0);
283                        output_samples.push(sample);
284                    }
285                }
286            }
287            total_write_time += write_start.elapsed();
288
289            cursor = cursor.saturating_add(step);
290            let progress = (cursor as f32 / job.length_samples as f32).clamp(0.0, 1.0);
291
292            if progress - last_reported_progress >= 0.01 || cursor >= job.length_samples {
293                last_reported_progress = progress;
294                let _ = self
295                    .tx
296                    .send(Message::OfflineBounceFinished {
297                        result: Ok(Action::TrackOfflineBounceProgress {
298                            track_name: job.track_name.clone(),
299                            progress,
300                            operation: Some("Rendering freeze".to_string()),
301                        }),
302                    })
303                    .await;
304            }
305        }
306
307        if let Err(e) = crate::audio_codec::write_wav_f32(
308            std::path::Path::new(&job.output_path),
309            &output_samples,
310            channels,
311            sample_rate as u32,
312        ) {
313            let _ = std::fs::remove_file(&job.output_path);
314            if let Some((original_level, original_balance)) = freeze_state {
315                let t = target_track.lock();
316                Self::restore_track_after_freeze_render(t, original_level, original_balance);
317            }
318            let _ = self
319                .tx
320                .send(Message::OfflineBounceFinished {
321                    result: Err(format!(
322                        "Failed to write offline bounce '{}': {e}",
323                        job.output_path
324                    )),
325                })
326                .await;
327            let _ = self.tx.send(Message::Ready(self.id)).await;
328            return;
329        }
330
331        if let Some((original_level, original_balance)) = freeze_state {
332            let t = target_track.lock();
333            Self::restore_track_after_freeze_render(t, original_level, original_balance);
334        }
335
336        let _ = self
337            .tx
338            .send(Message::OfflineBounceFinished {
339                result: Ok(Action::TrackOfflineBounce {
340                    track_name: job.track_name,
341                    output_path: job.output_path,
342                    start_sample: job.start_sample,
343                    length_samples: job.length_samples,
344                    automation_lanes: vec![],
345                    apply_fader: job.apply_fader,
346                }),
347            })
348            .await;
349        let _ = self.tx.send(Message::Ready(self.id)).await;
350    }
351
352    #[cfg(unix)]
353    fn try_enable_realtime(priority: i32) -> Result<(), String> {
354        let thread = unsafe { libc::pthread_self() };
355        let policy = libc::SCHED_FIFO;
356        let param = unsafe {
357            let mut p = std::mem::zeroed::<libc::sched_param>();
358            p.sched_priority = priority;
359            p
360        };
361        let rc = unsafe { libc::pthread_setschedparam(thread, policy, &param) };
362        if rc == 0 {
363            Ok(())
364        } else {
365            Err(format!("pthread_setschedparam failed with errno {}", rc))
366        }
367    }
368
369    #[cfg(not(unix))]
370    fn try_enable_realtime(_priority: i32) -> Result<(), String> {
371        Err("Realtime thread priority is not supported on this platform".to_string())
372    }
373
374    pub async fn new(
375        id: usize,
376        rx: Receiver<Message>,
377        tx: Sender<Message>,
378        realtime_priority: i32,
379    ) -> Worker {
380        let worker = Worker {
381            id,
382            rx,
383            tx,
384            realtime_priority,
385        };
386        worker.send(Message::Ready(id)).await;
387        worker
388    }
389
390    pub async fn send(&self, message: Message) {
391        self.tx
392            .send(message)
393            .await
394            .expect("Failed to send message from worker");
395    }
396
397    pub async fn work(&mut self) {
398        crate::enable_flush_denormals_to_zero();
399        let _ = Self::try_enable_realtime(self.realtime_priority);
400        while let Some(message) = self.rx.recv().await {
401            match message {
402                Message::Request(Action::Quit) => {
403                    return;
404                }
405                Message::ProcessTrack(t) => {
406                    let (track_name, output_linear, process_epoch, parameter_updates) = {
407                        let track = t.lock();
408                        let process_epoch = track.process_epoch;
409                        track.process();
410                        track.audio.processing = false;
411                        let updates = std::mem::take(track.echoed_parameter_updates.lock());
412                        (
413                            track.name.clone(),
414                            track.output_meter_linear(),
415                            process_epoch,
416                            updates,
417                        )
418                    };
419                    let _ = self
420                        .tx
421                        .send(Message::Finished {
422                            worker_id: self.id,
423                            track_name,
424                            output_linear,
425                            process_epoch,
426                            parameter_updates,
427                        })
428                        .await;
429                }
430                Message::ProcessOfflineBounce(job) => {
431                    self.process_offline_bounce(job).await;
432                }
433                _ => {}
434            }
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::Worker;
442    use crate::message::{
443        Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
444        OfflineBounceWork,
445    };
446    use crate::mutex::UnsafeMutex;
447    use crate::state::State;
448    use crate::track::Track;
449    use std::path::PathBuf;
450    use std::sync::{Arc, atomic::AtomicBool};
451    use std::time::{SystemTime, UNIX_EPOCH};
452    use tokio::sync::mpsc::channel;
453
454    fn make_state_with_track(track: Track) -> Arc<UnsafeMutex<State>> {
455        let mut state = State::default();
456        state.tracks.insert(
457            track.name.clone(),
458            Arc::new(UnsafeMutex::new(Box::new(track))),
459        );
460        Arc::new(UnsafeMutex::new(state))
461    }
462
463    fn unique_temp_wav(name: &str) -> PathBuf {
464        let nanos = SystemTime::now()
465            .duration_since(UNIX_EPOCH)
466            .expect("clock")
467            .as_nanos();
468        std::env::temp_dir().join(format!("maolan_{name}_{nanos}.wav"))
469    }
470
471    #[test]
472    fn prepare_track_for_freeze_render_neutralizes_level_and_balance() {
473        let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
474        track.set_level(-6.0);
475        track.set_balance(0.35);
476
477        let (level, balance) = Worker::prepare_track_for_freeze_render(&mut track);
478
479        assert_eq!(level, -6.0);
480        assert_eq!(balance, 0.35);
481        assert_eq!(track.level(), 0.0);
482        assert_eq!(track.balance, 0.0);
483
484        Worker::restore_track_after_freeze_render(&mut track, level, balance);
485        assert_eq!(track.level(), -6.0);
486        assert_eq!(track.balance, 0.35);
487    }
488
489    #[test]
490    fn freeze_automation_ignores_volume_and_balance_lanes() {
491        let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
492        let lanes = vec![
493            OfflineAutomationLane {
494                target: OfflineAutomationTarget::Volume,
495                points: vec![OfflineAutomationPoint {
496                    sample: 0,
497                    value: 0.0,
498                }],
499            },
500            OfflineAutomationLane {
501                target: OfflineAutomationTarget::Balance,
502                points: vec![OfflineAutomationPoint {
503                    sample: 0,
504                    value: 1.0,
505                }],
506            },
507            OfflineAutomationLane {
508                target: OfflineAutomationTarget::Mute,
509                points: vec![OfflineAutomationPoint {
510                    sample: 0,
511                    value: 1.0,
512                }],
513            },
514        ];
515
516        Worker::apply_freeze_automation_at_sample(&mut track, 0, &lanes);
517
518        assert_eq!(track.level(), 0.0);
519        assert_eq!(track.balance, 0.0);
520        assert!(track.muted);
521    }
522
523    #[test]
524    fn automation_lane_value_at_interpolates_between_points() {
525        let value = Worker::automation_lane_value_at(
526            &[
527                OfflineAutomationPoint {
528                    sample: 10,
529                    value: 0.25,
530                },
531                OfflineAutomationPoint {
532                    sample: 20,
533                    value: 0.75,
534                },
535            ],
536            15,
537        )
538        .expect("value");
539
540        assert!((value - 0.5).abs() < 1.0e-6);
541    }
542
543    #[test]
544    fn freeze_automation_applies_interpolated_mute_lane() {
545        let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 64, 48_000.0);
546        let lanes = vec![OfflineAutomationLane {
547            target: OfflineAutomationTarget::Mute,
548            points: vec![
549                OfflineAutomationPoint {
550                    sample: 0,
551                    value: 0.0,
552                },
553                OfflineAutomationPoint {
554                    sample: 10,
555                    value: 1.0,
556                },
557            ],
558        }];
559
560        Worker::apply_freeze_automation_at_sample(&mut track, 5, &lanes);
561        assert!(track.muted);
562
563        track.set_muted(false);
564        Worker::apply_freeze_automation_at_sample(&mut track, 2, &lanes);
565        assert!(!track.muted);
566    }
567
568    #[tokio::test]
569    async fn process_offline_bounce_errors_when_track_is_missing() {
570        let (_rx_unused_tx, rx_unused) = channel(1);
571        let (tx, mut out_rx) = channel(8);
572        let worker = Worker {
573            id: 7,
574            rx: rx_unused,
575            tx,
576            realtime_priority: 0,
577        };
578        let job = OfflineBounceWork {
579            state: Arc::new(UnsafeMutex::new(State::default())),
580            track_name: "missing".to_string(),
581            output_path: unique_temp_wav("missing").to_string_lossy().to_string(),
582            start_sample: 0,
583            length_samples: 8,
584            tempo_bpm: 120.0,
585            tsig_num: 4,
586            tsig_denom: 4,
587            automation_lanes: vec![],
588            cancel: Arc::new(AtomicBool::new(false)),
589            apply_fader: false,
590        };
591
592        worker.process_offline_bounce(job).await;
593
594        match out_rx.recv().await.expect("message") {
595            Message::OfflineBounceFinished { result: Err(err) } => {
596                assert!(err.contains("Track not found: missing"));
597            }
598            other => panic!("unexpected message: {other:?}"),
599        }
600    }
601
602    #[tokio::test]
603    async fn process_offline_bounce_cancels_and_restores_track_state() {
604        let (_rx_unused_tx, rx_unused) = channel(1);
605        let (tx, mut out_rx) = channel(8);
606        let worker = Worker {
607            id: 5,
608            rx: rx_unused,
609            tx,
610            realtime_priority: 0,
611        };
612        let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
613        track.set_level(-9.0);
614        track.set_balance(-0.3);
615        let state = make_state_with_track(track);
616        let job = OfflineBounceWork {
617            state: state.clone(),
618            track_name: "track".to_string(),
619            output_path: unique_temp_wav("cancel").to_string_lossy().to_string(),
620            start_sample: 0,
621            length_samples: 8,
622            tempo_bpm: 120.0,
623            tsig_num: 4,
624            tsig_denom: 4,
625            automation_lanes: vec![],
626            cancel: Arc::new(AtomicBool::new(true)),
627            apply_fader: false,
628        };
629
630        worker.process_offline_bounce(job).await;
631
632        match out_rx.recv().await.expect("message") {
633            Message::OfflineBounceFinished {
634                result: Ok(Action::TrackOfflineBounceCanceled { track_name }),
635            } => assert_eq!(track_name, "track"),
636            other => panic!("unexpected message: {other:?}"),
637        }
638        assert!(matches!(out_rx.recv().await, Some(Message::Ready(5))));
639        let track = state.lock().tracks.get("track").expect("track").lock();
640        assert_eq!(track.level(), -9.0);
641        assert_eq!(track.balance, -0.3);
642    }
643
644    #[tokio::test]
645    async fn process_offline_bounce_restores_track_state_on_write_failure() {
646        let (_rx_unused_tx, rx_unused) = channel(1);
647        let (tx, mut out_rx) = channel(8);
648        let worker = Worker {
649            id: 3,
650            rx: rx_unused,
651            tx,
652            realtime_priority: 0,
653        };
654        let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
655        track.set_level(-4.0);
656        track.set_balance(0.25);
657        let state = make_state_with_track(track);
658        let output_path = std::env::temp_dir().to_string_lossy().to_string();
659        let job = OfflineBounceWork {
660            state: state.clone(),
661            track_name: "track".to_string(),
662            output_path,
663            start_sample: 0,
664            length_samples: 4,
665            tempo_bpm: 120.0,
666            tsig_num: 4,
667            tsig_denom: 4,
668            automation_lanes: vec![],
669            cancel: Arc::new(AtomicBool::new(false)),
670            apply_fader: false,
671        };
672
673        worker.process_offline_bounce(job).await;
674
675        let mut saw_error = false;
676        while let Some(message) = out_rx.recv().await {
677            match message {
678                Message::OfflineBounceFinished {
679                    result: Ok(Action::TrackOfflineBounceProgress { .. }),
680                } => {}
681                Message::OfflineBounceFinished { result: Err(err) } => {
682                    assert!(
683                        err.contains("Failed to create offline bounce")
684                            || err.contains("Failed to write offline bounce")
685                            || err.contains("Failed to finalize offline bounce")
686                    );
687                    saw_error = true;
688                }
689                Message::Ready(3) => break,
690                other => panic!("unexpected message: {other:?}"),
691            }
692        }
693        assert!(saw_error);
694        let track = state.lock().tracks.get("track").expect("track").lock();
695        assert_eq!(track.level(), -4.0);
696        assert_eq!(track.balance, 0.25);
697    }
698
699    #[tokio::test]
700    async fn process_offline_bounce_emits_progress_and_completion() {
701        let (_rx_unused_tx, rx_unused) = channel(1);
702        let (tx, mut out_rx) = channel(16);
703        let worker = Worker {
704            id: 2,
705            rx: rx_unused,
706            tx,
707            realtime_priority: 0,
708        };
709        let mut track = Track::new("track".to_string(), 1, 1, 0, 0, 4, 48_000.0);
710        track.set_level(-3.0);
711        track.set_balance(0.4);
712        let state = make_state_with_track(track);
713        let output = unique_temp_wav("success");
714        let job = OfflineBounceWork {
715            state: state.clone(),
716            track_name: "track".to_string(),
717            output_path: output.to_string_lossy().to_string(),
718            start_sample: 0,
719            length_samples: 8,
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_progress = false;
731        let mut saw_complete = false;
732        let mut saw_ready = false;
733        while let Some(message) = out_rx.recv().await {
734            match message {
735                Message::OfflineBounceFinished {
736                    result:
737                        Ok(Action::TrackOfflineBounceProgress {
738                            track_name,
739                            progress,
740                            ..
741                        }),
742                } => {
743                    assert_eq!(track_name, "track");
744                    assert!(progress > 0.0);
745                    saw_progress = true;
746                }
747                Message::OfflineBounceFinished {
748                    result:
749                        Ok(Action::TrackOfflineBounce {
750                            track_name,
751                            output_path,
752                            ..
753                        }),
754                } => {
755                    assert_eq!(track_name, "track");
756                    assert_eq!(output_path, output.to_string_lossy());
757                    saw_complete = true;
758                }
759                Message::Ready(2) => {
760                    saw_ready = true;
761                    break;
762                }
763                other => panic!("unexpected message: {other:?}"),
764            }
765        }
766
767        assert!(saw_progress);
768        assert!(saw_complete);
769        assert!(saw_ready);
770        assert!(output.exists());
771        std::fs::remove_file(&output).expect("remove temp wav");
772        let track = state.lock().tracks.get("track").expect("track").lock();
773        assert_eq!(track.level(), -3.0);
774        assert_eq!(track.balance, 0.4);
775        assert!(!track.muted);
776    }
777}