Improved metronome for better xrun handling

This commit is contained in:
Geens 2025-06-10 19:28:11 +02:00
parent deb6bd8f4c
commit 7a78c6b9e6
11 changed files with 680 additions and 337 deletions

View File

@ -89,12 +89,12 @@ impl AudioChunk {
pub fn len(&self) -> usize {
let mut total = self.sample_count;
let mut current = &self.next;
while let Some(chunk) = current {
total += chunk.sample_count;
current = &chunk.next;
}
total
}

View File

@ -5,17 +5,17 @@ use crate::*;
pub enum AudioData {
/// No audio data present
Empty,
/// Unconsolidated linked chunks with sync offset (used during/after recording)
Unconsolidated {
chunks: Arc<AudioChunk>,
sync_offset: usize, // samples from column start to recording start
length: usize, // total samples in recording
sync_offset: usize, // samples from column start to recording start
length: usize, // total samples in recording
},
/// Consolidated single buffer, reordered for optimal playback
Consolidated {
buffer: Box<[f32]>, // single optimized array
buffer: Box<[f32]>, // single optimized array
},
}
@ -24,7 +24,7 @@ impl AudioData {
pub fn new_empty() -> Self {
Self::Empty
}
/// Create new unconsolidated audio data from initial chunk
pub fn new_unconsolidated<F: ChunkFactory>(
chunk_factory: &mut F,
@ -36,7 +36,7 @@ impl AudioData {
length: 0,
})
}
/// Get total length in samples
pub fn len(&self) -> usize {
match self {
@ -45,12 +45,12 @@ impl AudioData {
Self::Consolidated { buffer } => buffer.len(),
}
}
/// Check if audio data is empty
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Append samples during recording (only valid for Unconsolidated state)
pub fn append_samples<F: ChunkFactory>(
&mut self,
@ -66,7 +66,7 @@ impl AudioData {
_ => Err(LooperError::OutOfBounds(std::panic::Location::caller())),
}
}
/// Copy samples to output buffer with volume scaling and looping
/// logical_position is the position within the column cycle (0 = beat 1)
pub fn copy_samples_to_output(
@ -80,41 +80,42 @@ impl AudioData {
output_slice.fill(0.0);
Ok(())
}
Self::Unconsolidated { chunks, sync_offset, length } => {
self.copy_unconsolidated_samples(
chunks,
*sync_offset,
*length,
output_slice,
logical_position,
volume
)
}
Self::Unconsolidated {
chunks,
sync_offset,
length,
} => self.copy_unconsolidated_samples(
chunks,
*sync_offset,
*length,
output_slice,
logical_position,
volume,
),
Self::Consolidated { buffer } => {
self.copy_consolidated_samples(
buffer,
output_slice,
logical_position,
volume
)
self.copy_consolidated_samples(buffer, output_slice, logical_position, volume)
}
}
}
/// Get underlying chunk for post-record processing
pub fn get_chunk_for_processing(&self) -> Option<(Arc<AudioChunk>, usize)> {
match self {
Self::Unconsolidated { chunks, sync_offset, .. } => {
Some((chunks.clone(), *sync_offset))
}
Self::Unconsolidated {
chunks,
sync_offset,
..
} => Some((chunks.clone(), *sync_offset)),
_ => None,
}
}
/// Replace with consolidated buffer from post-record processing
pub fn set_consolidated_buffer(&mut self, buffer: Box<[f32]>) -> Result<()> {
match self {
Self::Unconsolidated { length: old_length, .. } => {
Self::Unconsolidated {
length: old_length, ..
} => {
if buffer.len() != *old_length {
return Err(LooperError::OutOfBounds(std::panic::Location::caller()));
}
@ -124,7 +125,7 @@ impl AudioData {
_ => Err(LooperError::OutOfBounds(std::panic::Location::caller())),
}
}
/// Clear all audio data
pub fn clear(&mut self) -> Result<()> {
*self = Self::Empty;
@ -147,41 +148,41 @@ impl AudioData {
output_slice.fill(0.0);
return Ok(());
}
let mut samples_written = 0;
let samples_needed = output_slice.len();
while samples_written < samples_needed {
let samples_remaining = samples_needed - samples_written;
let samples_until_loop = length - logical_position;
let samples_to_copy = samples_remaining.min(samples_until_loop);
// Map logical position to buffer position using sync offset
let buffer_position = (logical_position + sync_offset) % length;
// Copy from chunks to output slice
chunks.copy_samples(
&mut output_slice[samples_written..samples_written + samples_to_copy],
buffer_position,
)?;
// Apply volume scaling in-place
for sample in &mut output_slice[samples_written..samples_written + samples_to_copy] {
*sample *= volume;
}
samples_written += samples_to_copy;
logical_position += samples_to_copy;
// Handle looping
if logical_position >= length {
logical_position = 0;
}
}
Ok(())
}
/// Copy samples from consolidated buffer (already reordered)
fn copy_consolidated_samples(
&self,
@ -195,34 +196,34 @@ impl AudioData {
output_slice.fill(0.0);
return Ok(());
}
let mut samples_written = 0;
let samples_needed = output_slice.len();
while samples_written < samples_needed {
let samples_remaining = samples_needed - samples_written;
let samples_until_loop = length - logical_position;
let samples_to_copy = samples_remaining.min(samples_until_loop);
// Direct copy since consolidated buffer is already reordered
let src = &buffer[logical_position..logical_position + samples_to_copy];
let dest = &mut output_slice[samples_written..samples_written + samples_to_copy];
dest.copy_from_slice(src);
// Apply volume scaling in-place
for sample in dest {
*sample *= volume;
}
samples_written += samples_to_copy;
logical_position += samples_to_copy;
// Handle looping
if logical_position >= length {
logical_position = 0;
}
}
Ok(())
}
}
@ -235,190 +236,210 @@ mod tests {
#[test]
fn test_new_empty() {
let audio_data = AudioData::new_empty();
assert_eq!(audio_data.len(), 0);
assert!(audio_data.is_empty());
}
#[test]
#[test]
fn test_new_unconsolidated() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(1024)]);
let sync_offset = 100;
let audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap();
match audio_data {
AudioData::Unconsolidated { sync_offset: offset, length, .. } => {
AudioData::Unconsolidated {
sync_offset: offset,
length,
..
} => {
assert_eq!(offset, 100);
assert_eq!(length, 0);
}
_ => panic!("Expected Unconsolidated variant"),
}
}
#[test]
fn test_append_samples_unconsolidated() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(1024)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
let samples = vec![1.0, 2.0, 3.0, 4.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
assert_eq!(audio_data.len(), 4);
assert!(!audio_data.is_empty());
}
#[test]
fn test_append_samples_invalid_state() {
let mut factory = MockFactory::new(vec![]);
let mut audio_data = AudioData::new_empty();
let samples = vec![1.0, 2.0];
let result = audio_data.append_samples(&samples, &mut factory);
assert!(result.is_err());
}
#[test]
fn test_copy_samples_empty() {
let audio_data = AudioData::new_empty();
let mut output = vec![99.0; 4]; // Fill with non-zero to verify silence
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap();
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap();
assert_eq!(output, vec![0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn test_copy_samples_unconsolidated_no_offset() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
// Add some test samples
let samples = vec![1.0, 2.0, 3.0, 4.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
let mut output = vec![0.0; 4];
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap();
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap();
assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn test_copy_samples_unconsolidated_with_offset() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let sync_offset = 2;
let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap();
// Recording order: [A, B, C, D] but recorded starting at beat 3 (sync_offset=2)
// So logical mapping should be: beat1=C, beat2=D, beat3=A, beat4=B
let samples = vec![10.0, 20.0, 30.0, 40.0]; // A, B, C, D
audio_data.append_samples(&samples, &mut factory).unwrap();
let mut output = vec![0.0; 4];
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap(); // Start at logical beat 1
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap(); // Start at logical beat 1
// Should get: [C, D, A, B] = [30.0, 40.0, 10.0, 20.0]
assert_eq!(output, vec![30.0, 40.0, 10.0, 20.0]);
}
#[test]
fn test_copy_samples_unconsolidated_with_volume() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
let samples = vec![1.0, 2.0, 3.0, 4.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
let mut output = vec![0.0; 4];
audio_data.copy_samples_to_output(&mut output, 0, 0.5).unwrap(); // 50% volume
audio_data
.copy_samples_to_output(&mut output, 0, 0.5)
.unwrap(); // 50% volume
assert_eq!(output, vec![0.5, 1.0, 1.5, 2.0]);
}
#[test]
fn test_copy_samples_unconsolidated_looping() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
let samples = vec![1.0, 2.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
let mut output = vec![0.0; 6]; // Request more samples than available
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap();
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap();
// Should loop: [1.0, 2.0, 1.0, 2.0, 1.0, 2.0]
assert_eq!(output, vec![1.0, 2.0, 1.0, 2.0, 1.0, 2.0]);
}
#[test]
fn test_copy_samples_unconsolidated_offset_with_looping() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let sync_offset = 1;
let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap();
// Recording: [A, B, C] starting at beat 2 (sync_offset=1)
// Logical mapping: beat1=B, beat2=C, beat3=A
let samples = vec![10.0, 20.0, 30.0];
let samples = vec![10.0, 20.0, 30.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
let mut output = vec![0.0; 6]; // Request 2 full loops
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap();
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap();
// Should get: [B, C, A, B, C, A] = [20.0, 30.0, 10.0, 20.0, 30.0, 10.0]
assert_eq!(output, vec![20.0, 30.0, 10.0, 20.0, 30.0, 10.0]);
}
#[test]
fn test_copy_samples_consolidated() {
// Create consolidated data directly
let buffer = vec![1.0, 2.0, 3.0, 4.0].into_boxed_slice();
let audio_data = AudioData::Consolidated { buffer };
let mut output = vec![0.0; 4];
audio_data.copy_samples_to_output(&mut output, 0, 1.0).unwrap();
audio_data
.copy_samples_to_output(&mut output, 0, 1.0)
.unwrap();
assert_eq!(output, vec![1.0, 2.0, 3.0, 4.0]);
}
#[test]
fn test_get_chunk_for_processing() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let sync_offset = 42;
let mut audio_data = AudioData::new_unconsolidated(&mut factory, sync_offset).unwrap();
let samples = vec![1.0, 2.0, 3.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
let result = audio_data.get_chunk_for_processing();
assert!(result.is_some());
let (chunk, offset) = result.unwrap();
assert_eq!(offset, 42);
assert_eq!(chunk.len(), 3);
}
#[test]
fn test_get_chunk_for_processing_wrong_state() {
let audio_data = AudioData::new_empty();
let result = audio_data.get_chunk_for_processing();
assert!(result.is_none());
}
#[test]
fn test_set_consolidated_buffer() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 100).unwrap();
let samples = vec![1.0, 2.0, 3.0, 4.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
// Create consolidated buffer with same length
let consolidated_buffer = vec![10.0, 20.0, 30.0, 40.0].into_boxed_slice();
audio_data.set_consolidated_buffer(consolidated_buffer).unwrap();
audio_data
.set_consolidated_buffer(consolidated_buffer)
.unwrap();
// Should now be Consolidated variant
match audio_data {
AudioData::Consolidated { buffer } => {
@ -427,39 +448,39 @@ mod tests {
_ => panic!("Expected Consolidated variant"),
}
}
#[test]
fn test_set_consolidated_buffer_length_mismatch() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
let samples = vec![1.0, 2.0]; // Length 2
audio_data.append_samples(&samples, &mut factory).unwrap();
// Try to set buffer with different length
let wrong_length_buffer = vec![10.0, 20.0, 30.0, 40.0].into_boxed_slice(); // Length 4
let result = audio_data.set_consolidated_buffer(wrong_length_buffer);
assert!(result.is_err());
}
#[test]
fn test_clear() {
let mut factory = MockFactory::new(vec![AudioChunk::allocate(10)]);
let mut audio_data = AudioData::new_unconsolidated(&mut factory, 0).unwrap();
let samples = vec![1.0, 2.0, 3.0];
audio_data.append_samples(&samples, &mut factory).unwrap();
assert_eq!(audio_data.len(), 3);
audio_data.clear().unwrap();
assert_eq!(audio_data.len(), 0);
assert!(audio_data.is_empty());
match audio_data {
AudioData::Empty => (), // Expected
_ => panic!("Expected Empty variant after clear"),
}
}
}
}

View File

@ -1,16 +1,17 @@
mod allocator;
mod audio_chunk;
mod audio_data;
mod beep;
mod chunk_factory;
mod connection_manager;
mod looper_error;
mod metronome;
mod midi;
mod notification_handler;
mod persistence_manager;
mod post_record_handler;
mod process_handler;
mod state;
mod persistence_manager;
mod track;
use std::sync::Arc;
@ -18,19 +19,19 @@ use std::sync::Arc;
use allocator::Allocator;
use audio_chunk::AudioChunk;
use audio_data::AudioData;
use beep::generate_beep;
use chunk_factory::ChunkFactory;
use connection_manager::ConnectionManager;
use looper_error::LooperError;
use looper_error::Result;
use metronome::Metronome;
use metronome::generate_beep;
use notification_handler::JackNotification;
use notification_handler::NotificationHandler;
use post_record_handler::PostRecordHandler;
use persistence_manager::PersistenceManager;
use post_record_handler::PostRecordController;
use post_record_handler::PostRecordHandler;
use process_handler::ProcessHandler;
use state::State;
use persistence_manager::PersistenceManager;
use track::Track;
use track::TrackState;
use track::TrackTiming;
@ -58,22 +59,24 @@ async fn main() {
let notification_handler = NotificationHandler::new();
let mut notification_channel = notification_handler.subscribe();
let (mut persistence_manager, state_watch) = PersistenceManager::new(notification_handler.subscribe());
let (mut persistence_manager, state_watch) =
PersistenceManager::new(notification_handler.subscribe());
// Load state values for metronome configuration
let initial_state = state_watch.borrow().clone();
// Create post-record handler and get controller for ProcessHandler
let (mut post_record_handler, post_record_controller) = PostRecordHandler::new()
.expect("Could not create post-record handler");
let (mut post_record_handler, post_record_controller) =
PostRecordHandler::new().expect("Could not create post-record handler");
let process_handler = ProcessHandler::new(
ports,
allocator,
beep_samples,
ports,
allocator,
beep_samples,
&initial_state,
post_record_controller,
).expect("Could not create process handler");
)
.expect("Could not create process handler");
let mut connection_manager = ConnectionManager::new(
state_watch,

25
src/beep.rs Normal file
View File

@ -0,0 +1,25 @@
use crate::*;
/// Generate a 100ms sine wave beep at 1000Hz
pub fn generate_beep<F: ChunkFactory>(
sample_rate: u32,
chunk_factory: &mut F,
) -> Result<Arc<AudioChunk>> {
const FREQUENCY_HZ: f32 = 1000.0;
const DURATION_MS: f32 = 100.0;
let sample_count = ((sample_rate as f32) * (DURATION_MS / 1000.0)) as usize;
let mut samples = Vec::with_capacity(sample_count);
for i in 0..sample_count {
let t = i as f32 / sample_rate as f32;
let sample = (std::f32::consts::TAU * FREQUENCY_HZ * t).sin();
samples.push(sample);
}
// Create AudioChunk and fill it with samples
let mut chunk = chunk_factory.create_chunk()?;
chunk.append_samples(&samples, chunk_factory)?;
Ok(chunk)
}

View File

@ -1,123 +1,368 @@
use crate::*;
use std::f32::consts::TAU;
pub struct Metronome {
// Audio playback
beep_samples: Arc<AudioChunk>,
click_volume: f32, // 0.0 to 1.0
// Timing state
samples_per_beat: u32,
beat_time: u32,
click_samples: Arc<AudioChunk>,
click_volume: f32,
// Timing state
frames_per_beat: u32,
frames_since_last_beat: u32, // 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: u32,
/// Beat index within the missed frames (if any)
pub beat_in_missed: Option<u32>,
}
impl Metronome {
pub fn new(beep_samples: Arc<AudioChunk>, state: &State) -> Self {
pub fn new(click_samples: Arc<AudioChunk>, state: &State) -> Self {
Self {
beep_samples,
click_samples,
click_volume: state.metronome.click_volume,
samples_per_beat: state.metronome.samples_per_beat,
beat_time: 0,
frames_per_beat: state.metronome.frames_per_beat,
frames_since_last_beat: 0,
last_frame_time: None,
}
}
pub fn samples_per_beat(&self) -> u32 {
self.samples_per_beat
pub fn frames_per_beat(&self) -> u32 {
self.frames_per_beat
}
/// Process audio for current buffer, writing to output slice
/// Returns the sample index where a beat occurred, if any
pub fn process(&mut self, ps: &jack::ProcessScope, ports: &mut JackPorts) -> Result<Option<u32>> {
if 0 == self.beat_time {
self.beat_time = ps.last_frame_time();
pub fn process(
&mut self,
ps: &jack::ProcessScope,
ports: &mut JackPorts,
) -> Result<BufferTiming> {
let buffer_size = ps.n_frames();
let current_frame_time = ps.last_frame_time();
// 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, current_frame_time, &timing, click_output);
Ok(timing)
}
fn render_click(
&mut self,
buffer_size: u32,
current_frame_time: u32,
timing: &BufferTiming,
click_output: &mut [f32],
) {
// Calculate current position within the beep (frames since last beat started)
let frames_since_beat_start = current_frame_time - self.frames_since_last_beat;
let click_length = self.click_samples.sample_count as u32;
if let Some(beat_offset) = timing.beat_in_buffer {
// Write silence up to beat boundary
let silence_end = beat_offset.min(buffer_size);
click_output[0..silence_end as _].fill(0.0);
// Write click samples from boundary onward
if beat_offset < buffer_size {
let remaining_buffer = buffer_size - beat_offset;
let samples_to_write = remaining_buffer.min(click_length);
// Copy click samples in bulk
let dest = &mut click_output
[beat_offset as usize..beat_offset as usize + samples_to_write as usize];
if self.click_samples.copy_samples(dest, 0).is_ok() {
// Apply volume scaling with iterators
dest.iter_mut()
.for_each(|sample| *sample *= self.click_volume);
}
// Fill remaining buffer with silence
click_output[(beat_offset as usize + samples_to_write as usize)..].fill(0.0);
}
} else if frames_since_beat_start < click_length {
// Continue playing click from previous beat if still within beep duration
let click_start_offset = frames_since_beat_start;
let remaining_click_samples = click_length - click_start_offset;
let samples_to_write = buffer_size.min(remaining_click_samples);
// Copy remaining beep samples in bulk
let dest = &mut click_output[0..samples_to_write as _];
if self
.click_samples
.copy_samples(dest, click_start_offset as _)
.is_ok()
{
// Apply volume scaling with iterators
dest.iter_mut()
.for_each(|sample| *sample *= self.click_volume);
}
// Fill remaining buffer with silence
click_output[samples_to_write as _..].fill(0.0);
} else {
click_output.fill(0.0);
}
}
let time_since_last_beat = ps.last_frame_time() - self.beat_time;
pub fn calculate_timing(
&mut self,
current_frame_time: u32,
buffer_size: u32,
) -> Result<BufferTiming> {
// Detect xrun
let (missed_samples, 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);
if time_since_last_beat >= (2 * self.samples_per_beat) && self.beat_time != 0 {
return Err(LooperError::Xrun(std::panic::Location::caller()));
}
// Check if we missed multiple beats
let total_samples = self.frames_since_last_beat + missed + buffer_size;
if total_samples >= 2 * self.frames_per_beat {
return Err(LooperError::Xrun(std::panic::Location::caller()));
}
let beat_sample_index = if time_since_last_beat >= self.samples_per_beat {
self.beat_time += self.samples_per_beat;
Some(self.beat_time - ps.last_frame_time())
// Check if a beat occurred in the missed section
let beat_in_missed = if self.frames_since_last_beat + missed >= self.frames_per_beat
{
Some(self.frames_per_beat - self.frames_since_last_beat)
} 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_samples) % self.frames_per_beat;
let beat_in_buffer = if start_position + buffer_size >= self.frames_per_beat {
Some(self.frames_per_beat - start_position)
} else {
None
};
// Get output buffer for click track
let click_output = ports.click_track_out.as_mut_slice(ps);
let buffer_size = click_output.len();
// Calculate current position within the beep (frames since last beat started)
let frames_since_beat_start = ps.last_frame_time() - self.beat_time;
let beep_length = self.beep_samples.sample_count as u32;
// Update state - advance by total samples (missed + buffer)
self.frames_since_last_beat =
(self.frames_since_last_beat + missed_samples + buffer_size) % self.frames_per_beat;
self.last_frame_time = Some(current_frame_time);
if let Some(beat_offset) = beat_sample_index {
let beat_offset = beat_offset as usize;
// Write silence up to beat boundary
let silence_end = beat_offset.min(buffer_size);
click_output[0..silence_end].fill(0.0);
// Write beep samples from boundary onward
if beat_offset < buffer_size {
let remaining_buffer = buffer_size - beat_offset;
let samples_to_write = remaining_buffer.min(beep_length as usize);
// Copy beep samples in bulk
let dest = &mut click_output[beat_offset..beat_offset + samples_to_write];
if self.beep_samples.copy_samples(dest, 0).is_ok() {
// Apply volume scaling with iterators
dest.iter_mut().for_each(|sample| *sample *= self.click_volume);
}
// Fill remaining buffer with silence
click_output[(beat_offset + samples_to_write)..].fill(0.0);
}
} else if frames_since_beat_start < beep_length {
// Continue playing beep from previous beat if still within beep duration
let beep_start_offset = frames_since_beat_start as usize;
let remaining_beep_samples = beep_length as usize - beep_start_offset;
let samples_to_write = buffer_size.min(remaining_beep_samples);
// Copy remaining beep samples in bulk
let dest = &mut click_output[0..samples_to_write];
if self.beep_samples.copy_samples(dest, beep_start_offset).is_ok() {
// Apply volume scaling with iterators
dest.iter_mut().for_each(|sample| *sample *= self.click_volume);
}
// Fill remaining buffer with silence
click_output[samples_to_write..].fill(0.0);
} else {
click_output.fill(0.0);
Ok(BufferTiming {
beat_in_buffer,
missed_frames: missed_samples,
beat_in_missed,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_metronome(samples_per_beat: u32) -> 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: samples_per_beat,
frames_since_last_beat: 0,
last_frame_time: None,
}
}
Ok(beat_sample_index)
#[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);
}
}
/// Generate a 100ms sine wave beep at 1000Hz
pub fn generate_beep<F: ChunkFactory>(
sample_rate: u32,
chunk_factory: &mut F,
) -> Result<Arc<AudioChunk>> {
const FREQUENCY_HZ: f32 = 1000.0;
const DURATION_MS: f32 = 100.0;
let sample_count = ((sample_rate as f32) * (DURATION_MS / 1000.0)) as usize;
let mut samples = Vec::with_capacity(sample_count);
for i in 0..sample_count {
let t = i as f32 / sample_rate as f32;
let sample = (TAU * FREQUENCY_HZ * t).sin();
samples.push(sample);
}
// Create AudioChunk and fill it with samples
let mut chunk = chunk_factory.create_chunk()?;
chunk.append_samples(&samples, chunk_factory)?;
Ok(chunk)
}

View File

@ -72,4 +72,4 @@ pub fn process_events<F: ChunkFactory>(
}
Ok(())
}
}

View File

@ -63,7 +63,12 @@ impl jack::NotificationHandler for NotificationHandler {
.expect("Could not send port connection notification");
}
fn port_registration(&mut self, _client: &jack::Client, _port_id: jack::PortId, register: bool) {
fn port_registration(
&mut self,
_client: &jack::Client,
_port_id: jack::PortId,
register: bool,
) {
if register {
let notification = JackNotification::PortRegistered {};
self.channel

View File

@ -24,11 +24,20 @@ pub struct PostRecordController {
impl PostRecordController {
/// Send a post-record processing request (RT-safe)
pub fn send_request(&self, chunk_chain: Arc<AudioChunk>, sync_offset: u32, sample_rate: u32) -> Result<()> {
let request = PostRecordRequest { chunk_chain, sync_offset, sample_rate };
pub fn send_request(
&self,
chunk_chain: Arc<AudioChunk>,
sync_offset: u32,
sample_rate: u32,
) -> Result<()> {
let request = PostRecordRequest {
chunk_chain,
sync_offset,
sample_rate,
};
match self.request_sender.try_send(request) {
Ok(true) => Ok(()), // Successfully sent
Ok(true) => Ok(()), // Successfully sent
Ok(false) => Err(LooperError::ChunkAllocation(std::panic::Location::caller())), // Channel full
Err(_) => Err(LooperError::ChunkAllocation(std::panic::Location::caller())), // Channel closed
}
@ -38,7 +47,7 @@ impl PostRecordController {
pub fn try_recv_response(&self) -> Option<PostRecordResponse> {
match self.response_receiver.try_recv() {
Ok(Some(response)) => Some(response),
Ok(None) | Err(_) => None, // No message or channel closed = None
Ok(None) | Err(_) => None, // No message or channel closed = None
}
}
}
@ -86,24 +95,27 @@ impl PostRecordHandler {
}
/// Process a single post-record request
async fn process_request(
&self,
request: PostRecordRequest,
) -> Result<()> {
log::debug!("Processing post-record request for {} samples with sync_offset {}",
request.chunk_chain.len(), request.sync_offset);
async fn process_request(&self, request: PostRecordRequest) -> Result<()> {
log::debug!(
"Processing post-record request for {} samples with sync_offset {}",
request.chunk_chain.len(),
request.sync_offset
);
// Step 1: Consolidate and reorder chunk chain based on sync offset
let consolidated_buffer = self.consolidate_with_sync_offset(
&request.chunk_chain,
request.sync_offset as usize
)?;
log::debug!("Consolidated and reordered {} samples", consolidated_buffer.len());
let consolidated_buffer =
self.consolidate_with_sync_offset(&request.chunk_chain, request.sync_offset as usize)?;
log::debug!(
"Consolidated and reordered {} samples",
consolidated_buffer.len()
);
// Step 2: Send consolidated buffer back to RT thread immediately
let response = PostRecordResponse { consolidated_buffer };
let response = PostRecordResponse {
consolidated_buffer,
};
if let Err(_) = self.response_sender.send(response).await {
log::warn!("Failed to send consolidated buffer to RT thread");
}
@ -113,7 +125,10 @@ impl PostRecordHandler {
let consolidated_chunk = AudioChunk::consolidate(&request.chunk_chain);
let file_path = self.get_file_path();
match self.save_wav_file(&consolidated_chunk, request.sample_rate, &file_path).await {
match self
.save_wav_file(&consolidated_chunk, request.sample_rate, &file_path)
.await
{
Ok(_) => log::info!("Saved recording to {:?}", file_path),
Err(e) => log::error!("Failed to save recording to {:?}: {}", file_path, e),
}
@ -122,9 +137,13 @@ impl PostRecordHandler {
}
/// Consolidate chunk chain and reorder samples based on sync offset
fn consolidate_with_sync_offset(&self, chunk_chain: &Arc<AudioChunk>, sync_offset: usize) -> Result<Box<[f32]>> {
fn consolidate_with_sync_offset(
&self,
chunk_chain: &Arc<AudioChunk>,
sync_offset: usize,
) -> Result<Box<[f32]>> {
let total_length = chunk_chain.len();
if total_length == 0 {
return Ok(Vec::new().into_boxed_slice());
}
@ -141,7 +160,7 @@ impl PostRecordHandler {
}
let mut reordered = Vec::with_capacity(total_length);
// For each output position, calculate which input position to take from
for i in 0..total_length {
let source_index = (i + sync_offset) % total_length;
@ -161,7 +180,7 @@ impl PostRecordHandler {
// Run WAV writing in blocking task to avoid blocking async runtime
let chunk_samples: Vec<f32> = chunk.samples[..chunk.sample_count].to_vec();
let file_path_clone = file_path.clone();
tokio::task::spawn_blocking(move || {
let spec = hound::WavSpec {
channels: 1,
@ -175,11 +194,13 @@ impl PostRecordHandler {
// Write all samples from the chunk
for sample in chunk_samples {
writer.write_sample(sample)
writer
.write_sample(sample)
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
}
writer.finalize()
writer
.finalize()
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
Ok::<(), LooperError>(())
@ -190,8 +211,7 @@ impl PostRecordHandler {
/// Create save directory and return path
fn create_directory() -> Result<PathBuf> {
let mut path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."));
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".fcb_looper");
std::fs::create_dir_all(&path)
@ -204,4 +224,4 @@ impl PostRecordHandler {
fn get_file_path(&self) -> PathBuf {
self.directory.join("track.wav")
}
}
}

View File

@ -1,8 +1,8 @@
use crate::*;
// Testing constants for sync offset functionality
const SYNC_OFFSET_BEATS: u32 = 2; // Start recording at beat 3 (0-indexed)
const AUTO_STOP_BEATS: u32 = 4; // Record for 4 beats total
const SYNC_OFFSET_BEATS: u32 = 2; // Start recording at beat 3 (0-indexed)
const AUTO_STOP_BEATS: u32 = 4; // Record for 4 beats total
pub struct ProcessHandler<F: ChunkFactory> {
track: Track,
@ -15,9 +15,9 @@ pub struct ProcessHandler<F: ChunkFactory> {
impl<F: ChunkFactory> ProcessHandler<F> {
pub fn new(
ports: JackPorts,
chunk_factory: F,
beep_samples: Arc<AudioChunk>,
ports: JackPorts,
chunk_factory: F,
beep_samples: Arc<AudioChunk>,
state: &State,
post_record_controller: PostRecordController,
) -> Result<Self> {
@ -43,13 +43,14 @@ impl<F: ChunkFactory> ProcessHandler<F> {
Ok(())
}
/// Handle auto-stop record button (Button 3)
/// Handle auto-stop record button (Button 3)
pub fn record_auto_stop(&mut self) -> Result<()> {
let samples_per_beat = self.metronome.samples_per_beat();
let samples_per_beat = self.metronome.frames_per_beat();
let sync_offset = SYNC_OFFSET_BEATS * samples_per_beat;
let target_samples = AUTO_STOP_BEATS * samples_per_beat;
self.track.queue_record_auto_stop(target_samples as usize, sync_offset as usize);
self.track
.queue_record_auto_stop(target_samples as usize, sync_offset as usize);
Ok(())
}
@ -75,30 +76,32 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
log::error!("Error processing metronome: {}", e);
return jack::Control::Quit;
}
};
}
.beat_in_buffer;
let buffer_size = client.buffer_size() as usize;
let state_before = self.track.current_state().clone();
// Calculate timing information for track processing
let timing = self.calculate_track_timing(beat_sample_index, &state_before);
// Process track audio with calculated timing
let should_consolidate = match self.track.process(
ps,
&mut self.ports,
timing,
&mut self.chunk_factory
) {
Ok(consolidate) => consolidate,
Err(e) => {
log::error!("Error processing track: {}", e);
return jack::Control::Quit;
}
};
let should_consolidate =
match self
.track
.process(ps, &mut self.ports, timing, &mut self.chunk_factory)
{
Ok(consolidate) => consolidate,
Err(e) => {
log::error!("Error processing track: {}", e);
return jack::Control::Quit;
}
};
// Handle post-record processing
if let Err(e) = self.handle_post_record_processing(should_consolidate, client.sample_rate() as u32) {
if let Err(e) =
self.handle_post_record_processing(should_consolidate, client.sample_rate() as u32)
{
log::error!("Error handling post-record processing: {}", e);
return jack::Control::Quit;
}
@ -112,17 +115,26 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
impl<F: ChunkFactory> ProcessHandler<F> {
/// Handle post-record processing: send requests and swap buffers
fn handle_post_record_processing(&mut self, should_consolidate: bool, sample_rate: u32) -> Result<()> {
fn handle_post_record_processing(
&mut self,
should_consolidate: bool,
sample_rate: u32,
) -> Result<()> {
// Send audio data for processing if track indicates consolidation needed
if should_consolidate {
if let Some((chunk_chain, sync_offset)) = self.track.get_audio_data_for_processing() {
self.post_record_controller.send_request(chunk_chain, sync_offset as u32, sample_rate)?;
self.post_record_controller.send_request(
chunk_chain,
sync_offset as u32,
sample_rate,
)?;
}
}
// Check for consolidation response
if let Some(response) = self.post_record_controller.try_recv_response() {
self.track.set_consolidated_buffer(response.consolidated_buffer)?;
self.track
.set_consolidated_buffer(response.consolidated_buffer)?;
}
Ok(())
@ -145,7 +157,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
let beat_index = beat_index as usize;
let pre_beat_position = self.playback_position;
let post_beat_position = self.calculate_post_beat_position(state_before);
TrackTiming::Beat {
pre_beat_position,
post_beat_position,
@ -154,11 +166,11 @@ impl<F: ChunkFactory> ProcessHandler<F> {
}
}
}
/// Calculate the correct playback position after a beat transition
fn calculate_post_beat_position(&self, state_before: &TrackState) -> usize {
let state_after = self.track.next_state(); // Use next_state since transition hasn't happened yet
match (state_before, state_after) {
(_, TrackState::Playing) if !matches!(state_before, TrackState::Playing) => {
// Just started playing - start from beginning
@ -185,7 +197,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
state_before: &TrackState,
) {
let state_after = self.track.current_state().clone();
match beat_sample_index {
None => {
// No beat - simple position update
@ -195,19 +207,21 @@ impl<F: ChunkFactory> ProcessHandler<F> {
}
Some(beat_index) => {
let beat_index = beat_index as usize;
// Handle position updates around beat boundary
if beat_index > 0 && *state_before == TrackState::Playing {
// Advance position for samples before beat
self.advance_playback_position(beat_index);
}
// Check if state transition at beat affects position
if state_after == TrackState::Playing && !matches!(state_before, TrackState::Playing) {
if state_after == TrackState::Playing
&& !matches!(state_before, TrackState::Playing)
{
// Started playing at beat - reset position to post-beat calculation
self.playback_position = self.calculate_post_beat_position(state_before);
}
// Advance position for samples after beat if playing
if beat_index < buffer_size && state_after == TrackState::Playing {
let samples_after_beat = buffer_size - beat_index;
@ -216,19 +230,19 @@ impl<F: ChunkFactory> ProcessHandler<F> {
}
}
}
/// Advance playback position with looping
fn advance_playback_position(&mut self, samples: usize) {
if self.track.len() == 0 {
self.playback_position = 0;
return;
}
self.playback_position += samples;
// Handle looping
while self.playback_position >= self.track.len() {
self.playback_position -= self.track.len();
}
}
}
}

View File

@ -16,8 +16,8 @@ pub struct ConnectionState {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetronomeState {
pub samples_per_beat: u32,
pub click_volume: f32, // 0.0 to 1.0
pub frames_per_beat: u32,
pub click_volume: f32, // 0.0 to 1.0
}
impl Default for State {
@ -30,12 +30,9 @@ impl Default for State {
click_track_out: Vec::new(),
},
metronome: MetronomeState {
samples_per_beat: 96000, // 120 BPM at 192kHz sample rate
click_volume: 0.5, // Default 50% volume
frames_per_beat: 96000, // 120 BPM at 192kHz sample rate
click_volume: 0.5, // Default 50% volume
},
}
}
}

View File

@ -6,22 +6,22 @@ pub enum TrackState {
Idle, // Has data, not playing (READY)
Playing, // Currently playing (PLAY)
Recording, // Currently recording (REC) - manual stop
RecordingAutoStop {
target_samples: usize, // Auto-stop when this many samples recorded
sync_offset: usize, // Offset in samples from column start
RecordingAutoStop {
target_samples: usize, // Auto-stop when this many samples recorded
sync_offset: usize, // Offset in samples from column start
},
}
#[derive(Debug)]
pub enum TrackTiming {
NoBeat {
position: usize
NoBeat {
position: usize,
},
Beat {
Beat {
pre_beat_position: usize,
post_beat_position: usize,
beat_sample_index: usize,
}
},
}
pub struct Track {
@ -67,10 +67,10 @@ impl Track {
)?;
false // No state transition possible without beat
}
TrackTiming::Beat {
pre_beat_position,
post_beat_position,
beat_sample_index
TrackTiming::Beat {
pre_beat_position,
post_beat_position,
beat_sample_index,
} => {
if beat_sample_index > 0 {
// Process samples before beat with current state
@ -129,8 +129,9 @@ impl Track {
TrackState::Recording => {
// Record input samples (manual recording)
let samples_to_record = &input_buffer[start_index..end_index];
self.audio_data.append_samples(samples_to_record, chunk_factory)?;
self.audio_data
.append_samples(samples_to_record, chunk_factory)?;
// Output silence during recording
output_buffer[start_index..end_index].fill(0.0);
}
@ -138,25 +139,25 @@ impl Track {
// Record input samples with auto-stop logic
let samples_to_record = &input_buffer[start_index..end_index];
let current_length = self.audio_data.len();
if current_length < *target_samples {
// Still recording - determine how many samples to actually record
let samples_needed = *target_samples - current_length;
let samples_to_append = samples_to_record.len().min(samples_needed);
if samples_to_append > 0 {
self.audio_data.append_samples(
&samples_to_record[..samples_to_append],
chunk_factory
&samples_to_record[..samples_to_append],
chunk_factory,
)?;
}
// Check if we've reached target and should auto-transition
if self.audio_data.len() >= *target_samples {
self.next_state = TrackState::Playing;
}
}
// Output silence during recording
output_buffer[start_index..end_index].fill(0.0);
}
@ -175,25 +176,25 @@ impl Track {
/// Apply state transition from next_state to current_state
/// Returns true if track should be consolidated and saved
fn apply_state_transition<F: ChunkFactory>(
&mut self,
chunk_factory: &mut F
) -> Result<bool> {
fn apply_state_transition<F: ChunkFactory>(&mut self, chunk_factory: &mut F) -> Result<bool> {
// Check if this is a recording → playing transition (consolidation trigger)
let should_consolidate = matches!(
(&self.current_state, &self.next_state),
(TrackState::Recording, TrackState::Playing) |
(TrackState::RecordingAutoStop { .. }, TrackState::Playing)
(TrackState::Recording, TrackState::Playing)
| (TrackState::RecordingAutoStop { .. }, TrackState::Playing)
);
// Handle transitions that require setup
match (&self.current_state, &self.next_state) {
(current_state, TrackState::Recording) if !matches!(current_state, TrackState::Recording) => {
(current_state, TrackState::Recording)
if !matches!(current_state, TrackState::Recording) =>
{
// Starting manual recording - clear previous data and create new unconsolidated data
self.audio_data = AudioData::new_unconsolidated(chunk_factory, 0)?;
}
(current_state, TrackState::RecordingAutoStop { sync_offset, .. })
if !matches!(current_state, TrackState::RecordingAutoStop { .. }) => {
(current_state, TrackState::RecordingAutoStop { sync_offset, .. })
if !matches!(current_state, TrackState::RecordingAutoStop { .. }) =>
{
// Starting auto-stop recording - clear previous data and create new unconsolidated data with offset
self.audio_data = AudioData::new_unconsolidated(chunk_factory, *sync_offset)?;
}
@ -215,7 +216,7 @@ impl Track {
// Apply the state transition
self.current_state = self.next_state.clone();
Ok(should_consolidate)
}
@ -273,19 +274,31 @@ impl Track {
pub fn queue_record_auto_stop(&mut self, target_samples: usize, sync_offset: usize) {
match self.current_state {
TrackState::Empty | TrackState::Idle => {
self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset };
self.next_state = TrackState::RecordingAutoStop {
target_samples,
sync_offset,
};
}
TrackState::Recording => {
// Switch from manual to auto-stop recording
self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset };
self.next_state = TrackState::RecordingAutoStop {
target_samples,
sync_offset,
};
}
TrackState::RecordingAutoStop { .. } => {
// Already auto-recording - update parameters
self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset };
self.next_state = TrackState::RecordingAutoStop {
target_samples,
sync_offset,
};
}
TrackState::Playing => {
// Stop playing and start auto-recording
self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset };
self.next_state = TrackState::RecordingAutoStop {
target_samples,
sync_offset,
};
}
}
}
@ -318,4 +331,4 @@ impl Track {
pub fn queue_clear(&mut self) {
self.next_state = TrackState::Empty;
}
}
}