Skip to main content

phosphor_core/
mixer.rs

1//! Per-track audio mixer with MIDI recording and clip playback.
2//!
3//! The mixer owns all audio tracks and processes the track graph:
4//! routing MIDI to the active track, recording armed tracks,
5//! playing back clips, applying mute/solo/volume, and mixing to master.
6
7use std::sync::Arc;
8
9use crossbeam_channel::{Receiver, Sender};
10use phosphor_midi::message::MidiMessage;
11use phosphor_plugin::{MidiEvent, Plugin};
12
13use crate::clip::{ClipEvent, ClipSnapshot, MidiClip, RecordBuffer};
14use crate::engine::VuLevels;
15use crate::metronome::Metronome;
16use crate::project::{TrackHandle, TrackKind};
17use crate::transport::Transport;
18
19// ── Commands ──
20
21pub enum MixerCommand {
22    AddTrack {
23        kind: TrackKind,
24        handle: Arc<TrackHandle>,
25    },
26    SetInstrument {
27        track_id: usize,
28        instrument: Box<dyn Plugin + Send>,
29    },
30    RemoveTrack {
31        track_id: usize,
32    },
33    SetParameter {
34        track_id: usize,
35        param_index: usize,
36        value: f32,
37    },
38    /// Create a new empty clip on a track.
39    CreateClip {
40        track_id: usize,
41        start_tick: i64,
42        length_ticks: i64,
43    },
44    /// Replace a clip's events with edited data from the UI.
45    UpdateClip {
46        track_id: usize,
47        clip_index: usize,
48        events: Vec<ClipEvent>,
49    },
50    /// Update a clip's timeline position and length on the audio thread.
51    UpdateClipPosition {
52        track_id: usize,
53        clip_index: usize,
54        start_tick: i64,
55        length_ticks: i64,
56    },
57    /// Remove a clip from a track on the audio thread.
58    RemoveClip {
59        track_id: usize,
60        clip_index: usize,
61    },
62}
63
64// ── Command budget ──
65//
66// The audio callback has a hard deadline — 1.45 ms at the default 64 frames,
67// 0.73 ms if the device asks for 32 — and applying commands is the one thing
68// in it whose size the audio thread does not control. Loading a preset queues
69// one command per control, 59 of them on the Odyssey; opening a session
70// queues an AddTrack, a SetInstrument and a full parameter block per track,
71// plus two commands per clip. Draining all of that in one callback is an
72// unbounded amount of work behind a fixed deadline, which is a dropout.
73//
74// So each callback spends a fixed budget and stops. Nothing is dropped and
75// nothing is reordered: what is left stays queued, in order, and the next
76// callback continues from there. A burst that does not fit is spread over
77// consecutive callbacks — for a session load that is a few milliseconds with
78// the transport stopped, and for a preset it is at worst one buffer rendered
79// with part of the old panel, which is 1.45 ms.
80
81/// The cost of a command that goes to the allocator. See [`command_cost`].
82const HEAVY_COMMAND: u32 = 16;
83
84/// What one command costs, in the units [`COMMAND_BUDGET`] is denominated in.
85///
86/// Two tiers, and the line between them is the allocator:
87///
88/// * **1** — writes into memory that already exists. Setting a parameter is a
89///   clamp and a store; moving a clip writes two integers.
90/// * **[`HEAVY_COMMAND`]** — allocates, frees, or both. `SetInstrument` calls
91///   `Plugin::init`, which builds a voice array and, on the Juno, a chorus
92///   delay line; `AddTrack` allocates two audio buffers; `RemoveTrack` and
93///   `UpdateClip` free what they replace.
94///
95/// Measured in release on a 64-frame callback: four instrument loads take
96/// 30 µs against 1.4 µs for four `AddTrack` and 6.8 µs for sixty-four
97/// parameter changes, and the callback's own rendering with one instrument on
98/// it is 15 µs. So a flat count would be wrong in both directions: sixty-four
99/// parameter changes belong in one callback, and sixty-four instrument loads
100/// would be half a millisecond of it.
101fn command_cost(cmd: &MixerCommand) -> u32 {
102    match cmd {
103        MixerCommand::SetParameter { .. } | MixerCommand::UpdateClipPosition { .. } => 1,
104        MixerCommand::AddTrack { .. }
105        | MixerCommand::SetInstrument { .. }
106        | MixerCommand::RemoveTrack { .. }
107        | MixerCommand::CreateClip { .. }
108        | MixerCommand::UpdateClip { .. }
109        | MixerCommand::RemoveClip { .. } => HEAVY_COMMAND,
110    }
111}
112
113/// How much command work one callback will do.
114///
115/// 64 units: a whole parameter block in one callback — the widest panel in the
116/// project is the Odyssey's 59 controls — or four allocating commands.
117///
118/// A panel wider than this is not a fault, only a preset load spread over two
119/// callbacks, which shows up as one buffer rendered with part of the old panel
120/// and is 1.45 ms long.
121///
122/// Sized against the shortest callback the application can be given, 32 frames
123/// at 44.1 kHz, which is 726 µs: a full budget of the expensive kind measures
124/// 30 µs, or four percent of that deadline, and the cheap kind 7 µs.
125///
126/// The bound this buys is `COMMAND_BUDGET - 1 + HEAVY_COMMAND` units of work
127/// per callback, not `COMMAND_BUDGET`: the budget is checked before a command
128/// is taken and its cost is known only after. Tightening that would need a
129/// `peek` the channel does not offer, and the overshoot is one command.
130const COMMAND_BUDGET: u32 = 64;
131
132/// How many tracks a mixer has room for before its track list has to grow.
133///
134/// Growing it is a reallocation on the audio thread, so the list is built with
135/// room for more tracks than a session is going to hold. It is not a limit:
136/// `AddTrack` past this still works, at the cost of one reallocation, and the
137/// next 64 are free again. 64 `AudioTrack` headers are a few kilobytes, which
138/// is nothing next to the two audio buffers each one already owns.
139const TRACK_CAPACITY: usize = 64;
140
141// ── Master limiter ──
142
143/// Peak ceiling the limiter holds the master bus to, −1 dBFS.
144///
145/// Not 1.0: the samples we write are points on a waveform the converter
146/// reconstructs between, and that reconstruction can overshoot the samples
147/// themselves. A dB of margin is the usual allowance for it.
148const LIMITER_CEILING: f32 = 0.891;
149
150/// Release time constant, 50 ms.
151///
152/// Long enough not to modulate the waveform of a low note — a 40 Hz cycle is
153/// 25 ms, and a release near that period distorts the fundamental instead of
154/// riding it. Short enough that a single loud transient does not duck the
155/// following bar. Attack is not a time constant at all: see [`MasterLimiter`].
156const LIMITER_RELEASE_SECONDS: f32 = 0.050;
157
158/// Stereo-linked peak limiter on the master bus.
159///
160/// The last stage before the audio device, and the only hard guarantee that
161/// nothing leaves at more than full scale. Gain staging in the instruments
162/// and the soft saturator on their outputs are what keep this idle; this is
163/// what catches everything they cannot — many loud tracks at once, a plugin
164/// with no output bound, a NaN out of a diverging filter.
165///
166/// Design notes:
167///
168/// * **Stereo-linked.** One gain, computed from `max(|L|, |R|)` and applied
169///   to both channels, so a peak in one channel does not pull the image
170///   across to the other.
171/// * **Instant attack.** The gain that a sample needs is applied to that
172///   same sample, not `n` samples later, so there is no overshoot to clean
173///   up afterwards and no lookahead buffer to pay for. The alternative — a
174///   millisecond attack — would let a millisecond of overshoot through, and
175///   the only thing left to catch it would be a hard clip.
176/// * **Smooth release.** One-pole, so the gain walks back to unity rather
177///   than stepping.
178///
179/// Real-time safe: three floats of state, no allocation, no locks, no
180/// branches that can panic.
181struct MasterLimiter {
182    /// Current gain, 0..=1. Never above unity: this only ever attenuates.
183    gain: f32,
184    /// One-pole coefficient for the release ramp.
185    release_coeff: f32,
186}
187
188impl MasterLimiter {
189    fn new(sample_rate: u32) -> Self {
190        let sr = (sample_rate as f32).max(1.0);
191        Self {
192            gain: 1.0,
193            release_coeff: 1.0 - (-1.0 / (LIMITER_RELEASE_SECONDS * sr)).exp(),
194        }
195    }
196
197    fn reset(&mut self) {
198        self.gain = 1.0;
199    }
200
201    /// Limit an interleaved stereo buffer in place.
202    ///
203    /// On return every sample is finite and within ±1.0. Any frame that was
204    /// not finite on the way in leaves as silence.
205    fn process(&mut self, output: &mut [f32]) {
206        let mut frames = output.chunks_exact_mut(2);
207        for frame in frames.by_ref() {
208            // A NaN or infinity reaching the device is a full-scale noise
209            // burst, so it is turned into silence here — and, just as
210            // important, before it can be fed into the detector below, where
211            // it would poison the gain state for every sample after it.
212            let l = if frame[0].is_finite() { frame[0] } else { 0.0 };
213            let r = if frame[1].is_finite() { frame[1] } else { 0.0 };
214
215            let peak = l.abs().max(r.abs());
216            // The backoff is not a fudge factor. `CEILING / peak` rounds to
217            // nearest, and so does the multiply that applies it, so the
218            // product can land up to three rounding steps above the ceiling.
219            // Two epsilons of headroom covers that with margin and makes "at
220            // or below the ceiling" exact rather than approximate.
221            let target = if peak > LIMITER_CEILING {
222                (LIMITER_CEILING / peak) * (1.0 - 2.0 * f32::EPSILON)
223            } else {
224                1.0
225            };
226
227            if target < self.gain {
228                self.gain = target;
229            } else {
230                self.gain += (target - self.gain) * self.release_coeff;
231            }
232
233            // Belt and braces. `gain <= CEILING / peak` holds by
234            // construction, so the product cannot exceed the ceiling and this
235            // clamp cannot fire — it is here because it is the last line
236            // before the audio device and the cost of being wrong is a
237            // speaker.
238            frame[0] = (l * self.gain).clamp(-1.0, 1.0);
239            frame[1] = (r * self.gain).clamp(-1.0, 1.0);
240        }
241
242        // An interleaved stereo buffer with an odd sample count is malformed
243        // and no device produces one, but the guarantee is unconditional: a
244        // trailing sample gets the same treatment rather than going out
245        // unchecked.
246        for tail in frames.into_remainder() {
247            let s = if tail.is_finite() { *tail } else { 0.0 };
248            *tail = (s * self.gain).clamp(-LIMITER_CEILING, LIMITER_CEILING);
249        }
250    }
251}
252
253// ── AudioTrack ──
254
255pub struct AudioTrack {
256    pub id: usize,
257    pub kind: TrackKind,
258    pub handle: Arc<TrackHandle>,
259    pub instrument: Option<Box<dyn Plugin>>,
260    /// Recorded clips on this track's timeline.
261    pub clips: Vec<MidiClip>,
262    /// Active recording buffer (when armed + transport recording).
263    record_buf: RecordBuffer,
264    /// Whether we were recording last buffer (to detect stop).
265    was_recording: bool,
266    /// Last tick position seen during recording (to detect loop wraps).
267    last_record_tick: i64,
268    /// Last tick position seen during playback (to detect loop wraps for clip playback).
269    last_playback_tick: i64,
270    buf_l: Vec<f32>,
271    buf_r: Vec<f32>,
272    plugin_events: Vec<MidiEvent>,
273}
274
275impl AudioTrack {
276    pub fn new(handle: Arc<TrackHandle>, max_buffer_size: usize) -> Self {
277        Self {
278            id: handle.id,
279            kind: handle.kind,
280            handle,
281            instrument: None,
282            clips: Vec::new(),
283            record_buf: RecordBuffer::new(),
284            was_recording: false,
285            last_record_tick: -1,
286            last_playback_tick: -1,
287            buf_l: vec![0.0; max_buffer_size],
288            buf_r: vec![0.0; max_buffer_size],
289            plugin_events: Vec::with_capacity(256),
290        }
291    }
292}
293
294// ── Mixer ──
295
296pub struct Mixer {
297    tracks: Vec<AudioTrack>,
298    master_vu: Arc<VuLevels>,
299    command_rx: Receiver<MixerCommand>,
300    clip_tx: Sender<ClipSnapshot>,
301    metronome: Metronome,
302    sample_rate: u32,
303    max_buffer_size: usize,
304    /// Pre-allocated scratch buffers for mix — avoids allocation in process().
305    scratch_l: Vec<f32>,
306    scratch_r: Vec<f32>,
307    /// Pre-allocated buffer for live MIDI conversion.
308    live_events: Vec<MidiEvent>,
309    /// Final stage before the audio device — see [`MasterLimiter`].
310    limiter: MasterLimiter,
311}
312
313impl Mixer {
314    pub fn new(
315        command_rx: Receiver<MixerCommand>,
316        master_vu: Arc<VuLevels>,
317        clip_tx: Sender<ClipSnapshot>,
318        sample_rate: u32,
319        max_buffer_size: usize,
320    ) -> Self {
321        Self {
322            tracks: Vec::with_capacity(TRACK_CAPACITY),
323            master_vu,
324            command_rx,
325            clip_tx,
326            metronome: Metronome::new(sample_rate as f64),
327            sample_rate,
328            max_buffer_size,
329            scratch_l: vec![0.0; max_buffer_size],
330            scratch_r: vec![0.0; max_buffer_size],
331            live_events: Vec::with_capacity(256),
332            limiter: MasterLimiter::new(sample_rate),
333        }
334    }
335
336    /// Process one buffer cycle.
337    pub fn process(&mut self, output: &mut [f32], midi_messages: &[MidiMessage], transport: &Transport) {
338        // Bounded: whatever does not fit in this callback's budget is applied
339        // by the next one, in order. See `drain_commands`.
340        let _ = self.drain_commands();
341
342        let num_frames = output.len() / 2;
343        let playing = transport.is_playing();
344        let recording = transport.is_recording();
345        let looping = transport.is_looping();
346        let current_tick = transport.position_ticks();
347        let bpm = transport.tempo_bpm();
348        let ticks_per_sample = (bpm * Transport::PPQ as f64) / (60.0 * self.sample_rate as f64);
349        let buffer_ticks = (num_frames as f64 * ticks_per_sample) as i64;
350        let loop_end = transport.loop_end();
351
352        // Convert live MIDI to plugin events (reuse pre-allocated buffer)
353        self.live_events.clear();
354        for msg in midi_messages {
355            if let Some(ev) = midi_to_plugin_event(msg) {
356                self.live_events.push(ev);
357            }
358        }
359
360        let any_solo = self.tracks.iter().any(|t| t.handle.config.is_soloed());
361
362        // Reuse pre-allocated scratch buffers for master mix.
363        // Swap out of self to avoid borrow conflicts in the track loop.
364        let mut master_l = std::mem::take(&mut self.scratch_l);
365        let mut master_r = std::mem::take(&mut self.scratch_r);
366        let live_events = std::mem::take(&mut self.live_events);
367        if master_l.len() < num_frames {
368            master_l.resize(num_frames, 0.0);
369            master_r.resize(num_frames, 0.0);
370        }
371        master_l[..num_frames].fill(0.0);
372        master_r[..num_frames].fill(0.0);
373
374        let clip_tx = &self.clip_tx;
375
376        for track in &mut self.tracks {
377            if track.buf_l.len() < num_frames {
378                track.buf_l.resize(num_frames, 0.0);
379                track.buf_r.resize(num_frames, 0.0);
380            }
381            track.buf_l[..num_frames].fill(0.0);
382            track.buf_r[..num_frames].fill(0.0);
383            track.plugin_events.clear();
384
385            let is_midi_active = track.kind == TrackKind::Instrument
386                && track.handle.config.is_midi_active();
387            let is_armed = track.handle.config.is_armed();
388            let should_record = playing && recording && is_armed && is_midi_active;
389
390            // ── Recording ──
391            if should_record && !track.was_recording {
392                // Start recording at the loop start, not the current position,
393                // so the clip spans the full loop region
394                let rec_start = if looping { transport.loop_start() } else { current_tick };
395                track.record_buf.start(rec_start);
396                tracing::debug!("rec start track={} tick={}", track.id, current_tick);
397            }
398
399            // Detect loop wrap: current tick jumped backward means transport looped.
400            if should_record && track.was_recording && looping
401                && track.record_buf.is_active() && track.last_record_tick >= 0
402                && current_tick < track.last_record_tick
403            {
404                commit_recording(track, loop_end, clip_tx);
405                // Start new recording at loop start, not current_tick
406                // (current_tick may be a few ticks past 0 due to buffer boundaries)
407                track.record_buf.start(transport.loop_start());
408            }
409            if should_record {
410                track.last_record_tick = current_tick;
411            }
412
413            // Commit when recording stops (user pressed stop)
414            if !should_record && track.was_recording {
415                commit_recording(track, current_tick, clip_tx);
416            }
417            track.was_recording = should_record;
418
419            // Record live MIDI events (and pass through for monitoring)
420            if is_midi_active {
421                for ev in &live_events {
422                    track.plugin_events.push(*ev);
423                    if should_record {
424                        let event_tick = current_tick
425                            + (ev.sample_offset as f64 * ticks_per_sample) as i64;
426                        track.record_buf.record(event_tick, ev.status, ev.data1, ev.data2);
427                    }
428                }
429            }
430
431            // ── Playback ──
432            if playing && !track.clips.is_empty() {
433                let from = current_tick;
434                let to = current_tick + buffer_ticks;
435
436                // Detect loop wrap using dedicated playback tick tracker
437                // (separate from recording tick to avoid interference)
438                let just_wrapped = looping && track.last_playback_tick >= 0
439                    && current_tick < track.last_playback_tick;
440                track.last_playback_tick = current_tick;
441
442                if just_wrapped {
443                    // Play events from loop_start to current position (the wrapped portion)
444                    let wrap_start = transport.loop_start();
445                    for clip in &track.clips {
446                        for (tick_offset, event) in clip.events_in_range(wrap_start, to) {
447                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
448                            track.plugin_events.push(MidiEvent {
449                                sample_offset: sample_offset.min(num_frames as u32 - 1),
450                                status: event.status,
451                                data1: event.data1,
452                                data2: event.data2,
453                            });
454                        }
455                    }
456                } else {
457                    for clip in &track.clips {
458                        for (tick_offset, event) in clip.events_in_range(from, to) {
459                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
460                            track.plugin_events.push(MidiEvent {
461                                sample_offset: sample_offset.min(num_frames as u32 - 1),
462                                status: event.status,
463                                data1: event.data1,
464                                data2: event.data2,
465                            });
466                        }
467                    }
468                }
469                track.plugin_events.sort_by_key(|e| e.sample_offset);
470            }
471
472            // Track position for wrap detection (used by both recording and playback)
473            if playing {
474                track.last_record_tick = current_tick;
475            }
476
477            // ── Process instrument (allocation-free) ──
478            if let Some(ref mut instrument) = track.instrument {
479                let out_l = &mut track.buf_l[..num_frames];
480                let out_r = &mut track.buf_r[..num_frames];
481                let mut out_slices: [&mut [f32]; 2] = [out_l, out_r];
482                instrument.process(&[], &mut out_slices, &track.plugin_events);
483            }
484
485            // ── VU + Mix ──
486            let muted = track.handle.config.is_muted();
487            let soloed = track.handle.config.is_soloed();
488            let audible = !muted && (!any_solo || soloed);
489            let volume = track.handle.config.get_volume();
490
491            let mut peak_l = 0.0f32;
492            let mut peak_r = 0.0f32;
493            for i in 0..num_frames {
494                peak_l = peak_l.max(track.buf_l[i].abs());
495                peak_r = peak_r.max(track.buf_r[i].abs());
496            }
497
498            let (old_l, old_r) = track.handle.vu.get();
499            let decay = 0.85f32;
500            track.handle.vu.set(
501                if peak_l > old_l { peak_l } else { old_l * decay },
502                if peak_r > old_r { peak_r } else { old_r * decay },
503            );
504
505            if audible {
506                for i in 0..num_frames {
507                    master_l[i] += track.buf_l[i] * volume;
508                    master_r[i] += track.buf_r[i] * volume;
509                }
510            }
511        }
512
513        // Write tracks to interleaved output
514        for i in 0..num_frames {
515            output[i * 2] = master_l[i];
516            output[i * 2 + 1] = master_r[i];
517        }
518
519        // Return scratch buffers to self (no allocation, just moves)
520        self.scratch_l = master_l;
521        self.scratch_r = master_r;
522        self.live_events = live_events;
523
524        // Mix metronome click into output (after tracks, so it's always audible)
525        self.metronome.process(output, transport);
526
527        // ── Master limiter ──
528        // Everything that reaches the device passes through here, the
529        // metronome included: it is summed on top of the track mix, so
530        // limiting before it would leave a gap in the guarantee.
531        self.limiter.process(output);
532
533        // Master VU (includes metronome), read after limiting so the meter
534        // shows what actually left rather than what would have.
535        let mut mp_l = 0.0f32;
536        let mut mp_r = 0.0f32;
537        for i in 0..num_frames {
538            mp_l = mp_l.max(output[i * 2].abs());
539            mp_r = mp_r.max(output[i * 2 + 1].abs());
540        }
541
542        let (old_l, old_r) = self.master_vu.get();
543        let decay = 0.85f32;
544        self.master_vu.set(
545            if mp_l > old_l { mp_l } else { old_l * decay },
546            if mp_r > old_r { mp_r } else { old_r * decay },
547        );
548    }
549
550    pub fn reset_all(&mut self) {
551        let clip_tx = &self.clip_tx;
552        for track in &mut self.tracks {
553            if let Some(ref mut inst) = track.instrument {
554                inst.reset();
555            }
556            track.handle.vu.set(0.0, 0.0);
557            // Commit any active recording before resetting (don't lose overdubs)
558            if track.record_buf.is_active() && track.was_recording {
559                let end_tick = track.last_record_tick.max(0);
560                commit_recording(track, end_tick, clip_tx);
561            } else if track.record_buf.is_active() {
562                track.record_buf.discard();
563            }
564            track.was_recording = false;
565            track.last_playback_tick = -1;
566        }
567        self.metronome.reset();
568        self.limiter.reset();
569    }
570
571    /// Apply queued commands until the callback's budget is spent.
572    ///
573    /// Returns the units spent, which is what the tests assert the bound on.
574    ///
575    /// Anything left in the channel stays there, in the order it was sent, and
576    /// the next callback continues from it. That is the whole of the ordering
577    /// guarantee: commands are taken one at a time from a FIFO and applied
578    /// immediately, so `AddTrack` before `SetInstrument` for the same track
579    /// cannot be seen the other way round even when the two land in different
580    /// callbacks.
581    fn drain_commands(&mut self) -> u32 {
582        let mut spent = 0;
583        while spent < COMMAND_BUDGET {
584            let Ok(cmd) = self.command_rx.try_recv() else { break };
585            spent += command_cost(&cmd);
586            self.apply_command(cmd);
587        }
588        spent
589    }
590
591    fn apply_command(&mut self, cmd: MixerCommand) {
592        match cmd {
593            MixerCommand::AddTrack { kind: _, handle } => {
594                let track = AudioTrack::new(handle, self.max_buffer_size);
595                self.tracks.push(track);
596            }
597            MixerCommand::SetInstrument { track_id, mut instrument } => {
598                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
599                    instrument.init(self.sample_rate as f64, self.max_buffer_size);
600                    track.instrument = Some(instrument);
601                }
602            }
603            MixerCommand::RemoveTrack { track_id } => {
604                self.tracks.retain(|t| t.id != track_id);
605            }
606            MixerCommand::SetParameter { track_id, param_index, value } => {
607                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
608                    if let Some(ref mut inst) = track.instrument {
609                        inst.set_parameter(param_index, value);
610                    }
611                }
612            }
613            MixerCommand::CreateClip { track_id, start_tick, length_ticks } => {
614                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
615                    track.clips.push(MidiClip::new(start_tick, length_ticks, Vec::new()));
616                }
617            }
618            MixerCommand::UpdateClip { track_id, clip_index, events } => {
619                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
620                    if let Some(clip) = track.clips.get_mut(clip_index) {
621                        clip.events = events;
622                        clip.events.sort_by_key(|e| e.tick);
623                    }
624                }
625            }
626            MixerCommand::UpdateClipPosition { track_id, clip_index, start_tick, length_ticks } => {
627                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
628                    if let Some(clip) = track.clips.get_mut(clip_index) {
629                        clip.start_tick = start_tick;
630                        clip.length_ticks = length_ticks;
631                    }
632                }
633            }
634            MixerCommand::RemoveClip { track_id, clip_index } => {
635                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
636                    if clip_index < track.clips.len() {
637                        track.clips.remove(clip_index);
638                    }
639                }
640            }
641        }
642    }
643}
644
645/// Commit a recording buffer into a clip and send snapshot to UI.
646fn commit_recording(track: &mut AudioTrack, end_tick: i64, clip_tx: &Sender<ClipSnapshot>) {
647    if let Some(clip) = track.record_buf.commit(end_tick) {
648        let idx = track.clips.len();
649        tracing::debug!(
650            "rec commit track={}: {} events, ticks {}..{}",
651            track.id, clip.events.len(), clip.start_tick, clip.end_tick()
652        );
653        let snapshot = ClipSnapshot::from_clip(track.id, idx, &clip);
654        track.clips.push(clip);
655        let _ = clip_tx.send(snapshot);
656    }
657}
658
659pub fn midi_to_plugin_event(msg: &MidiMessage) -> Option<MidiEvent> {
660    use phosphor_midi::message::MidiMessageType;
661    match msg.message_type {
662        MidiMessageType::NoteOn { .. }
663        | MidiMessageType::NoteOff { .. }
664        | MidiMessageType::ControlChange { .. }
665        | MidiMessageType::PitchBend { .. } => Some(MidiEvent {
666            sample_offset: 0,
667            status: msg.raw[0],
668            data1: msg.raw[1],
669            data2: msg.raw[2],
670        }),
671        _ => None,
672    }
673}
674
675pub fn mixer_command_channel() -> (Sender<MixerCommand>, Receiver<MixerCommand>) {
676    crossbeam_channel::unbounded()
677}
678
679/// Create a channel for clip snapshots (audio → UI).
680pub fn clip_snapshot_channel() -> (Sender<ClipSnapshot>, Receiver<ClipSnapshot>) {
681    crossbeam_channel::unbounded()
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use crate::project::TrackConfig;
688    use phosphor_dsp::synth::PhosphorSynth;
689    use phosphor_midi::message::{MidiMessage, MidiMessageType};
690
691    fn make_note_on(note: u8, vel: u8) -> MidiMessage {
692        MidiMessage {
693            timestamp: Some(0),
694            message_type: MidiMessageType::NoteOn { channel: 0, note, velocity: vel },
695            raw: [0x90, note, vel],
696            len: 3,
697        }
698    }
699
700    fn make_note_off(note: u8) -> MidiMessage {
701        MidiMessage {
702            timestamp: Some(0),
703            message_type: MidiMessageType::NoteOff { channel: 0, note, velocity: 0 },
704            raw: [0x80, note, 0],
705            len: 3,
706        }
707    }
708
709    fn setup_mixer() -> (Mixer, Sender<MixerCommand>, Receiver<ClipSnapshot>, Arc<Transport>) {
710        let (tx, rx) = mixer_command_channel();
711        let (clip_tx, clip_rx) = clip_snapshot_channel();
712        let master_vu = Arc::new(VuLevels::new());
713        let transport = Arc::new(Transport::new(120.0));
714        let mixer = Mixer::new(rx, master_vu, clip_tx, 44100, 256);
715        (mixer, tx, clip_rx, transport)
716    }
717
718    fn add_armed_synth(tx: &Sender<MixerCommand>, id: usize) -> Arc<TrackHandle> {
719        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
720        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
721        handle.config.armed.store(true, std::sync::atomic::Ordering::Relaxed);
722        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
723        tx.send(MixerCommand::SetInstrument { track_id: id, instrument: Box::new(PhosphorSynth::new()) }).unwrap();
724        handle
725    }
726
727    #[test]
728    fn mixer_empty_output() {
729        let (mut mixer, _tx, _clip_rx, transport) = setup_mixer();
730        let mut output = vec![0.0f32; 128];
731        mixer.process(&mut output, &[], &transport);
732        assert!(output.iter().all(|&s| s == 0.0));
733    }
734
735    #[test]
736    fn mixer_live_midi_produces_sound() {
737        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
738        let _handle = add_armed_synth(&tx, 0);
739        transport.play();
740
741        let midi = vec![make_note_on(60, 100)];
742        let mut output = vec![0.0f32; 512];
743        mixer.process(&mut output, &midi, &transport);
744
745        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
746        // Threshold is "not silence", not a level check — the instruments
747        // carry a deep headroom trim on their output.
748        assert!(peak > 0.001, "Should produce sound, peak={peak}");
749    }
750
751    #[test]
752    fn mixer_records_midi_clip() {
753        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
754        let _handle = add_armed_synth(&tx, 0);
755        transport.play();
756        transport.toggle_record();
757
758        // Play a note while recording
759        let midi = vec![make_note_on(60, 100)];
760        let mut output = vec![0.0f32; 512];
761        mixer.process(&mut output, &midi, &transport);
762
763        // Note off
764        let midi = vec![make_note_off(60)];
765        mixer.process(&mut output, &midi, &transport);
766
767        // Stop recording
768        transport.toggle_record();
769        mixer.process(&mut output, &[], &transport);
770
771        // Should have received a clip snapshot
772        let snap = clip_rx.try_recv().expect("Should receive clip snapshot");
773        assert_eq!(snap.track_id, 0);
774        assert!(snap.event_count >= 2, "Should have note on + off, got {}", snap.event_count);
775        assert!(!snap.notes.is_empty(), "Should have parsed notes");
776    }
777
778    #[test]
779    fn mixer_plays_back_recorded_clip() {
780        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
781        let _handle = add_armed_synth(&tx, 0);
782        transport.play();
783        transport.toggle_record();
784
785        // Record a note
786        let midi = vec![make_note_on(60, 100)];
787        let mut output = vec![0.0f32; 512];
788        mixer.process(&mut output, &midi, &transport);
789
790        let midi = vec![make_note_off(60)];
791        mixer.process(&mut output, &midi, &transport);
792
793        // Stop recording
794        transport.toggle_record();
795        mixer.process(&mut output, &[], &transport);
796
797        // Stop and rewind
798        transport.stop();
799
800        // Play back — should hear the recorded clip
801        transport.play();
802        output.fill(0.0);
803        mixer.process(&mut output, &[], &transport);
804
805        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
806        assert!(peak > 0.001, "Playback should produce sound, peak={peak}");
807    }
808
809    #[test]
810    fn mixer_mute_silences() {
811        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
812        let handle = add_armed_synth(&tx, 0);
813        handle.config.muted.store(true, std::sync::atomic::Ordering::Relaxed);
814        transport.play();
815
816        let midi = vec![make_note_on(60, 100)];
817        let mut output = vec![0.0f32; 512];
818        mixer.process(&mut output, &midi, &transport);
819
820        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
821        assert!(peak == 0.0, "Muted track should be silent, peak={peak}");
822    }
823
824    #[test]
825    fn mixer_no_record_when_not_armed() {
826        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
827        let handle = add_armed_synth(&tx, 0);
828        handle.config.armed.store(false, std::sync::atomic::Ordering::Relaxed);
829        transport.play();
830        transport.toggle_record();
831
832        let midi = vec![make_note_on(60, 100)];
833        let mut output = vec![0.0f32; 512];
834        mixer.process(&mut output, &midi, &transport);
835
836        transport.toggle_record();
837        mixer.process(&mut output, &[], &transport);
838
839        assert!(clip_rx.try_recv().is_err(), "Should not record when not armed");
840    }
841
842    #[test]
843    fn mixer_reset_commits_recording() {
844        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
845        let _handle = add_armed_synth(&tx, 0);
846        transport.play();
847        transport.toggle_record();
848
849        let midi = vec![make_note_on(60, 100)];
850        let mut output = vec![0.0f32; 512];
851        mixer.process(&mut output, &midi, &transport);
852
853        mixer.reset_all();
854
855        // Reset should commit the active recording, not discard it
856        assert!(clip_rx.try_recv().is_ok(), "Reset should commit active recording");
857    }
858
859    #[test]
860    fn end_to_end_record_and_playback() {
861        // Simulates exact app flow: add track, arm, record, play notes,
862        // stop, rewind, play back — with transport.advance() each buffer.
863        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
864        let _handle = add_armed_synth(&tx, 0);
865        let sr = 44100u32;
866        let buf_frames = 256;
867        let buf_samples = buf_frames * 2; // stereo
868
869        // 1. Enable recording, then play
870        transport.toggle_record();
871        transport.play();
872
873        // 2. Process a few empty buffers (advance transport)
874        let mut output = vec![0.0f32; buf_samples];
875        for _ in 0..4 {
876            mixer.process(&mut output, &[], &transport);
877            transport.advance(buf_frames as u32, sr);
878        }
879
880        // 3. Play a note (should be recorded)
881        let midi = vec![make_note_on(60, 100)];
882        mixer.process(&mut output, &midi, &transport);
883        let peak_during = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
884        assert!(peak_during > 0.001, "Should hear note during recording (monitoring)");
885        transport.advance(buf_frames as u32, sr);
886
887        // 4. A few more buffers of sustain
888        for _ in 0..8 {
889            output.fill(0.0);
890            mixer.process(&mut output, &[], &transport);
891            transport.advance(buf_frames as u32, sr);
892        }
893
894        // 5. Note off
895        let midi = vec![make_note_off(60)];
896        mixer.process(&mut output, &midi, &transport);
897        transport.advance(buf_frames as u32, sr);
898
899        // 6. A few more buffers
900        for _ in 0..4 {
901            output.fill(0.0);
902            mixer.process(&mut output, &[], &transport);
903            transport.advance(buf_frames as u32, sr);
904        }
905
906        // 7. Stop recording (commit clip)
907        transport.toggle_record();
908        mixer.process(&mut output, &[], &transport);
909        transport.advance(buf_frames as u32, sr);
910
911        // 8. Check we got a clip snapshot
912        let snap = clip_rx.try_recv().expect("Should receive clip snapshot after stopping record");
913        assert!(snap.event_count >= 2, "Clip should have note on + off");
914        assert!(!snap.notes.is_empty(), "Clip should have parsed notes");
915
916        // 9. Stop transport and rewind to 0
917        transport.stop();
918
919        // 10. Play back — the synth should be reset (no stuck notes from recording)
920        transport.play();
921
922        // 11. Process enough buffers to reach the recorded note position
923        // The note was recorded after 4 initial buffers, so roughly at that tick position
924        for _ in 0..4 {
925            output.fill(0.0);
926            mixer.process(&mut output, &[], &transport);
927            transport.advance(buf_frames as u32, sr);
928        }
929
930        // 12. The next buffer should contain the played-back note
931        output.fill(0.0);
932        mixer.process(&mut output, &[], &transport);
933        let peak_playback = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
934        assert!(peak_playback > 0.001, "Playback should produce sound at the recorded position, peak={peak_playback}");
935    }
936
937    #[test]
938    fn loop_record_commits_on_wrap() {
939        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
940        let _handle = add_armed_synth(&tx, 0);
941        let sr = 44100u32;
942        let buf_frames = 256u32;
943
944        // Set loop to 1 bar (3840 ticks at 120bpm ≈ 346 buffers of 256 samples)
945        transport.set_loop_bars(1, 1);
946        transport.start_loop_record();
947
948        let mut output = vec![0.0f32; buf_frames as usize * 2];
949
950        // Play a note early in the loop
951        let midi = vec![make_note_on(60, 100)];
952        mixer.process(&mut output, &midi, &transport);
953        transport.advance(buf_frames, sr);
954
955        // Note off a few buffers later
956        for _ in 0..5 {
957            mixer.process(&mut output, &[], &transport);
958            transport.advance(buf_frames, sr);
959        }
960        let midi = vec![make_note_off(60)];
961        mixer.process(&mut output, &midi, &transport);
962        transport.advance(buf_frames, sr);
963
964        // Continue until we cross the loop boundary
965        // 1 bar at 120bpm, 256 frames, 44100Hz ≈ 346 buffers
966        for _ in 0..400 {
967            mixer.process(&mut output, &[], &transport);
968            transport.advance(buf_frames, sr);
969
970            if let Ok(snap) = clip_rx.try_recv() {
971                assert!(snap.event_count >= 2, "Clip should have events, got {}", snap.event_count);
972                assert!(!snap.notes.is_empty(), "Clip should have notes");
973                // Recording committed on loop wrap — success
974                transport.stop_loop_record();
975                return;
976            }
977        }
978
979        panic!("Recording should have committed when the loop wrapped");
980    }
981
982    #[test]
983    fn loop_playback_after_record() {
984        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
985        let _handle = add_armed_synth(&tx, 0);
986        let sr = 44100u32;
987        let bf = 256u32;
988
989        // Set loop to 1 bar, start recording
990        transport.set_loop_bars(1, 1);
991        transport.start_loop_record();
992
993        let mut output = vec![0.0f32; bf as usize * 2];
994
995        // Record a note
996        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
997        transport.advance(bf, sr);
998        for _ in 0..3 {
999            mixer.process(&mut output, &[], &transport);
1000            transport.advance(bf, sr);
1001        }
1002        mixer.process(&mut output, &[make_note_off(60)], &transport);
1003        transport.advance(bf, sr);
1004
1005        // Run until loop wraps and clip commits
1006        for _ in 0..200 {
1007            mixer.process(&mut output, &[], &transport);
1008            transport.advance(bf, sr);
1009            if clip_rx.try_recv().is_ok() { break; }
1010        }
1011
1012        // Stop recording, rewind
1013        transport.stop_loop_record();
1014        transport.set_position(0);
1015
1016        // Play back with looping on
1017        transport.toggle_loop(); // enable looping
1018        transport.play();
1019
1020        output.fill(0.0);
1021        mixer.process(&mut output, &[], &transport);
1022        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1023        assert!(peak > 0.001, "Should hear playback, peak={peak}");
1024    }
1025
1026    // ── Command budget ──
1027
1028    /// The most work one callback can do, in the units [`command_cost`]
1029    /// returns: the budget is tested before a command is taken and charged
1030    /// after, so the last one can overshoot by its own cost.
1031    const WORST_CALLBACK: u32 = COMMAND_BUDGET - 1 + HEAVY_COMMAND;
1032
1033    /// A plugin that remembers every parameter it was given, in order, so a
1034    /// test can see exactly what reached the audio thread and when.
1035    ///
1036    /// The lock is not something an instrument would do — nothing may block in
1037    /// `process` — but `set_parameter` is called from the command drain and
1038    /// this one never renders.
1039    #[derive(Clone)]
1040    struct ParamLog(Arc<std::sync::Mutex<Vec<(usize, f32)>>>);
1041
1042    impl ParamLog {
1043        fn new() -> Self {
1044            Self(Arc::new(std::sync::Mutex::new(Vec::new())))
1045        }
1046        fn seen(&self) -> Vec<(usize, f32)> {
1047            self.0.lock().unwrap().clone()
1048        }
1049    }
1050
1051    impl Plugin for ParamLog {
1052        fn info(&self) -> phosphor_plugin::PluginInfo {
1053            phosphor_plugin::PluginInfo {
1054                name: "ParamLog".into(),
1055                version: "0".into(),
1056                author: "test".into(),
1057                category: phosphor_plugin::PluginCategory::Instrument,
1058            }
1059        }
1060        fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
1061        fn process(&mut self, _inputs: &[&[f32]], _outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {}
1062        fn parameter_count(&self) -> usize { 8 }
1063        fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
1064        fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
1065        fn set_parameter(&mut self, index: usize, value: f32) {
1066            self.0.lock().unwrap().push((index, value));
1067        }
1068        fn reset(&mut self) {}
1069    }
1070
1071    /// Add a track carrying a [`ParamLog`], applying the commands immediately.
1072    fn add_logging_track(mixer: &mut Mixer, tx: &Sender<MixerCommand>, id: usize) -> ParamLog {
1073        let log = ParamLog::new();
1074        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1075        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1076        tx.send(MixerCommand::SetInstrument {
1077            track_id: id,
1078            instrument: Box::new(log.clone()),
1079        }).unwrap();
1080        mixer.drain_commands();
1081        log
1082    }
1083
1084    /// The defect: the drain used to be `while let Ok(cmd) = try_recv()`, so
1085    /// the callback did as much work as the UI had queued. Opening a session
1086    /// queues hundreds of commands and the callback has a hard deadline.
1087    #[test]
1088    fn one_callback_applies_a_bounded_amount_of_work() {
1089        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1090        let log = add_logging_track(&mut mixer, &tx, 0);
1091
1092        for i in 0..500 {
1093            tx.send(MixerCommand::SetParameter {
1094                track_id: 0,
1095                param_index: i % 8,
1096                value: i as f32,
1097            }).unwrap();
1098        }
1099
1100        let spent = mixer.drain_commands();
1101        assert!(
1102            spent <= WORST_CALLBACK,
1103            "one callback spent {spent} units, over the {WORST_CALLBACK} bound"
1104        );
1105        assert_eq!(
1106            log.seen().len(),
1107            COMMAND_BUDGET as usize,
1108            "a parameter costs one unit, so a full budget is exactly that many"
1109        );
1110        assert!(!mixer.command_rx.is_empty(), "the rest has to still be queued");
1111    }
1112
1113    /// Bounded is only half of it: everything queued still has to arrive, once
1114    /// each, in the order it was sent.
1115    #[test]
1116    fn nothing_is_lost_or_reordered_across_callbacks() {
1117        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1118        let log = add_logging_track(&mut mixer, &tx, 0);
1119
1120        let sent: Vec<(usize, f32)> = (0..500).map(|i| (i % 8, i as f32)).collect();
1121        for &(param_index, value) in &sent {
1122            tx.send(MixerCommand::SetParameter { track_id: 0, param_index, value }).unwrap();
1123        }
1124
1125        // Run callbacks until the queue is empty, counting them: 500 commands
1126        // at one unit each cannot fit in fewer than eight budgets, which is
1127        // what makes this a test of the bound and not just of the FIFO.
1128        let mut output = vec![0.0f32; 128];
1129        let mut callbacks = 0;
1130        while !mixer.command_rx.is_empty() {
1131            mixer.process(&mut output, &[], &transport);
1132            callbacks += 1;
1133            assert!(callbacks < 100, "the drain is not making progress");
1134        }
1135        assert!(
1136            callbacks >= 500 / COMMAND_BUDGET as usize,
1137            "500 commands went through in {callbacks} callbacks, so the budget did not hold"
1138        );
1139        assert_eq!(log.seen(), sent, "the audio thread saw a different sequence");
1140    }
1141
1142    /// The ordering guarantee, at the one place it matters: a track has to
1143    /// exist before its instrument is attached. Splitting the queue between
1144    /// the two would drop the instrument on the floor — `SetInstrument` for a
1145    /// track that is not there yet is silently discarded — and the track would
1146    /// play nothing for the rest of the session.
1147    #[test]
1148    fn a_track_and_its_instrument_survive_a_budget_boundary() {
1149        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1150        let log = ParamLog::new();
1151
1152        // Fill this callback's budget with cheap commands first, so that the
1153        // pair below is guaranteed to land in a later one.
1154        for _ in 0..COMMAND_BUDGET {
1155            tx.send(MixerCommand::SetParameter { track_id: 99, param_index: 0, value: 0.0 })
1156                .unwrap();
1157        }
1158        let handle = Arc::new(TrackHandle::new(7, TrackKind::Instrument));
1159        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1160        tx.send(MixerCommand::SetInstrument {
1161            track_id: 7,
1162            instrument: Box::new(log.clone()),
1163        }).unwrap();
1164        tx.send(MixerCommand::SetParameter { track_id: 7, param_index: 3, value: 0.5 }).unwrap();
1165
1166        let mut output = vec![0.0f32; 128];
1167        mixer.process(&mut output, &[], &transport);
1168        assert!(mixer.tracks.is_empty(), "the budget did not stop at the parameters");
1169
1170        while !mixer.command_rx.is_empty() {
1171            mixer.process(&mut output, &[], &transport);
1172        }
1173        assert_eq!(mixer.tracks.len(), 1);
1174        assert!(mixer.tracks[0].instrument.is_some(), "the instrument never arrived");
1175        assert_eq!(
1176            log.seen(),
1177            vec![(3, 0.5)],
1178            "the parameter that follows the instrument did not reach it"
1179        );
1180    }
1181
1182    /// An instrument load is not a parameter change: it calls `Plugin::init`,
1183    /// which allocates a voice array and, on some instruments, a delay line.
1184    /// A flat count of commands per callback would let sixteen of those
1185    /// through where it lets sixteen stores through.
1186    #[test]
1187    fn an_instrument_load_costs_more_than_a_parameter() {
1188        let param = MixerCommand::SetParameter { track_id: 0, param_index: 0, value: 0.0 };
1189        let load = MixerCommand::SetInstrument {
1190            track_id: 0,
1191            instrument: Box::new(FixedOutput(0.0)),
1192        };
1193        assert!(command_cost(&load) > command_cost(&param));
1194
1195        // Four loads per callback, not sixty-four.
1196        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1197        for id in 0..8 {
1198            let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1199            tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1200        }
1201        while !mixer.command_rx.is_empty() {
1202            mixer.drain_commands();
1203        }
1204        for id in 0..8 {
1205            tx.send(MixerCommand::SetInstrument {
1206                track_id: id,
1207                instrument: Box::new(FixedOutput(0.25)),
1208            }).unwrap();
1209        }
1210        mixer.drain_commands();
1211        let loaded = mixer.tracks.iter().filter(|t| t.instrument.is_some()).count();
1212        assert_eq!(loaded, (COMMAND_BUDGET / HEAVY_COMMAND) as usize);
1213    }
1214
1215    /// `AddTrack` pushes onto the track list, and a push that grows the list
1216    /// reallocates — on the audio thread. The list is built with room for more
1217    /// tracks than a session will hold so that it does not.
1218    #[test]
1219    fn adding_tracks_does_not_grow_the_track_list() {
1220        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1221        let capacity = mixer.tracks.capacity();
1222        assert!(capacity >= TRACK_CAPACITY);
1223
1224        for id in 0..TRACK_CAPACITY {
1225            let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1226            tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1227        }
1228        while !mixer.command_rx.is_empty() {
1229            mixer.drain_commands();
1230        }
1231        assert_eq!(mixer.tracks.len(), TRACK_CAPACITY);
1232        assert_eq!(
1233            mixer.tracks.capacity(), capacity,
1234            "the track list reallocated on the audio thread"
1235        );
1236    }
1237
1238    // ── Master limiter ──
1239
1240    /// A plugin that writes whatever it is told to, so the limiter can be
1241    /// driven with signals no real instrument would produce.
1242    struct FixedOutput(f32);
1243
1244    impl Plugin for FixedOutput {
1245        fn info(&self) -> phosphor_plugin::PluginInfo {
1246            phosphor_plugin::PluginInfo {
1247                name: "Fixed".into(),
1248                version: "0".into(),
1249                author: "test".into(),
1250                category: phosphor_plugin::PluginCategory::Instrument,
1251            }
1252        }
1253        fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
1254        fn process(&mut self, _inputs: &[&[f32]], outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {
1255            for ch in outputs.iter_mut() {
1256                ch.fill(self.0);
1257            }
1258        }
1259        fn parameter_count(&self) -> usize { 0 }
1260        fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
1261        fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
1262        fn set_parameter(&mut self, _index: usize, _value: f32) {}
1263        fn reset(&mut self) {}
1264    }
1265
1266    fn add_fixed_track(tx: &Sender<MixerCommand>, id: usize, value: f32) -> Arc<TrackHandle> {
1267        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1268        handle.config.set_volume(1.0);
1269        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
1270        tx.send(MixerCommand::SetInstrument {
1271            track_id: id,
1272            instrument: Box::new(FixedOutput(value)),
1273        }).unwrap();
1274        handle
1275    }
1276
1277    /// The guarantee. Six tracks each running at three quarters of full scale
1278    /// sum to 4.5x — without the limiter that is what would reach the device.
1279    #[test]
1280    fn master_limiter_bounds_many_loud_tracks() {
1281        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1282        for id in 0..6 {
1283            add_fixed_track(&tx, id, 0.75);
1284        }
1285        transport.play();
1286
1287        let mut output = vec![0.0f32; 512];
1288        for _ in 0..8 {
1289            mixer.process(&mut output, &[], &transport);
1290            for (i, &s) in output.iter().enumerate() {
1291                assert!(s.is_finite(), "non-finite sample at {i}");
1292                assert!(s.abs() <= 1.0, "sample {i} left the mixer at {s}");
1293            }
1294        }
1295
1296        // And it is actually holding the ceiling, not silencing the mix.
1297        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1298        assert!(peak > 0.8, "limiter over-attenuated, peak={peak}");
1299    }
1300
1301    /// A NaN out of a diverging filter must not reach the device: at full
1302    /// scale it is a noise burst, and it also poisons every sample after it
1303    /// if it is allowed into the limiter's gain state.
1304    #[test]
1305    fn non_finite_track_output_becomes_silence() {
1306        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1307        add_fixed_track(&tx, 0, f32::NAN);
1308        transport.play();
1309
1310        let mut output = vec![0.0f32; 512];
1311        mixer.process(&mut output, &[], &transport);
1312        assert!(output.iter().all(|s| *s == 0.0), "NaN track should render as silence");
1313
1314        // ...and the mixer still works afterwards: the gain state was not
1315        // left as NaN by the sample that was thrown away.
1316        tx.send(MixerCommand::RemoveTrack { track_id: 0 }).unwrap();
1317        add_fixed_track(&tx, 1, 0.5);
1318        mixer.process(&mut output, &[], &transport);
1319        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1320        assert!((peak - 0.5).abs() < 1.0e-6, "mixer did not recover, peak={peak}");
1321    }
1322
1323    #[test]
1324    fn infinite_track_output_becomes_silence() {
1325        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1326        add_fixed_track(&tx, 0, f32::INFINITY);
1327        transport.play();
1328
1329        let mut output = vec![0.0f32; 512];
1330        mixer.process(&mut output, &[], &transport);
1331        assert!(output.iter().all(|s| *s == 0.0), "infinite track should render as silence");
1332    }
1333
1334    /// Below the ceiling the limiter is not a processor, it is a wire. Any
1335    /// deviation here would be gain riding on material that never asked for
1336    /// it — which is exactly what makes a limiter audible.
1337    #[test]
1338    fn limiter_is_bit_identical_below_the_ceiling() {
1339        let mut limiter = MasterLimiter::new(44_100);
1340
1341        // A sweep of levels up to the ceiling, plus signs and denormals.
1342        let mut input: Vec<f32> = Vec::new();
1343        for i in 0..20_000u32 {
1344            let phase = i as f32 * 0.01;
1345            let amp = LIMITER_CEILING * (i as f32 / 20_000.0);
1346            input.push(phase.sin() * amp);
1347            input.push(phase.cos() * amp);
1348        }
1349        input.push(LIMITER_CEILING);
1350        input.push(-LIMITER_CEILING);
1351        input.push(0.0);
1352        input.push(-0.0);
1353        input.push(f32::MIN_POSITIVE);
1354        input.push(-f32::MIN_POSITIVE);
1355
1356        let mut output = input.clone();
1357        limiter.process(&mut output);
1358
1359        for (i, (a, b)) in input.iter().zip(output.iter()).enumerate() {
1360            assert_eq!(a.to_bits(), b.to_bits(), "limiter altered sample {i}: {a} -> {b}");
1361        }
1362    }
1363
1364    /// The ceiling holds for anything, including levels no instrument in the
1365    /// project can produce.
1366    #[test]
1367    fn limiter_holds_the_ceiling_under_abuse() {
1368        let mut limiter = MasterLimiter::new(44_100);
1369        for amplitude in [1.0f32, 2.0, 10.0, 1.0e3, 1.0e6, 1.0e30] {
1370            let mut buf: Vec<f32> = (0..4_096)
1371                .map(|i| (i as f32 * 0.05).sin() * amplitude)
1372                .collect();
1373            limiter.process(&mut buf);
1374            for (i, &s) in buf.iter().enumerate() {
1375                assert!(s.is_finite(), "amplitude {amplitude}: sample {i} is {s}");
1376                assert!(
1377                    s.abs() <= LIMITER_CEILING,
1378                    "amplitude {amplitude}: sample {i} reached {s}, above the ceiling"
1379                );
1380            }
1381        }
1382    }
1383
1384    /// A step from silence to well over the ceiling: the very first sample of
1385    /// the step must already be limited. Anything else means overshoot, and
1386    /// the only thing left to catch overshoot is a hard clip.
1387    #[test]
1388    fn limiter_attack_has_no_overshoot() {
1389        let mut limiter = MasterLimiter::new(44_100);
1390        let mut buf = vec![0.0f32; 64];
1391        limiter.process(&mut buf);
1392        let mut step = vec![4.0f32; 64];
1393        limiter.process(&mut step);
1394        assert!(
1395            step[0].abs() <= LIMITER_CEILING,
1396            "first sample of the step overshot to {}",
1397            step[0]
1398        );
1399    }
1400
1401    /// Gain reduction must come back smoothly, not step. A step would be a
1402    /// click; a release faster than a low note's period would distort it.
1403    #[test]
1404    fn limiter_release_is_gradual() {
1405        let mut limiter = MasterLimiter::new(44_100);
1406        let mut loud = vec![4.0f32; 64];
1407        limiter.process(&mut loud);
1408        let reduced = limiter.gain;
1409        assert!(reduced < 0.5, "limiter did not engage, gain={reduced}");
1410
1411        // 10 ms of quiet material (441 stereo frames): partly recovered, not
1412        // all the way.
1413        let mut quiet = vec![0.1f32; 441 * 2];
1414        limiter.process(&mut quiet);
1415        assert!(limiter.gain > reduced, "gain did not recover at all");
1416        assert!(
1417            limiter.gain < 1.0,
1418            "gain snapped back to unity within 10 ms, which is a click"
1419        );
1420
1421        // 500 ms is ten time constants: fully recovered.
1422        let mut long = vec![0.1f32; 22_050 * 2];
1423        limiter.process(&mut long);
1424        assert!(
1425            (limiter.gain - 1.0).abs() < 1.0e-4,
1426            "gain never returned to unity: {}",
1427            limiter.gain
1428        );
1429    }
1430
1431    /// Stereo-linked: one gain from `max(|L|, |R|)`, so a peak on one side
1432    /// does not pull the image across to the other.
1433    #[test]
1434    fn limiter_does_not_shift_the_stereo_image() {
1435        let mut limiter = MasterLimiter::new(44_100);
1436        // Left twice the level of right, both well over the ceiling.
1437        let mut buf: Vec<f32> = Vec::new();
1438        for i in 0..1_024 {
1439            let phase = i as f32 * 0.05;
1440            buf.push(phase.sin() * 3.0);
1441            buf.push(phase.sin() * 1.5);
1442        }
1443        limiter.process(&mut buf);
1444        for frame in buf.chunks_exact(2) {
1445            if frame[1].abs() > 1.0e-4 {
1446                let ratio = frame[0] / frame[1];
1447                assert!(
1448                    (ratio - 2.0).abs() < 1.0e-3,
1449                    "channel balance moved: L/R = {ratio}"
1450                );
1451            }
1452        }
1453    }
1454
1455    /// The loudest single voice in the project: ROM3A's TIMPANI, voice 147 of
1456    /// the DX7's 256 factory voices, which is what `phosphor-dsp`'s headroom
1457    /// sweep measures as the hottest thing any instrument here can produce.
1458    ///
1459    /// The DX7 has two selectors — a cartridge and a voice — so picking one by
1460    /// number goes through `voice_knobs`.
1461    fn loudest_dx7_voice() -> phosphor_dsp::dx7::Dx7Synth {
1462        use phosphor_dsp::dx7;
1463        let mut synth = dx7::Dx7Synth::new();
1464        let (bank, patch) = dx7::voice_knobs(147);
1465        synth.set_parameter(dx7::P_BANK, bank);
1466        synth.set_parameter(dx7::P_PATCH, patch);
1467        debug_assert_eq!(dx7::voice_name(147), "TIMPANI");
1468        synth
1469    }
1470
1471    /// Four tracks of the loudest DX7 voice, each playing a two-handed
1472    /// eight-note chord at full velocity with the fader open — a heavier mix
1473    /// than anything the application can produce by accident.
1474    #[test]
1475    fn master_limiter_bounds_four_loud_instrument_tracks() {
1476        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1477        for id in 0..4 {
1478            let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1479            handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1480            handle.config.set_volume(1.0);
1481            let synth = loudest_dx7_voice();
1482            tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1483            tx.send(MixerCommand::SetInstrument {
1484                track_id: id,
1485                instrument: Box::new(synth),
1486            }).unwrap();
1487        }
1488        transport.play();
1489
1490        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1491            .iter()
1492            .map(|&note| make_note_on(note, 127))
1493            .collect();
1494
1495        let mut output = vec![0.0f32; 512];
1496        let mut peak = 0.0f32;
1497        for block in 0..200 {
1498            output.fill(0.0);
1499            if block == 0 {
1500                mixer.process(&mut output, &chord, &transport);
1501            } else {
1502                mixer.process(&mut output, &[], &transport);
1503            }
1504            for (i, &s) in output.iter().enumerate() {
1505                assert!(s.is_finite(), "block {block} sample {i} is {s}");
1506                assert!(s.abs() <= 1.0, "block {block} sample {i} left the mixer at {s}");
1507                peak = peak.max(s.abs());
1508            }
1509        }
1510        assert!(peak > 0.5, "four loud tracks should be loud, peak={peak}");
1511    }
1512
1513    /// The limiter must be inaudible in ordinary playing, which means it must
1514    /// not engage at all. The worst single track the application can produce
1515    /// is the loudest preset in the bank, an eight-note chord at velocity 127,
1516    /// with the fader all the way open — and that still has to leave the gain
1517    /// at exactly unity, so the mix is the track sum sample for sample.
1518    #[test]
1519    fn limiter_idle_for_the_worst_single_track() {
1520        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1521        let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1522        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1523        handle.config.set_volume(1.0);
1524        let synth = loudest_dx7_voice();
1525        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1526        tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
1527        transport.play();
1528
1529        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1530            .iter()
1531            .map(|&note| make_note_on(note, 127))
1532            .collect();
1533
1534        let mut output = vec![0.0f32; 512];
1535        let mut peak = 0.0f32;
1536        for block in 0..200 {
1537            output.fill(0.0);
1538            if block == 0 {
1539                mixer.process(&mut output, &chord, &transport);
1540            } else {
1541                mixer.process(&mut output, &[], &transport);
1542            }
1543            peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
1544            assert_eq!(
1545                mixer.limiter.gain, 1.0,
1546                "limiter engaged at block {block}, peak {peak}"
1547            );
1548        }
1549        assert!(peak > 0.3, "expected a loud chord, peak={peak}");
1550    }
1551
1552    // ── Fader ──
1553
1554    /// Render the loudest thing one track in this project can produce, with
1555    /// the fader at `volume`. Returns the output peak and the lowest gain the
1556    /// limiter reached.
1557    fn worst_track_through_the_mixer(volume: f32) -> (f32, f32) {
1558        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1559        let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1560        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1561        handle.config.set_volume(volume);
1562        let synth = loudest_dx7_voice();
1563        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1564        tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
1565        transport.play();
1566
1567        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1568            .iter()
1569            .map(|&note| make_note_on(note, 127))
1570            .collect();
1571
1572        let mut output = vec![0.0f32; 512];
1573        let mut peak = 0.0f32;
1574        let mut min_gain = 1.0f32;
1575        for block in 0..200 {
1576            output.fill(0.0);
1577            if block == 0 {
1578                mixer.process(&mut output, &chord, &transport);
1579            } else {
1580                mixer.process(&mut output, &[], &transport);
1581            }
1582            for &s in output.iter() {
1583                assert!(s.is_finite(), "block {block}: non-finite sample");
1584                assert!(s.abs() <= 1.0, "block {block}: sample left the mixer at {s}");
1585                peak = peak.max(s.abs());
1586            }
1587            min_gain = min_gain.min(mixer.limiter.gain);
1588        }
1589        (peak, min_gain)
1590    }
1591
1592    /// Anywhere from the bottom of the fader up to unity, the limiter is not
1593    /// in the signal path at all — not "barely", not at all — even for the
1594    /// loudest patch in the project played as hard as the format allows.
1595    ///
1596    /// This is what the instrument trims buy. Gain reduction on the master
1597    /// bus is then always a mix decision (several loud tracks at once) rather
1598    /// than something one instrument can cause on its own.
1599    #[test]
1600    fn fader_below_unity_never_engages_the_limiter() {
1601        for volume in [
1602            0.25,
1603            TrackConfig::DEFAULT_VOLUME,
1604            TrackConfig::UNITY_VOLUME,
1605        ] {
1606            let (peak, min_gain) = worst_track_through_the_mixer(volume);
1607            assert_eq!(
1608                min_gain, 1.0,
1609                "limiter reduced by {:.2} dB at fader {volume} (peak {peak:.4})",
1610                20.0 * min_gain.log10()
1611            );
1612        }
1613    }
1614
1615    /// Above unity the fader is makeup gain the user asked for, and the
1616    /// limiter is what makes asking for it safe. Two things have to hold:
1617    /// the output stays bounded, and turning the fader up never makes the
1618    /// track quieter than leaving it at unity — a limiter that over-ducks
1619    /// would turn the top of the fader into a trap.
1620    #[test]
1621    fn fader_makeup_gain_is_bounded_not_wasted() {
1622        let (unity_peak, _) = worst_track_through_the_mixer(TrackConfig::UNITY_VOLUME);
1623        let (max_peak, min_gain) = worst_track_through_the_mixer(TrackConfig::MAX_VOLUME);
1624
1625        assert!(
1626            max_peak <= LIMITER_CEILING,
1627            "fader at maximum let {max_peak:.4} through, above the ceiling"
1628        );
1629        assert!(
1630            max_peak >= unity_peak,
1631            "turning the fader up made the track quieter: {unity_peak:.4} -> {max_peak:.4}"
1632        );
1633        // The limiter took back some of the boost, but not more than the
1634        // fader added — otherwise it is attenuating, not limiting.
1635        let reduction_db = -20.0 * min_gain.log10();
1636        let boost_db = 20.0 * (TrackConfig::MAX_VOLUME / TrackConfig::UNITY_VOLUME).log10();
1637        assert!(
1638            reduction_db <= boost_db,
1639            "limiter took {reduction_db:.2} dB off a {boost_db:.2} dB boost"
1640        );
1641    }
1642
1643    // ── Metronome balance ──
1644
1645    /// The click has no fader and is not mixed through a track, so nothing
1646    /// downstream can compensate for it being wrong: it only sits right
1647    /// relative to the music if `CLICK_VOLUME` tracks the instruments'
1648    /// headroom trims. That coupling is invisible from either file and has
1649    /// already drifted once, when the trims moved and the click did not.
1650    ///
1651    /// So: a click against the level a user hears while playing — the default
1652    /// preset, a triad at velocity 100, fader at its default. Loud enough to
1653    /// play to, not so loud it is the loudest thing in the mix.
1654    #[test]
1655    fn metronome_click_sits_with_the_music() {
1656        use phosphor_dsp::dx7;
1657
1658        fn render(with_track: bool, metronome: bool) -> f32 {
1659            let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1660            let chord: Vec<MidiMessage> = if with_track {
1661                let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1662                handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1663                tx.send(MixerCommand::AddTrack {
1664                    kind: TrackKind::Instrument,
1665                    handle,
1666                })
1667                .unwrap();
1668                tx.send(MixerCommand::SetInstrument {
1669                    track_id: 0,
1670                    instrument: Box::new(dx7::Dx7Synth::new()),
1671                })
1672                .unwrap();
1673                [60u8, 64, 67].iter().map(|&n| make_note_on(n, 100)).collect()
1674            } else {
1675                Vec::new()
1676            };
1677            if metronome {
1678                transport.toggle_metronome();
1679            }
1680            transport.play();
1681
1682            let mut output = vec![0.0f32; 512];
1683            let mut peak = 0.0f32;
1684            for block in 0..200 {
1685                output.fill(0.0);
1686                if block == 0 {
1687                    mixer.process(&mut output, &chord, &transport);
1688                } else {
1689                    mixer.process(&mut output, &[], &transport);
1690                }
1691                peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
1692                transport.advance(256, 44_100);
1693            }
1694            peak
1695        }
1696
1697        let music = render(true, false);
1698        let click = render(false, true);
1699        assert!(music > 0.0 && click > 0.0, "music {music}, click {click}");
1700
1701        let relative_db = 20.0 * (click / music).log10();
1702        assert!(
1703            (-12.0..=0.0).contains(&relative_db),
1704            "the click is {relative_db:.1} dB against a triad (click {click:.4}, \
1705             music {music:.4}); it has to be audible over the music without \
1706             being the loudest thing in the mix"
1707        );
1708    }
1709
1710    /// The fader reaches the audio thread. Not a tautology: `volume` is read
1711    /// per buffer through the atomic, so this catches a mix path that caches
1712    /// it or ignores it.
1713    #[test]
1714    fn fader_scales_the_track() {
1715        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1716        let handle = add_fixed_track(&tx, 0, 0.25);
1717        transport.play();
1718
1719        let mut output = vec![0.0f32; 512];
1720        for (volume, expected) in [(0.0f32, 0.0f32), (0.5, 0.125), (1.0, 0.25), (2.0, 0.5)] {
1721            handle.config.set_volume(volume);
1722            output.fill(0.0);
1723            mixer.process(&mut output, &[], &transport);
1724            let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1725            assert!(
1726                (peak - expected).abs() < 1.0e-6,
1727                "fader at {volume} gave {peak}, expected {expected}"
1728            );
1729        }
1730    }
1731}