Skip to main content

maolan_engine/workers/
worker.rs

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