consolidate and save to disk
This commit is contained in:
parent
675216eb71
commit
6ab1884553
7
Cargo.lock
generated
7
Cargo.lock
generated
@ -125,6 +125,12 @@ version = "0.31.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hound"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.15"
|
version = "1.0.15"
|
||||||
@ -221,6 +227,7 @@ name = "looper"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
|
"hound",
|
||||||
"jack",
|
"jack",
|
||||||
"kanal",
|
"kanal",
|
||||||
"log",
|
"log",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ path = "src/sendmidi.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
hound = "3.5"
|
||||||
jack = "0.13"
|
jack = "0.13"
|
||||||
kanal = "0.1"
|
kanal = "0.1"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
|
|||||||
@ -85,6 +85,19 @@ impl AudioChunk {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get total sample count across entire chunk chain
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
pub fn copy_samples(self: &Arc<Self>, dest: &mut [f32], start: usize) -> Result<()> {
|
pub fn copy_samples(self: &Arc<Self>, dest: &mut [f32], start: usize) -> Result<()> {
|
||||||
if start < self.sample_count {
|
if start < self.sample_count {
|
||||||
// Copy from this chunk
|
// Copy from this chunk
|
||||||
@ -113,6 +126,123 @@ impl AudioChunk {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_single_chunk_with_samples() {
|
||||||
|
let chunk = Arc::new(AudioChunk {
|
||||||
|
samples: vec![1.0, 2.0, 3.0, 4.0, 5.0].into_boxed_slice(),
|
||||||
|
sample_count: 5,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_single_chunk_empty() {
|
||||||
|
let chunk = Arc::new(AudioChunk {
|
||||||
|
samples: vec![0.0, 0.0, 0.0].into_boxed_slice(),
|
||||||
|
sample_count: 0,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_single_chunk_partial() {
|
||||||
|
let chunk = Arc::new(AudioChunk {
|
||||||
|
samples: vec![1.0, 2.0, 3.0, 0.0, 0.0].into_boxed_slice(),
|
||||||
|
sample_count: 3, // Only first 3 samples are valid
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_linked_chunks() {
|
||||||
|
let chunk3 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![7.0, 8.0, 9.0].into_boxed_slice(),
|
||||||
|
sample_count: 3,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk2 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![4.0, 5.0].into_boxed_slice(),
|
||||||
|
sample_count: 2,
|
||||||
|
next: Some(chunk3),
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk1 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![1.0, 2.0, 3.0].into_boxed_slice(),
|
||||||
|
sample_count: 3,
|
||||||
|
next: Some(chunk2),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk1.len(), 8); // 3 + 2 + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_chain_with_empty_chunks() {
|
||||||
|
let chunk3 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![4.0, 5.0].into_boxed_slice(),
|
||||||
|
sample_count: 2,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk2 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![0.0, 0.0].into_boxed_slice(),
|
||||||
|
sample_count: 0, // Empty chunk in middle
|
||||||
|
next: Some(chunk3),
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk1 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![1.0, 2.0, 3.0].into_boxed_slice(),
|
||||||
|
sample_count: 3,
|
||||||
|
next: Some(chunk2),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk1.len(), 5); // 3 + 0 + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_all_empty_chunks() {
|
||||||
|
let chunk2 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![0.0, 0.0].into_boxed_slice(),
|
||||||
|
sample_count: 0,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let chunk1 = Arc::new(AudioChunk {
|
||||||
|
samples: vec![0.0, 0.0, 0.0].into_boxed_slice(),
|
||||||
|
sample_count: 0,
|
||||||
|
next: Some(chunk2),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(chunk1.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_len_long_chain() {
|
||||||
|
// Create a longer chain to test iteration
|
||||||
|
let mut current_chunk = Arc::new(AudioChunk {
|
||||||
|
samples: vec![10.0].into_boxed_slice(),
|
||||||
|
sample_count: 1,
|
||||||
|
next: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build chain backwards: 9 -> 8 -> ... -> 1 -> 0
|
||||||
|
for i in (0..10).rev() {
|
||||||
|
current_chunk = Arc::new(AudioChunk {
|
||||||
|
samples: vec![i as f32].into_boxed_slice(),
|
||||||
|
sample_count: 1,
|
||||||
|
next: Some(current_chunk),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(current_chunk.len(), 11); // 11 chunks, each with 1 sample
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_allocate_creates_empty_chunk() {
|
fn test_allocate_creates_empty_chunk() {
|
||||||
let chunk = AudioChunk::allocate(1024);
|
let chunk = AudioChunk::allocate(1024);
|
||||||
|
|||||||
@ -6,9 +6,10 @@ mod looper_error;
|
|||||||
mod metronome;
|
mod metronome;
|
||||||
mod midi;
|
mod midi;
|
||||||
mod notification_handler;
|
mod notification_handler;
|
||||||
|
mod post_record_handler;
|
||||||
mod process_handler;
|
mod process_handler;
|
||||||
mod state;
|
mod state;
|
||||||
mod state_manager;
|
mod persistence_manager;
|
||||||
mod track;
|
mod track;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@ -19,14 +20,18 @@ use chunk_factory::ChunkFactory;
|
|||||||
use connection_manager::ConnectionManager;
|
use connection_manager::ConnectionManager;
|
||||||
use looper_error::LooperError;
|
use looper_error::LooperError;
|
||||||
use looper_error::Result;
|
use looper_error::Result;
|
||||||
|
use metronome::Metronome;
|
||||||
use metronome::generate_beep;
|
use metronome::generate_beep;
|
||||||
use notification_handler::JackNotification;
|
use notification_handler::JackNotification;
|
||||||
use notification_handler::NotificationHandler;
|
use notification_handler::NotificationHandler;
|
||||||
|
use post_record_handler::PostRecordHandler;
|
||||||
|
use post_record_handler::PostRecordController;
|
||||||
use process_handler::ProcessHandler;
|
use process_handler::ProcessHandler;
|
||||||
use state::State;
|
use state::State;
|
||||||
use state_manager::StateManager;
|
use persistence_manager::PersistenceManager;
|
||||||
use track::Track;
|
use track::Track;
|
||||||
use track::TrackState;
|
use track::TrackState;
|
||||||
|
use track::TrackTiming;
|
||||||
|
|
||||||
pub struct JackPorts {
|
pub struct JackPorts {
|
||||||
pub audio_in: jack::Port<jack::AudioIn>,
|
pub audio_in: jack::Port<jack::AudioIn>,
|
||||||
@ -51,20 +56,25 @@ async fn main() {
|
|||||||
let notification_handler = NotificationHandler::new();
|
let notification_handler = NotificationHandler::new();
|
||||||
let mut notification_channel = notification_handler.subscribe();
|
let mut notification_channel = notification_handler.subscribe();
|
||||||
|
|
||||||
let (mut state_manager, state_rx) = StateManager::new(notification_handler.subscribe());
|
let (mut persistence_manager, state_watch) = PersistenceManager::new(notification_handler.subscribe());
|
||||||
|
|
||||||
// Load state values for metronome configuration
|
// Load state values for metronome configuration
|
||||||
let initial_state = state_rx.borrow().clone();
|
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 process_handler = ProcessHandler::new(
|
let process_handler = ProcessHandler::new(
|
||||||
ports,
|
ports,
|
||||||
allocator,
|
allocator,
|
||||||
beep_samples,
|
beep_samples,
|
||||||
&initial_state,
|
&initial_state,
|
||||||
|
post_record_controller,
|
||||||
).expect("Could not create process handler");
|
).expect("Could not create process handler");
|
||||||
|
|
||||||
let mut connection_manager = ConnectionManager::new(
|
let mut connection_manager = ConnectionManager::new(
|
||||||
state_rx,
|
state_watch,
|
||||||
notification_handler.subscribe(),
|
notification_handler.subscribe(),
|
||||||
jack_client.name().to_string(),
|
jack_client.name().to_string(),
|
||||||
)
|
)
|
||||||
@ -82,7 +92,7 @@ async fn main() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result = state_manager.run() => {
|
result = persistence_manager.run() => {
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
log::error!("StateManager task failed: {}", e);
|
log::error!("StateManager task failed: {}", e);
|
||||||
}
|
}
|
||||||
@ -94,6 +104,12 @@ async fn main() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
result = post_record_handler.run() => {
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::error!("PostRecordHandler task failed: {}", e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
_ = tokio::signal::ctrl_c() => {
|
_ = tokio::signal::ctrl_c() => {
|
||||||
log::info!("Stopping");
|
log::info!("Stopping");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -2,7 +2,7 @@ use crate::*;
|
|||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::{broadcast, watch};
|
||||||
|
|
||||||
pub struct ConnectionManager {
|
pub struct ConnectionManager {
|
||||||
state_rx: watch::Receiver<State>,
|
state_watch: watch::Receiver<State>,
|
||||||
notification_rx: broadcast::Receiver<JackNotification>,
|
notification_rx: broadcast::Receiver<JackNotification>,
|
||||||
jack_client: jack::Client,
|
jack_client: jack::Client,
|
||||||
jack_client_name: String,
|
jack_client_name: String,
|
||||||
@ -10,7 +10,7 @@ pub struct ConnectionManager {
|
|||||||
|
|
||||||
impl ConnectionManager {
|
impl ConnectionManager {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
state_rx: watch::Receiver<State>,
|
state_watch: watch::Receiver<State>,
|
||||||
notification_rx: broadcast::Receiver<JackNotification>,
|
notification_rx: broadcast::Receiver<JackNotification>,
|
||||||
jack_client_name: String,
|
jack_client_name: String,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
@ -21,7 +21,7 @@ impl ConnectionManager {
|
|||||||
.map_err(|_| LooperError::JackConnection(std::panic::Location::caller()))?;
|
.map_err(|_| LooperError::JackConnection(std::panic::Location::caller()))?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
state_rx,
|
state_watch,
|
||||||
notification_rx,
|
notification_rx,
|
||||||
jack_client,
|
jack_client,
|
||||||
jack_client_name,
|
jack_client_name,
|
||||||
@ -39,7 +39,7 @@ impl ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_connections(&self) {
|
async fn restore_connections(&self) {
|
||||||
let state = self.state_rx.borrow();
|
let state = self.state_watch.borrow();
|
||||||
|
|
||||||
for external_port in &state.connections.midi_in {
|
for external_port in &state.connections.midi_in {
|
||||||
let our_port = format!("{}:midi_in", self.jack_client_name);
|
let our_port = format!("{}:midi_in", self.jack_client_name);
|
||||||
|
|||||||
@ -63,14 +63,7 @@ impl jack::NotificationHandler for NotificationHandler {
|
|||||||
.expect("Could not send port connection notification");
|
.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) {
|
||||||
let Some(port_name) = client.port_by_id(port_id) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(port_name) = port_name.name() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if register {
|
if register {
|
||||||
let notification = JackNotification::PortRegistered {};
|
let notification = JackNotification::PortRegistered {};
|
||||||
self.channel
|
self.channel
|
||||||
|
|||||||
@ -2,14 +2,14 @@ use crate::*;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::sync::{broadcast, watch};
|
use tokio::sync::{broadcast, watch};
|
||||||
|
|
||||||
pub struct StateManager {
|
pub struct PersistenceManager {
|
||||||
state: State,
|
state: State,
|
||||||
state_tx: watch::Sender<State>,
|
state_tx: watch::Sender<State>,
|
||||||
notification_rx: broadcast::Receiver<JackNotification>,
|
notification_rx: broadcast::Receiver<JackNotification>,
|
||||||
state_file_path: PathBuf,
|
state_file_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StateManager {
|
impl PersistenceManager {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
notification_rx: broadcast::Receiver<JackNotification>,
|
notification_rx: broadcast::Receiver<JackNotification>,
|
||||||
) -> (Self, watch::Receiver<State>) {
|
) -> (Self, watch::Receiver<State>) {
|
||||||
173
src/post_record_handler.rs
Normal file
173
src/post_record_handler.rs
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
use crate::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Request to process a recorded chunk chain
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PostRecordRequest {
|
||||||
|
pub chunk_chain: Arc<AudioChunk>,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response containing the consolidated chunk
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PostRecordResponse {
|
||||||
|
pub consolidated_chunk: Arc<AudioChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RT-side interface for post-record operations
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PostRecordController {
|
||||||
|
request_sender: kanal::Sender<PostRecordRequest>,
|
||||||
|
response_receiver: kanal::Receiver<PostRecordResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
|
||||||
|
match self.request_sender.try_send(request) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to receive a processing response (RT-safe)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles post-record processing: consolidation and saving
|
||||||
|
pub struct PostRecordHandler {
|
||||||
|
request_receiver: kanal::AsyncReceiver<PostRecordRequest>,
|
||||||
|
response_sender: kanal::AsyncSender<PostRecordResponse>,
|
||||||
|
directory: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostRecordHandler {
|
||||||
|
/// Create new handler and return RT controller
|
||||||
|
pub fn new() -> Result<(Self, PostRecordController)> {
|
||||||
|
// Create channels for bidirectional communication
|
||||||
|
let (request_sender, request_receiver) = kanal::bounded(16);
|
||||||
|
let (response_sender, response_receiver) = kanal::bounded(16);
|
||||||
|
|
||||||
|
let request_receiver = request_receiver.to_async();
|
||||||
|
let response_sender = response_sender.to_async();
|
||||||
|
|
||||||
|
let controller = PostRecordController {
|
||||||
|
request_sender,
|
||||||
|
response_receiver,
|
||||||
|
};
|
||||||
|
|
||||||
|
let handler = Self {
|
||||||
|
request_receiver,
|
||||||
|
response_sender,
|
||||||
|
directory: Self::create_directory()?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((handler, controller))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the async processing task
|
||||||
|
pub async fn run(&mut self) -> Result<()> {
|
||||||
|
while let Ok(request) = self.request_receiver.recv().await {
|
||||||
|
if let Err(e) = self.process_request(request).await {
|
||||||
|
log::error!("Failed to process post-record request: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process a single post-record request
|
||||||
|
async fn process_request(
|
||||||
|
&self,
|
||||||
|
request: PostRecordRequest,
|
||||||
|
) -> Result<()> {
|
||||||
|
log::debug!("Processing post-record request for {} samples",
|
||||||
|
request.chunk_chain.len());
|
||||||
|
|
||||||
|
// Step 1: Consolidate chunk chain (CPU intensive)
|
||||||
|
let consolidated_chunk = AudioChunk::consolidate(&request.chunk_chain);
|
||||||
|
|
||||||
|
log::debug!("Consolidated {} samples", consolidated_chunk.sample_count);
|
||||||
|
|
||||||
|
// Step 2: Send consolidated chunk back to RT thread immediately
|
||||||
|
let response = PostRecordResponse {
|
||||||
|
consolidated_chunk: consolidated_chunk.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(_) = self.response_sender.send(response).await {
|
||||||
|
log::warn!("Failed to send consolidated chunk to RT thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Save WAV file in background (I/O intensive)
|
||||||
|
let file_path = self.get_file_path();
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save consolidated chunk as WAV file
|
||||||
|
async fn save_wav_file(
|
||||||
|
&self,
|
||||||
|
chunk: &AudioChunk,
|
||||||
|
sample_rate: u32,
|
||||||
|
file_path: &PathBuf,
|
||||||
|
) -> Result<()> {
|
||||||
|
// 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,
|
||||||
|
sample_rate,
|
||||||
|
bits_per_sample: 32,
|
||||||
|
sample_format: hound::SampleFormat::Float,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut writer = hound::WavWriter::create(&file_path_clone, spec)
|
||||||
|
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
|
||||||
|
|
||||||
|
// Write all samples from the chunk
|
||||||
|
for sample in chunk_samples {
|
||||||
|
writer.write_sample(sample)
|
||||||
|
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.finalize()
|
||||||
|
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
|
||||||
|
|
||||||
|
Ok::<(), LooperError>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create save directory and return path
|
||||||
|
fn create_directory() -> Result<PathBuf> {
|
||||||
|
let mut path = dirs::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
path.push(".fcb_looper");
|
||||||
|
|
||||||
|
std::fs::create_dir_all(&path)
|
||||||
|
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get file path for track recording
|
||||||
|
fn get_file_path(&self) -> PathBuf {
|
||||||
|
self.directory.join("track.wav")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,4 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
use crate::metronome::Metronome;
|
|
||||||
use crate::track::{TrackTiming, TrackState};
|
|
||||||
|
|
||||||
pub struct ProcessHandler<F: ChunkFactory> {
|
pub struct ProcessHandler<F: ChunkFactory> {
|
||||||
track: Track,
|
track: Track,
|
||||||
@ -8,6 +6,7 @@ pub struct ProcessHandler<F: ChunkFactory> {
|
|||||||
pub ports: JackPorts,
|
pub ports: JackPorts,
|
||||||
chunk_factory: F,
|
chunk_factory: F,
|
||||||
metronome: Metronome,
|
metronome: Metronome,
|
||||||
|
post_record_controller: PostRecordController,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<F: ChunkFactory> ProcessHandler<F> {
|
impl<F: ChunkFactory> ProcessHandler<F> {
|
||||||
@ -16,6 +15,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
|
|||||||
mut chunk_factory: F,
|
mut chunk_factory: F,
|
||||||
beep_samples: Arc<AudioChunk>,
|
beep_samples: Arc<AudioChunk>,
|
||||||
state: &State,
|
state: &State,
|
||||||
|
post_record_controller: PostRecordController,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
track: Track::new(&mut chunk_factory)?,
|
track: Track::new(&mut chunk_factory)?,
|
||||||
@ -23,6 +23,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
|
|||||||
ports,
|
ports,
|
||||||
chunk_factory,
|
chunk_factory,
|
||||||
metronome: Metronome::new(beep_samples, state),
|
metronome: Metronome::new(beep_samples, state),
|
||||||
|
post_record_controller,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,18 +67,27 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
|
|||||||
let state_before = self.track.current_state().clone();
|
let state_before = self.track.current_state().clone();
|
||||||
|
|
||||||
// Calculate timing information for track processing
|
// Calculate timing information for track processing
|
||||||
let timing = self.calculate_track_timing(beat_sample_index, buffer_size, &state_before);
|
let timing = self.calculate_track_timing(beat_sample_index, &state_before);
|
||||||
|
|
||||||
// Process track audio with calculated timing
|
// Process track audio with calculated timing
|
||||||
if let Err(e) = self.track.process(
|
let should_consolidate = match self.track.process(
|
||||||
ps,
|
ps,
|
||||||
&mut self.ports,
|
&mut self.ports,
|
||||||
timing,
|
timing,
|
||||||
&mut self.chunk_factory
|
&mut self.chunk_factory
|
||||||
) {
|
) {
|
||||||
|
Ok(consolidate) => consolidate,
|
||||||
|
Err(e) => {
|
||||||
log::error!("Error processing track: {}", e);
|
log::error!("Error processing track: {}", e);
|
||||||
return jack::Control::Quit;
|
return jack::Control::Quit;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle post-record processing
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Update playback position based on what happened
|
// Update playback position based on what happened
|
||||||
self.update_playback_position(beat_sample_index, buffer_size, &state_before);
|
self.update_playback_position(beat_sample_index, buffer_size, &state_before);
|
||||||
@ -87,11 +97,35 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<F: ChunkFactory> ProcessHandler<F> {
|
impl<F: ChunkFactory> ProcessHandler<F> {
|
||||||
|
/// Handle post-record processing: send requests and swap chunks
|
||||||
|
fn handle_post_record_processing(&mut self, should_consolidate: bool, sample_rate: u32) -> Result<()> {
|
||||||
|
// Send chunk chain 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate timing information for track processing
|
/// Calculate timing information for track processing
|
||||||
fn calculate_track_timing(
|
fn calculate_track_timing(
|
||||||
&self,
|
&self,
|
||||||
beat_sample_index: Option<u32>,
|
beat_sample_index: Option<u32>,
|
||||||
buffer_size: usize,
|
|
||||||
state_before: &TrackState,
|
state_before: &TrackState,
|
||||||
) -> TrackTiming {
|
) -> TrackTiming {
|
||||||
match beat_sample_index {
|
match beat_sample_index {
|
||||||
|
|||||||
75
src/track.rs
75
src/track.rs
@ -21,7 +21,7 @@ pub enum TrackTiming {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Track {
|
pub struct Track {
|
||||||
audio_chunks: Option<Arc<AudioChunk>>,
|
audio_chunk: Arc<AudioChunk>,
|
||||||
length: usize,
|
length: usize,
|
||||||
current_state: TrackState,
|
current_state: TrackState,
|
||||||
next_state: TrackState,
|
next_state: TrackState,
|
||||||
@ -31,7 +31,7 @@ pub struct Track {
|
|||||||
impl Track {
|
impl Track {
|
||||||
pub fn new<F: ChunkFactory>(chunk_factory: &mut F) -> Result<Self> {
|
pub fn new<F: ChunkFactory>(chunk_factory: &mut F) -> Result<Self> {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
audio_chunks: None,
|
audio_chunk: chunk_factory.create_chunk()?,
|
||||||
length: 0,
|
length: 0,
|
||||||
current_state: TrackState::Empty,
|
current_state: TrackState::Empty,
|
||||||
next_state: TrackState::Empty,
|
next_state: TrackState::Empty,
|
||||||
@ -40,18 +40,19 @@ impl Track {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Main audio processing method called from ProcessHandler
|
/// Main audio processing method called from ProcessHandler
|
||||||
|
/// Returns true if track should be consolidated and saved
|
||||||
pub fn process<F: ChunkFactory>(
|
pub fn process<F: ChunkFactory>(
|
||||||
&mut self,
|
&mut self,
|
||||||
ps: &jack::ProcessScope,
|
ps: &jack::ProcessScope,
|
||||||
ports: &mut JackPorts,
|
ports: &mut JackPorts,
|
||||||
timing: TrackTiming,
|
timing: TrackTiming,
|
||||||
chunk_factory: &mut F,
|
chunk_factory: &mut F,
|
||||||
) -> Result<()> {
|
) -> Result<bool> {
|
||||||
let input_buffer = ports.audio_in.as_slice(ps);
|
let input_buffer = ports.audio_in.as_slice(ps);
|
||||||
let output_buffer = ports.audio_out.as_mut_slice(ps);
|
let output_buffer = ports.audio_out.as_mut_slice(ps);
|
||||||
let buffer_size = output_buffer.len();
|
let buffer_size = output_buffer.len();
|
||||||
|
|
||||||
match timing {
|
let should_consolidate = match timing {
|
||||||
TrackTiming::NoBeat { position } => {
|
TrackTiming::NoBeat { position } => {
|
||||||
// No beat in this buffer - process entire buffer with current state
|
// No beat in this buffer - process entire buffer with current state
|
||||||
self.process_audio_range(
|
self.process_audio_range(
|
||||||
@ -62,6 +63,7 @@ impl Track {
|
|||||||
position,
|
position,
|
||||||
chunk_factory,
|
chunk_factory,
|
||||||
)?;
|
)?;
|
||||||
|
false // No state transition possible without beat
|
||||||
}
|
}
|
||||||
TrackTiming::Beat {
|
TrackTiming::Beat {
|
||||||
pre_beat_position,
|
pre_beat_position,
|
||||||
@ -80,8 +82,8 @@ impl Track {
|
|||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply state transition at beat boundary
|
// Apply state transition at beat boundary and check if consolidation needed
|
||||||
self.apply_state_transition(chunk_factory)?;
|
let should_consolidate = self.apply_state_transition(chunk_factory)?;
|
||||||
|
|
||||||
if beat_sample_index < buffer_size {
|
if beat_sample_index < buffer_size {
|
||||||
// Process samples after beat with new current state
|
// Process samples after beat with new current state
|
||||||
@ -94,10 +96,12 @@ impl Track {
|
|||||||
chunk_factory,
|
chunk_factory,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
should_consolidate
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(should_consolidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process audio for a specific range within the buffer
|
/// Process audio for a specific range within the buffer
|
||||||
@ -142,19 +146,24 @@ impl Track {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apply state transition from next_state to current_state
|
/// Apply state transition from next_state to current_state
|
||||||
|
/// Returns true if track should be consolidated and saved
|
||||||
fn apply_state_transition<F: ChunkFactory>(
|
fn apply_state_transition<F: ChunkFactory>(
|
||||||
&mut self,
|
&mut self,
|
||||||
chunk_factory: &mut F
|
chunk_factory: &mut F
|
||||||
) -> Result<()> {
|
) -> 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;
|
||||||
|
|
||||||
// Handle transitions that require setup
|
// Handle transitions that require setup
|
||||||
match (&self.current_state, &self.next_state) {
|
match (&self.current_state, &self.next_state) {
|
||||||
(_, TrackState::Recording) => {
|
(current_state, TrackState::Recording) if *current_state != TrackState::Recording => {
|
||||||
// Starting to record - clear previous data and create new chunk
|
// Starting to record - clear previous data and create new chunk
|
||||||
self.clear_audio_data(chunk_factory)?;
|
self.clear_audio_data(chunk_factory)?;
|
||||||
}
|
}
|
||||||
(_, TrackState::Playing) => {
|
(_, TrackState::Playing) => {
|
||||||
// Starting playback - check if we have audio data
|
// Starting playback - check if we have audio data
|
||||||
if self.audio_chunks.is_none() || self.length == 0 {
|
if self.length == 0 {
|
||||||
// No audio data - transition to Idle instead
|
// No audio data - transition to Idle instead
|
||||||
self.next_state = TrackState::Idle;
|
self.next_state = TrackState::Idle;
|
||||||
}
|
}
|
||||||
@ -162,7 +171,7 @@ impl Track {
|
|||||||
(_, TrackState::Empty) => {
|
(_, TrackState::Empty) => {
|
||||||
// Clear operation - remove audio data
|
// Clear operation - remove audio data
|
||||||
// Note: Actual deallocation will happen later via IO thread
|
// Note: Actual deallocation will happen later via IO thread
|
||||||
self.audio_chunks = None;
|
self.audio_chunk = chunk_factory.create_chunk()?;
|
||||||
self.length = 0;
|
self.length = 0;
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
@ -173,7 +182,7 @@ impl Track {
|
|||||||
// Apply the state transition
|
// Apply the state transition
|
||||||
self.current_state = self.next_state.clone();
|
self.current_state = self.next_state.clone();
|
||||||
|
|
||||||
Ok(())
|
Ok(should_consolidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copy samples from track audio to output buffer with looping
|
/// Copy samples from track audio to output buffer with looping
|
||||||
@ -183,7 +192,6 @@ impl Track {
|
|||||||
sample_count: usize,
|
sample_count: usize,
|
||||||
mut playback_position: usize,
|
mut playback_position: usize,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(ref audio_chunks) = self.audio_chunks {
|
|
||||||
if self.length == 0 {
|
if self.length == 0 {
|
||||||
output_slice.fill(0.0);
|
output_slice.fill(0.0);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -196,8 +204,8 @@ impl Track {
|
|||||||
let samples_until_loop = self.length - playback_position;
|
let samples_until_loop = self.length - playback_position;
|
||||||
let samples_to_copy = samples_remaining.min(samples_until_loop);
|
let samples_to_copy = samples_remaining.min(samples_until_loop);
|
||||||
|
|
||||||
// Copy directly from audio chunks to output slice
|
// Copy directly from audio chunk to output slice
|
||||||
audio_chunks.copy_samples(
|
self.audio_chunk.copy_samples(
|
||||||
&mut output_slice[samples_written..samples_written + samples_to_copy],
|
&mut output_slice[samples_written..samples_written + samples_to_copy],
|
||||||
playback_position,
|
playback_position,
|
||||||
)?;
|
)?;
|
||||||
@ -215,10 +223,6 @@ impl Track {
|
|||||||
playback_position = 0;
|
playback_position = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
output_slice.fill(0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,15 +232,8 @@ impl Track {
|
|||||||
samples: &[f32],
|
samples: &[f32],
|
||||||
chunk_factory: &mut F,
|
chunk_factory: &mut F,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if self.audio_chunks.is_none() {
|
self.audio_chunk.append_samples(samples, chunk_factory)?;
|
||||||
self.audio_chunks = Some(chunk_factory.create_chunk()?);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref mut chunks) = self.audio_chunks {
|
|
||||||
chunks.append_samples(samples, chunk_factory)?;
|
|
||||||
self.length += samples.len();
|
self.length += samples.len();
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,11 +242,25 @@ impl Track {
|
|||||||
&mut self,
|
&mut self,
|
||||||
chunk_factory: &mut F,
|
chunk_factory: &mut F,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
self.audio_chunks = Some(chunk_factory.create_chunk()?);
|
self.audio_chunk = chunk_factory.create_chunk()?;
|
||||||
self.length = 0;
|
self.length = 0;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
// Public accessors and commands for MIDI handling
|
// Public accessors and commands for MIDI handling
|
||||||
pub fn current_state(&self) -> &TrackState {
|
pub fn current_state(&self) -> &TrackState {
|
||||||
&self.current_state
|
&self.current_state
|
||||||
@ -259,10 +270,6 @@ impl Track {
|
|||||||
&self.next_state
|
&self.next_state
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_next_state(&mut self, state: TrackState) {
|
|
||||||
self.next_state = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.length
|
self.length
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user