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// ── AudioTrack ──
65
66pub struct AudioTrack {
67    pub id: usize,
68    pub kind: TrackKind,
69    pub handle: Arc<TrackHandle>,
70    pub instrument: Option<Box<dyn Plugin>>,
71    /// Recorded clips on this track's timeline.
72    pub clips: Vec<MidiClip>,
73    /// Active recording buffer (when armed + transport recording).
74    record_buf: RecordBuffer,
75    /// Whether we were recording last buffer (to detect stop).
76    was_recording: bool,
77    /// Last tick position seen during recording (to detect loop wraps).
78    last_record_tick: i64,
79    buf_l: Vec<f32>,
80    buf_r: Vec<f32>,
81    plugin_events: Vec<MidiEvent>,
82}
83
84impl AudioTrack {
85    pub fn new(handle: Arc<TrackHandle>, max_buffer_size: usize) -> Self {
86        Self {
87            id: handle.id,
88            kind: handle.kind,
89            handle,
90            instrument: None,
91            clips: Vec::new(),
92            record_buf: RecordBuffer::new(),
93            was_recording: false,
94            last_record_tick: -1,
95            buf_l: vec![0.0; max_buffer_size],
96            buf_r: vec![0.0; max_buffer_size],
97            plugin_events: Vec::with_capacity(256),
98        }
99    }
100}
101
102// ── Mixer ──
103
104pub struct Mixer {
105    tracks: Vec<AudioTrack>,
106    master_vu: Arc<VuLevels>,
107    command_rx: Receiver<MixerCommand>,
108    clip_tx: Sender<ClipSnapshot>,
109    metronome: Metronome,
110    sample_rate: u32,
111    max_buffer_size: usize,
112    /// Pre-allocated scratch buffers for mix — avoids allocation in process().
113    scratch_l: Vec<f32>,
114    scratch_r: Vec<f32>,
115    /// Pre-allocated buffer for live MIDI conversion.
116    live_events: Vec<MidiEvent>,
117}
118
119impl Mixer {
120    pub fn new(
121        command_rx: Receiver<MixerCommand>,
122        master_vu: Arc<VuLevels>,
123        clip_tx: Sender<ClipSnapshot>,
124        sample_rate: u32,
125        max_buffer_size: usize,
126    ) -> Self {
127        Self {
128            tracks: Vec::new(),
129            master_vu,
130            command_rx,
131            clip_tx,
132            metronome: Metronome::new(sample_rate as f64),
133            sample_rate,
134            max_buffer_size,
135            scratch_l: vec![0.0; max_buffer_size],
136            scratch_r: vec![0.0; max_buffer_size],
137            live_events: Vec::with_capacity(256),
138        }
139    }
140
141    /// Process one buffer cycle.
142    pub fn process(&mut self, output: &mut [f32], midi_messages: &[MidiMessage], transport: &Transport) {
143        self.drain_commands();
144
145        let num_frames = output.len() / 2;
146        let playing = transport.is_playing();
147        let recording = transport.is_recording();
148        let looping = transport.is_looping();
149        let current_tick = transport.position_ticks();
150        let bpm = transport.tempo_bpm();
151        let ticks_per_sample = (bpm * Transport::PPQ as f64) / (60.0 * self.sample_rate as f64);
152        let buffer_ticks = (num_frames as f64 * ticks_per_sample) as i64;
153        let loop_end = transport.loop_end();
154
155        // Convert live MIDI to plugin events (reuse pre-allocated buffer)
156        self.live_events.clear();
157        for msg in midi_messages {
158            if let Some(ev) = midi_to_plugin_event(msg) {
159                self.live_events.push(ev);
160            }
161        }
162
163        let any_solo = self.tracks.iter().any(|t| t.handle.config.is_soloed());
164
165        // Reuse pre-allocated scratch buffers for master mix.
166        // Swap out of self to avoid borrow conflicts in the track loop.
167        let mut master_l = std::mem::take(&mut self.scratch_l);
168        let mut master_r = std::mem::take(&mut self.scratch_r);
169        let live_events = std::mem::take(&mut self.live_events);
170        if master_l.len() < num_frames {
171            master_l.resize(num_frames, 0.0);
172            master_r.resize(num_frames, 0.0);
173        }
174        master_l[..num_frames].fill(0.0);
175        master_r[..num_frames].fill(0.0);
176
177        let clip_tx = &self.clip_tx;
178
179        for track in &mut self.tracks {
180            if track.buf_l.len() < num_frames {
181                track.buf_l.resize(num_frames, 0.0);
182                track.buf_r.resize(num_frames, 0.0);
183            }
184            track.buf_l[..num_frames].fill(0.0);
185            track.buf_r[..num_frames].fill(0.0);
186            track.plugin_events.clear();
187
188            let is_midi_active = track.kind == TrackKind::Instrument
189                && track.handle.config.is_midi_active();
190            let is_armed = track.handle.config.is_armed();
191            let should_record = playing && recording && is_armed && is_midi_active;
192
193            // ── Recording ──
194            if should_record && !track.was_recording {
195                // Start recording at the loop start, not the current position,
196                // so the clip spans the full loop region
197                let rec_start = if looping { transport.loop_start() } else { current_tick };
198                track.record_buf.start(rec_start);
199                tracing::debug!("rec start track={} tick={}", track.id, current_tick);
200            }
201
202            // Detect loop wrap: current tick jumped backward means transport looped.
203            if should_record && track.was_recording && looping
204                && track.record_buf.is_active() && track.last_record_tick >= 0
205                && current_tick < track.last_record_tick
206            {
207                commit_recording(track, loop_end, clip_tx);
208                // Start new recording at loop start, not current_tick
209                // (current_tick may be a few ticks past 0 due to buffer boundaries)
210                track.record_buf.start(transport.loop_start());
211            }
212            if should_record {
213                track.last_record_tick = current_tick;
214            }
215
216            // Commit when recording stops (user pressed stop)
217            if !should_record && track.was_recording {
218                commit_recording(track, current_tick, clip_tx);
219            }
220            track.was_recording = should_record;
221
222            // Record live MIDI events (and pass through for monitoring)
223            if is_midi_active {
224                for ev in &live_events {
225                    track.plugin_events.push(*ev);
226                    if should_record {
227                        let event_tick = current_tick
228                            + (ev.sample_offset as f64 * ticks_per_sample) as i64;
229                        track.record_buf.record(event_tick, ev.status, ev.data1, ev.data2);
230                    }
231                }
232            }
233
234            // ── Playback ──
235            if playing && !track.clips.is_empty() {
236                let from = current_tick;
237                let to = current_tick + buffer_ticks;
238
239                // Check if we just wrapped (position jumped backward since last buffer)
240                // If so, we need to play events from the loop start that we skipped
241                let just_wrapped = looping && track.last_record_tick >= 0
242                    && current_tick < track.last_record_tick;
243
244                if just_wrapped {
245                    // Play events from loop_start to current position (the wrapped portion)
246                    let wrap_start = transport.loop_start();
247                    for clip in &track.clips {
248                        for (tick_offset, event) in clip.events_in_range(wrap_start, to) {
249                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
250                            track.plugin_events.push(MidiEvent {
251                                sample_offset: sample_offset.min(num_frames as u32 - 1),
252                                status: event.status,
253                                data1: event.data1,
254                                data2: event.data2,
255                            });
256                        }
257                    }
258                } else {
259                    for clip in &track.clips {
260                        for (tick_offset, event) in clip.events_in_range(from, to) {
261                            let sample_offset = (tick_offset as f64 / ticks_per_sample) as u32;
262                            track.plugin_events.push(MidiEvent {
263                                sample_offset: sample_offset.min(num_frames as u32 - 1),
264                                status: event.status,
265                                data1: event.data1,
266                                data2: event.data2,
267                            });
268                        }
269                    }
270                }
271                track.plugin_events.sort_by_key(|e| e.sample_offset);
272            }
273
274            // Track position for wrap detection (used by both recording and playback)
275            if playing {
276                track.last_record_tick = current_tick;
277            }
278
279            // ── Process instrument (allocation-free) ──
280            if let Some(ref mut instrument) = track.instrument {
281                let out_l = &mut track.buf_l[..num_frames];
282                let out_r = &mut track.buf_r[..num_frames];
283                let mut out_slices: [&mut [f32]; 2] = [out_l, out_r];
284                instrument.process(&[], &mut out_slices, &track.plugin_events);
285            }
286
287            // ── VU + Mix ──
288            let muted = track.handle.config.is_muted();
289            let soloed = track.handle.config.is_soloed();
290            let audible = !muted && (!any_solo || soloed);
291            let volume = track.handle.config.get_volume();
292
293            let mut peak_l = 0.0f32;
294            let mut peak_r = 0.0f32;
295            for i in 0..num_frames {
296                peak_l = peak_l.max(track.buf_l[i].abs());
297                peak_r = peak_r.max(track.buf_r[i].abs());
298            }
299
300            let (old_l, old_r) = track.handle.vu.get();
301            let decay = 0.85f32;
302            track.handle.vu.set(
303                if peak_l > old_l { peak_l } else { old_l * decay },
304                if peak_r > old_r { peak_r } else { old_r * decay },
305            );
306
307            if audible {
308                for i in 0..num_frames {
309                    master_l[i] += track.buf_l[i] * volume;
310                    master_r[i] += track.buf_r[i] * volume;
311                }
312            }
313        }
314
315        // Write tracks to interleaved output
316        for i in 0..num_frames {
317            output[i * 2] = master_l[i];
318            output[i * 2 + 1] = master_r[i];
319        }
320
321        // Return scratch buffers to self (no allocation, just moves)
322        self.scratch_l = master_l;
323        self.scratch_r = master_r;
324        self.live_events = live_events;
325
326        // Mix metronome click into output (after tracks, so it's always audible)
327        self.metronome.process(output, transport);
328
329        // Master VU (includes metronome)
330        let mut mp_l = 0.0f32;
331        let mut mp_r = 0.0f32;
332        for i in 0..num_frames {
333            mp_l = mp_l.max(output[i * 2].abs());
334            mp_r = mp_r.max(output[i * 2 + 1].abs());
335        }
336
337        let (old_l, old_r) = self.master_vu.get();
338        let decay = 0.85f32;
339        self.master_vu.set(
340            if mp_l > old_l { mp_l } else { old_l * decay },
341            if mp_r > old_r { mp_r } else { old_r * decay },
342        );
343    }
344
345    pub fn reset_all(&mut self) {
346        let clip_tx = &self.clip_tx;
347        for track in &mut self.tracks {
348            if let Some(ref mut inst) = track.instrument {
349                inst.reset();
350            }
351            track.handle.vu.set(0.0, 0.0);
352            // Commit any active recording before resetting (don't lose overdubs)
353            if track.record_buf.is_active() && track.was_recording {
354                let end_tick = track.last_record_tick.max(0);
355                commit_recording(track, end_tick, clip_tx);
356            } else if track.record_buf.is_active() {
357                track.record_buf.discard();
358            }
359            track.was_recording = false;
360        }
361        self.metronome.reset();
362    }
363
364    fn drain_commands(&mut self) {
365        while let Ok(cmd) = self.command_rx.try_recv() {
366            match cmd {
367                MixerCommand::AddTrack { kind: _, handle } => {
368                    let track = AudioTrack::new(handle, self.max_buffer_size);
369                    self.tracks.push(track);
370                }
371                MixerCommand::SetInstrument { track_id, mut instrument } => {
372                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
373                        instrument.init(self.sample_rate as f64, self.max_buffer_size);
374                        track.instrument = Some(instrument);
375                    }
376                }
377                MixerCommand::RemoveTrack { track_id } => {
378                    self.tracks.retain(|t| t.id != track_id);
379                }
380                MixerCommand::SetParameter { track_id, param_index, value } => {
381                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
382                        if let Some(ref mut inst) = track.instrument {
383                            inst.set_parameter(param_index, value);
384                        }
385                    }
386                }
387                MixerCommand::CreateClip { track_id, start_tick, length_ticks } => {
388                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
389                        track.clips.push(MidiClip::new(start_tick, length_ticks, Vec::new()));
390                    }
391                }
392                MixerCommand::UpdateClip { track_id, clip_index, events } => {
393                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
394                        if let Some(clip) = track.clips.get_mut(clip_index) {
395                            clip.events = events;
396                            clip.events.sort_by_key(|e| e.tick);
397                        }
398                    }
399                }
400                MixerCommand::UpdateClipPosition { track_id, clip_index, start_tick, length_ticks } => {
401                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
402                        if let Some(clip) = track.clips.get_mut(clip_index) {
403                            clip.start_tick = start_tick;
404                            clip.length_ticks = length_ticks;
405                        }
406                    }
407                }
408                MixerCommand::RemoveClip { track_id, clip_index } => {
409                    if let Some(track) = self.tracks.iter_mut().find(|t| t.id == track_id) {
410                        if clip_index < track.clips.len() {
411                            track.clips.remove(clip_index);
412                        }
413                    }
414                }
415            }
416        }
417    }
418}
419
420/// Commit a recording buffer into a clip and send snapshot to UI.
421fn commit_recording(track: &mut AudioTrack, end_tick: i64, clip_tx: &Sender<ClipSnapshot>) {
422    if let Some(clip) = track.record_buf.commit(end_tick) {
423        let idx = track.clips.len();
424        tracing::debug!(
425            "rec commit track={}: {} events, ticks {}..{}",
426            track.id, clip.events.len(), clip.start_tick, clip.end_tick()
427        );
428        let snapshot = ClipSnapshot::from_clip(track.id, idx, &clip);
429        track.clips.push(clip);
430        let _ = clip_tx.send(snapshot);
431    }
432}
433
434fn midi_to_plugin_event(msg: &MidiMessage) -> Option<MidiEvent> {
435    use phosphor_midi::message::MidiMessageType;
436    match msg.message_type {
437        MidiMessageType::NoteOn { .. }
438        | MidiMessageType::NoteOff { .. }
439        | MidiMessageType::ControlChange { .. }
440        | MidiMessageType::PitchBend { .. } => Some(MidiEvent {
441            sample_offset: 0,
442            status: msg.raw[0],
443            data1: msg.raw[1],
444            data2: msg.raw[2],
445        }),
446        _ => None,
447    }
448}
449
450pub fn mixer_command_channel() -> (Sender<MixerCommand>, Receiver<MixerCommand>) {
451    crossbeam_channel::unbounded()
452}
453
454/// Create a channel for clip snapshots (audio → UI).
455pub fn clip_snapshot_channel() -> (Sender<ClipSnapshot>, Receiver<ClipSnapshot>) {
456    crossbeam_channel::unbounded()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use phosphor_dsp::synth::PhosphorSynth;
463    use phosphor_midi::message::{MidiMessage, MidiMessageType};
464
465    fn make_note_on(note: u8, vel: u8) -> MidiMessage {
466        MidiMessage {
467            timestamp: Some(0),
468            message_type: MidiMessageType::NoteOn { channel: 0, note, velocity: vel },
469            raw: [0x90, note, vel],
470            len: 3,
471        }
472    }
473
474    fn make_note_off(note: u8) -> MidiMessage {
475        MidiMessage {
476            timestamp: Some(0),
477            message_type: MidiMessageType::NoteOff { channel: 0, note, velocity: 0 },
478            raw: [0x80, note, 0],
479            len: 3,
480        }
481    }
482
483    fn setup_mixer() -> (Mixer, Sender<MixerCommand>, Receiver<ClipSnapshot>, Arc<Transport>) {
484        let (tx, rx) = mixer_command_channel();
485        let (clip_tx, clip_rx) = clip_snapshot_channel();
486        let master_vu = Arc::new(VuLevels::new());
487        let transport = Arc::new(Transport::new(120.0));
488        let mixer = Mixer::new(rx, master_vu, clip_tx, 44100, 256);
489        (mixer, tx, clip_rx, transport)
490    }
491
492    fn add_armed_synth(tx: &Sender<MixerCommand>, id: usize) -> Arc<TrackHandle> {
493        let handle = Arc::new(TrackHandle::new(id, TrackKind::Instrument));
494        handle.config.midi_active.store(true, std::sync::atomic::Ordering::Relaxed);
495        handle.config.armed.store(true, std::sync::atomic::Ordering::Relaxed);
496        tx.send(MixerCommand::AddTrack { kind: TrackKind::Instrument, handle: handle.clone() }).unwrap();
497        tx.send(MixerCommand::SetInstrument { track_id: id, instrument: Box::new(PhosphorSynth::new()) }).unwrap();
498        handle
499    }
500
501    #[test]
502    fn mixer_empty_output() {
503        let (mut mixer, _tx, _clip_rx, transport) = setup_mixer();
504        let mut output = vec![0.0f32; 128];
505        mixer.process(&mut output, &[], &transport);
506        assert!(output.iter().all(|&s| s == 0.0));
507    }
508
509    #[test]
510    fn mixer_live_midi_produces_sound() {
511        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
512        let _handle = add_armed_synth(&tx, 0);
513        transport.play();
514
515        let midi = vec![make_note_on(60, 100)];
516        let mut output = vec![0.0f32; 512];
517        mixer.process(&mut output, &midi, &transport);
518
519        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
520        assert!(peak > 0.01, "Should produce sound, peak={peak}");
521    }
522
523    #[test]
524    fn mixer_records_midi_clip() {
525        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
526        let _handle = add_armed_synth(&tx, 0);
527        transport.play();
528        transport.toggle_record();
529
530        // Play a note while recording
531        let midi = vec![make_note_on(60, 100)];
532        let mut output = vec![0.0f32; 512];
533        mixer.process(&mut output, &midi, &transport);
534
535        // Note off
536        let midi = vec![make_note_off(60)];
537        mixer.process(&mut output, &midi, &transport);
538
539        // Stop recording
540        transport.toggle_record();
541        mixer.process(&mut output, &[], &transport);
542
543        // Should have received a clip snapshot
544        let snap = clip_rx.try_recv().expect("Should receive clip snapshot");
545        assert_eq!(snap.track_id, 0);
546        assert!(snap.event_count >= 2, "Should have note on + off, got {}", snap.event_count);
547        assert!(!snap.notes.is_empty(), "Should have parsed notes");
548    }
549
550    #[test]
551    fn mixer_plays_back_recorded_clip() {
552        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
553        let _handle = add_armed_synth(&tx, 0);
554        transport.play();
555        transport.toggle_record();
556
557        // Record a note
558        let midi = vec![make_note_on(60, 100)];
559        let mut output = vec![0.0f32; 512];
560        mixer.process(&mut output, &midi, &transport);
561
562        let midi = vec![make_note_off(60)];
563        mixer.process(&mut output, &midi, &transport);
564
565        // Stop recording
566        transport.toggle_record();
567        mixer.process(&mut output, &[], &transport);
568
569        // Stop and rewind
570        transport.stop();
571
572        // Play back — should hear the recorded clip
573        transport.play();
574        output.fill(0.0);
575        mixer.process(&mut output, &[], &transport);
576
577        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
578        assert!(peak > 0.01, "Playback should produce sound, peak={peak}");
579    }
580
581    #[test]
582    fn mixer_mute_silences() {
583        let (mut mixer, tx, _clip_rx, transport) = setup_mixer();
584        let handle = add_armed_synth(&tx, 0);
585        handle.config.muted.store(true, std::sync::atomic::Ordering::Relaxed);
586        transport.play();
587
588        let midi = vec![make_note_on(60, 100)];
589        let mut output = vec![0.0f32; 512];
590        mixer.process(&mut output, &midi, &transport);
591
592        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
593        assert!(peak == 0.0, "Muted track should be silent, peak={peak}");
594    }
595
596    #[test]
597    fn mixer_no_record_when_not_armed() {
598        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
599        let handle = add_armed_synth(&tx, 0);
600        handle.config.armed.store(false, std::sync::atomic::Ordering::Relaxed);
601        transport.play();
602        transport.toggle_record();
603
604        let midi = vec![make_note_on(60, 100)];
605        let mut output = vec![0.0f32; 512];
606        mixer.process(&mut output, &midi, &transport);
607
608        transport.toggle_record();
609        mixer.process(&mut output, &[], &transport);
610
611        assert!(clip_rx.try_recv().is_err(), "Should not record when not armed");
612    }
613
614    #[test]
615    fn mixer_reset_commits_recording() {
616        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
617        let _handle = add_armed_synth(&tx, 0);
618        transport.play();
619        transport.toggle_record();
620
621        let midi = vec![make_note_on(60, 100)];
622        let mut output = vec![0.0f32; 512];
623        mixer.process(&mut output, &midi, &transport);
624
625        mixer.reset_all();
626
627        // Reset should commit the active recording, not discard it
628        assert!(clip_rx.try_recv().is_ok(), "Reset should commit active recording");
629    }
630
631    #[test]
632    fn end_to_end_record_and_playback() {
633        // Simulates exact app flow: add track, arm, record, play notes,
634        // stop, rewind, play back — with transport.advance() each buffer.
635        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
636        let _handle = add_armed_synth(&tx, 0);
637        let sr = 44100u32;
638        let buf_frames = 256;
639        let buf_samples = buf_frames * 2; // stereo
640
641        // 1. Enable recording, then play
642        transport.toggle_record();
643        transport.play();
644
645        // 2. Process a few empty buffers (advance transport)
646        let mut output = vec![0.0f32; buf_samples];
647        for _ in 0..4 {
648            mixer.process(&mut output, &[], &transport);
649            transport.advance(buf_frames as u32, sr);
650        }
651
652        // 3. Play a note (should be recorded)
653        let midi = vec![make_note_on(60, 100)];
654        mixer.process(&mut output, &midi, &transport);
655        let peak_during = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
656        assert!(peak_during > 0.01, "Should hear note during recording (monitoring)");
657        transport.advance(buf_frames as u32, sr);
658
659        // 4. A few more buffers of sustain
660        for _ in 0..8 {
661            output.fill(0.0);
662            mixer.process(&mut output, &[], &transport);
663            transport.advance(buf_frames as u32, sr);
664        }
665
666        // 5. Note off
667        let midi = vec![make_note_off(60)];
668        mixer.process(&mut output, &midi, &transport);
669        transport.advance(buf_frames as u32, sr);
670
671        // 6. A few more buffers
672        for _ in 0..4 {
673            output.fill(0.0);
674            mixer.process(&mut output, &[], &transport);
675            transport.advance(buf_frames as u32, sr);
676        }
677
678        // 7. Stop recording (commit clip)
679        transport.toggle_record();
680        mixer.process(&mut output, &[], &transport);
681        transport.advance(buf_frames as u32, sr);
682
683        // 8. Check we got a clip snapshot
684        let snap = clip_rx.try_recv().expect("Should receive clip snapshot after stopping record");
685        assert!(snap.event_count >= 2, "Clip should have note on + off");
686        assert!(!snap.notes.is_empty(), "Clip should have parsed notes");
687
688        // 9. Stop transport and rewind to 0
689        transport.stop();
690
691        // 10. Play back — the synth should be reset (no stuck notes from recording)
692        transport.play();
693
694        // 11. Process enough buffers to reach the recorded note position
695        // The note was recorded after 4 initial buffers, so roughly at that tick position
696        for _ in 0..4 {
697            output.fill(0.0);
698            mixer.process(&mut output, &[], &transport);
699            transport.advance(buf_frames as u32, sr);
700        }
701
702        // 12. The next buffer should contain the played-back note
703        output.fill(0.0);
704        mixer.process(&mut output, &[], &transport);
705        let peak_playback = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
706        assert!(peak_playback > 0.001, "Playback should produce sound at the recorded position, peak={peak_playback}");
707    }
708
709    #[test]
710    fn loop_record_commits_on_wrap() {
711        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
712        let _handle = add_armed_synth(&tx, 0);
713        let sr = 44100u32;
714        let buf_frames = 256u32;
715
716        // Set loop to 1 bar (3840 ticks at 120bpm ≈ 346 buffers of 256 samples)
717        transport.set_loop_bars(1, 1);
718        transport.start_loop_record();
719
720        let mut output = vec![0.0f32; buf_frames as usize * 2];
721
722        // Play a note early in the loop
723        let midi = vec![make_note_on(60, 100)];
724        mixer.process(&mut output, &midi, &transport);
725        transport.advance(buf_frames, sr);
726
727        // Note off a few buffers later
728        for _ in 0..5 {
729            mixer.process(&mut output, &[], &transport);
730            transport.advance(buf_frames, sr);
731        }
732        let midi = vec![make_note_off(60)];
733        mixer.process(&mut output, &midi, &transport);
734        transport.advance(buf_frames, sr);
735
736        // Continue until we cross the loop boundary
737        // 1 bar at 120bpm, 256 frames, 44100Hz ≈ 346 buffers
738        for _ in 0..400 {
739            mixer.process(&mut output, &[], &transport);
740            transport.advance(buf_frames, sr);
741
742            if let Ok(snap) = clip_rx.try_recv() {
743                assert!(snap.event_count >= 2, "Clip should have events, got {}", snap.event_count);
744                assert!(!snap.notes.is_empty(), "Clip should have notes");
745                // Recording committed on loop wrap — success
746                transport.stop_loop_record();
747                return;
748            }
749        }
750
751        panic!("Recording should have committed when the loop wrapped");
752    }
753
754    #[test]
755    fn loop_playback_after_record() {
756        let (mut mixer, tx, clip_rx, transport) = setup_mixer();
757        let _handle = add_armed_synth(&tx, 0);
758        let sr = 44100u32;
759        let bf = 256u32;
760
761        // Set loop to 1 bar, start recording
762        transport.set_loop_bars(1, 1);
763        transport.start_loop_record();
764
765        let mut output = vec![0.0f32; bf as usize * 2];
766
767        // Record a note
768        mixer.process(&mut output, &[make_note_on(60, 100)], &transport);
769        transport.advance(bf, sr);
770        for _ in 0..3 {
771            mixer.process(&mut output, &[], &transport);
772            transport.advance(bf, sr);
773        }
774        mixer.process(&mut output, &[make_note_off(60)], &transport);
775        transport.advance(bf, sr);
776
777        // Run until loop wraps and clip commits
778        for _ in 0..200 {
779            mixer.process(&mut output, &[], &transport);
780            transport.advance(bf, sr);
781            if clip_rx.try_recv().is_ok() { break; }
782        }
783
784        // Stop recording, rewind
785        transport.stop_loop_record();
786        transport.set_position(0);
787
788        // Play back with looping on
789        transport.toggle_loop(); // enable looping
790        transport.play();
791
792        output.fill(0.0);
793        mixer.process(&mut output, &[], &transport);
794        let peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max);
795        assert!(peak > 0.001, "Should hear playback, peak={peak}");
796    }
797}