Restore ppqn functionality

This commit is contained in:
Niels Geens 2025-08-18 11:51:49 +02:00
parent 4065b274f5
commit 62edeeebce
2 changed files with 280 additions and 2 deletions

View File

@ -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<u32> = 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<u32> = 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]

View File

@ -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