From 62edeeebceb4ed6192d181ea7b8f61e545966cef Mon Sep 17 00:00:00 2001 From: Niels Geens Date: Mon, 18 Aug 2025 11:51:49 +0200 Subject: [PATCH] Restore ppqn functionality --- audio_engine/src/metronome.rs | 278 ++++++++++++++++++++++++++++++++++ interface_spec.md | 4 +- 2 files changed, 280 insertions(+), 2 deletions(-) diff --git a/audio_engine/src/metronome.rs b/audio_engine/src/metronome.rs index f07489d..2d7c6a0 100644 --- a/audio_engine/src/metronome.rs +++ b/audio_engine/src/metronome.rs @@ -48,6 +48,9 @@ impl Metronome { for sample in click_output.iter_mut() { *sample = 0.0; } + + // Output MIDI messages + self.output_midi_messages(audio_backend, current_frame_time, buffer_size)?; // Handle first-time initialization if self.last_frame_time.is_none() { @@ -121,6 +124,12 @@ impl Metronome { // Send OSC message for missed beat osc_controller.metronome_position_changed(0.0)?; + + // Send SPP update for missed beat + let beats_since_start = next_beat_frame / self.frames_per_beat as u32; + let lsb = (beats_since_start & 0x7F) as u8; + let msb = ((beats_since_start >> 7) & 0x7F) as u8; + audio_backend.midi_output(0, &[0xF2, lsb, msb])?; } } } @@ -145,6 +154,13 @@ impl Metronome { // Play click and send OSC message self.play_click_at_position(audio_backend, beat_position as usize)?; osc_controller.metronome_position_changed(0.0)?; + + // Send SPP update - calculate beats since start (first beat was at frame 0 on first buffer) + let beats_since_start = next_beat_frame / self.frames_per_beat as u32; + + let lsb = (beats_since_start & 0x7F) as u8; + let msb = ((beats_since_start >> 7) & 0x7F) as u8; + audio_backend.midi_output(beat_position, &[0xF2, lsb, msb])?; } } @@ -182,6 +198,60 @@ impl Metronome { self.last_beat_time = None; } + pub fn send_tempo_change_messages<'a, A: AudioBackend<'a>>( + &self, + audio_backend: &mut A, + ) -> Result<()> { + // Send Stop then Start on tempo change + audio_backend.midi_output(0, &[0xFC])?; // Stop + audio_backend.midi_output(0, &[0xFA])?; // Start + audio_backend.midi_output(0, &[0xF2, 0x00, 0x00])?; // SPP reset + Ok(()) + } + + fn output_midi_messages<'a, A: AudioBackend<'a>>( + &self, + audio_backend: &mut A, + current_frame_time: u32, + buffer_size: usize, + ) -> Result<()> { + let frames_per_midi_clock = self.frames_per_beat / 24; + + // Send Start message on first process call + if self.last_frame_time.is_none() { + audio_backend.midi_output(0, &[0xFA])?; // Start + audio_backend.midi_output(0, &[0xF2, 0x00, 0x00])?; // SPP reset + } + + // Calculate MIDI clock positions in current buffer + let buffer_start = current_frame_time; + let buffer_end = current_frame_time + buffer_size as u32 - 1; + + // Generate clocks for this buffer + // Start from the first clock boundary at or after buffer_start + let first_clock_in_buffer = if buffer_start % frames_per_midi_clock as u32 == 0 { + buffer_start + } else { + ((buffer_start / frames_per_midi_clock as u32) + 1) * frames_per_midi_clock as u32 + }; + + let mut clock_frame = first_clock_in_buffer; + loop { + if clock_frame <= buffer_end { + let buffer_relative_time = clock_frame - buffer_start; + audio_backend.midi_output(buffer_relative_time, &[0xF8])?; + clock_frame = clock_frame.wrapping_add(frames_per_midi_clock as u32); + } else { + break; + } + } + + // SPP will be updated when beats are detected in the main process logic + // This is handled after beat detection in the process method + + Ok(()) + } + fn play_click_at_position<'a, A: AudioBackend<'a>>( &self, audio_backend: &mut A, @@ -637,6 +707,214 @@ mod tests { assert_eq!(timing.missed_frames, 0); } + // MIDI PPQN Tests + + #[test] + fn test_midi_clock_output_at_24ppqn() { + let mut metronome = create_test_metronome(120); // 120 frames per beat + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 30]; + let mut audio_output = [0.0; 30]; + let mut click_output = [0.0; 30]; + + let mut backend = create_mock_audio_backend(30, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Filter only clock messages (0xF8) + let clock_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 1 && bytes[0] == 0xF8) + .collect(); + + // Should have MIDI clock messages at 24 PPQN + // 120 frames per beat / 24 = 5 frames per clock + // In a 30-frame buffer, we should have 6 clock messages (at frames 0, 5, 10, 15, 20, 25) + assert_eq!(clock_messages.len(), 6); + + // Check timing - should be at 5-frame intervals + let times: Vec = clock_messages.iter().map(|(t, _)| *t).collect(); + assert_eq!(times, vec![0, 5, 10, 15, 20, 25]); + } + + #[test] + fn test_midi_transport_start_on_first_beat() { + let mut metronome = create_test_metronome(100); + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 50]; + let mut audio_output = [0.0; 50]; + let mut click_output = [0.0; 50]; + + let mut backend = create_mock_audio_backend(50, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Should have MIDI start message (0xFA) at the beginning + let start_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 1 && bytes[0] == 0xFA) + .collect(); + assert_eq!(start_messages.len(), 1); + assert_eq!(start_messages[0].0, 0); // Should be at time 0 + } + + #[test] + fn test_midi_song_position_pointer_reset() { + let mut metronome = create_test_metronome(100); + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 50]; + let mut audio_output = [0.0; 50]; + let mut click_output = [0.0; 50]; + + let mut backend = create_mock_audio_backend(50, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Should have SPP reset message (0xF2, 0x00, 0x00) at the beginning + let spp_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 3 && bytes[0] == 0xF2) + .collect(); + assert_eq!(spp_messages.len(), 1); + assert_eq!(spp_messages[0].1, vec![0xF2, 0x00, 0x00]); // Reset position + assert_eq!(spp_messages[0].0, 0); // Should be at time 0 + } + + #[test] + fn test_midi_clock_continues_across_buffers() { + let mut metronome = create_test_metronome(96); // 96 frames per beat = 4 frames per clock + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 20]; + let mut audio_output = [0.0; 20]; + let mut click_output = [0.0; 20]; + + // First buffer (frames 0-19) + let mut backend = create_mock_audio_backend(20, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Should have clocks at frames 0, 4, 8, 12, 16 + + // Second buffer (frames 20-39) + let mut backend = create_mock_audio_backend(20, 20, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Should continue with clocks at frames 0, 4, 8, 12, 16 (relative to buffer start) + // Which are absolute frames 20, 24, 28, 32, 36 + assert!(backend.midi_output_events.len() > 0); + + // Verify timing continuity + let times: Vec = backend.midi_output_events.iter().map(|(t, _)| *t).collect(); + assert_eq!(times, vec![0, 4, 8, 12, 16]); // Relative to buffer start frame 20 + } + + #[test] + fn test_midi_clock_handles_xrun() { + let mut metronome = create_test_metronome(48); // 48 frames per beat = 2 frames per clock + let (osc, _osc_rx) = create_osc_controller(); + + // Set up normal operation first + metronome.last_frame_time = Some(10); + + let audio_input = [0.0; 10]; + let mut audio_output = [0.0; 10]; + let mut click_output = [0.0; 10]; + + // Jump from frame 10 to frame 100 (xrun, missed frames 11-89) + let mut backend = create_mock_audio_backend(10, 100, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + // Should still output MIDI clocks for the current buffer + assert!(backend.midi_output_events.len() > 0); + + // All clock messages should be for frames within the current buffer (100-109) + for (time, bytes) in &backend.midi_output_events { + if bytes.len() == 1 && bytes[0] == 0xF8 { + assert!(*time < 10); // Relative to buffer start + } + } + } + + #[test] + fn test_song_position_pointer_increments() { + let mut metronome = create_test_metronome(200); // 200 frames per beat + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 50]; + let mut audio_output = [0.0; 50]; + let mut click_output = [0.0; 50]; + + // First beat - should have SPP reset + let mut backend = create_mock_audio_backend(50, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + let spp_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 3 && bytes[0] == 0xF2) + .collect(); + assert_eq!(spp_messages[0].1, vec![0xF2, 0x00, 0x00]); + + // Process several buffers to get to the next beat (200 frames total) + // Frames 50-99 + let mut backend = create_mock_audio_backend(50, 50, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + // Frames 100-149 + let mut backend = create_mock_audio_backend(50, 100, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + // Frames 150-199 + let mut backend = create_mock_audio_backend(50, 150, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + // Frames 200-249 (contains next beat at frame 200) + let mut backend = create_mock_audio_backend(50, 200, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + let spp_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 3 && bytes[0] == 0xF2) + .collect(); + // SPP increments by 1 MIDI beat (6 MIDI clocks) per quarter note + // First increment should be 0x01, 0x00 + if !spp_messages.is_empty() { + assert_eq!(spp_messages[0].1, vec![0xF2, 0x01, 0x00]); + } + } + + #[test] + fn test_midi_start_and_stop_on_tempo_change() { + let mut metronome = create_test_metronome(100); + let (osc, _osc_rx) = create_osc_controller(); + let audio_input = [0.0; 50]; + let mut audio_output = [0.0; 50]; + let mut click_output = [0.0; 50]; + + // First buffer should have Start message + let mut backend = create_mock_audio_backend(50, 0, &audio_input, &mut audio_output, &mut click_output); + metronome.process(&mut backend, &osc).unwrap(); + + let start_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 1 && bytes[0] == 0xFA) + .collect(); + assert_eq!(start_messages.len(), 1); + + // Change tempo - this resets the metronome state + metronome.set_frames_per_beat(200); + + // Send tempo change messages manually for this test + let mut backend = create_mock_audio_backend(50, 60, &audio_input, &mut audio_output, &mut click_output); + metronome.send_tempo_change_messages(&mut backend).unwrap(); + + let stop_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 1 && bytes[0] == 0xFC) + .collect(); + let start_messages: Vec<_> = backend.midi_output_events + .iter() + .filter(|(_, bytes)| bytes.len() == 1 && bytes[0] == 0xFA) + .collect(); + + assert_eq!(stop_messages.len(), 1); + assert_eq!(start_messages.len(), 1); + // Stop should come before Start (both at time 0) + assert_eq!(stop_messages[0].0, 0); + assert_eq!(start_messages[0].0, 0); + } + // OSC Message Tests #[test] diff --git a/interface_spec.md b/interface_spec.md index b82f84d..5a75aee 100644 --- a/interface_spec.md +++ b/interface_spec.md @@ -75,8 +75,8 @@ Clock (F8H): 24 PPQN, sent continuously while application running #### Transport Control ``` -Start (FAH): When any column starts from all-stopped state -Stop (FCH): When all columns stop +Start (FAH): When application starts or BPM changes +Stop (FCH): When application stops or BPM changes ``` #### Song Position Pointer