533 lines
19 KiB
Rust
533 lines
19 KiB
Rust
use crate::*;
|
|
|
|
pub struct Metronome {
|
|
// Audio playback
|
|
click_samples: Arc<AudioChunk>,
|
|
click_volume: f32,
|
|
|
|
// Timing state
|
|
frames_per_beat: usize,
|
|
frames_since_last_beat: usize, // Where we are in the current beat cycle
|
|
last_frame_time: Option<u32>, // For xrun detection
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct BufferTiming {
|
|
/// Beat index within the current buffer (if any)
|
|
pub beat_in_buffer: Option<u32>,
|
|
|
|
/// Number of frames missed due to xrun (0 if no xrun)
|
|
pub missed_frames: usize,
|
|
|
|
/// Beat index within the missed frames (if any)
|
|
pub beat_in_missed: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct PpqnTick {
|
|
/// Sample offset within the buffer where this tick occurs
|
|
pub sample_offset: usize,
|
|
/// PPQN tick index (0-23)
|
|
pub tick_index: usize,
|
|
/// Position within the beat (0.0 - 1.0)
|
|
pub position: f32,
|
|
}
|
|
|
|
impl Metronome {
|
|
pub fn new(click_samples: Arc<AudioChunk>, state: &State) -> Self {
|
|
Self {
|
|
click_samples,
|
|
click_volume: state.metronome.click_volume,
|
|
frames_per_beat: state.metronome.frames_per_beat,
|
|
frames_since_last_beat: 0,
|
|
last_frame_time: None,
|
|
}
|
|
}
|
|
|
|
/// Process audio for current buffer, writing to output slice
|
|
pub fn process(
|
|
&mut self,
|
|
ps: &jack::ProcessScope,
|
|
ports: &mut JackPorts,
|
|
osc_controller: &OscController,
|
|
) -> Result<BufferTiming> {
|
|
let buffer_size = ps.n_frames();
|
|
let current_frame_time = ps.last_frame_time();
|
|
|
|
// Process PPQN ticks for this buffer
|
|
self.process_ppqn_ticks(ps, ports, osc_controller)?;
|
|
|
|
// Calculate timing for this buffer
|
|
let timing = self.calculate_timing(current_frame_time, buffer_size)?;
|
|
|
|
// Get output buffer for click track
|
|
let click_output = ports.click_track_out.as_mut_slice(ps);
|
|
|
|
self.render_click(buffer_size, &timing, click_output);
|
|
|
|
Ok(timing)
|
|
}
|
|
|
|
fn process_ppqn_ticks(
|
|
&mut self,
|
|
ps: &jack::ProcessScope,
|
|
ports: &mut JackPorts,
|
|
osc_controller: &OscController,
|
|
) -> Result<()> {
|
|
let buffer_size = ps.n_frames() as usize;
|
|
|
|
// Use the pure function to find tick (allocation-free)
|
|
if let Some(tick) = Self::find_next_ppqn_tick_in_buffer(
|
|
self.frames_since_last_beat,
|
|
self.frames_per_beat,
|
|
buffer_size,
|
|
) {
|
|
// Send MIDI clock
|
|
let midi_clock_message = [0xF8];
|
|
let mut midi_writer = ports.midi_out.writer(ps);
|
|
midi_writer
|
|
.write(&jack::RawMidi {
|
|
time: tick.sample_offset as u32,
|
|
bytes: &midi_clock_message,
|
|
})
|
|
.map_err(|_| LooperError::Midi(std::panic::Location::caller()))?;
|
|
|
|
// Send OSC position message
|
|
osc_controller.metronome_position_changed(tick.position)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn render_click(&mut self, buffer_size: u32, timing: &BufferTiming, click_output: &mut [f32]) {
|
|
let click_length = self.click_samples.sample_count;
|
|
|
|
// Calculate our position at the START of this buffer (before calculate_timing updated it)
|
|
// We need to go back by: buffer_size + any missed samples
|
|
let total_advancement = buffer_size as usize + timing.missed_frames;
|
|
let start_position = (self.frames_since_last_beat + self.frames_per_beat
|
|
- total_advancement)
|
|
% self.frames_per_beat;
|
|
|
|
if let Some(beat_offset) = timing.beat_in_buffer {
|
|
let beat_offset = beat_offset as usize;
|
|
|
|
// Check if we're still playing a click from before this beat
|
|
if start_position < click_length {
|
|
// Continue click until beat offset
|
|
let click_samples_remaining = click_length as usize - start_position as usize;
|
|
let samples_to_write = beat_offset.min(click_samples_remaining);
|
|
|
|
let dest = &mut click_output[0..samples_to_write];
|
|
if self
|
|
.click_samples
|
|
.copy_samples(dest, start_position as usize)
|
|
.is_ok()
|
|
{
|
|
dest.iter_mut()
|
|
.for_each(|sample| *sample *= self.click_volume);
|
|
}
|
|
|
|
// Fill gap between end of click and new beat with silence
|
|
click_output[samples_to_write..beat_offset].fill(0.0);
|
|
} else {
|
|
// Write silence up to beat
|
|
click_output[0..beat_offset].fill(0.0);
|
|
}
|
|
|
|
// Start new click at beat offset
|
|
if beat_offset < buffer_size as usize {
|
|
let remaining_buffer = buffer_size as usize - beat_offset;
|
|
let samples_to_write = remaining_buffer.min(click_length as usize);
|
|
|
|
let dest = &mut click_output[beat_offset..beat_offset + samples_to_write];
|
|
if self.click_samples.copy_samples(dest, 0).is_ok() {
|
|
dest.iter_mut()
|
|
.for_each(|sample| *sample *= self.click_volume);
|
|
}
|
|
|
|
// Fill any remaining buffer with silence
|
|
click_output[(beat_offset + samples_to_write)..].fill(0.0);
|
|
}
|
|
} else {
|
|
// No beat in this buffer - check if we're continuing a click
|
|
if start_position < click_length {
|
|
let click_offset = start_position as usize;
|
|
let remaining_click = click_length as usize - click_offset;
|
|
let samples_to_write = (buffer_size as usize).min(remaining_click);
|
|
|
|
let dest = &mut click_output[0..samples_to_write];
|
|
if self.click_samples.copy_samples(dest, click_offset).is_ok() {
|
|
dest.iter_mut()
|
|
.for_each(|sample| *sample *= self.click_volume);
|
|
}
|
|
|
|
// Fill remaining with silence
|
|
click_output[samples_to_write..].fill(0.0);
|
|
} else {
|
|
// No click playing - all silence
|
|
click_output.fill(0.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn calculate_timing(
|
|
&mut self,
|
|
current_frame_time: u32,
|
|
buffer_size: u32,
|
|
) -> Result<BufferTiming> {
|
|
// Detect xrun
|
|
let (missed_frames, beat_in_missed) = if let Some(last) = self.last_frame_time {
|
|
let expected = last.wrapping_add(buffer_size); // Handle u32 wrap
|
|
if current_frame_time != expected {
|
|
// We have a gap
|
|
let missed = current_frame_time.wrapping_sub(expected) as usize;
|
|
|
|
// Check if we missed multiple beats
|
|
let total_samples =
|
|
self.frames_since_last_beat + missed as usize + buffer_size as usize;
|
|
if total_samples >= 2 * self.frames_per_beat {
|
|
return Err(LooperError::Xrun(std::panic::Location::caller()));
|
|
}
|
|
|
|
// Check if a beat occurred in the missed section
|
|
let beat_in_missed =
|
|
if self.frames_since_last_beat + missed as usize >= self.frames_per_beat {
|
|
Some((self.frames_per_beat - self.frames_since_last_beat) as u32)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
(missed, beat_in_missed)
|
|
} else {
|
|
(0, None)
|
|
}
|
|
} else {
|
|
// First call
|
|
(0, None)
|
|
};
|
|
|
|
// Check for beat in current buffer
|
|
// We need to account for any missed samples here too
|
|
let start_position = (self.frames_since_last_beat + missed_frames) % self.frames_per_beat;
|
|
let beat_in_buffer = if start_position + buffer_size as usize >= self.frames_per_beat {
|
|
Some((self.frames_per_beat - start_position) as u32)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Update state - advance by total samples (missed + buffer)
|
|
self.frames_since_last_beat =
|
|
(self.frames_since_last_beat + missed_frames + buffer_size as usize)
|
|
% self.frames_per_beat;
|
|
self.last_frame_time = Some(current_frame_time);
|
|
|
|
Ok(BufferTiming {
|
|
beat_in_buffer,
|
|
missed_frames: missed_frames,
|
|
beat_in_missed,
|
|
})
|
|
}
|
|
|
|
/// Pure function to find the next PPQN tick in a buffer (allocation-free)
|
|
/// Returns Some(tick) if there's a tick in this buffer, None otherwise
|
|
pub fn find_next_ppqn_tick_in_buffer(
|
|
frames_since_last_beat: usize,
|
|
frames_per_beat: usize,
|
|
buffer_size: usize,
|
|
) -> Option<PpqnTick> {
|
|
const TICKS_PER_BEAT: usize = 24;
|
|
let frames_per_tick = frames_per_beat / TICKS_PER_BEAT;
|
|
|
|
// Find distance to next tick boundary
|
|
let frames_since_last_tick = frames_since_last_beat % frames_per_tick;
|
|
let frames_to_next_tick = if frames_since_last_tick == 0 {
|
|
0 // We're exactly on a tick
|
|
} else {
|
|
frames_per_tick - frames_since_last_tick
|
|
};
|
|
|
|
// Check if this tick occurs within our buffer
|
|
if frames_to_next_tick < buffer_size {
|
|
let absolute_tick_position = frames_since_last_beat + frames_to_next_tick;
|
|
let tick_index = (absolute_tick_position / frames_per_tick) % TICKS_PER_BEAT;
|
|
let position = (tick_index as f32) / (TICKS_PER_BEAT as f32);
|
|
|
|
Some(PpqnTick {
|
|
sample_offset: frames_to_next_tick,
|
|
tick_index,
|
|
position,
|
|
})
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn create_test_metronome(frames_per_beat: usize) -> Metronome {
|
|
let beep_samples = Arc::new(AudioChunk {
|
|
samples: vec![1.0; 100].into_boxed_slice(),
|
|
sample_count: 100,
|
|
next: None,
|
|
});
|
|
|
|
Metronome {
|
|
click_samples: beep_samples,
|
|
click_volume: 1.0,
|
|
frames_per_beat,
|
|
frames_since_last_beat: 0,
|
|
last_frame_time: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_first_call_initialization() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
let result = metronome.calculate_timing(5000, 128).unwrap();
|
|
|
|
assert_eq!(metronome.frames_since_last_beat, 128);
|
|
assert_eq!(metronome.last_frame_time, Some(5000));
|
|
assert_eq!(result.missed_frames, 0);
|
|
assert_eq!(result.beat_in_missed, None);
|
|
assert_eq!(result.beat_in_buffer, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_normal_buffer_no_beat() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 1000
|
|
metronome.calculate_timing(1000, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 128);
|
|
|
|
// Next buffer at 1128 - no beat expected
|
|
let result = metronome.calculate_timing(1128, 128).unwrap();
|
|
|
|
assert_eq!(metronome.frames_since_last_beat, 256);
|
|
assert_eq!(result.missed_frames, 0);
|
|
assert_eq!(result.beat_in_missed, None);
|
|
assert_eq!(result.beat_in_buffer, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_beat_in_buffer() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 0
|
|
metronome.calculate_timing(0, 512).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 512);
|
|
|
|
// Next buffer: 512 -> 1024, beat at 1000 (offset 488)
|
|
let result = metronome.calculate_timing(512, 512).unwrap();
|
|
|
|
assert_eq!(result.missed_frames, 0);
|
|
assert_eq!(result.beat_in_missed, None);
|
|
assert_eq!(result.beat_in_buffer, Some(488)); // 1000 - 512
|
|
assert_eq!(metronome.frames_since_last_beat, 24); // (512 + 512) % 1000
|
|
}
|
|
|
|
#[test]
|
|
fn test_xrun_no_missed_beat() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 1000
|
|
metronome.calculate_timing(1000, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 128);
|
|
|
|
// Normal buffer at 1128
|
|
metronome.calculate_timing(1128, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 256);
|
|
|
|
// Xrun: expected 1256 but got 1428 (172 samples missed)
|
|
let result = metronome.calculate_timing(1428, 128).unwrap();
|
|
|
|
assert_eq!(result.missed_frames, 172);
|
|
assert_eq!(result.beat_in_missed, None);
|
|
assert_eq!(result.beat_in_buffer, None);
|
|
assert_eq!(metronome.frames_since_last_beat, 556); // 256 + 172 + 128
|
|
}
|
|
|
|
#[test]
|
|
fn test_xrun_with_missed_beat() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 1000
|
|
metronome.calculate_timing(1000, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 128);
|
|
|
|
// Normal buffer at 1128
|
|
metronome.calculate_timing(1128, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 256);
|
|
|
|
// Xrun: expected 1256 but got 2228 (972 samples missed)
|
|
// We're at position 256, miss 972 samples = 1228 total
|
|
// Beat occurs at position 1000, so beat_in_missed = 1000 - 256 = 744
|
|
let result = metronome.calculate_timing(2228, 128).unwrap();
|
|
|
|
assert_eq!(result.missed_frames, 972);
|
|
assert_eq!(result.beat_in_missed, Some(744)); // 1000 - 256
|
|
assert_eq!(result.beat_in_buffer, None);
|
|
assert_eq!(metronome.frames_since_last_beat, 356); // (256 + 972 + 128) % 1000
|
|
}
|
|
|
|
#[test]
|
|
fn test_xrun_with_missed_beat_and_upcoming_beat() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 1000
|
|
metronome.calculate_timing(1000, 128).unwrap();
|
|
|
|
// Normal buffer at 1128
|
|
metronome.calculate_timing(1128, 128).unwrap();
|
|
assert_eq!(metronome.frames_since_last_beat, 256);
|
|
|
|
// Xrun: expected 1256 but got 2078 (822 samples missed)
|
|
// We're at position 256, miss 822 samples = 1078 total
|
|
// Beat occurs at position 1000, so beat_in_missed = 1000 - 256 = 744
|
|
// After missed: position = 78, buffer = 128, no beat in buffer
|
|
let result = metronome.calculate_timing(2078, 128).unwrap();
|
|
|
|
assert_eq!(result.missed_frames, 822);
|
|
assert_eq!(result.beat_in_missed, Some(744)); // 1000 - 256
|
|
assert_eq!(result.beat_in_buffer, None); // 78 + 128 < 1000
|
|
assert_eq!(metronome.frames_since_last_beat, 206); // (256 + 822 + 128) % 1000
|
|
}
|
|
|
|
#[test]
|
|
fn test_xrun_multiple_beats_error() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize at time 1000
|
|
metronome.calculate_timing(1000, 128).unwrap();
|
|
|
|
// Normal buffer at 1128
|
|
metronome.calculate_timing(1128, 128).unwrap();
|
|
|
|
// Xrun: expected 1256 but got 3328 (2072 samples missed)
|
|
// Total advancement would be 256 + 2072 + 128 = 2456 samples
|
|
// That's more than 2 beats (2000 samples), so error
|
|
let result = metronome.calculate_timing(3328, 128);
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_consecutive_buffers_with_beat() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// First buffer - initialization
|
|
let result1 = metronome.calculate_timing(0, 512).unwrap();
|
|
assert_eq!(result1.beat_in_buffer, None);
|
|
assert_eq!(metronome.frames_since_last_beat, 512);
|
|
|
|
// Second buffer - beat should occur at position 1000
|
|
let result2 = metronome.calculate_timing(512, 512).unwrap();
|
|
assert_eq!(result2.beat_in_buffer, Some(488)); // 1000 - 512
|
|
assert_eq!(metronome.frames_since_last_beat, 24); // (512 + 512) % 1000
|
|
|
|
// Third buffer - no beat
|
|
let result3 = metronome.calculate_timing(1024, 512).unwrap();
|
|
assert_eq!(result3.beat_in_buffer, None);
|
|
assert_eq!(metronome.frames_since_last_beat, 536);
|
|
|
|
// Fourth buffer - next beat at position 1000 again
|
|
let result4 = metronome.calculate_timing(1536, 512).unwrap();
|
|
assert_eq!(result4.beat_in_buffer, Some(464)); // 1000 - 536
|
|
assert_eq!(metronome.frames_since_last_beat, 48); // (536 + 512) % 1000
|
|
}
|
|
|
|
#[test]
|
|
fn test_u32_wrapping() {
|
|
let mut metronome = create_test_metronome(1000);
|
|
|
|
// Initialize near u32::MAX
|
|
let start_time = u32::MAX - 100;
|
|
metronome.calculate_timing(start_time, 128).unwrap();
|
|
|
|
// Next buffer wraps around
|
|
let next_time = start_time.wrapping_add(128);
|
|
let result = metronome.calculate_timing(next_time, 128).unwrap();
|
|
|
|
assert_eq!(result.missed_frames, 0);
|
|
assert_eq!(metronome.frames_since_last_beat, 256);
|
|
}
|
|
|
|
// Tests for the pure PPQN calculation function
|
|
#[test]
|
|
fn test_ppqn_no_tick_in_buffer() {
|
|
let tick = Metronome::find_next_ppqn_tick_in_buffer(100, 960000, 128);
|
|
assert_eq!(tick, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ppqn_tick_at_buffer_start() {
|
|
let frames_per_beat = 960000;
|
|
|
|
// Position exactly at tick boundary
|
|
let tick = Metronome::find_next_ppqn_tick_in_buffer(0, frames_per_beat, 128).unwrap();
|
|
assert_eq!(tick.sample_offset, 0);
|
|
assert_eq!(tick.tick_index, 0);
|
|
assert_eq!(tick.position, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ppqn_tick_in_middle_of_buffer() {
|
|
let frames_per_beat = 960000;
|
|
let frames_per_tick = frames_per_beat / 24; // 40000
|
|
|
|
// Position 64 frames before tick 1
|
|
let frames_since_last_beat = frames_per_tick - 64;
|
|
let tick =
|
|
Metronome::find_next_ppqn_tick_in_buffer(frames_since_last_beat, frames_per_beat, 128)
|
|
.unwrap();
|
|
|
|
assert_eq!(tick.sample_offset, 64);
|
|
assert_eq!(tick.tick_index, 1);
|
|
assert_eq!(tick.position, 1.0 / 24.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ppqn_tick_wrapping() {
|
|
let frames_per_beat = 960000;
|
|
let frames_per_tick = frames_per_beat / 24; // 40000
|
|
|
|
// Position near end of beat cycle
|
|
let frames_since_last_beat = 23 * frames_per_tick - 64; // 64 frames before tick 23
|
|
let tick =
|
|
Metronome::find_next_ppqn_tick_in_buffer(frames_since_last_beat, frames_per_beat, 128)
|
|
.unwrap();
|
|
|
|
assert_eq!(tick.sample_offset, 64);
|
|
assert_eq!(tick.tick_index, 23);
|
|
assert_eq!(tick.position, 23.0 / 24.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_ppqn_real_world_scenario() {
|
|
// Your actual values
|
|
let frames_per_beat = 960000;
|
|
let frames_since_last_beat = 39936;
|
|
let buffer_size = 128;
|
|
|
|
let tick = Metronome::find_next_ppqn_tick_in_buffer(
|
|
frames_since_last_beat,
|
|
frames_per_beat,
|
|
buffer_size,
|
|
);
|
|
|
|
if let Some(tick) = tick {
|
|
println!(
|
|
"Tick {} at offset {} (position {})",
|
|
tick.tick_index, tick.sample_offset, tick.position
|
|
);
|
|
assert_eq!(tick.sample_offset, 64); // 40000 - 39936 = 64
|
|
assert_eq!(tick.tick_index, 1);
|
|
}
|
|
}
|
|
}
|