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        // Dead code in practice, and deliberately kept. `max_buffer_size` is
368        // the largest block the device said it could deliver, so a block that
369        // does not fit means a driver exceeded its own stated maximum. One
370        // allocation is a glitch; the alternative here is wrong output or a
371        // panic on the audio thread.
372        if master_l.len() < num_frames {
373            master_l.resize(num_frames, 0.0);
374            master_r.resize(num_frames, 0.0);
375        }
376        master_l[..num_frames].fill(0.0);
377        master_r[..num_frames].fill(0.0);
378
379        let clip_tx = &self.clip_tx;
380
381        for track in &mut self.tracks {
382            if track.buf_l.len() < num_frames {
383                track.buf_l.resize(num_frames, 0.0);
384                track.buf_r.resize(num_frames, 0.0);
385            }
386            track.buf_l[..num_frames].fill(0.0);
387            track.buf_r[..num_frames].fill(0.0);
388            track.plugin_events.clear();
389
390            let is_midi_active = track.kind == TrackKind::Instrument
391                && track.handle.config.is_midi_active();
392            let is_armed = track.handle.config.is_armed();
393            let should_record = playing && recording && is_armed && is_midi_active;
394
395            // ── Recording ──
396            if should_record && !track.was_recording {
397                // Start recording at the loop start, not the current position,
398                // so the clip spans the full loop region
399                let rec_start = if looping { transport.loop_start() } else { current_tick };
400                track.record_buf.start(rec_start);
401                tracing::debug!("rec start track={} tick={}", track.id, current_tick);
402            }
403
404            // Detect loop wrap: current tick jumped backward means transport looped.
405            if should_record && track.was_recording && looping
406                && track.record_buf.is_active() && track.last_record_tick >= 0
407                && current_tick < track.last_record_tick
408            {
409                commit_recording(track, loop_end, clip_tx);
410                // Start new recording at loop start, not current_tick
411                // (current_tick may be a few ticks past 0 due to buffer boundaries)
412                track.record_buf.start(transport.loop_start());
413            }
414            if should_record {
415                track.last_record_tick = current_tick;
416            }
417
418            // Commit when recording stops (user pressed stop)
419            if !should_record && track.was_recording {
420                commit_recording(track, current_tick, clip_tx);
421            }
422            track.was_recording = should_record;
423
424            // Record live MIDI events (and pass through for monitoring)
425            if is_midi_active {
426                for ev in &live_events {
427                    track.plugin_events.push(*ev);
428                    if should_record {
429                        let event_tick = current_tick
430                            + (ev.sample_offset as f64 * ticks_per_sample) as i64;
431                        track.record_buf.record(event_tick, ev.status, ev.data1, ev.data2);
432                    }
433                }
434            }
435
436            // ── Playback ──
437            if playing && !track.clips.is_empty() {
438                let from = current_tick;
439                let to = current_tick + buffer_ticks;
440
441                // Detect loop wrap using dedicated playback tick tracker
442                // (separate from recording tick to avoid interference)
443                let just_wrapped = looping && track.last_playback_tick >= 0
444                    && current_tick < track.last_playback_tick;
445                track.last_playback_tick = current_tick;
446
447                if just_wrapped {
448                    // Play events from loop_start to current position (the wrapped portion)
449                    let wrap_start = transport.loop_start();
450                    for clip in &track.clips {
451                        for (tick_offset, event) in clip.events_in_range(wrap_start, to) {
452                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
453                            track.plugin_events.push(MidiEvent {
454                                sample_offset: sample_offset.min(num_frames as u32 - 1),
455                                status: event.status,
456                                data1: event.data1,
457                                data2: event.data2,
458                            });
459                        }
460                    }
461                } else {
462                    for clip in &track.clips {
463                        for (tick_offset, event) in clip.events_in_range(from, to) {
464                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
465                            track.plugin_events.push(MidiEvent {
466                                sample_offset: sample_offset.min(num_frames as u32 - 1),
467                                status: event.status,
468                                data1: event.data1,
469                                data2: event.data2,
470                            });
471                        }
472                    }
473                }
474                track.plugin_events.sort_by_key(|e| e.sample_offset);
475            }
476
477            // Track position for wrap detection (used by both recording and playback)
478            if playing {
479                track.last_record_tick = current_tick;
480            }
481
482            // ── Process instrument (allocation-free) ──
483            if let Some(ref mut instrument) = track.instrument {
484                let out_l = &mut track.buf_l[..num_frames];
485                let out_r = &mut track.buf_r[..num_frames];
486                let mut out_slices: [&mut [f32]; 2] = [out_l, out_r];
487                instrument.process(&[], &mut out_slices, &track.plugin_events);
488            }
489
490            // ── VU + Mix ──
491            let muted = track.handle.config.is_muted();
492            let soloed = track.handle.config.is_soloed();
493            let audible = !muted && (!any_solo || soloed);
494            let volume = track.handle.config.get_volume();
495
496            let mut peak_l = 0.0f32;
497            let mut peak_r = 0.0f32;
498            for i in 0..num_frames {
499                peak_l = peak_l.max(track.buf_l[i].abs());
500                peak_r = peak_r.max(track.buf_r[i].abs());
501            }
502
503            let (old_l, old_r) = track.handle.vu.get();
504            let decay = 0.85f32;
505            track.handle.vu.set(
506                if peak_l > old_l { peak_l } else { old_l * decay },
507                if peak_r > old_r { peak_r } else { old_r * decay },
508            );
509
510            if audible {
511                for i in 0..num_frames {
512                    master_l[i] += track.buf_l[i] * volume;
513                    master_r[i] += track.buf_r[i] * volume;
514                }
515            }
516        }
517
518        // Write tracks to interleaved output
519        for i in 0..num_frames {
520            output[i * 2] = master_l[i];
521            output[i * 2 + 1] = master_r[i];
522        }
523
524        // Return scratch buffers to self (no allocation, just moves)
525        self.scratch_l = master_l;
526        self.scratch_r = master_r;
527        self.live_events = live_events;
528
529        // Mix metronome click into output (after tracks, so it's always audible)
530        self.metronome.process(output, transport);
531
532        // ── Master limiter ──
533        // Everything that reaches the device passes through here, the
534        // metronome included: it is summed on top of the track mix, so
535        // limiting before it would leave a gap in the guarantee.
536        self.limiter.process(output);
537
538        // Master VU (includes metronome), read after limiting so the meter
539        // shows what actually left rather than what would have.
540        let mut mp_l = 0.0f32;
541        let mut mp_r = 0.0f32;
542        for i in 0..num_frames {
543            mp_l = mp_l.max(output[i * 2].abs());
544            mp_r = mp_r.max(output[i * 2 + 1].abs());
545        }
546
547        let (old_l, old_r) = self.master_vu.get();
548        let decay = 0.85f32;
549        self.master_vu.set(
550            if mp_l > old_l { mp_l } else { old_l * decay },
551            if mp_r > old_r { mp_r } else { old_r * decay },
552        );
553    }
554
555    pub fn reset_all(&mut self) {
556        let clip_tx = &self.clip_tx;
557        for track in &mut self.tracks {
558            if let Some(ref mut inst) = track.instrument {
559                inst.reset();
560            }
561            track.handle.vu.set(0.0, 0.0);
562            // Commit any active recording before resetting (don't lose overdubs)
563            if track.record_buf.is_active() && track.was_recording {
564                let end_tick = track.last_record_tick.max(0);
565                commit_recording(track, end_tick, clip_tx);
566            } else if track.record_buf.is_active() {
567                track.record_buf.discard();
568            }
569            track.was_recording = false;
570            track.last_playback_tick = -1;
571        }
572        self.metronome.reset();
573        self.limiter.reset();
574    }
575
576    /// Apply queued commands until the callback's budget is spent.
577    ///
578    /// Returns the units spent, which is what the tests assert the bound on.
579    ///
580    /// Anything left in the channel stays there, in the order it was sent, and
581    /// the next callback continues from it. That is the whole of the ordering
582    /// guarantee: commands are taken one at a time from a FIFO and applied
583    /// immediately, so `AddTrack` before `SetInstrument` for the same track
584    /// cannot be seen the other way round even when the two land in different
585    /// callbacks.
586    fn drain_commands(&mut self) -> u32 {
587        let mut spent = 0;
588        while spent < COMMAND_BUDGET {
589            let Ok(cmd) = self.command_rx.try_recv() else { break };
590            spent += command_cost(&cmd);
591            self.apply_command(cmd);
592        }
593        spent
594    }
595
596    fn apply_command(&mut self, cmd: MixerCommand) {
597        match cmd {
598            MixerCommand::AddTrack { kind: _, handle } => {
599                let track = AudioTrack::new(handle, self.max_buffer_size);
600                self.tracks.push(track);
601            }
602            MixerCommand::SetInstrument { track_id, mut instrument } => {
603                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
604                    instrument.init(self.sample_rate as f64, self.max_buffer_size);
605                    track.instrument = Some(instrument);
606                }
607            }
608            MixerCommand::RemoveTrack { track_id } => {
609                self.tracks.retain(|t| t.id != track_id);
610            }
611            MixerCommand::SetParameter { track_id, param_index, value } => {
612                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
613                    if let Some(ref mut inst) = track.instrument {
614                        inst.set_parameter(param_index, value);
615                    }
616                }
617            }
618            MixerCommand::CreateClip { track_id, start_tick, length_ticks } => {
619                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
620                    track.clips.push(MidiClip::new(start_tick, length_ticks, Vec::new()));
621                }
622            }
623            MixerCommand::UpdateClip { track_id, clip_index, events } => {
624                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
625                    if let Some(clip) = track.clips.get_mut(clip_index) {
626                        clip.events = events;
627                        clip.events.sort_by_key(|e| e.tick);
628                    }
629                }
630            }
631            MixerCommand::UpdateClipPosition { track_id, clip_index, start_tick, length_ticks } => {
632                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
633                    if let Some(clip) = track.clips.get_mut(clip_index) {
634                        clip.start_tick = start_tick;
635                        clip.length_ticks = length_ticks;
636                    }
637                }
638            }
639            MixerCommand::RemoveClip { track_id, clip_index } => {
640                if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
641                    if clip_index < track.clips.len() {
642                        track.clips.remove(clip_index);
643                    }
644                }
645            }
646        }
647    }
648}
649
650/// Commit a recording buffer into a clip and send snapshot to UI.
651fn commit_recording(track: &mut AudioTrack, end_tick: i64, clip_tx: &Sender<ClipSnapshot>) {
652    if let Some(clip) = track.record_buf.commit(end_tick) {
653        let idx = track.clips.len();
654        tracing::debug!(
655            "rec commit track={}: {} events, ticks {}..{}",
656            track.id, clip.events.len(), clip.start_tick, clip.end_tick()
657        );
658        let snapshot = ClipSnapshot::from_clip(track.id, idx, &clip);
659        track.clips.push(clip);
660        let _ = clip_tx.send(snapshot);
661    }
662}
663
664/// Which live MIDI messages reach a plugin.
665///
666/// Channel pressure is here because instruments route it: the Prophet-6 has
667/// an aftertouch section with six destinations and an amount that reads as
668/// bipolar, and every one of its 500 factory programs stores a setting for
669/// it. It is a two-byte message, so `raw[2]` is whatever the parser left
670/// there and a plugin reads the pressure from `data1`, as the MIDI
671/// specification puts it.
672///
673/// Polyphonic key pressure is *not* here, and that is the instruments rather
674/// than an oversight — the Prophet-6 provides "monophonic (or 'channel')
675/// aftertouch" and nothing in the rack has a per-key pressure destination.
676/// `phosphor-midi` does not parse it into a variant of its own either.
677pub fn midi_to_plugin_event(msg: &MidiMessage) -> Option<MidiEvent> {
678    use phosphor_midi::message::MidiMessageType;
679    match msg.message_type {
680        MidiMessageType::NoteOn { .. }
681        | MidiMessageType::NoteOff { .. }
682        | MidiMessageType::ControlChange { .. }
683        | MidiMessageType::PitchBend { .. }
684        | MidiMessageType::ChannelPressure { .. } => Some(MidiEvent {
685            sample_offset: 0,
686            status: msg.raw[0],
687            data1: msg.raw[1],
688            data2: msg.raw[2],
689        }),
690        _ => None,
691    }
692}
693
694pub fn mixer_command_channel() -> (Sender<MixerCommand>, Receiver<MixerCommand>) {
695    crossbeam_channel::unbounded()
696}
697
698/// Create a channel for clip snapshots (audio → UI).
699pub fn clip_snapshot_channel() -> (Sender<ClipSnapshot>, Receiver<ClipSnapshot>) {
700    crossbeam_channel::unbounded()
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706    use crate::cpal_backend::{Requested, StreamFormat};
707    use crate::project::TrackConfig;
708    use phosphor_dsp::synth::PhosphorSynth;
709    use phosphor_midi::message::{MidiMessage, MidiMessageType};
710
711    fn make_note_on(note: u8, vel: u8) -> MidiMessage {
712        MidiMessage {
713            timestamp: Some(0),
714            message_type: MidiMessageType::NoteOn { channel: 0, note, velocity: vel },
715            raw: [0x90, note, vel],
716            len: 3,
717        }
718    }
719
720    /// Aftertouch has to reach a plugin, or an instrument with an aftertouch
721    /// section has one that never does anything.
722    #[test]
723    fn channel_pressure_reaches_the_plugin_and_key_pressure_does_not() {
724        let pressure = MidiMessage {
725            timestamp: Some(0),
726            message_type: MidiMessageType::ChannelPressure { channel: 0, pressure: 96 },
727            raw: [0xD0, 96, 0],
728            len: 2,
729        };
730        let event = midi_to_plugin_event(&pressure).expect("channel pressure is dropped");
731        assert_eq!(event.status, 0xD0);
732        assert_eq!(event.data1, 96);
733
734        // Polyphonic key pressure parses as `Other` and stays there: nothing
735        // in the rack has a per-key pressure destination.
736        let key = MidiMessage::from_bytes(&[0xA0, 60, 96], 0).expect("parsed");
737        assert!(
738            midi_to_plugin_event(&key).is_none(),
739            "polyphonic key pressure has no destination in the rack"
740        );
741    }
742
743    fn make_note_off(note: u8) -> MidiMessage {
744        MidiMessage {
745            timestamp: Some(0),
746            message_type: MidiMessageType::NoteOff { channel: 0, note, velocity: 0 },
747            raw: [0x80, note, 0],
748            len: 3,
749        }
750    }
751
752    fn setup_mixer() -> (Mixer, Sender<MixerCommand>, Receiver<ClipSnapshot>, Arc<Transport>) {
753        let (tx, rx) = mixer_command_channel();
754        let (clip_tx, clip_rx) = clip_snapshot_channel();
755        let master_vu = Arc::new(VuLevels::new());
756        let transport = Arc::new(Transport::new(120.0));
757        let mixer = Mixer::new(rx, master_vu, clip_tx, 44100, 256);
758        (mixer, tx, clip_rx, transport)
759    }
760
761    fn add_armed_synth(tx: &Sender<MixerCommand>, id: usize) -> Arc<TrackHandle> {
762        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
763        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
764        handle.config.armed.store(true, std::sync::atomic::Ordering::Relaxed);
765        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
766        tx.send(MixerCommand::SetInstrument { track_id: id, instrument: Box::new(PhosphorSynth::new()) }).unwrap();
767        handle
768    }
769
770    #[test]
771    fn mixer_empty_output() {
772        let (mut mixer, _tx, _clip_rx, transport) = setup_mixer();
773        let mut output = vec![0.0f32; 128];
774        mixer.process(&mut output, &[], &transport);
775        assert!(output.iter().all(|&s| s == 0.0));
776    }
777
778    #[test]
779    fn mixer_live_midi_produces_sound() {
780        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
781        let _handle = add_armed_synth(&tx, 0);
782        transport.play();
783
784        let midi = vec![make_note_on(60, 100)];
785        let mut output = vec![0.0f32; 512];
786        mixer.process(&mut output, &midi, &transport);
787
788        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
789        // Threshold is "not silence", not a level check — the instruments
790        // carry a deep headroom trim on their output.
791        assert!(peak > 0.001, "Should produce sound, peak={peak}");
792    }
793
794    #[test]
795    fn mixer_records_midi_clip() {
796        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
797        let _handle = add_armed_synth(&tx, 0);
798        transport.play();
799        transport.toggle_record();
800
801        // Play a note while recording
802        let midi = vec![make_note_on(60, 100)];
803        let mut output = vec![0.0f32; 512];
804        mixer.process(&mut output, &midi, &transport);
805
806        // Note off
807        let midi = vec![make_note_off(60)];
808        mixer.process(&mut output, &midi, &transport);
809
810        // Stop recording
811        transport.toggle_record();
812        mixer.process(&mut output, &[], &transport);
813
814        // Should have received a clip snapshot
815        let snap = clip_rx.try_recv().expect("Should receive clip snapshot");
816        assert_eq!(snap.track_id, 0);
817        assert!(snap.event_count >= 2, "Should have note on + off, got {}", snap.event_count);
818        assert!(!snap.notes.is_empty(), "Should have parsed notes");
819    }
820
821    #[test]
822    fn mixer_plays_back_recorded_clip() {
823        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
824        let _handle = add_armed_synth(&tx, 0);
825        transport.play();
826        transport.toggle_record();
827
828        // Record a note
829        let midi = vec![make_note_on(60, 100)];
830        let mut output = vec![0.0f32; 512];
831        mixer.process(&mut output, &midi, &transport);
832
833        let midi = vec![make_note_off(60)];
834        mixer.process(&mut output, &midi, &transport);
835
836        // Stop recording
837        transport.toggle_record();
838        mixer.process(&mut output, &[], &transport);
839
840        // Stop and rewind
841        transport.stop();
842
843        // Play back — should hear the recorded clip
844        transport.play();
845        output.fill(0.0);
846        mixer.process(&mut output, &[], &transport);
847
848        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
849        assert!(peak > 0.001, "Playback should produce sound, peak={peak}");
850    }
851
852    #[test]
853    fn mixer_mute_silences() {
854        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
855        let handle = add_armed_synth(&tx, 0);
856        handle.config.muted.store(true, std::sync::atomic::Ordering::Relaxed);
857        transport.play();
858
859        let midi = vec![make_note_on(60, 100)];
860        let mut output = vec![0.0f32; 512];
861        mixer.process(&mut output, &midi, &transport);
862
863        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
864        assert!(peak == 0.0, "Muted track should be silent, peak={peak}");
865    }
866
867    #[test]
868    fn mixer_no_record_when_not_armed() {
869        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
870        let handle = add_armed_synth(&tx, 0);
871        handle.config.armed.store(false, std::sync::atomic::Ordering::Relaxed);
872        transport.play();
873        transport.toggle_record();
874
875        let midi = vec![make_note_on(60, 100)];
876        let mut output = vec![0.0f32; 512];
877        mixer.process(&mut output, &midi, &transport);
878
879        transport.toggle_record();
880        mixer.process(&mut output, &[], &transport);
881
882        assert!(clip_rx.try_recv().is_err(), "Should not record when not armed");
883    }
884
885    #[test]
886    fn mixer_reset_commits_recording() {
887        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
888        let _handle = add_armed_synth(&tx, 0);
889        transport.play();
890        transport.toggle_record();
891
892        let midi = vec![make_note_on(60, 100)];
893        let mut output = vec![0.0f32; 512];
894        mixer.process(&mut output, &midi, &transport);
895
896        mixer.reset_all();
897
898        // Reset should commit the active recording, not discard it
899        assert!(clip_rx.try_recv().is_ok(), "Reset should commit active recording");
900    }
901
902    #[test]
903    fn end_to_end_record_and_playback() {
904        // Simulates exact app flow: add track, arm, record, play notes,
905        // stop, rewind, play back — with transport.advance() each buffer.
906        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
907        let _handle = add_armed_synth(&tx, 0);
908        let sr = 44100u32;
909        let buf_frames = 256;
910        let buf_samples = buf_frames * 2; // stereo
911
912        // 1. Enable recording, then play
913        transport.toggle_record();
914        transport.play();
915
916        // 2. Process a few empty buffers (advance transport)
917        let mut output = vec![0.0f32; buf_samples];
918        for _ in 0..4 {
919            mixer.process(&mut output, &[], &transport);
920            transport.advance(buf_frames as u32, sr);
921        }
922
923        // 3. Play a note (should be recorded)
924        let midi = vec![make_note_on(60, 100)];
925        mixer.process(&mut output, &midi, &transport);
926        let peak_during = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
927        assert!(peak_during > 0.001, "Should hear note during recording (monitoring)");
928        transport.advance(buf_frames as u32, sr);
929
930        // 4. A few more buffers of sustain
931        for _ in 0..8 {
932            output.fill(0.0);
933            mixer.process(&mut output, &[], &transport);
934            transport.advance(buf_frames as u32, sr);
935        }
936
937        // 5. Note off
938        let midi = vec![make_note_off(60)];
939        mixer.process(&mut output, &midi, &transport);
940        transport.advance(buf_frames as u32, sr);
941
942        // 6. A few more buffers
943        for _ in 0..4 {
944            output.fill(0.0);
945            mixer.process(&mut output, &[], &transport);
946            transport.advance(buf_frames as u32, sr);
947        }
948
949        // 7. Stop recording (commit clip)
950        transport.toggle_record();
951        mixer.process(&mut output, &[], &transport);
952        transport.advance(buf_frames as u32, sr);
953
954        // 8. Check we got a clip snapshot
955        let snap = clip_rx.try_recv().expect("Should receive clip snapshot after stopping record");
956        assert!(snap.event_count >= 2, "Clip should have note on + off");
957        assert!(!snap.notes.is_empty(), "Clip should have parsed notes");
958
959        // 9. Stop transport and rewind to 0
960        transport.stop();
961
962        // 10. Play back — the synth should be reset (no stuck notes from recording)
963        transport.play();
964
965        // 11. Process enough buffers to reach the recorded note position
966        // The note was recorded after 4 initial buffers, so roughly at that tick position
967        for _ in 0..4 {
968            output.fill(0.0);
969            mixer.process(&mut output, &[], &transport);
970            transport.advance(buf_frames as u32, sr);
971        }
972
973        // 12. The next buffer should contain the played-back note
974        output.fill(0.0);
975        mixer.process(&mut output, &[], &transport);
976        let peak_playback = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
977        assert!(peak_playback > 0.001, "Playback should produce sound at the recorded position, peak={peak_playback}");
978    }
979
980    #[test]
981    fn loop_record_commits_on_wrap() {
982        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
983        let _handle = add_armed_synth(&tx, 0);
984        let sr = 44100u32;
985        let buf_frames = 256u32;
986
987        // Set loop to 1 bar (3840 ticks at 120bpm ≈ 346 buffers of 256 samples)
988        transport.set_loop_bars(1, 1);
989        transport.start_loop_record();
990
991        let mut output = vec![0.0f32; buf_frames as usize * 2];
992
993        // Play a note early in the loop
994        let midi = vec![make_note_on(60, 100)];
995        mixer.process(&mut output, &midi, &transport);
996        transport.advance(buf_frames, sr);
997
998        // Note off a few buffers later
999        for _ in 0..5 {
1000            mixer.process(&mut output, &[], &transport);
1001            transport.advance(buf_frames, sr);
1002        }
1003        let midi = vec![make_note_off(60)];
1004        mixer.process(&mut output, &midi, &transport);
1005        transport.advance(buf_frames, sr);
1006
1007        // Continue until we cross the loop boundary
1008        // 1 bar at 120bpm, 256 frames, 44100Hz ≈ 346 buffers
1009        for _ in 0..400 {
1010            mixer.process(&mut output, &[], &transport);
1011            transport.advance(buf_frames, sr);
1012
1013            if let Ok(snap) = clip_rx.try_recv() {
1014                assert!(snap.event_count >= 2, "Clip should have events, got {}", snap.event_count);
1015                assert!(!snap.notes.is_empty(), "Clip should have notes");
1016                // Recording committed on loop wrap — success
1017                transport.stop_loop_record();
1018                return;
1019            }
1020        }
1021
1022        panic!("Recording should have committed when the loop wrapped");
1023    }
1024
1025    #[test]
1026    fn loop_playback_after_record() {
1027        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
1028        let _handle = add_armed_synth(&tx, 0);
1029        let sr = 44100u32;
1030        let bf = 256u32;
1031
1032        // Set loop to 1 bar, start recording
1033        transport.set_loop_bars(1, 1);
1034        transport.start_loop_record();
1035
1036        let mut output = vec![0.0f32; bf as usize * 2];
1037
1038        // Record a note
1039        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
1040        transport.advance(bf, sr);
1041        for _ in 0..3 {
1042            mixer.process(&mut output, &[], &transport);
1043            transport.advance(bf, sr);
1044        }
1045        mixer.process(&mut output, &[make_note_off(60)], &transport);
1046        transport.advance(bf, sr);
1047
1048        // Run until loop wraps and clip commits
1049        for _ in 0..200 {
1050            mixer.process(&mut output, &[], &transport);
1051            transport.advance(bf, sr);
1052            if clip_rx.try_recv().is_ok() { break; }
1053        }
1054
1055        // Stop recording, rewind
1056        transport.stop_loop_record();
1057        transport.set_position(0);
1058
1059        // Play back with looping on
1060        transport.toggle_loop(); // enable looping
1061        transport.play();
1062
1063        output.fill(0.0);
1064        mixer.process(&mut output, &[], &transport);
1065        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1066        assert!(peak > 0.001, "Should hear playback, peak={peak}");
1067    }
1068
1069    // ── Command budget ──
1070
1071    /// The most work one callback can do, in the units [`command_cost`]
1072    /// returns: the budget is tested before a command is taken and charged
1073    /// after, so the last one can overshoot by its own cost.
1074    const WORST_CALLBACK: u32 = COMMAND_BUDGET - 1 + HEAVY_COMMAND;
1075
1076    /// A plugin that remembers every parameter it was given, in order, so a
1077    /// test can see exactly what reached the audio thread and when.
1078    ///
1079    /// The lock is not something an instrument would do — nothing may block in
1080    /// `process` — but `set_parameter` is called from the command drain and
1081    /// this one never renders.
1082    #[derive(Clone)]
1083    struct ParamLog(Arc<std::sync::Mutex<Vec<(usize, f32)>>>);
1084
1085    impl ParamLog {
1086        fn new() -> Self {
1087            Self(Arc::new(std::sync::Mutex::new(Vec::new())))
1088        }
1089        fn seen(&self) -> Vec<(usize, f32)> {
1090            self.0.lock().unwrap().clone()
1091        }
1092    }
1093
1094    impl Plugin for ParamLog {
1095        fn info(&self) -> phosphor_plugin::PluginInfo {
1096            phosphor_plugin::PluginInfo {
1097                name: "ParamLog".into(),
1098                version: "0".into(),
1099                author: "test".into(),
1100                category: phosphor_plugin::PluginCategory::Instrument,
1101            }
1102        }
1103        fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
1104        fn process(&mut self, _inputs: &[&[f32]], _outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {}
1105        fn parameter_count(&self) -> usize { 8 }
1106        fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
1107        fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
1108        fn set_parameter(&mut self, index: usize, value: f32) {
1109            self.0.lock().unwrap().push((index, value));
1110        }
1111        fn reset(&mut self) {}
1112    }
1113
1114    /// Add a track carrying a [`ParamLog`], applying the commands immediately.
1115    fn add_logging_track(mixer: &mut Mixer, tx: &Sender<MixerCommand>, id: usize) -> ParamLog {
1116        let log = ParamLog::new();
1117        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1118        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1119        tx.send(MixerCommand::SetInstrument {
1120            track_id: id,
1121            instrument: Box::new(log.clone()),
1122        }).unwrap();
1123        mixer.drain_commands();
1124        log
1125    }
1126
1127    /// The defect: the drain used to be `while let Ok(cmd) = try_recv()`, so
1128    /// the callback did as much work as the UI had queued. Opening a session
1129    /// queues hundreds of commands and the callback has a hard deadline.
1130    #[test]
1131    fn one_callback_applies_a_bounded_amount_of_work() {
1132        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1133        let log = add_logging_track(&mut mixer, &tx, 0);
1134
1135        for i in 0..500 {
1136            tx.send(MixerCommand::SetParameter {
1137                track_id: 0,
1138                param_index: i % 8,
1139                value: i as f32,
1140            }).unwrap();
1141        }
1142
1143        let spent = mixer.drain_commands();
1144        assert!(
1145            spent <= WORST_CALLBACK,
1146            "one callback spent {spent} units, over the {WORST_CALLBACK} bound"
1147        );
1148        assert_eq!(
1149            log.seen().len(),
1150            COMMAND_BUDGET as usize,
1151            "a parameter costs one unit, so a full budget is exactly that many"
1152        );
1153        assert!(!mixer.command_rx.is_empty(), "the rest has to still be queued");
1154    }
1155
1156    /// Bounded is only half of it: everything queued still has to arrive, once
1157    /// each, in the order it was sent.
1158    #[test]
1159    fn nothing_is_lost_or_reordered_across_callbacks() {
1160        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1161        let log = add_logging_track(&mut mixer, &tx, 0);
1162
1163        let sent: Vec<(usize, f32)> = (0..500).map(|i| (i % 8, i as f32)).collect();
1164        for &(param_index, value) in &sent {
1165            tx.send(MixerCommand::SetParameter { track_id: 0, param_index, value }).unwrap();
1166        }
1167
1168        // Run callbacks until the queue is empty, counting them: 500 commands
1169        // at one unit each cannot fit in fewer than eight budgets, which is
1170        // what makes this a test of the bound and not just of the FIFO.
1171        let mut output = vec![0.0f32; 128];
1172        let mut callbacks = 0;
1173        while !mixer.command_rx.is_empty() {
1174            mixer.process(&mut output, &[], &transport);
1175            callbacks += 1;
1176            assert!(callbacks < 100, "the drain is not making progress");
1177        }
1178        assert!(
1179            callbacks >= 500 / COMMAND_BUDGET as usize,
1180            "500 commands went through in {callbacks} callbacks, so the budget did not hold"
1181        );
1182        assert_eq!(log.seen(), sent, "the audio thread saw a different sequence");
1183    }
1184
1185    /// The ordering guarantee, at the one place it matters: a track has to
1186    /// exist before its instrument is attached. Splitting the queue between
1187    /// the two would drop the instrument on the floor — `SetInstrument` for a
1188    /// track that is not there yet is silently discarded — and the track would
1189    /// play nothing for the rest of the session.
1190    #[test]
1191    fn a_track_and_its_instrument_survive_a_budget_boundary() {
1192        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1193        let log = ParamLog::new();
1194
1195        // Fill this callback's budget with cheap commands first, so that the
1196        // pair below is guaranteed to land in a later one.
1197        for _ in 0..COMMAND_BUDGET {
1198            tx.send(MixerCommand::SetParameter { track_id: 99, param_index: 0, value: 0.0 })
1199                .unwrap();
1200        }
1201        let handle = Arc::new(TrackHandle::new(7, TrackKind::Instrument));
1202        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1203        tx.send(MixerCommand::SetInstrument {
1204            track_id: 7,
1205            instrument: Box::new(log.clone()),
1206        }).unwrap();
1207        tx.send(MixerCommand::SetParameter { track_id: 7, param_index: 3, value: 0.5 }).unwrap();
1208
1209        let mut output = vec![0.0f32; 128];
1210        mixer.process(&mut output, &[], &transport);
1211        assert!(mixer.tracks.is_empty(), "the budget did not stop at the parameters");
1212
1213        while !mixer.command_rx.is_empty() {
1214            mixer.process(&mut output, &[], &transport);
1215        }
1216        assert_eq!(mixer.tracks.len(), 1);
1217        assert!(mixer.tracks[0].instrument.is_some(), "the instrument never arrived");
1218        assert_eq!(
1219            log.seen(),
1220            vec![(3, 0.5)],
1221            "the parameter that follows the instrument did not reach it"
1222        );
1223    }
1224
1225    /// An instrument load is not a parameter change: it calls `Plugin::init`,
1226    /// which allocates a voice array and, on some instruments, a delay line.
1227    /// A flat count of commands per callback would let sixteen of those
1228    /// through where it lets sixteen stores through.
1229    #[test]
1230    fn an_instrument_load_costs_more_than_a_parameter() {
1231        let param = MixerCommand::SetParameter { track_id: 0, param_index: 0, value: 0.0 };
1232        let load = MixerCommand::SetInstrument {
1233            track_id: 0,
1234            instrument: Box::new(FixedOutput(0.0)),
1235        };
1236        assert!(command_cost(&load) > command_cost(&param));
1237
1238        // Four loads per callback, not sixty-four.
1239        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1240        for id in 0..8 {
1241            let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1242            tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1243        }
1244        while !mixer.command_rx.is_empty() {
1245            mixer.drain_commands();
1246        }
1247        for id in 0..8 {
1248            tx.send(MixerCommand::SetInstrument {
1249                track_id: id,
1250                instrument: Box::new(FixedOutput(0.25)),
1251            }).unwrap();
1252        }
1253        mixer.drain_commands();
1254        let loaded = mixer.tracks.iter().filter(|t| t.instrument.is_some()).count();
1255        assert_eq!(loaded, (COMMAND_BUDGET / HEAVY_COMMAND) as usize);
1256    }
1257
1258    /// `AddTrack` pushes onto the track list, and a push that grows the list
1259    /// reallocates — on the audio thread. The list is built with room for more
1260    /// tracks than a session will hold so that it does not.
1261    #[test]
1262    fn adding_tracks_does_not_grow_the_track_list() {
1263        let (mut mixer, tx, _clip_rx, _transport) = setup_mixer();
1264        let capacity = mixer.tracks.capacity();
1265        assert!(capacity >= TRACK_CAPACITY);
1266
1267        for id in 0..TRACK_CAPACITY {
1268            let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1269            tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1270        }
1271        while !mixer.command_rx.is_empty() {
1272            mixer.drain_commands();
1273        }
1274        assert_eq!(mixer.tracks.len(), TRACK_CAPACITY);
1275        assert_eq!(
1276            mixer.tracks.capacity(), capacity,
1277            "the track list reallocated on the audio thread"
1278        );
1279    }
1280
1281    // ── Master limiter ──
1282
1283    /// A plugin that writes whatever it is told to, so the limiter can be
1284    /// driven with signals no real instrument would produce.
1285    struct FixedOutput(f32);
1286
1287    impl Plugin for FixedOutput {
1288        fn info(&self) -> phosphor_plugin::PluginInfo {
1289            phosphor_plugin::PluginInfo {
1290                name: "Fixed".into(),
1291                version: "0".into(),
1292                author: "test".into(),
1293                category: phosphor_plugin::PluginCategory::Instrument,
1294            }
1295        }
1296        fn init(&mut self, _sample_rate: f64, _max_buffer_size: usize) {}
1297        fn process(&mut self, _inputs: &[&[f32]], outputs: &mut [&mut [f32]], _midi: &[MidiEvent]) {
1298            for ch in outputs.iter_mut() {
1299                ch.fill(self.0);
1300            }
1301        }
1302        fn parameter_count(&self) -> usize { 0 }
1303        fn parameter_info(&self, _index: usize) -> Option<phosphor_plugin::ParameterInfo> { None }
1304        fn get_parameter(&self, _index: usize) -> f32 { 0.0 }
1305        fn set_parameter(&mut self, _index: usize, _value: f32) {}
1306        fn reset(&mut self) {}
1307    }
1308
1309    fn add_fixed_track(tx: &Sender<MixerCommand>, id: usize, value: f32) -> Arc<TrackHandle> {
1310        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
1311        handle.config.set_volume(1.0);
1312        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
1313        tx.send(MixerCommand::SetInstrument {
1314            track_id: id,
1315            instrument: Box::new(FixedOutput(value)),
1316        }).unwrap();
1317        handle
1318    }
1319
1320    /// The guarantee. Six tracks each running at three quarters of full scale
1321    /// sum to 4.5x — without the limiter that is what would reach the device.
1322    #[test]
1323    fn master_limiter_bounds_many_loud_tracks() {
1324        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1325        for id in 0..6 {
1326            add_fixed_track(&tx, id, 0.75);
1327        }
1328        transport.play();
1329
1330        let mut output = vec![0.0f32; 512];
1331        for _ in 0..8 {
1332            mixer.process(&mut output, &[], &transport);
1333            for (i, &s) in output.iter().enumerate() {
1334                assert!(s.is_finite(), "non-finite sample at {i}");
1335                assert!(s.abs() <= 1.0, "sample {i} left the mixer at {s}");
1336            }
1337        }
1338
1339        // And it is actually holding the ceiling, not silencing the mix.
1340        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1341        assert!(peak > 0.8, "limiter over-attenuated, peak={peak}");
1342    }
1343
1344    /// A NaN out of a diverging filter must not reach the device: at full
1345    /// scale it is a noise burst, and it also poisons every sample after it
1346    /// if it is allowed into the limiter's gain state.
1347    #[test]
1348    fn non_finite_track_output_becomes_silence() {
1349        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1350        add_fixed_track(&tx, 0, f32::NAN);
1351        transport.play();
1352
1353        let mut output = vec![0.0f32; 512];
1354        mixer.process(&mut output, &[], &transport);
1355        assert!(output.iter().all(|s| *s == 0.0), "NaN track should render as silence");
1356
1357        // ...and the mixer still works afterwards: the gain state was not
1358        // left as NaN by the sample that was thrown away.
1359        tx.send(MixerCommand::RemoveTrack { track_id: 0 }).unwrap();
1360        add_fixed_track(&tx, 1, 0.5);
1361        mixer.process(&mut output, &[], &transport);
1362        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1363        assert!((peak - 0.5).abs() < 1.0e-6, "mixer did not recover, peak={peak}");
1364    }
1365
1366    #[test]
1367    fn infinite_track_output_becomes_silence() {
1368        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1369        add_fixed_track(&tx, 0, f32::INFINITY);
1370        transport.play();
1371
1372        let mut output = vec![0.0f32; 512];
1373        mixer.process(&mut output, &[], &transport);
1374        assert!(output.iter().all(|s| *s == 0.0), "infinite track should render as silence");
1375    }
1376
1377    /// Below the ceiling the limiter is not a processor, it is a wire. Any
1378    /// deviation here would be gain riding on material that never asked for
1379    /// it — which is exactly what makes a limiter audible.
1380    #[test]
1381    fn limiter_is_bit_identical_below_the_ceiling() {
1382        let mut limiter = MasterLimiter::new(44_100);
1383
1384        // A sweep of levels up to the ceiling, plus signs and denormals.
1385        let mut input: Vec<f32> = Vec::new();
1386        for i in 0..20_000u32 {
1387            let phase = i as f32 * 0.01;
1388            let amp = LIMITER_CEILING * (i as f32 / 20_000.0);
1389            input.push(phase.sin() * amp);
1390            input.push(phase.cos() * amp);
1391        }
1392        input.push(LIMITER_CEILING);
1393        input.push(-LIMITER_CEILING);
1394        input.push(0.0);
1395        input.push(-0.0);
1396        input.push(f32::MIN_POSITIVE);
1397        input.push(-f32::MIN_POSITIVE);
1398
1399        let mut output = input.clone();
1400        limiter.process(&mut output);
1401
1402        for (i, (a, b)) in input.iter().zip(output.iter()).enumerate() {
1403            assert_eq!(a.to_bits(), b.to_bits(), "limiter altered sample {i}: {a} -> {b}");
1404        }
1405    }
1406
1407    /// The ceiling holds for anything, including levels no instrument in the
1408    /// project can produce.
1409    #[test]
1410    fn limiter_holds_the_ceiling_under_abuse() {
1411        let mut limiter = MasterLimiter::new(44_100);
1412        for amplitude in [1.0f32, 2.0, 10.0, 1.0e3, 1.0e6, 1.0e30] {
1413            let mut buf: Vec<f32> = (0..4_096)
1414                .map(|i| (i as f32 * 0.05).sin() * amplitude)
1415                .collect();
1416            limiter.process(&mut buf);
1417            for (i, &s) in buf.iter().enumerate() {
1418                assert!(s.is_finite(), "amplitude {amplitude}: sample {i} is {s}");
1419                assert!(
1420                    s.abs() <= LIMITER_CEILING,
1421                    "amplitude {amplitude}: sample {i} reached {s}, above the ceiling"
1422                );
1423            }
1424        }
1425    }
1426
1427    /// A step from silence to well over the ceiling: the very first sample of
1428    /// the step must already be limited. Anything else means overshoot, and
1429    /// the only thing left to catch overshoot is a hard clip.
1430    #[test]
1431    fn limiter_attack_has_no_overshoot() {
1432        let mut limiter = MasterLimiter::new(44_100);
1433        let mut buf = vec![0.0f32; 64];
1434        limiter.process(&mut buf);
1435        let mut step = vec![4.0f32; 64];
1436        limiter.process(&mut step);
1437        assert!(
1438            step[0].abs() <= LIMITER_CEILING,
1439            "first sample of the step overshot to {}",
1440            step[0]
1441        );
1442    }
1443
1444    /// Gain reduction must come back smoothly, not step. A step would be a
1445    /// click; a release faster than a low note's period would distort it.
1446    #[test]
1447    fn limiter_release_is_gradual() {
1448        let mut limiter = MasterLimiter::new(44_100);
1449        let mut loud = vec![4.0f32; 64];
1450        limiter.process(&mut loud);
1451        let reduced = limiter.gain;
1452        assert!(reduced < 0.5, "limiter did not engage, gain={reduced}");
1453
1454        // 10 ms of quiet material (441 stereo frames): partly recovered, not
1455        // all the way.
1456        let mut quiet = vec![0.1f32; 441 * 2];
1457        limiter.process(&mut quiet);
1458        assert!(limiter.gain > reduced, "gain did not recover at all");
1459        assert!(
1460            limiter.gain < 1.0,
1461            "gain snapped back to unity within 10 ms, which is a click"
1462        );
1463
1464        // 500 ms is ten time constants: fully recovered.
1465        let mut long = vec![0.1f32; 22_050 * 2];
1466        limiter.process(&mut long);
1467        assert!(
1468            (limiter.gain - 1.0).abs() < 1.0e-4,
1469            "gain never returned to unity: {}",
1470            limiter.gain
1471        );
1472    }
1473
1474    /// Stereo-linked: one gain from `max(|L|, |R|)`, so a peak on one side
1475    /// does not pull the image across to the other.
1476    #[test]
1477    fn limiter_does_not_shift_the_stereo_image() {
1478        let mut limiter = MasterLimiter::new(44_100);
1479        // Left twice the level of right, both well over the ceiling.
1480        let mut buf: Vec<f32> = Vec::new();
1481        for i in 0..1_024 {
1482            let phase = i as f32 * 0.05;
1483            buf.push(phase.sin() * 3.0);
1484            buf.push(phase.sin() * 1.5);
1485        }
1486        limiter.process(&mut buf);
1487        for frame in buf.chunks_exact(2) {
1488            if frame[1].abs() > 1.0e-4 {
1489                let ratio = frame[0] / frame[1];
1490                assert!(
1491                    (ratio - 2.0).abs() < 1.0e-3,
1492                    "channel balance moved: L/R = {ratio}"
1493                );
1494            }
1495        }
1496    }
1497
1498    /// The loudest single voice in the project: ROM3A's TIMPANI, voice 147 of
1499    /// the DX7's 256 factory voices, which is what `phosphor-dsp`'s headroom
1500    /// sweep measures as the hottest thing any instrument here can produce.
1501    ///
1502    /// The DX7 has two selectors — a cartridge and a voice — so picking one by
1503    /// number goes through `voice_knobs`.
1504    fn loudest_dx7_voice() -> phosphor_dsp::dx7::Dx7Synth {
1505        use phosphor_dsp::dx7;
1506        let mut synth = dx7::Dx7Synth::new();
1507        let (bank, patch) = dx7::voice_knobs(147);
1508        synth.set_parameter(dx7::P_BANK, bank);
1509        synth.set_parameter(dx7::P_PATCH, patch);
1510        debug_assert_eq!(dx7::voice_name(147), "TIMPANI");
1511        synth
1512    }
1513
1514    /// Four tracks of the loudest DX7 voice, each playing a two-handed
1515    /// eight-note chord at full velocity with the fader open — a heavier mix
1516    /// than anything the application can produce by accident.
1517    #[test]
1518    fn master_limiter_bounds_four_loud_instrument_tracks() {
1519        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1520        for id in 0..4 {
1521            let handle = Arc::new(TrackHandle::new(id, 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 {
1527                track_id: id,
1528                instrument: Box::new(synth),
1529            }).unwrap();
1530        }
1531        transport.play();
1532
1533        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1534            .iter()
1535            .map(|&note| make_note_on(note, 127))
1536            .collect();
1537
1538        let mut output = vec![0.0f32; 512];
1539        let mut peak = 0.0f32;
1540        for block in 0..200 {
1541            output.fill(0.0);
1542            if block == 0 {
1543                mixer.process(&mut output, &chord, &transport);
1544            } else {
1545                mixer.process(&mut output, &[], &transport);
1546            }
1547            for (i, &s) in output.iter().enumerate() {
1548                assert!(s.is_finite(), "block {block} sample {i} is {s}");
1549                assert!(s.abs() <= 1.0, "block {block} sample {i} left the mixer at {s}");
1550                peak = peak.max(s.abs());
1551            }
1552        }
1553        assert!(peak > 0.5, "four loud tracks should be loud, peak={peak}");
1554    }
1555
1556    /// The limiter must be inaudible in ordinary playing, which means it must
1557    /// not engage at all. The worst single track the application can produce
1558    /// is the loudest preset in the bank, an eight-note chord at velocity 127,
1559    /// with the fader all the way open — and that still has to leave the gain
1560    /// at exactly unity, so the mix is the track sum sample for sample.
1561    #[test]
1562    fn limiter_idle_for_the_worst_single_track() {
1563        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1564        let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1565        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1566        handle.config.set_volume(1.0);
1567        let synth = loudest_dx7_voice();
1568        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1569        tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
1570        transport.play();
1571
1572        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1573            .iter()
1574            .map(|&note| make_note_on(note, 127))
1575            .collect();
1576
1577        let mut output = vec![0.0f32; 512];
1578        let mut peak = 0.0f32;
1579        for block in 0..200 {
1580            output.fill(0.0);
1581            if block == 0 {
1582                mixer.process(&mut output, &chord, &transport);
1583            } else {
1584                mixer.process(&mut output, &[], &transport);
1585            }
1586            peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
1587            assert_eq!(
1588                mixer.limiter.gain, 1.0,
1589                "limiter engaged at block {block}, peak {peak}"
1590            );
1591        }
1592        assert!(peak > 0.3, "expected a loud chord, peak={peak}");
1593    }
1594
1595    // ── Fader ──
1596
1597    /// Render the loudest thing one track in this project can produce, with
1598    /// the fader at `volume`. Returns the output peak and the lowest gain the
1599    /// limiter reached.
1600    fn worst_track_through_the_mixer(volume: f32) -> (f32, f32) {
1601        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1602        let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1603        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1604        handle.config.set_volume(volume);
1605        let synth = loudest_dx7_voice();
1606        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle }).unwrap();
1607        tx.send(MixerCommand::SetInstrument { track_id: 0, instrument: Box::new(synth) }).unwrap();
1608        transport.play();
1609
1610        let chord: Vec<MidiMessage> = [36u8, 43, 48, 55, 60, 64, 67, 72]
1611            .iter()
1612            .map(|&note| make_note_on(note, 127))
1613            .collect();
1614
1615        let mut output = vec![0.0f32; 512];
1616        let mut peak = 0.0f32;
1617        let mut min_gain = 1.0f32;
1618        for block in 0..200 {
1619            output.fill(0.0);
1620            if block == 0 {
1621                mixer.process(&mut output, &chord, &transport);
1622            } else {
1623                mixer.process(&mut output, &[], &transport);
1624            }
1625            for &s in output.iter() {
1626                assert!(s.is_finite(), "block {block}: non-finite sample");
1627                assert!(s.abs() <= 1.0, "block {block}: sample left the mixer at {s}");
1628                peak = peak.max(s.abs());
1629            }
1630            min_gain = min_gain.min(mixer.limiter.gain);
1631        }
1632        (peak, min_gain)
1633    }
1634
1635    /// Anywhere from the bottom of the fader up to unity, the limiter is not
1636    /// in the signal path at all — not "barely", not at all — even for the
1637    /// loudest patch in the project played as hard as the format allows.
1638    ///
1639    /// This is what the instrument trims buy. Gain reduction on the master
1640    /// bus is then always a mix decision (several loud tracks at once) rather
1641    /// than something one instrument can cause on its own.
1642    #[test]
1643    fn fader_below_unity_never_engages_the_limiter() {
1644        for volume in [
1645            0.25,
1646            TrackConfig::DEFAULT_VOLUME,
1647            TrackConfig::UNITY_VOLUME,
1648        ] {
1649            let (peak, min_gain) = worst_track_through_the_mixer(volume);
1650            assert_eq!(
1651                min_gain, 1.0,
1652                "limiter reduced by {:.2} dB at fader {volume} (peak {peak:.4})",
1653                20.0 * min_gain.log10()
1654            );
1655        }
1656    }
1657
1658    /// Above unity the fader is makeup gain the user asked for, and the
1659    /// limiter is what makes asking for it safe. Two things have to hold:
1660    /// the output stays bounded, and turning the fader up never makes the
1661    /// track quieter than leaving it at unity — a limiter that over-ducks
1662    /// would turn the top of the fader into a trap.
1663    #[test]
1664    fn fader_makeup_gain_is_bounded_not_wasted() {
1665        let (unity_peak, _) = worst_track_through_the_mixer(TrackConfig::UNITY_VOLUME);
1666        let (max_peak, min_gain) = worst_track_through_the_mixer(TrackConfig::MAX_VOLUME);
1667
1668        assert!(
1669            max_peak <= LIMITER_CEILING,
1670            "fader at maximum let {max_peak:.4} through, above the ceiling"
1671        );
1672        assert!(
1673            max_peak >= unity_peak,
1674            "turning the fader up made the track quieter: {unity_peak:.4} -> {max_peak:.4}"
1675        );
1676        // The limiter took back some of the boost, but not more than the
1677        // fader added — otherwise it is attenuating, not limiting.
1678        let reduction_db = -20.0 * min_gain.log10();
1679        let boost_db = 20.0 * (TrackConfig::MAX_VOLUME / TrackConfig::UNITY_VOLUME).log10();
1680        assert!(
1681            reduction_db <= boost_db,
1682            "limiter took {reduction_db:.2} dB off a {boost_db:.2} dB boost"
1683        );
1684    }
1685
1686    // ── Metronome balance ──
1687
1688    /// The click has no fader and is not mixed through a track, so nothing
1689    /// downstream can compensate for it being wrong: it only sits right
1690    /// relative to the music if `CLICK_VOLUME` tracks the instruments'
1691    /// headroom trims. That coupling is invisible from either file and has
1692    /// already drifted once, when the trims moved and the click did not.
1693    ///
1694    /// So: a click against the level a user hears while playing — the default
1695    /// preset, a triad at velocity 100, fader at its default. Loud enough to
1696    /// play to, not so loud it is the loudest thing in the mix.
1697    #[test]
1698    fn metronome_click_sits_with_the_music() {
1699        use phosphor_dsp::dx7;
1700
1701        fn render(with_track: bool, metronome: bool) -> f32 {
1702            let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1703            let chord: Vec<MidiMessage> = if with_track {
1704                let handle = Arc::new(TrackHandle::new(0, TrackKind::Instrument));
1705                handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
1706                tx.send(MixerCommand::AddTrack {
1707                    kind: TrackKind::Instrument,
1708                    handle,
1709                })
1710                .unwrap();
1711                tx.send(MixerCommand::SetInstrument {
1712                    track_id: 0,
1713                    instrument: Box::new(dx7::Dx7Synth::new()),
1714                })
1715                .unwrap();
1716                [60u8, 64, 67].iter().map(|&n| make_note_on(n, 100)).collect()
1717            } else {
1718                Vec::new()
1719            };
1720            if metronome {
1721                transport.toggle_metronome();
1722            }
1723            transport.play();
1724
1725            let mut output = vec![0.0f32; 512];
1726            let mut peak = 0.0f32;
1727            for block in 0..200 {
1728                output.fill(0.0);
1729                if block == 0 {
1730                    mixer.process(&mut output, &chord, &transport);
1731                } else {
1732                    mixer.process(&mut output, &[], &transport);
1733                }
1734                peak = peak.max(output.iter().map(|s| s.abs()).fold(0.0f32, f32::max));
1735                transport.advance(256, 44_100);
1736            }
1737            peak
1738        }
1739
1740        let music = render(true, false);
1741        let click = render(false, true);
1742        assert!(music > 0.0 && click > 0.0, "music {music}, click {click}");
1743
1744        let relative_db = 20.0 * (click / music).log10();
1745        assert!(
1746            (-12.0..=0.0).contains(&relative_db),
1747            "the click is {relative_db:.1} dB against a triad (click {click:.4}, \
1748             music {music:.4}); it has to be audible over the music without \
1749             being the loudest thing in the mix"
1750        );
1751    }
1752
1753    /// The fader reaches the audio thread. Not a tautology: `volume` is read
1754    /// per buffer through the atomic, so this catches a mix path that caches
1755    /// it or ignores it.
1756    #[test]
1757    fn fader_scales_the_track() {
1758        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
1759        let handle = add_fixed_track(&tx, 0, 0.25);
1760        transport.play();
1761
1762        let mut output = vec![0.0f32; 512];
1763        for (volume, expected) in [(0.0f32, 0.0f32), (0.5, 0.125), (1.0, 0.25), (2.0, 0.5)] {
1764            handle.config.set_volume(volume);
1765            output.fill(0.0);
1766            mixer.process(&mut output, &[], &transport);
1767            let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
1768            assert!(
1769                (peak - expected).abs() < 1.0e-6,
1770                "fader at {volume} gave {peak}, expected {expected}"
1771            );
1772        }
1773    }
1774
1775    // ── The device decides the rate ──
1776
1777    /// A device that would not give us the rate we asked for.
1778    fn refused(asked: u32, sample_rate: u32, max_buffer_frames: u32) -> StreamFormat {
1779        StreamFormat {
1780            sample_rate,
1781            buffer_size: Some(64),
1782            max_buffer_frames,
1783            channels: 2,
1784            sample_rate_request: Requested::Refused(asked),
1785            buffer_size_request: Requested::Granted,
1786        }
1787    }
1788
1789    /// The defect: the mixer was built from the command-line sample rate while
1790    /// the stream ran at the device's. Everything the mixer derives from the
1791    /// rate — oscillator increments, envelope times, the tick advance — was
1792    /// then wrong by the ratio between the two.
1793    #[test]
1794    fn the_mixer_runs_at_the_rate_the_device_granted() {
1795        let requested = crate::EngineConfig { buffer_size: 64, sample_rate: 44100 };
1796        let format = refused(44100, 48000, 4096);
1797        let effective = crate::EngineConfig::from(format);
1798
1799        let (_tx, rx) = mixer_command_channel();
1800        let (clip_tx, _clip_rx) = clip_snapshot_channel();
1801        let mixer = Mixer::new(
1802            rx,
1803            Arc::new(VuLevels::new()),
1804            clip_tx,
1805            effective.sample_rate,
1806            format.max_buffer_frames as usize,
1807        );
1808
1809        assert_eq!(mixer.sample_rate, 48000, "mixer must adopt the device's rate");
1810        assert_ne!(
1811            mixer.sample_rate, requested.sample_rate,
1812            "the request was 44100 and the device said 48000; taking the \
1813             request here is the 8.84%-sharp bug"
1814        );
1815        assert_eq!(mixer.max_buffer_size, 4096);
1816    }
1817
1818    /// A device that offers exactly what was asked for changes nothing.
1819    #[test]
1820    fn a_device_that_agrees_leaves_the_request_alone() {
1821        let requested = crate::EngineConfig { buffer_size: 64, sample_rate: 44100 };
1822        let format = StreamFormat {
1823            sample_rate: 44100,
1824            buffer_size: Some(64),
1825            max_buffer_frames: 4096,
1826            channels: 2,
1827            sample_rate_request: Requested::Granted,
1828            buffer_size_request: Requested::Granted,
1829        };
1830        assert_eq!(crate::EngineConfig::from(format), requested);
1831    }
1832
1833    /// The default path, and the one that has to be right for the most
1834    /// people: nothing asked for, so the mixer is built at whatever the
1835    /// device was already set to.
1836    #[test]
1837    fn asking_for_nothing_builds_the_mixer_at_the_devices_rate() {
1838        let format = StreamFormat {
1839            sample_rate: 48000,
1840            buffer_size: None,
1841            max_buffer_frames: 4096,
1842            channels: 2,
1843            sample_rate_request: Requested::Unasked,
1844            buffer_size_request: Requested::Unasked,
1845        };
1846        let effective = crate::EngineConfig::from(format);
1847
1848        let (_tx, rx) = mixer_command_channel();
1849        let (clip_tx, _clip_rx) = clip_snapshot_channel();
1850        let mixer = Mixer::new(
1851            rx,
1852            Arc::new(VuLevels::new()),
1853            clip_tx,
1854            effective.sample_rate,
1855            format.max_buffer_frames as usize,
1856        );
1857        assert_eq!(mixer.sample_rate, 48000);
1858        assert_eq!(mixer.max_buffer_size, 4096);
1859        assert!(format.divergence_notice().is_none(), "following the device is not news");
1860    }
1861
1862    /// The defect: buffers were sized from the requested block, the device
1863    /// handed the callback a larger one, and `process` grew them — a heap
1864    /// allocation on the audio thread, on the very first callback.
1865    #[test]
1866    fn the_largest_block_the_device_promised_never_grows_a_buffer() {
1867        let max_frames = 512usize;
1868        let (tx, rx) = mixer_command_channel();
1869        let (clip_tx, _clip_rx) = clip_snapshot_channel();
1870        let mut mixer = Mixer::new(
1871            rx,
1872            Arc::new(VuLevels::new()),
1873            clip_tx,
1874            48000,
1875            max_frames,
1876        );
1877        let transport = Arc::new(Transport::new(120.0));
1878        let _handle = add_armed_synth(&tx, 0);
1879        mixer.drain_commands();
1880
1881        // Snapshot after the track exists: adding one is a UI-driven
1882        // allocation, not a per-callback one.
1883        let before = (
1884            mixer.scratch_l.capacity(),
1885            mixer.scratch_r.capacity(),
1886            mixer.tracks[0].buf_l.capacity(),
1887            mixer.tracks[0].buf_r.capacity(),
1888        );
1889
1890        transport.play();
1891        let mut output = vec![0.0f32; max_frames * 2];
1892        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
1893
1894        let after = (
1895            mixer.scratch_l.capacity(),
1896            mixer.scratch_r.capacity(),
1897            mixer.tracks[0].buf_l.capacity(),
1898            mixer.tracks[0].buf_r.capacity(),
1899        );
1900        assert_eq!(
1901            before, after,
1902            "a block the size the device promised must fit the buffers as \
1903             allocated; growing one means the audio thread called the allocator"
1904        );
1905    }
1906
1907    /// The invariant stated everywhere in this crate, held to by the
1908    /// allocator rather than by reading the code: a steady-state callback
1909    /// touches no heap.
1910    #[test]
1911    fn a_steady_state_callback_does_not_allocate() {
1912        let max_frames = 512usize;
1913        let (tx, rx) = mixer_command_channel();
1914        let (clip_tx, _clip_rx) = clip_snapshot_channel();
1915        let mut mixer = Mixer::new(rx, Arc::new(VuLevels::new()), clip_tx, 48000, max_frames);
1916        let transport = Arc::new(Transport::new(120.0));
1917        let _handle = add_armed_synth(&tx, 0);
1918        mixer.drain_commands();
1919        transport.play();
1920
1921        let mut output = vec![0.0f32; max_frames * 2];
1922        // One warm-up block: anything lazily built on first use — the
1923        // wavetable bank behind its `OnceLock`, for one — is built here,
1924        // outside the region under test.
1925        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
1926
1927        let allocations = crate::alloc_count::allocations_during(|| {
1928            for _ in 0..8 {
1929                mixer.process(&mut output, &[], &transport);
1930            }
1931        });
1932        assert_eq!(allocations, 0, "Mixer::process reached the allocator");
1933    }
1934
1935    /// The same, for the shorter blocks the device may hand us when the
1936    /// buffers were sized for its maximum.
1937    #[test]
1938    fn a_short_callback_does_not_allocate_either() {
1939        let max_frames = 512usize;
1940        let (tx, rx) = mixer_command_channel();
1941        let (clip_tx, _clip_rx) = clip_snapshot_channel();
1942        let mut mixer = Mixer::new(rx, Arc::new(VuLevels::new()), clip_tx, 48000, max_frames);
1943        let transport = Arc::new(Transport::new(120.0));
1944        let _handle = add_armed_synth(&tx, 0);
1945        mixer.drain_commands();
1946        transport.play();
1947
1948        let mut output = vec![0.0f32; 64 * 2];
1949        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
1950
1951        let allocations = crate::alloc_count::allocations_during(|| {
1952            for _ in 0..8 {
1953                mixer.process(&mut output, &[], &transport);
1954            }
1955        });
1956        assert_eq!(allocations, 0, "Mixer::process reached the allocator");
1957    }
1958}