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