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