Restore ppqn functionality
This commit is contained in:
parent
4065b274f5
commit
62edeeebce
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user