fcb_looper/audio_engine/src/metronome.rs
2025-06-21 22:58:49 +02:00

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);
}
}
}