1use 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
19pub 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 CreateClip {
40 track_id: usize,
41 start_tick: i64,
42 length_ticks: i64,
43 },
44 UpdateClip {
46 track_id: usize,
47 clip_index: usize,
48 events: Vec<ClipEvent>,
49 },
50 UpdateClipPosition {
52 track_id: usize,
53 clip_index: usize,
54 start_tick: i64,
55 length_ticks: i64,
56 },
57 RemoveClip {
59 track_id: usize,
60 clip_index: usize,
61 },
62}
63
64pub struct AudioTrack {
67 pub id: usize,
68 pub kind: TrackKind,
69 pub handle: Arc<TrackHandle>,
70 pub instrument: Option<Box<dyn Plugin>>,
71 pub clips: Vec<MidiClip>,
73 record_buf: RecordBuffer,
75 was_recording: bool,
77 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
102pub 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 scratch_l: Vec<f32>,
114 scratch_r: Vec<f32>,
115 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 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 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 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 if should_record && !track.was_recording {
195 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 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 track.record_buf.start(transport.loop_start());
211 }
212 if should_record {
213 track.last_record_tick = current_tick;
214 }
215
216 if !should_record && track.was_recording {
218 commit_recording(track, current_tick, clip_tx);
219 }
220 track.was_recording = should_record;
221
222 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 if playing && !track.clips.is_empty() {
236 let from = current_tick;
237 let to = current_tick + buffer_ticks;
238
239 let just_wrapped = looping && track.last_record_tick >= 0
242 && current_tick < track.last_record_tick;
243
244 if just_wrapped {
245 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 if playing {
276 track.last_record_tick = current_tick;
277 }
278
279 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 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 for i in 0..num_frames {
317 output[i * 2] = master_l[i];
318 output[i * 2 + 1] = master_r[i];
319 }
320
321 self.scratch_l = master_l;
323 self.scratch_r = master_r;
324 self.live_events = live_events;
325
326 self.metronome.process(output, transport);
328
329 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 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
420fn 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
454pub 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 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 let midi = vec![make_note_off(60)];
537 mixer.process(&mut output, &midi, &transport);
538
539 transport.toggle_record();
541 mixer.process(&mut output, &[], &transport);
542
543 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 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 transport.toggle_record();
567 mixer.process(&mut output, &[], &transport);
568
569 transport.stop();
571
572 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 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 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; transport.toggle_record();
643 transport.play();
644
645 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 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 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 let midi = vec![make_note_off(60)];
668 mixer.process(&mut output, &midi, &transport);
669 transport.advance(buf_frames as u32, sr);
670
671 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 transport.toggle_record();
680 mixer.process(&mut output, &[], &transport);
681 transport.advance(buf_frames as u32, sr);
682
683 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 transport.stop();
690
691 transport.play();
693
694 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 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 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 let midi = vec![make_note_on(60, 100)];
724 mixer.process(&mut output, &midi, &transport);
725 transport.advance(buf_frames, sr);
726
727 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 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 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 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 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 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 transport.stop_loop_record();
786 transport.set_position(0);
787
788 transport.toggle_loop(); 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}