Skip to main content

maolan_engine/workers/
worker.rs

1use crate::{
2    executor::NodeJob,
3    message::{
4        Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
5        OfflineBounceWork, ProcessTask,
6    },
7    midi::io::MidiEvent,
8    render_plan::Op,
9};
10#[cfg(unix)]
11use nix::libc;
12use std::sync::Arc;
13use std::time::{Duration, Instant};
14use tokio::sync::mpsc::{Receiver, Sender};
15
16pub(crate) struct NodeJobResult {
17    pub(crate) worker_id: usize,
18    pub(crate) epoch: u64,
19    pub(crate) node: u32,
20    pub(crate) output_linear: Vec<f32>,
21    pub(crate) parameter_updates: Vec<Action>,
22}
23
24#[derive(Debug)]
25pub struct Worker {
26    id: usize,
27    rx: Receiver<Message>,
28    tx: Sender<Message>,
29    realtime_priority: i32,
30}
31
32impl Worker {
33    fn automation_lane_value_at(points: &[OfflineAutomationPoint], sample: usize) -> Option<f32> {
34        if points.is_empty() {
35            return None;
36        }
37        if sample <= points[0].sample {
38            return Some(points[0].value.clamp(0.0, 1.0));
39        }
40        if sample >= points[points.len().saturating_sub(1)].sample {
41            return Some(points[points.len().saturating_sub(1)].value.clamp(0.0, 1.0));
42        }
43        for segment in points.windows(2) {
44            let left = &segment[0];
45            let right = &segment[1];
46            if sample < left.sample || sample > right.sample {
47                continue;
48            }
49            let span = right.sample.saturating_sub(left.sample).max(1) as f32;
50            let t = (sample.saturating_sub(left.sample) as f32 / span).clamp(0.0, 1.0);
51            return Some((left.value + (right.value - left.value) * t).clamp(0.0, 1.0));
52        }
53        None
54    }
55
56    fn apply_freeze_automation_at_sample(
57        track: &mut crate::track::TrackData,
58        sample: usize,
59        lanes: &[OfflineAutomationLane],
60    ) {
61        for lane in lanes {
62            if matches!(
63                lane.target,
64                OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance
65            ) {
66                continue;
67            }
68            let Some(value) = Self::automation_lane_value_at(&lane.points, sample) else {
69                continue;
70            };
71            match lane.target {
72                OfflineAutomationTarget::Volume | OfflineAutomationTarget::Balance => {}
73                OfflineAutomationTarget::MidiCc { channel, cc } => {
74                    let cc_value = (value * 127.0).round() as u8;
75                    track.rt.pending_automation_midi_events.push(MidiEvent::new(
76                        0,
77                        vec![0xB0 | channel.min(15), cc.min(127), cc_value],
78                    ));
79                }
80                #[cfg(all(unix, not(target_os = "macos")))]
81                OfflineAutomationTarget::Lv2Parameter {
82                    instance_id,
83                    index,
84                    min,
85                    max,
86                } => {
87                    let lo = min.min(max);
88                    let hi = max.max(min);
89                    let param_value = (lo + value * (hi - lo)).clamp(lo, hi);
90                    let _ = track.set_lv2_control_value(
91                        instance_id,
92                        index as usize,
93                        param_value as f64,
94                    );
95                }
96                OfflineAutomationTarget::Vst3Parameter {
97                    instance_id,
98                    param_id,
99                } => {
100                    let _ = track.set_vst3_parameter(instance_id, param_id, value.clamp(0.0, 1.0));
101                }
102                OfflineAutomationTarget::ClapParameter {
103                    instance_id,
104                    param_id,
105                    min,
106                    max,
107                } => {
108                    let lo = min.min(max);
109                    let hi = max.max(min);
110                    let param_value = (lo + value as f64 * (hi - lo)).clamp(lo, hi);
111                    let _ = track.set_clap_parameter_at(instance_id, param_id, param_value, 0);
112                }
113            }
114        }
115    }
116
117    fn prepare_track_for_freeze_render(track: &mut crate::track::TrackData) -> (f32, f32) {
118        let original_level = track.level();
119        let original_balance = track.balance();
120        track.set_level(0.0);
121        track.set_balance(0.0);
122        (original_level, original_balance)
123    }
124
125    fn restore_track_after_freeze_render(
126        track: &mut crate::track::TrackData,
127        original_level: f32,
128        original_balance: f32,
129    ) {
130        track.set_level(original_level);
131        track.set_balance(original_balance);
132    }
133
134    async fn process_offline_bounce(&self, job: OfflineBounceWork) {
135        let track_handle = job.state.tracks.get(&job.track_name).cloned();
136        let Some(target_track) = track_handle else {
137            let _ = self
138                .tx
139                .send(Message::OfflineBounceFinished {
140                    result: Err(format!("Track not found: {}", job.track_name)),
141                })
142                .await;
143            let _ = self.tx.send(Message::Ready(self.id)).await;
144            return;
145        };
146        let (channels, block_size, sample_rate) = {
147            let t = target_track.lock();
148            let block_size = t
149                .audio
150                .outs
151                .first()
152                .map(|io| io.buffer_size())
153                .or_else(|| t.audio.ins.first().map(|io| io.buffer_size()))
154                .unwrap_or(0)
155                .max(1);
156            (
157                t.audio.outs.len().max(1),
158                block_size,
159                t.sample_rate.round().max(1.0) as i32,
160            )
161        };
162        let freeze_state = if job.apply_fader {
163            None
164        } else {
165            let mut t = target_track.lock();
166            Some(Self::prepare_track_for_freeze_render(&mut t))
167        };
168
169        let all_tracks: Vec<_> = job.state.tracks.values().cloned().collect();
170        let plan_collector = basedrop::Collector::new();
171        let render_plan = crate::render_plan::RenderPlan::compile(&job.state, &[], &[], block_size);
172        let render_plan = Arc::new(basedrop::Owned::new(&plan_collector.handle(), render_plan));
173
174        let mut output_samples =
175            Vec::<f32>::with_capacity(job.length_samples.saturating_mul(channels.max(1)));
176
177        let mut cursor = 0usize;
178        let mut last_reported_progress = 0.0_f32;
179        let mut total_process_time = Duration::ZERO;
180        let mut total_write_time = Duration::ZERO;
181        while cursor < job.length_samples {
182            if job.cancel.load(std::sync::atomic::Ordering::Relaxed) {
183                let _ = std::fs::remove_file(&job.output_path);
184                if let Some((original_level, original_balance)) = freeze_state {
185                    let mut t = target_track.lock();
186                    Self::restore_track_after_freeze_render(
187                        &mut t,
188                        original_level,
189                        original_balance,
190                    );
191                }
192                let _ = self
193                    .tx
194                    .send(Message::OfflineBounceFinished {
195                        result: Ok(Action::TrackOfflineBounceCanceled {
196                            track_name: job.track_name.clone(),
197                        }),
198                    })
199                    .await;
200                let _ = self.tx.send(Message::Ready(self.id)).await;
201                return;
202            }
203
204            let step = (job.length_samples - cursor).min(block_size);
205            for handle in &all_tracks {
206                let mut t = handle.lock();
207                t.audio.set_finished(false);
208                t.audio.set_processing(false);
209                t.set_transport_sample(job.start_sample.saturating_add(cursor));
210                t.set_loop_config(false, None);
211                t.set_transport_timing(job.tempo_bpm, job.tsig_num, job.tsig_denom);
212                t.set_clip_playback_enabled(true);
213                t.set_record_tap_enabled(false);
214            }
215            {
216                let mut t = target_track.lock();
217                Self::apply_freeze_automation_at_sample(
218                    &mut t,
219                    job.start_sample.saturating_add(cursor),
220                    &job.automation_lanes,
221                );
222            }
223
224            let p_start = Instant::now();
225            for node in 0..render_plan.nodes.len() as crate::render_plan::NodeId {
226                let _ = Self::process_node_job_result(
227                    self.id,
228                    NodeJob {
229                        epoch: 0,
230                        plan: render_plan.clone(),
231                        node,
232                    },
233                );
234            }
235            total_process_time += p_start.elapsed();
236
237            let write_start = Instant::now();
238            {
239                let t = target_track.lock();
240                let outs = t.last_audio_outputs();
241                for i in 0..step {
242                    for ch in 0..channels {
243                        let sample = outs
244                            .get(ch)
245                            .and_then(|out| out.get(i))
246                            .copied()
247                            .unwrap_or(0.0);
248                        output_samples.push(sample);
249                    }
250                }
251            }
252            total_write_time += write_start.elapsed();
253
254            cursor = cursor.saturating_add(step);
255            let progress = (cursor as f32 / job.length_samples as f32).clamp(0.0, 1.0);
256
257            if progress - last_reported_progress >= 0.01 || cursor >= job.length_samples {
258                last_reported_progress = progress;
259                let _ = self
260                    .tx
261                    .send(Message::OfflineBounceFinished {
262                        result: Ok(Action::TrackOfflineBounceProgress {
263                            track_name: job.track_name.clone(),
264                            progress,
265                            operation: Some("Rendering freeze".to_string()),
266                        }),
267                    })
268                    .await;
269            }
270        }
271
272        if let Err(e) = crate::audio_codec::write_wav_f32(
273            std::path::Path::new(&job.output_path),
274            &output_samples,
275            channels,
276            sample_rate as u32,
277        ) {
278            let _ = std::fs::remove_file(&job.output_path);
279            if let Some((original_level, original_balance)) = freeze_state {
280                let mut t = target_track.lock();
281                Self::restore_track_after_freeze_render(&mut t, original_level, original_balance);
282            }
283            let _ = self
284                .tx
285                .send(Message::OfflineBounceFinished {
286                    result: Err(format!(
287                        "Failed to write offline bounce '{}': {e}",
288                        job.output_path
289                    )),
290                })
291                .await;
292            let _ = self.tx.send(Message::Ready(self.id)).await;
293            return;
294        }
295
296        if let Some((original_level, original_balance)) = freeze_state {
297            let mut t = target_track.lock();
298            Self::restore_track_after_freeze_render(&mut t, original_level, original_balance);
299        }
300
301        let _ = self
302            .tx
303            .send(Message::OfflineBounceFinished {
304                result: Ok(Action::TrackOfflineBounce {
305                    track_name: job.track_name,
306                    output_path: job.output_path,
307                    start_sample: job.start_sample,
308                    length_samples: job.length_samples,
309                    automation_lanes: vec![],
310                    apply_fader: job.apply_fader,
311                }),
312            })
313            .await;
314        let _ = self.tx.send(Message::Ready(self.id)).await;
315    }
316
317    #[cfg(unix)]
318    pub(crate) fn try_enable_realtime(priority: i32) -> Result<(), String> {
319        let thread = unsafe { libc::pthread_self() };
320        let policy = libc::SCHED_FIFO;
321        let param = unsafe {
322            let mut p = std::mem::zeroed::<libc::sched_param>();
323            p.sched_priority = priority;
324            p
325        };
326        let rc = unsafe { libc::pthread_setschedparam(thread, policy, &param) };
327        if rc == 0 {
328            Ok(())
329        } else {
330            Err(format!("pthread_setschedparam failed with errno {}", rc))
331        }
332    }
333
334    #[cfg(not(unix))]
335    pub(crate) fn try_enable_realtime(_priority: i32) -> Result<(), String> {
336        Err("Realtime thread priority is not supported on this platform".to_string())
337    }
338
339    pub async fn new(
340        id: usize,
341        rx: Receiver<Message>,
342        tx: Sender<Message>,
343        realtime_priority: i32,
344    ) -> Worker {
345        let worker = Worker {
346            id,
347            rx,
348            tx,
349            realtime_priority,
350        };
351        worker.send(Message::Ready(id)).await;
352        worker
353    }
354
355    pub async fn send(&self, message: Message) {
356        self.tx
357            .send(message)
358            .await
359            .expect("Failed to send message from worker");
360    }
361
362    fn arena_input_slices<'a>(
363        plan: &'a crate::render_plan::RenderPlan,
364        ins: &[crate::render_plan::BufferId],
365    ) -> Vec<&'a [f32]> {
366        ins.iter()
367            .map(|&buf| {
368                // Safety: the plan dispatched this task only after every
369                // producer of the input buffer completed.
370                unsafe { plan.buffer(buf) }
371            })
372            .collect()
373    }
374
375    fn arena_source_slices<'a>(
376        plan: &'a crate::render_plan::RenderPlan,
377        writable: &[crate::render_plan::BufferId],
378    ) -> Vec<(usize, &'a [f32])> {
379        plan.port_map
380            .iter()
381            .filter_map(|(&key, &buf)| {
382                if writable.contains(&buf) {
383                    return None;
384                }
385                // Safety: every returned buffer is excluded from this node's
386                // writable outputs. Its producer completed before this task
387                // because the plan routes folder-output dependencies from
388                // child and plugin producer nodes.
389                Some((key, unsafe { plan.buffer(buf) }))
390            })
391            .collect()
392    }
393
394    fn metronome_output_buffer(
395        plan: &crate::render_plan::RenderPlan,
396        t: &crate::track::TrackData,
397        outs: &[crate::render_plan::BufferId],
398    ) -> Option<crate::render_plan::BufferId> {
399        let source = t.metronome_source()?;
400        let key = Arc::as_ptr(&source) as usize;
401        let &buf = plan.port_map.get(&key)?;
402        outs.contains(&buf).then_some(buf)
403    }
404
405    /// Execute one node of a render plan (Phase 2, see `LOCKLESS.md`).
406    ///
407    /// `Sum`/`Zero` are pure arena ops. Task nodes run the legacy track body
408    /// (it still re-sums its inputs from the port graph — identical to the
409    /// plan's `Sum` result) and then copy their output ports into the arena
410    /// so downstream `Sum` nodes and the hardware drain see the result.
411    pub(crate) fn process_node_job_result(worker_id: usize, job: NodeJob) -> NodeJobResult {
412        let NodeJob { epoch, plan, node } = job;
413        let (output_linear, parameter_updates) = match &plan.nodes[node as usize] {
414            Op::Zero { output } => {
415                // Safety: this worker executes this node; the plan's
416                // single-producer-chain invariant guarantees exclusive
417                // access to the output buffer.
418                unsafe { &mut *plan.buffer_ptr(*output) }.fill(0.0);
419                (Vec::new(), Vec::new())
420            }
421            Op::Sum { inputs, output } => {
422                // Safety: see `Op::Zero`; additionally, every input buffer's
423                // producer completed before this node was dispatched.
424                let out = unsafe { &mut *plan.buffer_ptr(*output) };
425                out.fill(0.0);
426                let mut sources = inputs.iter();
427                if let Some(&first) = sources.next() {
428                    let src = unsafe { plan.buffer(first) };
429                    crate::simd::copy_sanitized_inplace(out, src);
430                    if src.len() < out.len() {
431                        out[src.len()..].fill(0.0);
432                    }
433                }
434                for &input in sources {
435                    let src = unsafe { plan.buffer(input) };
436                    crate::simd::add_sanitized_inplace(out, src);
437                }
438                (Vec::new(), Vec::new())
439            }
440            Op::HwInput { .. } => {
441                // The hardware driver wrote this buffer before the cycle
442                // started; nothing to do.
443                (Vec::new(), Vec::new())
444            }
445            Op::Task { task, ins, outs } => {
446                let track = match task {
447                    ProcessTask::Track(t)
448                    | ProcessTask::FolderInput(t)
449                    | ProcessTask::FolderOutput(t) => t,
450                    ProcessTask::Plugin { track, .. } => track,
451                };
452                let mut t = track.lock();
453                match task {
454                    ProcessTask::Track(_) => {
455                        let audio_out_count = t.audio.outs.len();
456                        let metronome_output = Self::metronome_output_buffer(&plan, &t, outs);
457                        let input_ptrs = ins
458                            .iter()
459                            .map(|&buf| {
460                                // Safety: track tasks are registered as
461                                // in-place writers for their input buffers.
462                                unsafe { plan.buffer_ptr(buf) }
463                            })
464                            .collect::<Vec<_>>();
465                        let mut inputs = input_ptrs
466                            .iter()
467                            .map(|&ptr| {
468                                // Safety: each pointer came from a task input
469                                // buffer this node owns in-place.
470                                unsafe { (&mut *ptr).as_mut_slice() }
471                            })
472                            .collect::<Vec<_>>();
473                        let source_buffers = Self::arena_source_slices(&plan, outs);
474                        let output_ptrs = outs
475                            .iter()
476                            .take(audio_out_count)
477                            .map(|&buf| {
478                                // Safety: this worker executes the unique
479                                // producer node for each output buffer.
480                                unsafe { plan.buffer_ptr(buf) }
481                            })
482                            .collect::<Vec<_>>();
483                        let mut outputs = output_ptrs
484                            .iter()
485                            .map(|&ptr| {
486                                // Safety: each pointer came from a distinct
487                                // task output buffer owned by this node.
488                                unsafe { (&mut *ptr).as_mut_slice() }
489                            })
490                            .collect::<Vec<_>>();
491                        let metronome_output_ptr = metronome_output.map(|buf| {
492                            // Safety: the track task is the registered
493                            // producer of the metronome side-output buffer.
494                            unsafe { plan.buffer_ptr(buf) }
495                        });
496                        let metronome_output = metronome_output_ptr.map(|ptr| {
497                            // Safety: the side-output buffer is excluded from
498                            // the normal audio output slice above.
499                            unsafe { (&mut *ptr).as_mut_slice() }
500                        });
501                        t.process_render_block_with_audio_buffers_and_metronome(
502                            &mut inputs,
503                            &mut outputs,
504                            &source_buffers,
505                            metronome_output,
506                        );
507                    }
508                    ProcessTask::FolderInput(_) => {
509                        let metronome_output = Self::metronome_output_buffer(&plan, &t, outs);
510                        let input_ptrs = ins
511                            .iter()
512                            .map(|&buf| {
513                                // Safety: folder-input tasks are registered
514                                // as in-place writers for their input buffers.
515                                unsafe { plan.buffer_ptr(buf) }
516                            })
517                            .collect::<Vec<_>>();
518                        let mut inputs = input_ptrs
519                            .iter()
520                            .map(|&ptr| {
521                                // Safety: each pointer came from a task input
522                                // buffer this node owns in-place.
523                                unsafe { (&mut *ptr).as_mut_slice() }
524                            })
525                            .collect::<Vec<_>>();
526                        let metronome_output_ptr = metronome_output.map(|buf| {
527                            // Safety: the folder-input task is the registered
528                            // producer of the metronome side-output buffer.
529                            unsafe { plan.buffer_ptr(buf) }
530                        });
531                        let metronome_output = metronome_output_ptr.map(|ptr| {
532                            // Safety: this buffer is a side output, distinct
533                            // from the folder input buffers.
534                            unsafe { (&mut *ptr).as_mut_slice() }
535                        });
536                        t.process_folder_input_with_audio_buffers_and_metronome(
537                            &mut inputs,
538                            metronome_output,
539                        );
540                    }
541                    ProcessTask::FolderOutput(_) => {
542                        let source_buffers = Self::arena_source_slices(&plan, outs);
543                        let output_ptrs = outs
544                            .iter()
545                            .map(|&buf| {
546                                // Safety: this worker executes the unique
547                                // producer node for each output buffer.
548                                unsafe { plan.buffer_ptr(buf) }
549                            })
550                            .collect::<Vec<_>>();
551                        let mut outputs = output_ptrs
552                            .iter()
553                            .map(|&ptr| {
554                                // Safety: each pointer came from a distinct
555                                // task output buffer owned by this node.
556                                unsafe { (&mut *ptr).as_mut_slice() }
557                            })
558                            .collect::<Vec<_>>();
559                        t.process_folder_output_with_audio_buffers(&mut outputs, &source_buffers);
560                    }
561                    ProcessTask::Plugin { kind, index, .. } => {
562                        let inputs = Self::arena_input_slices(&plan, ins);
563                        let output_ptrs = outs
564                            .iter()
565                            .map(|&buf| {
566                                // Safety: this worker executes the unique
567                                // producer node for each output buffer.
568                                unsafe { plan.buffer_ptr(buf) }
569                            })
570                            .collect::<Vec<_>>();
571                        let mut outputs = output_ptrs
572                            .iter()
573                            .map(|&ptr| {
574                                // Safety: each pointer came from a distinct
575                                // task output buffer owned by this node.
576                                unsafe { (&mut *ptr).as_mut_slice() }
577                            })
578                            .collect::<Vec<_>>();
579                        t.process_plugin_with_audio_buffers(*kind, *index, &inputs, &mut outputs);
580                    }
581                }
582                t.audio.set_processing(false);
583                let updates = std::mem::take(&mut t.rt.echoed_parameter_updates);
584                let meter = t.output_meter_linear();
585                (meter, updates)
586            }
587        };
588        NodeJobResult {
589            worker_id,
590            epoch,
591            node,
592            output_linear,
593            parameter_updates,
594        }
595    }
596
597    async fn process_node_job(&self, job: NodeJob) {
598        let result = Self::process_node_job_result(self.id, job);
599        let _ = self.tx.send(result.into()).await;
600    }
601
602    pub async fn work(&mut self) {
603        crate::enable_flush_denormals_to_zero();
604        if let Err(e) = Self::try_enable_realtime(self.realtime_priority) {
605            tracing::warn!(
606                "Worker {} realtime priority {} not enabled: {}",
607                self.id,
608                self.realtime_priority,
609                e
610            );
611        }
612        while let Some(message) = self.rx.recv().await {
613            match message {
614                Message::Request(Action::Quit) => {
615                    return;
616                }
617                Message::ProcessOfflineBounce(job) => {
618                    self.process_offline_bounce(job).await;
619                }
620                Message::NodeJob(job) => {
621                    self.process_node_job(job).await;
622                }
623                _ => {}
624            }
625        }
626    }
627}
628
629impl From<NodeJobResult> for Message {
630    fn from(result: NodeJobResult) -> Self {
631        Message::NodeDone {
632            worker_id: result.worker_id,
633            epoch: result.epoch,
634            node: result.node,
635            output_linear: result.output_linear,
636            parameter_updates: result.parameter_updates,
637        }
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::Worker;
644    use crate::message::{
645        Action, Message, OfflineAutomationLane, OfflineAutomationPoint, OfflineAutomationTarget,
646        OfflineBounceWork,
647    };
648    use crate::state::State;
649    use crate::track::Track;
650    use std::path::PathBuf;
651    use std::sync::{Arc, atomic::AtomicBool};
652    use std::time::{SystemTime, UNIX_EPOCH};
653    use tokio::sync::mpsc::channel;
654
655    fn make_state_with_track(track: Track) -> State {
656        let mut state = State::default();
657        state.tracks.insert(track.name.clone(), Arc::new(track));
658        state
659    }
660
661    fn unique_temp_wav(name: &str) -> PathBuf {
662        let nanos = SystemTime::now()
663            .duration_since(UNIX_EPOCH)
664            .expect("clock")
665            .as_nanos();
666        std::env::temp_dir().join(format!("maolan_{name}_{nanos}.wav"))
667    }
668
669    #[test]
670    fn prepare_track_for_freeze_render_neutralizes_level_and_balance() {
671        let mut track = Track::new("track".to_string(), 1, 2, 0, 0, 64, 48_000.0);
672        track.set_level(-6.0);
673        track.set_balance(0.35);
674
675        let (level, balance) = Worker::prepare_track_for_freeze_render(&mut track);
676
677        assert_eq!(level, -6.0);
678        assert_eq!(balance, 0.35);
679        assert_eq!(track.level(), 0.0);
680        assert_eq!(track.balance(), 0.0);
681
682        Worker::restore_track_after_freeze_render(&mut track, level, balance);
683        assert_eq!(track.level(), -6.0);
684        assert_eq!(track.balance(), 0.35);
685    }
686
687    #[test]
688    fn freeze_automation_ignores_volume_and_balance_lanes() {
689        let mut track = Track::new("track".to_string(), 1, 2, 0, 1, 64, 48_000.0);
690        let lanes = vec![
691            OfflineAutomationLane {
692                target: OfflineAutomationTarget::Volume,
693                visible: true,
694                points: vec![OfflineAutomationPoint {
695                    sample: 0,
696                    value: 0.0,
697                }],
698            },
699            OfflineAutomationLane {
700                target: OfflineAutomationTarget::Balance,
701                visible: true,
702                points: vec![OfflineAutomationPoint {
703                    sample: 0,
704                    value: 1.0,
705                }],
706            },
707            OfflineAutomationLane {
708                target: OfflineAutomationTarget::MidiCc { channel: 0, cc: 7 },
709                visible: true,
710                points: vec![OfflineAutomationPoint {
711                    sample: 0,
712                    value: 1.0,
713                }],
714            },
715        ];
716
717        Worker::apply_freeze_automation_at_sample(&mut track, 0, &lanes);
718
719        assert_eq!(track.level(), 0.0);
720        assert_eq!(track.balance(), 0.0);
721        assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
722        assert_eq!(
723            track.rt.pending_automation_midi_events[0].data,
724            vec![0xB0, 7, 127]
725        );
726    }
727
728    #[test]
729    fn automation_lane_value_at_interpolates_between_points() {
730        let value = Worker::automation_lane_value_at(
731            &[
732                OfflineAutomationPoint {
733                    sample: 10,
734                    value: 0.25,
735                },
736                OfflineAutomationPoint {
737                    sample: 20,
738                    value: 0.75,
739                },
740            ],
741            15,
742        )
743        .expect("value");
744
745        assert!((value - 0.5).abs() < 1.0e-6);
746    }
747
748    #[test]
749    fn freeze_automation_applies_interpolated_midi_cc_lane() {
750        let mut track = Track::new("track".to_string(), 1, 1, 0, 1, 64, 48_000.0);
751        let lanes = vec![OfflineAutomationLane {
752            target: OfflineAutomationTarget::MidiCc { channel: 0, cc: 7 },
753            visible: true,
754            points: vec![
755                OfflineAutomationPoint {
756                    sample: 0,
757                    value: 0.0,
758                },
759                OfflineAutomationPoint {
760                    sample: 10,
761                    value: 1.0,
762                },
763            ],
764        }];
765
766        Worker::apply_freeze_automation_at_sample(&mut track, 5, &lanes);
767        assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
768        assert_eq!(track.rt.pending_automation_midi_events[0].data[2], 64);
769
770        track.rt.pending_automation_midi_events.clear();
771        Worker::apply_freeze_automation_at_sample(&mut track, 2, &lanes);
772        assert_eq!(track.rt.pending_automation_midi_events.len(), 1);
773        assert_eq!(track.rt.pending_automation_midi_events[0].data[2], 25);
774    }
775
776    #[cfg_attr(
777        all(miri, target_os = "freebsd"),
778        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
779    )]
780    #[tokio::test]
781    async fn process_node_job_sums_arena_buffers() {
782        use crate::render_plan::{Op, RenderPlan};
783        use std::cell::UnsafeCell;
784        use std::collections::HashMap;
785
786        let (_rx_unused_tx, rx_unused) = channel(1);
787        let (tx, mut out_rx) = channel(8);
788        let worker = Worker {
789            id: 4,
790            rx: rx_unused,
791            tx,
792            realtime_priority: 0,
793        };
794        let collector = basedrop::Collector::new();
795        let plan = RenderPlan {
796            buffer_size: 4,
797            buffers: (0..3).map(|_| UnsafeCell::new(vec![0.0; 4])).collect(),
798            nodes: vec![Op::Sum {
799                inputs: vec![0, 1],
800                output: 2,
801            }],
802            indegree: vec![0],
803            dependents: vec![vec![]],
804            sources: vec![0],
805            hw_in_map: vec![],
806            hw_out_map: vec![],
807            port_map: HashMap::new(),
808            midi_edges: vec![],
809            forced: vec![],
810        };
811        // Safety: test thread, no node is executing yet.
812        unsafe {
813            (&mut *plan.buffer_ptr(0)).copy_from_slice(&[0.25, 0.5, 0.75, 1.0]);
814            (&mut *plan.buffer_ptr(1)).copy_from_slice(&[0.75, 0.5, 0.25, f32::NAN]);
815        }
816        let shared = std::sync::Arc::new(basedrop::Owned::new(&collector.handle(), plan));
817
818        worker
819            .process_node_job(crate::executor::NodeJob {
820                epoch: 1,
821                plan: shared.clone(),
822                node: 0,
823            })
824            .await;
825
826        // Safety: the job completed, so the Sum node's writes are done.
827        // The NaN in the second source is sanitized to 0 before adding.
828        unsafe {
829            assert_eq!(
830                &*shared.buffer_ptr(2),
831                &vec![1.0, 1.0, 1.0, 1.0],
832                "sanitized sum in the arena"
833            );
834        }
835        match out_rx.recv().await.expect("message") {
836            Message::NodeDone {
837                worker_id,
838                epoch,
839                node,
840                output_linear,
841                ..
842            } => {
843                assert_eq!(worker_id, 4);
844                assert_eq!(epoch, 1);
845                assert_eq!(node, 0);
846                assert!(output_linear.is_empty());
847            }
848            other => panic!("unexpected message: {other:?}"),
849        }
850    }
851
852    #[cfg_attr(
853        all(miri, target_os = "freebsd"),
854        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
855    )]
856    #[tokio::test]
857    async fn process_offline_bounce_errors_when_track_is_missing() {
858        let (_rx_unused_tx, rx_unused) = channel(1);
859        let (tx, mut out_rx) = channel(8);
860        let worker = Worker {
861            id: 7,
862            rx: rx_unused,
863            tx,
864            realtime_priority: 0,
865        };
866        let job = OfflineBounceWork {
867            state: Arc::new(State::default().snapshot()),
868            track_name: "missing".to_string(),
869            output_path: unique_temp_wav("missing").to_string_lossy().to_string(),
870            start_sample: 0,
871            length_samples: 8,
872            tempo_bpm: 120.0,
873            tsig_num: 4,
874            tsig_denom: 4,
875            automation_lanes: vec![],
876            cancel: Arc::new(AtomicBool::new(false)),
877            apply_fader: false,
878        };
879
880        worker.process_offline_bounce(job).await;
881
882        match out_rx.recv().await.expect("message") {
883            Message::OfflineBounceFinished { result: Err(err) } => {
884                assert!(err.contains("Track not found: missing"));
885            }
886            other => panic!("unexpected message: {other:?}"),
887        }
888    }
889
890    #[cfg_attr(
891        all(miri, target_os = "freebsd"),
892        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
893    )]
894    #[tokio::test]
895    async fn process_offline_bounce_cancels_and_restores_track_state() {
896        let (_rx_unused_tx, rx_unused) = channel(1);
897        let (tx, mut out_rx) = channel(8);
898        let worker = Worker {
899            id: 5,
900            rx: rx_unused,
901            tx,
902            realtime_priority: 0,
903        };
904        let track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
905        track.set_level(-9.0);
906        track.set_balance(-0.3);
907        let state = make_state_with_track(track);
908        let job = OfflineBounceWork {
909            state: Arc::new(state.lock().snapshot()),
910            track_name: "track".to_string(),
911            output_path: unique_temp_wav("cancel").to_string_lossy().to_string(),
912            start_sample: 0,
913            length_samples: 8,
914            tempo_bpm: 120.0,
915            tsig_num: 4,
916            tsig_denom: 4,
917            automation_lanes: vec![],
918            cancel: Arc::new(AtomicBool::new(true)),
919            apply_fader: false,
920        };
921
922        worker.process_offline_bounce(job).await;
923
924        match out_rx.recv().await.expect("message") {
925            Message::OfflineBounceFinished {
926                result: Ok(Action::TrackOfflineBounceCanceled { track_name }),
927            } => assert_eq!(track_name, "track"),
928            other => panic!("unexpected message: {other:?}"),
929        }
930        assert!(matches!(out_rx.recv().await, Some(Message::Ready(5))));
931        let state_guard = state.lock();
932        let track = state_guard.tracks.get("track").expect("track").lock();
933        assert_eq!(track.level(), -9.0);
934        assert_eq!(track.balance(), -0.3);
935    }
936
937    #[cfg_attr(
938        all(miri, target_os = "freebsd"),
939        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
940    )]
941    #[tokio::test]
942    async fn process_offline_bounce_restores_track_state_on_write_failure() {
943        let (_rx_unused_tx, rx_unused) = channel(1);
944        let (tx, mut out_rx) = channel(8);
945        let worker = Worker {
946            id: 3,
947            rx: rx_unused,
948            tx,
949            realtime_priority: 0,
950        };
951        let track = Track::new("track".to_string(), 1, 2, 0, 0, 4, 48_000.0);
952        track.set_level(-4.0);
953        track.set_balance(0.25);
954        let state = make_state_with_track(track);
955        let output_path = std::env::temp_dir().to_string_lossy().to_string();
956        let job = OfflineBounceWork {
957            state: Arc::new(state.lock().snapshot()),
958            track_name: "track".to_string(),
959            output_path,
960            start_sample: 0,
961            length_samples: 4,
962            tempo_bpm: 120.0,
963            tsig_num: 4,
964            tsig_denom: 4,
965            automation_lanes: vec![],
966            cancel: Arc::new(AtomicBool::new(false)),
967            apply_fader: false,
968        };
969
970        worker.process_offline_bounce(job).await;
971
972        let mut saw_error = false;
973        while let Some(message) = out_rx.recv().await {
974            match message {
975                Message::OfflineBounceFinished {
976                    result: Ok(Action::TrackOfflineBounceProgress { .. }),
977                } => {}
978                Message::OfflineBounceFinished { result: Err(err) } => {
979                    assert!(
980                        err.contains("Failed to create offline bounce")
981                            || err.contains("Failed to write offline bounce")
982                            || err.contains("Failed to finalize offline bounce")
983                    );
984                    saw_error = true;
985                }
986                Message::Ready(3) => break,
987                other => panic!("unexpected message: {other:?}"),
988            }
989        }
990        assert!(saw_error);
991        let state_guard = state.lock();
992        let track = state_guard.tracks.get("track").expect("track").lock();
993        assert_eq!(track.level(), -4.0);
994        assert_eq!(track.balance(), 0.25);
995    }
996
997    #[cfg_attr(
998        all(miri, target_os = "freebsd"),
999        ignore = "Tokio runtime uses kqueue, which Miri does not support on FreeBSD"
1000    )]
1001    #[tokio::test]
1002    async fn process_offline_bounce_emits_progress_and_completion() {
1003        let (_rx_unused_tx, rx_unused) = channel(1);
1004        let (tx, mut out_rx) = channel(16);
1005        let worker = Worker {
1006            id: 2,
1007            rx: rx_unused,
1008            tx,
1009            realtime_priority: 0,
1010        };
1011        let track = Track::new("track".to_string(), 1, 1, 0, 0, 4, 48_000.0);
1012        track.set_level(-3.0);
1013        track.set_balance(0.4);
1014        let state = make_state_with_track(track);
1015        let output = unique_temp_wav("success");
1016        let job = OfflineBounceWork {
1017            state: Arc::new(state.lock().snapshot()),
1018            track_name: "track".to_string(),
1019            output_path: output.to_string_lossy().to_string(),
1020            start_sample: 0,
1021            length_samples: 8,
1022            tempo_bpm: 120.0,
1023            tsig_num: 4,
1024            tsig_denom: 4,
1025            automation_lanes: vec![],
1026            cancel: Arc::new(AtomicBool::new(false)),
1027            apply_fader: false,
1028        };
1029
1030        worker.process_offline_bounce(job).await;
1031
1032        let mut saw_progress = false;
1033        let mut saw_complete = false;
1034        let mut saw_ready = false;
1035        while let Some(message) = out_rx.recv().await {
1036            match message {
1037                Message::OfflineBounceFinished {
1038                    result:
1039                        Ok(Action::TrackOfflineBounceProgress {
1040                            track_name,
1041                            progress,
1042                            ..
1043                        }),
1044                } => {
1045                    assert_eq!(track_name, "track");
1046                    assert!(progress > 0.0);
1047                    saw_progress = true;
1048                }
1049                Message::OfflineBounceFinished {
1050                    result:
1051                        Ok(Action::TrackOfflineBounce {
1052                            track_name,
1053                            output_path,
1054                            ..
1055                        }),
1056                } => {
1057                    assert_eq!(track_name, "track");
1058                    assert_eq!(output_path, output.to_string_lossy());
1059                    saw_complete = true;
1060                }
1061                Message::Ready(2) => {
1062                    saw_ready = true;
1063                    break;
1064                }
1065                other => panic!("unexpected message: {other:?}"),
1066            }
1067        }
1068
1069        assert!(saw_progress);
1070        assert!(saw_complete);
1071        assert!(saw_ready);
1072        assert!(output.exists());
1073        std::fs::remove_file(&output).expect("remove temp wav");
1074        let state_guard = state.lock();
1075        let track = state_guard.tracks.get("track").expect("track").lock();
1076        assert_eq!(track.level(), -3.0);
1077        assert_eq!(track.balance(), 0.4);
1078        assert!(!track.muted());
1079    }
1080}