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