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