From 1bbb020566d9e2babf9a5d7525e999bd37f4b1db Mon Sep 17 00:00:00 2001 From: Geens Date: Fri, 13 Jun 2025 16:35:19 +0200 Subject: [PATCH] Implemented osc as client --- audio_engine/Cargo.lock | 274 ++++++++++++++++++++++++ audio_engine/Cargo.toml | 5 + audio_engine/src/args.rs | 33 +++ audio_engine/src/column.rs | 22 +- audio_engine/src/looper_error.rs | 6 + audio_engine/src/main.rs | 18 +- audio_engine/src/midi.rs | 2 +- audio_engine/src/osc.rs | 175 +++++++++++++++ audio_engine/src/persistence_manager.rs | 14 +- audio_engine/src/process_handler.rs | 17 +- audio_engine/src/track.rs | 35 +-- audio_engine/src/track_matrix.rs | 7 + 12 files changed, 575 insertions(+), 33 deletions(-) create mode 100644 audio_engine/src/args.rs create mode 100644 audio_engine/src/osc.rs diff --git a/audio_engine/Cargo.lock b/audio_engine/Cargo.lock index 7d209b6..7f9cda4 100644 --- a/audio_engine/Cargo.lock +++ b/audio_engine/Cargo.lock @@ -17,20 +17,75 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + [[package]] name = "audio_engine" version = "0.1.0" dependencies = [ + "bytes", + "clap", "dirs", + "futures", "hound", "jack", "kanal", "log", + "rosc", "serde", "serde_json", "simple_logger", "thiserror", "tokio", + "tokio-util", "wmidi", ] @@ -67,6 +122,12 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -79,6 +140,52 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "colored" version = "2.2.0" @@ -119,12 +226,95 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -142,12 +332,24 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hound" version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.15" @@ -245,6 +447,12 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.8" @@ -265,6 +473,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -289,6 +507,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + [[package]] name = "option-ext" version = "0.2.0" @@ -324,6 +548,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -374,6 +604,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rosc" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e63d9e6b0d090be1485cf159b1e04c3973d2d3e1614963544ea2ff47a4a981" +dependencies = [ + "byteorder", + "nom", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -445,6 +685,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.15.0" @@ -461,6 +710,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.101" @@ -554,12 +809,31 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/audio_engine/Cargo.toml b/audio_engine/Cargo.toml index afd35a8..bb7d7a3 100644 --- a/audio_engine/Cargo.toml +++ b/audio_engine/Cargo.toml @@ -4,14 +4,19 @@ version = "0.1.0" edition = "2021" [dependencies] +bytes = "1.0" +clap = { version = "4", features = ["derive", "env", "string"] } dirs = "5" +futures = "0.3" hound = "3.5" jack = "0.13" kanal = "0.1" log = "0.4" +rosc = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" simple_logger = "5" thiserror = "1" tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["codec"] } wmidi = "4" \ No newline at end of file diff --git a/audio_engine/src/args.rs b/audio_engine/src/args.rs new file mode 100644 index 0000000..a52ab3f --- /dev/null +++ b/audio_engine/src/args.rs @@ -0,0 +1,33 @@ +use clap::Parser; +use std::path::PathBuf; +use crate::*; + +#[derive(Parser)] +#[command(name = "audio_engine")] +#[command(about = "FCB1010 Looper Pedal Audio Engine")] +pub struct Args { + /// Path to Unix socket or named pipe name + #[arg(short = 's', long = "socket", env = "SOCKET", default_value = "/tmp/fcb_looper.sock")] + pub socket: String, + + /// Path to configuration file + #[arg(short = 'c', long = "config-file", env = "CONFIG_FILE", default_value = default_config_path())] + pub config_file: PathBuf, +} + +impl Args { + pub fn new() -> Result { + let res = Self::try_parse(); + if let Err(res) = &res { + log::error!("{res}"); + } + res.map_err(|_| LooperError::ArgsParse(std::panic::Location::caller())) + } +} + +fn default_config_path() -> String { + let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push(".fcb_looper"); + path.push("state.json"); + path.to_string_lossy().to_string() +} \ No newline at end of file diff --git a/audio_engine/src/column.rs b/audio_engine/src/column.rs index 873ebeb..83469b6 100644 --- a/audio_engine/src/column.rs +++ b/audio_engine/src/column.rs @@ -77,37 +77,42 @@ impl Column { self.tracks[row].set_consolidated_buffer(buffer) } - pub fn handle_xrun( + pub fn handle_xrun( &mut self, timing: &BufferTiming, chunk_factory: &mut impl ChunkFactory, - post_record_handler: H, + post_record_controller: PRC, + osc_controller: OC, ) -> Result<()> where - H: Fn(usize, Arc, usize) -> Result<()>, + PRC: Fn(usize, Arc, usize) -> Result<()>, + OC: Fn(usize, TrackState) -> Result<()>, { for (row, track) in self.tracks.iter_mut().enumerate() { track.handle_xrun( timing.beat_in_missed, timing.missed_frames, chunk_factory, - |chunk, sync_offset| post_record_handler(row, chunk, sync_offset), + |chunk, sync_offset| post_record_controller(row, chunk, sync_offset), + |state| osc_controller(row, state), )?; } Ok(()) } - pub fn process( + pub fn process( &mut self, timing: &BufferTiming, input_buffer: &[f32], output_buffer: &mut [f32], scratch_pad: &mut [f32], chunk_factory: &mut impl ChunkFactory, - post_record_handler: H, + post_record_controller: PRC, + osc_controller: OC, ) -> Result<()> where - H: Fn(usize, Arc, usize) -> Result<()>, + PRC: Fn(usize, Arc, usize) -> Result<()>, + OC: Fn(usize, TrackState) -> Result<()>, { let len = self.len(); if self.idle() { @@ -127,7 +132,8 @@ impl Column { input_buffer, scratch_pad, chunk_factory, - |chunk, sync_offset| post_record_handler(row, chunk, sync_offset), + |chunk, sync_offset| post_record_controller(row, chunk, sync_offset), + |state| osc_controller(row, state), )?; for (output_val, scratch_pad_val) in output_buffer.iter_mut().zip(scratch_pad.iter()) { *output_val += *scratch_pad_val; diff --git a/audio_engine/src/looper_error.rs b/audio_engine/src/looper_error.rs index ef4d3c7..90b8464 100644 --- a/audio_engine/src/looper_error.rs +++ b/audio_engine/src/looper_error.rs @@ -1,5 +1,8 @@ #[derive(Debug, thiserror::Error)] pub enum LooperError { + #[error("Failed to parse command line arguments")] + ArgsParse(&'static std::panic::Location<'static>), + #[error("Failed to allocate new audio chunk")] ChunkAllocation(&'static std::panic::Location<'static>), @@ -9,6 +12,9 @@ pub enum LooperError { #[error("Index out of bounds")] OutOfBounds(&'static std::panic::Location<'static>), + #[error("Failed to send OSC message")] + Osc(&'static std::panic::Location<'static>), + #[error("Failed to load state")] StateLoad(&'static std::panic::Location<'static>), diff --git a/audio_engine/src/main.rs b/audio_engine/src/main.rs index 1688733..0b8de1d 100644 --- a/audio_engine/src/main.rs +++ b/audio_engine/src/main.rs @@ -1,4 +1,5 @@ mod allocator; +mod args; mod audio_chunk; mod audio_data; mod beep; @@ -9,6 +10,7 @@ mod looper_error; mod metronome; mod midi; mod notification_handler; +mod osc; mod persistence_manager; mod post_record_handler; mod process_handler; @@ -19,6 +21,7 @@ mod track_matrix; use std::sync::Arc; use allocator::Allocator; +use args::Args; use audio_chunk::AudioChunk; use audio_data::AudioData; use beep::generate_beep; @@ -31,12 +34,15 @@ use metronome::BufferTiming; use metronome::Metronome; use notification_handler::JackNotification; use notification_handler::NotificationHandler; +use osc::OscController; +use osc::Osc; use persistence_manager::PersistenceManager; use post_record_handler::PostRecordController; use post_record_handler::PostRecordHandler; use process_handler::ProcessHandler; use state::State; use track::Track; +use track::TrackState; use track_matrix::TrackMatrix; pub struct JackPorts { @@ -48,10 +54,13 @@ pub struct JackPorts { #[tokio::main] async fn main() { + let args = Args::new().expect("Could not parse arguments"); simple_logger::SimpleLogger::new() .init() .expect("Could not initialize logger"); + let (mut osc, osc_controller) = Osc::new(&args.socket).await.expect("Could not create OSC server"); + let (jack_client, ports) = setup_jack(); let mut allocator = Allocator::spawn(jack_client.sample_rate(), 3); @@ -63,7 +72,7 @@ async fn main() { let mut notification_channel = notification_handler.subscribe(); let (mut persistence_manager, state_watch) = - PersistenceManager::new(notification_handler.subscribe()); + PersistenceManager::new(&args, notification_handler.subscribe()); // Load state values for metronome configuration let initial_state = state_watch.borrow().clone(); @@ -78,6 +87,7 @@ async fn main() { allocator, beep_samples, &initial_state, + osc_controller, post_record_controller, ) .expect("Could not create process handler"); @@ -95,6 +105,12 @@ async fn main() { loop { tokio::select! { + result = osc.run() => { + if let Err(e) = result { + log::error!("OSC task failed: {}", e); + } + break; + } notification = notification_channel.recv() => { if let Ok(JackNotification::Shutdown {reason, status}) = notification { log::error!("Jack shutdown: {reason} {status:?}"); diff --git a/audio_engine/src/midi.rs b/audio_engine/src/midi.rs index 7207044..be1e26a 100644 --- a/audio_engine/src/midi.rs +++ b/audio_engine/src/midi.rs @@ -12,7 +12,7 @@ pub fn process_events( let mut event_count = 0; // Collect events from the MIDI input iterator - let midi_input = process_handler.ports.midi_in.iter(ps); + let midi_input = process_handler.ports().midi_in.iter(ps); for midi_event in midi_input { if event_count < MAX_EVENTS && midi_event.bytes.len() >= 3 { raw_events[event_count][0] = midi_event.bytes[0]; diff --git a/audio_engine/src/osc.rs b/audio_engine/src/osc.rs new file mode 100644 index 0000000..884f57c --- /dev/null +++ b/audio_engine/src/osc.rs @@ -0,0 +1,175 @@ +use crate::*; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use futures::SinkExt; + +#[cfg(unix)] +use tokio::net::UnixStream; +#[cfg(windows)] +use tokio::net::windows::named_pipe::ClientOptions; + +#[cfg(unix)] +type PlatformStream = UnixStream; +#[cfg(windows)] +type PlatformStream = tokio::net::windows::named_pipe::NamedPipeClient; + +#[derive(Debug, Clone)] +enum OscMessage { + TrackStateChanged { + column: usize, + row: usize, + state: TrackState, + }, + SelectedColumnChanged { + column: usize, + }, + SelectedRowChanged { + row: usize, + }, +} + +#[derive(Debug)] +pub struct OscController { + sender: kanal::Sender, +} + +impl OscController { + pub fn track_state_changed( + &self, + column: usize, + row: usize, + state: TrackState, + ) -> Result<()> { + let message = OscMessage::TrackStateChanged { column, row, state }; + match self.sender.try_send(message) { + Ok(true) => Ok(()), + Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full + Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed + } + } + + pub fn selected_column_changed(&self, column: usize) -> Result<()> { + let message = OscMessage::SelectedColumnChanged { column }; + match self.sender.try_send(message) { + Ok(true) => Ok(()), + Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full + Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed + } + } + + pub fn selected_row_changed(&self, row: usize) -> Result<()> { + let message = OscMessage::SelectedRowChanged { row }; + match self.sender.try_send(message) { + Ok(true) => Ok(()), + Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full + Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed + } + } +} + +pub struct Osc { + receiver: kanal::AsyncReceiver, + framed_socket: Framed, +} + +impl Osc { + /// # Platform Notes + /// - Unix: socket_path should be a file path (e.g., "/tmp/fcb_looper.sock") + /// - Windows: socket_path will be converted to named pipe format (e.g., "fcb_looper" -> "\\.\pipe\fcb_looper") + pub async fn new(socket_path: &str) -> Result<(Self, OscController)> { + // Connect to platform-specific IPC + let stream = Self::connect_platform_stream(socket_path).await?; + + // Wrap with length-delimited codec + let framed_socket = Framed::new(stream, LengthDelimitedCodec::new()); + + // Create communication channel + let (sender, receiver) = kanal::bounded(64); + let receiver = receiver.to_async(); + + let controller = OscController { sender }; + + let server = Self { + receiver, + framed_socket, + }; + + Ok((server, controller)) + } + + #[cfg(unix)] + async fn connect_platform_stream(socket_path: &str) -> Result { + UnixStream::connect(socket_path) + .await + .map_err(|_| LooperError::JackConnection(std::panic::Location::caller())) + } + + #[cfg(windows)] + async fn connect_platform_stream(pipe_name: &str) -> Result { + ClientOptions::new() + .open(pipe_name) + .map_err(|_| LooperError::JackConnection(std::panic::Location::caller())) + } + + pub async fn run(&mut self) -> Result<()> { + while let Ok(message) = self.receiver.recv().await { + if let Err(e) = self.send_osc_message(message).await { + log::error!("Failed to send OSC message: {}", e); + // Continue processing other messages + } + } + Ok(()) + } + + async fn send_osc_message(&mut self, message: OscMessage) -> Result<()> { + let osc_packet = self.message_to_osc_packet(message)?; + + let osc_bytes = rosc::encoder::encode(&osc_packet) + .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; + + self.framed_socket + .send(bytes::Bytes::from(osc_bytes)) + .await + .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; + + Ok(()) + } + + fn message_to_osc_packet(&self, message: OscMessage) -> Result { + match message { + OscMessage::TrackStateChanged { column, row, state } => { + let osc_state = self.track_state_to_osc_string(state); + let address = format!("/looper/cell/{}/{}/state", column + 1, row + 1); // 1-based indexing + + Ok(rosc::OscPacket::Message(rosc::OscMessage { + addr: address, + args: vec![rosc::OscType::String(osc_state)], + })) + } + OscMessage::SelectedColumnChanged { column } => { + let address = "/looper/selected/column".to_string(); + + Ok(rosc::OscPacket::Message(rosc::OscMessage { + addr: address, + args: vec![rosc::OscType::Int((column + 1) as i32)], // 1-based indexing + })) + } + OscMessage::SelectedRowChanged { row } => { + let address = "/looper/selected/row".to_string(); + + Ok(rosc::OscPacket::Message(rosc::OscMessage { + addr: address, + args: vec![rosc::OscType::Int((row + 1) as i32)], // 1-based indexing + })) + } + } + } + + fn track_state_to_osc_string(&self, state: TrackState) -> String { + match state { + TrackState::Empty => "empty".to_string(), + TrackState::Idle => "ready".to_string(), // Assuming track has audio data + TrackState::Recording | TrackState::RecordingAutoStop { .. } => "recording".to_string(), + TrackState::Playing => "playing".to_string(), + } + } +} \ No newline at end of file diff --git a/audio_engine/src/persistence_manager.rs b/audio_engine/src/persistence_manager.rs index 6d2496b..a20a9bc 100644 --- a/audio_engine/src/persistence_manager.rs +++ b/audio_engine/src/persistence_manager.rs @@ -11,9 +11,13 @@ pub struct PersistenceManager { impl PersistenceManager { pub fn new( + args: &Args, notification_rx: broadcast::Receiver, ) -> (Self, watch::Receiver) { - let state_file_path = Self::get_state_file_path(); + let state_file_path = args.config_file.clone(); + if let Some(parent) = state_file_path.parent() { + std::fs::create_dir_all(parent).ok(); // Create directory if it doesn't exist + } let initial_state = Self::load_from_disk(&state_file_path).unwrap_or_default(); let (state_tx, state_rx) = watch::channel(initial_state.clone()); @@ -96,14 +100,6 @@ impl PersistenceManager { Ok(()) } - fn get_state_file_path() -> PathBuf { - let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - path.push(".fcb_looper"); - std::fs::create_dir_all(&path).ok(); // Create directory if it doesn't exist - path.push("state.json"); - path - } - fn load_from_disk(path: &PathBuf) -> Result { match std::fs::read_to_string(path) { Ok(content) => { diff --git a/audio_engine/src/process_handler.rs b/audio_engine/src/process_handler.rs index 497100b..1477c8b 100644 --- a/audio_engine/src/process_handler.rs +++ b/audio_engine/src/process_handler.rs @@ -4,7 +4,8 @@ const COLS: usize = 5; const ROWS: usize = 5; pub struct ProcessHandler { - pub ports: JackPorts, + osc: OscController, + ports: JackPorts, metronome: Metronome, track_matrix: TrackMatrix, selected_row: usize, @@ -18,6 +19,7 @@ impl ProcessHandler { chunk_factory: F, beep_samples: Arc, state: &State, + osc: OscController, post_record_controller: PostRecordController, ) -> Result { let track_matrix = TrackMatrix::new( @@ -27,6 +29,7 @@ impl ProcessHandler { post_record_controller, )?; Ok(Self { + osc, ports, metronome: Metronome::new(beep_samples, state), track_matrix, @@ -35,6 +38,10 @@ impl ProcessHandler { }) } + pub fn ports(&self) -> &JackPorts { + &self.ports + } + pub fn handle_button_1(&mut self) -> Result<()> { self.track_matrix.handle_record_button(self.selected_column, self.selected_row) } @@ -57,26 +64,31 @@ impl ProcessHandler { pub fn handle_button_6(&mut self) -> Result<()> { self.selected_column = 0; + self.osc.selected_column_changed(self.selected_column)?; Ok(()) } pub fn handle_button_7(&mut self) -> Result<()> { self.selected_column = 1; + self.osc.selected_column_changed(self.selected_column)?; Ok(()) } pub fn handle_button_8(&mut self) -> Result<()> { self.selected_column = 2; + self.osc.selected_column_changed(self.selected_column)?; Ok(()) } pub fn handle_button_9(&mut self) -> Result<()> { self.selected_column = 3; + self.osc.selected_column_changed(self.selected_column)?; Ok(()) } pub fn handle_button_10(&mut self) -> Result<()> { self.selected_column = 4; + self.osc.selected_column_changed(self.selected_column)?; Ok(()) } @@ -86,11 +98,13 @@ impl ProcessHandler { } else { self.selected_row -= 1; } + self.osc.selected_row_changed(self.selected_row)?; Ok(()) } pub fn handle_button_down(&mut self) -> Result<()> { self.selected_row = (self.selected_row + 1) % ROWS; + self.osc.selected_row_changed(self.selected_row)?; Ok(()) } } @@ -124,6 +138,7 @@ impl ProcessHandler { ps, &mut self.ports, &timing, + &mut self.osc, )?; Ok(()) diff --git a/audio_engine/src/track.rs b/audio_engine/src/track.rs index 57fd5a5..c5e8633 100644 --- a/audio_engine/src/track.rs +++ b/audio_engine/src/track.rs @@ -8,7 +8,7 @@ pub struct Track { } #[derive(Debug, Clone, PartialEq)] -enum TrackState { +pub enum TrackState { Empty, Idle, Playing, @@ -83,15 +83,17 @@ impl Track { self.audio_data.set_consolidated_buffer(buffer) } - pub fn handle_xrun( + pub fn handle_xrun( &mut self, beat_in_missed: Option, missed_frames: usize, chunk_factory: &mut impl ChunkFactory, - post_record_handler: H, + post_record_controller: PRC, + osc_controller: OC, ) -> Result<()> where - H: Fn(Arc, usize) -> Result<()>, + PRC: Fn(Arc, usize) -> Result<()>, + OC: Fn(TrackState) -> Result<()>, { match beat_in_missed { None => { @@ -109,7 +111,7 @@ impl Track { .append_silence(beat_offset as _, chunk_factory)?; } // Apply state transition at beat boundary - self.apply_state_transition(chunk_factory, post_record_handler)?; + self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?; // Insert silence after beat with new state let frames_after_beat = missed_frames - beat_offset as usize; if frames_after_beat > 0 && self.is_recording() { @@ -122,17 +124,19 @@ impl Track { } /// Audio processing - pub fn process( + pub fn process( &mut self, playback_position: usize, beat_in_buffer: Option, input_buffer: &[f32], output_buffer: &mut [f32], chunk_factory: &mut impl ChunkFactory, - post_record_handler: H, + post_record_controller: PRC, + osc_controller: OC, ) -> Result<()> where - H: Fn(Arc, usize) -> Result<()>, + PRC: Fn(Arc, usize) -> Result<()>, + OC: Fn(TrackState) -> Result<()>, { match beat_in_buffer { None => { @@ -156,7 +160,7 @@ impl Track { } // Apply state transition at beat boundary - self.apply_state_transition(chunk_factory, post_record_handler)?; + self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?; // Process samples after beat with new current state if (beat_index_in_buffer as usize) < output_buffer.len() { @@ -235,13 +239,15 @@ impl Track { /// Apply state transition from next_state to current_state /// Returns true if track should be consolidated and saved - fn apply_state_transition( + fn apply_state_transition( &mut self, chunk_factory: &mut impl ChunkFactory, - post_record_handler: H, + post_record_controller: PRC, + osc_controller: OC, ) -> Result<()> where - H: Fn(Arc, usize) -> Result<()>, + PRC: Fn(Arc, usize) -> Result<()>, + OC: Fn(TrackState) -> Result<()>, { // Check for auto-stop recording completion and transition to playing if no other state transition if self.current_state == self.next_state { @@ -286,12 +292,15 @@ impl Track { } // Apply the state transition + if self.current_state != self.next_state { + osc_controller(self.next_state.clone())?; + } self.current_state = self.next_state.clone(); // Handle post-record processing if was_recording && !self.is_recording() { let (chunk, sync_offset) = self.audio_data.get_chunk_for_processing()?; - post_record_handler(chunk, sync_offset)?; + post_record_controller(chunk, sync_offset)?; } Ok(()) } diff --git a/audio_engine/src/track_matrix.rs b/audio_engine/src/track_matrix.rs index 16492dc..cd0db77 100644 --- a/audio_engine/src/track_matrix.rs +++ b/audio_engine/src/track_matrix.rs @@ -41,6 +41,7 @@ impl TrackMatrix Result<()> { // Check for consolidation response if let Some(response) = self.post_record_controller.try_recv_response() { @@ -63,6 +64,9 @@ impl TrackMatrix TrackMatrix