Added audio_data and RecordingAutoStop
This commit is contained in:
parent
9b4fd1685c
commit
fc8ca5ce86
465
src/audio_data.rs
Normal file
465
src/audio_data.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>> {
|
||||
|
||||
@ -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()?;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
191
src/track.rs
191
src/track.rs
@ -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(());
|
||||
}
|
||||
|
||||
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(())
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user