Added audio_data and RecordingAutoStop

This commit is contained in:
Geens 2025-06-09 19:55:56 +02:00
parent 9b4fd1685c
commit fc8ca5ce86
7 changed files with 644 additions and 136 deletions

465
src/audio_data.rs Normal file
View File

@ -0,0 +1,465 @@
use crate::*;
/// Audio data representation supporting sync offsets for column-based timing
#[derive(Debug)]
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
},
/// Consolidated single buffer, reordered for optimal playback
Consolidated {
buffer: Box<[f32]>, // single optimized array
},
}
impl AudioData {
/// Create new empty audio data
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,
sync_offset: usize,
) -> Result<Self> {
Ok(Self::Unconsolidated {
chunks: chunk_factory.create_chunk()?,
sync_offset,
length: 0,
})
}
/// Get total length in samples
pub fn len(&self) -> usize {
match self {
Self::Empty => 0,
Self::Unconsolidated { length, .. } => *length,
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,
samples: &[f32],
chunk_factory: &mut F,
) -> Result<()> {
match self {
Self::Unconsolidated { chunks, length, .. } => {
chunks.append_samples(samples, chunk_factory)?;
*length += samples.len();
Ok(())
}
_ => 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(
&self,
output_slice: &mut [f32],
logical_position: usize,
volume: f32,
) -> Result<()> {
match self {
Self::Empty => {
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::Consolidated { buffer } => {
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))
}
_ => 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, .. } => {
if buffer.len() != *old_length {
return Err(LooperError::OutOfBounds(std::panic::Location::caller()));
}
*self = Self::Consolidated { buffer };
Ok(())
}
_ => Err(LooperError::OutOfBounds(std::panic::Location::caller())),
}
}
/// Clear all audio data
pub fn clear(&mut self) -> Result<()> {
*self = Self::Empty;
Ok(())
}
}
impl AudioData {
/// Copy samples from unconsolidated chunks with sync offset handling
fn copy_unconsolidated_samples(
&self,
chunks: &Arc<AudioChunk>,
sync_offset: usize,
length: usize,
output_slice: &mut [f32],
mut logical_position: usize,
volume: f32,
) -> Result<()> {
if length == 0 {
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,
buffer: &[f32],
output_slice: &mut [f32],
mut logical_position: usize,
volume: f32,
) -> Result<()> {
let length = buffer.len();
if length == 0 {
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(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk_factory::mock::MockFactory;
#[test]
fn test_new_empty() {
let audio_data = AudioData::new_empty();
assert_eq!(audio_data.len(), 0);
assert!(audio_data.is_empty());
}
#[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, .. } => {
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();
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();
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
// 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
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();
// 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];
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();
// 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();
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();
// Should now be Consolidated variant
match audio_data {
AudioData::Consolidated { buffer } => {
assert_eq!(buffer.len(), 4);
}
_ => 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,5 +1,6 @@
mod allocator;
mod audio_chunk;
mod audio_data;
mod chunk_factory;
mod connection_manager;
mod looper_error;
@ -16,6 +17,7 @@ use std::sync::Arc;
use allocator::Allocator;
use audio_chunk::AudioChunk;
use audio_data::AudioData;
use chunk_factory::ChunkFactory;
use connection_manager::ConnectionManager;
use looper_error::LooperError;

View File

@ -21,6 +21,10 @@ impl Metronome {
}
}
pub fn samples_per_beat(&self) -> u32 {
self.samples_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>> {

View File

@ -45,6 +45,10 @@ pub fn process_events<F: ChunkFactory>(
// Button 2: Play/Mute
process_handler.play_toggle()?;
}
22 => {
// Button 3: Auto-stop record
process_handler.record_auto_stop()?;
}
24 => {
// Button 5: Clear track
process_handler.clear_track()?;

View File

@ -1,17 +1,18 @@
use crate::*;
use std::path::PathBuf;
/// Request to process a recorded chunk chain
/// Request to process a recorded chunk chain with sync offset
#[derive(Debug)]
pub struct PostRecordRequest {
pub chunk_chain: Arc<AudioChunk>,
pub sync_offset: u32,
pub sample_rate: u32,
}
/// Response containing the consolidated chunk
/// Response containing the consolidated buffer
#[derive(Debug)]
pub struct PostRecordResponse {
pub consolidated_chunk: Arc<AudioChunk>,
pub consolidated_buffer: Box<[f32]>,
}
/// RT-side interface for post-record operations
@ -23,8 +24,8 @@ pub struct PostRecordController {
impl PostRecordController {
/// Send a post-record processing request (RT-safe)
pub fn send_request(&self, chunk_chain: Arc<AudioChunk>, sample_rate: u32) -> Result<()> {
let request = PostRecordRequest { chunk_chain, 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
@ -42,7 +43,7 @@ impl PostRecordController {
}
}
/// Handles post-record processing: consolidation and saving
/// Handles post-record processing: consolidation with sync offset and saving
pub struct PostRecordHandler {
request_receiver: kanal::AsyncReceiver<PostRecordRequest>,
response_sender: kanal::AsyncSender<PostRecordResponse>,
@ -89,24 +90,27 @@ impl PostRecordHandler {
&self,
request: PostRecordRequest,
) -> Result<()> {
log::debug!("Processing post-record request for {} samples",
request.chunk_chain.len());
log::debug!("Processing post-record request for {} samples with sync_offset {}",
request.chunk_chain.len(), request.sync_offset);
// Step 1: Consolidate chunk chain (CPU intensive)
let consolidated_chunk = AudioChunk::consolidate(&request.chunk_chain);
// 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 {} samples", consolidated_chunk.sample_count);
log::debug!("Consolidated and reordered {} samples", consolidated_buffer.len());
// Step 2: Send consolidated chunk back to RT thread immediately
let response = PostRecordResponse {
consolidated_chunk: consolidated_chunk.clone(),
};
// Step 2: Send consolidated buffer back to RT thread immediately
let response = PostRecordResponse { consolidated_buffer };
if let Err(_) = self.response_sender.send(response).await {
log::warn!("Failed to send consolidated chunk to RT thread");
log::warn!("Failed to send consolidated buffer to RT thread");
}
// Step 3: Save WAV file in background (I/O intensive)
// Use original chunk chain for saving (not reordered)
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 {
@ -117,6 +121,36 @@ impl PostRecordHandler {
Ok(())
}
/// 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]>> {
let total_length = chunk_chain.len();
if total_length == 0 {
return Ok(Vec::new().into_boxed_slice());
}
// Step 1: Extract all samples from the chunk chain into a temporary buffer
let mut all_samples = vec![0.0; total_length];
chunk_chain.copy_samples(&mut all_samples, 0)?;
// Step 2: Reorder samples based on sync offset
// The goal is to put the sample that represents "logical beat 1" at index 0
if sync_offset == 0 {
// No offset - return samples as-is
return Ok(all_samples.into_boxed_slice());
}
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;
reordered.push(all_samples[source_index]);
}
Ok(reordered.into_boxed_slice())
}
/// Save consolidated chunk as WAV file
async fn save_wav_file(
&self,

View File

@ -1,5 +1,9 @@
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
pub struct ProcessHandler<F: ChunkFactory> {
track: Track,
playback_position: usize,
@ -12,13 +16,13 @@ pub struct ProcessHandler<F: ChunkFactory> {
impl<F: ChunkFactory> ProcessHandler<F> {
pub fn new(
ports: JackPorts,
mut chunk_factory: F,
chunk_factory: F,
beep_samples: Arc<AudioChunk>,
state: &State,
post_record_controller: PostRecordController,
) -> Result<Self> {
Ok(Self {
track: Track::new(&mut chunk_factory)?,
track: Track::new(),
playback_position: 0,
ports,
chunk_factory,
@ -39,6 +43,16 @@ impl<F: ChunkFactory> ProcessHandler<F> {
Ok(())
}
/// 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 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);
Ok(())
}
/// Handle clear button (Button 5)
pub fn clear_track(&mut self) -> Result<()> {
self.track.queue_clear();
@ -97,26 +111,18 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
}
impl<F: ChunkFactory> ProcessHandler<F> {
/// Handle post-record processing: send requests and swap chunks
/// Handle post-record processing: send requests and swap buffers
fn handle_post_record_processing(&mut self, should_consolidate: bool, sample_rate: u32) -> Result<()> {
// Send chunk chain for processing if track indicates consolidation needed
// Send audio data for processing if track indicates consolidation needed
if should_consolidate {
let chunk_chain = self.track.get_audio_chunk();
log::debug!("Sending chunk chain with {} samples for post-record processing",
chunk_chain.len());
if let Err(e) = self.post_record_controller.send_request(chunk_chain, sample_rate) {
log::warn!("Failed to send post-record request: {}", e);
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)?;
}
}
// Check for consolidation response
if let Some(response) = self.post_record_controller.try_recv_response() {
log::debug!("Received consolidated chunk with {} samples, swapping in track",
response.consolidated_chunk.len());
// Swap the consolidated chunk into the track
self.track.set_audio_chunk(response.consolidated_chunk)?;
self.track.set_consolidated_buffer(response.consolidated_buffer)?;
}
Ok(())
@ -154,7 +160,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
let state_after = self.track.next_state(); // Use next_state since transition hasn't happened yet
match (state_before, state_after) {
(_, TrackState::Playing) if *state_before != TrackState::Playing => {
(_, TrackState::Playing) if !matches!(state_before, TrackState::Playing) => {
// Just started playing - start from beginning
// Note: In future Column implementation, this will be:
// column.get_sync_position() to sync with other playing tracks
@ -197,7 +203,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
}
// Check if state transition at beat affects position
if state_after == TrackState::Playing && *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);
}

View File

@ -5,7 +5,11 @@ pub enum TrackState {
Empty, // No audio data (---)
Idle, // Has data, not playing (READY)
Playing, // Currently playing (PLAY)
Recording, // Currently recording (REC)
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
},
}
#[derive(Debug)]
@ -21,22 +25,20 @@ pub enum TrackTiming {
}
pub struct Track {
audio_chunk: Arc<AudioChunk>,
length: usize,
audio_data: AudioData,
current_state: TrackState,
next_state: TrackState,
volume: f32,
}
impl Track {
pub fn new<F: ChunkFactory>(chunk_factory: &mut F) -> Result<Self> {
Ok(Self {
audio_chunk: chunk_factory.create_chunk()?,
length: 0,
pub fn new() -> Self {
Self {
audio_data: AudioData::new_empty(),
current_state: TrackState::Empty,
next_state: TrackState::Empty,
volume: 1.0,
})
}
}
/// Main audio processing method called from ProcessHandler
@ -119,25 +121,51 @@ impl Track {
return Ok(());
}
match self.current_state {
match &mut self.current_state {
TrackState::Empty | TrackState::Idle => {
// Output silence for this range
output_buffer[start_index..end_index].fill(0.0);
}
TrackState::Recording => {
// Record input samples
// Record input samples (manual recording)
let samples_to_record = &input_buffer[start_index..end_index];
self.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);
}
TrackState::RecordingAutoStop { target_samples, .. } => {
// 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
)?;
}
// 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);
}
TrackState::Playing => {
// Playback with looping
self.copy_samples_to_output(
self.audio_data.copy_samples_to_output(
&mut output_buffer[start_index..end_index],
sample_count,
playback_position,
self.volume,
)?;
}
}
@ -151,28 +179,34 @@ impl Track {
&mut self,
chunk_factory: &mut F
) -> Result<bool> {
// Check if this is a Recording → Playing transition (consolidation trigger)
let should_consolidate = self.current_state == TrackState::Recording
&& self.next_state == TrackState::Playing;
// 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)
);
// Handle transitions that require setup
match (&self.current_state, &self.next_state) {
(current_state, TrackState::Recording) if *current_state != TrackState::Recording => {
// Starting to record - clear previous data and create new chunk
self.clear_audio_data(chunk_factory)?;
(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 { .. }) => {
// Starting auto-stop recording - clear previous data and create new unconsolidated data with offset
self.audio_data = AudioData::new_unconsolidated(chunk_factory, *sync_offset)?;
}
(_, TrackState::Playing) => {
// Starting playback - check if we have audio data
if self.length == 0 {
if self.audio_data.is_empty() {
// No audio data - transition to Idle instead
self.next_state = TrackState::Idle;
}
}
(_, TrackState::Empty) => {
// Clear operation - remove audio data
// Note: Actual deallocation will happen later via IO thread
self.audio_chunk = chunk_factory.create_chunk()?;
self.length = 0;
self.audio_data.clear()?;
}
_ => {
// Other transitions don't require special handling
@ -185,80 +219,14 @@ impl Track {
Ok(should_consolidate)
}
/// Copy samples from track audio to output buffer with looping
fn copy_samples_to_output(
&self,
output_slice: &mut [f32],
sample_count: usize,
mut playback_position: usize,
) -> Result<()> {
if self.length == 0 {
output_slice.fill(0.0);
return Ok(());
/// Get audio data for post-record processing (returns chunk and sync offset)
pub fn get_audio_data_for_processing(&self) -> Option<(Arc<AudioChunk>, usize)> {
self.audio_data.get_chunk_for_processing()
}
let mut samples_written = 0;
while samples_written < sample_count {
let samples_remaining = sample_count - samples_written;
let samples_until_loop = self.length - playback_position;
let samples_to_copy = samples_remaining.min(samples_until_loop);
// Copy directly from audio chunk to output slice
self.audio_chunk.copy_samples(
&mut output_slice[samples_written..samples_written + samples_to_copy],
playback_position,
)?;
// Apply volume scaling in-place
for sample in &mut output_slice[samples_written..samples_written + samples_to_copy] {
*sample *= self.volume;
}
samples_written += samples_to_copy;
playback_position += samples_to_copy;
// Handle looping
if playback_position >= self.length {
playback_position = 0;
}
}
Ok(())
}
/// Append samples to the track's audio data
pub fn append_samples<F: ChunkFactory>(
&mut self,
samples: &[f32],
chunk_factory: &mut F,
) -> Result<()> {
self.audio_chunk.append_samples(samples, chunk_factory)?;
self.length += samples.len();
Ok(())
}
/// Clear all audio data from the track
fn clear_audio_data<F: ChunkFactory>(
&mut self,
chunk_factory: &mut F,
) -> Result<()> {
self.audio_chunk = chunk_factory.create_chunk()?;
self.length = 0;
Ok(())
}
/// Get audio chunk for post-record processing (read-only access)
pub fn get_audio_chunk(&self) -> Arc<AudioChunk> {
self.audio_chunk.clone()
}
/// Set audio chunk (for swapping in consolidated chunk)
pub fn set_audio_chunk(&mut self, chunk: Arc<AudioChunk>) -> Result<()> {
if chunk.len() != self.length {
return Err(LooperError::OutOfBounds(std::panic::Location::caller()));
}
self.audio_chunk = chunk;
Ok(())
/// Set consolidated buffer (for swapping in consolidated audio data)
pub fn set_consolidated_buffer(&mut self, buffer: Box<[f32]>) -> Result<()> {
self.audio_data.set_consolidated_buffer(buffer)
}
// Public accessors and commands for MIDI handling
@ -271,7 +239,7 @@ impl Track {
}
pub fn len(&self) -> usize {
self.length
self.audio_data.len()
}
pub fn volume(&self) -> f32 {
@ -291,12 +259,37 @@ impl Track {
TrackState::Recording => {
self.next_state = TrackState::Playing;
}
TrackState::RecordingAutoStop { .. } => {
// Auto-stop recording - can't manually stop, wait for auto-transition
self.next_state = self.current_state.clone();
}
TrackState::Playing => {
self.next_state = TrackState::Idle;
}
}
}
/// Handle auto-stop record command (sets next_state)
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 };
}
TrackState::Recording => {
// Switch from manual to auto-stop recording
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 };
}
TrackState::Playing => {
// Stop playing and start auto-recording
self.next_state = TrackState::RecordingAutoStop { target_samples, sync_offset };
}
}
}
/// Handle play/mute toggle command (sets next_state)
pub fn queue_play_toggle(&mut self) {
match self.current_state {
@ -305,15 +298,15 @@ impl Track {
self.next_state = TrackState::Empty;
}
TrackState::Idle => {
if self.length > 0 {
if !self.audio_data.is_empty() {
self.next_state = TrackState::Playing;
} else {
self.next_state = TrackState::Idle;
}
}
TrackState::Recording => {
TrackState::Recording | TrackState::RecordingAutoStop { .. } => {
// Don't change state while recording
self.next_state = TrackState::Recording;
self.next_state = self.current_state.clone();
}
TrackState::Playing => {
self.next_state = TrackState::Idle;