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