diff --git a/Cargo.lock b/Cargo.lock index 558b320..7029d7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,12 +81,44 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" version = "0.31.1" @@ -158,6 +190,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.1", + "libc", +] + [[package]] name = "lock_api" version = "0.4.13" @@ -178,9 +220,12 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" name = "looper" version = "0.1.0" dependencies = [ + "dirs", "jack", "kanal", "log", + "serde", + "serde_json", "simple_logger", "thiserror", "tokio", @@ -237,6 +282,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.4" @@ -305,12 +356,29 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "rustc-demangle" version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -337,6 +405,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.5" diff --git a/Cargo.toml b/Cargo.toml index d191256..e3bfabb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,13 @@ name = "sendmidi" path = "src/sendmidi.rs" [dependencies] -thiserror = "1" +dirs = "5" jack = "0.13" -tokio = { version = "1", features = ["full"] } -log = "0.4" -simple_logger = "5" kanal = "0.1" -wmidi = "4" +log = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +simple_logger = "5" +thiserror = "1" +tokio = { version = "1", features = ["full"] } +wmidi = "4" \ No newline at end of file diff --git a/interface_spec.md b/interface_spec.md index 2e0063c..700cdc1 100644 --- a/interface_spec.md +++ b/interface_spec.md @@ -218,6 +218,15 @@ For efficiency, column-beat messages are bundled: ```json { "version": "1.0", + "connections": { + "midi_in": [ + "sendmidi:midi_out" + ], + "audio_out": [ + ], + "audio_in": [ + ] + }, "ui_state": { "selected_column": 3, "selected_row": 2 diff --git a/src/allocator.rs b/src/allocator.rs index 413f779..4c23b99 100644 --- a/src/allocator.rs +++ b/src/allocator.rs @@ -5,13 +5,11 @@ pub struct Allocator { } impl Allocator { - pub fn spawn(buffer_size: usize, pool_size: usize) -> (Self, tokio::task::JoinHandle<()>) { + pub fn spawn(buffer_size: usize, pool_size: usize) -> Self { let (allocated_buffer_sender, allocated_buffer_receiver) = kanal::bounded(pool_size); // Pre-fill the channel with initial buffers - while let Ok(true) = allocated_buffer_sender - .try_send(AudioChunk::allocate(buffer_size)) - {} + while let Ok(true) = allocated_buffer_sender.try_send(AudioChunk::allocate(buffer_size)) {} let allocator = Allocator { channel: allocated_buffer_receiver, @@ -20,7 +18,7 @@ impl Allocator { let allocated_buffer_sender = allocated_buffer_sender.to_async(); // Spawn the background task that continuously allocates buffers - let join_handle = tokio::runtime::Handle::current().spawn(async move { + tokio::runtime::Handle::current().spawn(async move { loop { let chunk = AudioChunk::allocate(buffer_size); if allocated_buffer_sender.send(chunk).await.is_err() { @@ -29,7 +27,7 @@ impl Allocator { } }); - (allocator, join_handle) + allocator } } diff --git a/src/audio_chunk.rs b/src/audio_chunk.rs index e37261f..9b735f1 100644 --- a/src/audio_chunk.rs +++ b/src/audio_chunk.rs @@ -553,7 +553,7 @@ mod tests { assert!(result.is_ok()); assert_eq!(chunk.sample_count, 3); // First chunk unchanged - + let chunk2 = chunk.next.as_ref().unwrap(); assert_eq!(chunk2.sample_count, 2); assert_eq!(chunk2.samples[0], 4.0); @@ -587,7 +587,7 @@ mod tests { let result = AudioChunk::append_samples(&mut chunk1, &samples, &mut factory); assert!(result.is_ok()); - + // Navigate to chunk3 and verify the sample was added let chunk2 = chunk1.next.as_ref().unwrap(); let chunk3 = chunk2.next.as_ref().unwrap(); @@ -600,7 +600,7 @@ mod tests { let mut chunk = AudioChunk::allocate(5); let mut factory = || panic!("Factory should not be called"); - + // First append let result1 = AudioChunk::append_samples(&mut chunk, &[1.0, 2.0], &mut factory); assert!(result1.is_ok()); @@ -644,12 +644,12 @@ mod tests { let result = AudioChunk::append_samples(&mut chunk1, &samples, &mut factory); assert!(result.is_ok()); - + // Check that chunk2 got filled let chunk2 = chunk1.next.as_ref().unwrap(); assert_eq!(chunk2.sample_count, 3); assert_eq!(chunk2.samples[2], 6.0); - + // Check that chunk3 was created with remaining samples let chunk3 = chunk2.next.as_ref().unwrap(); assert_eq!(chunk3.sample_count, 2); diff --git a/src/audio_engine.rs b/src/audio_engine.rs index 398e3fa..257788e 100644 --- a/src/audio_engine.rs +++ b/src/audio_engine.rs @@ -1,10 +1,13 @@ mod allocator; mod audio_chunk; mod chunk_factory; +mod connection_manager; mod looper_error; mod midi; mod notification_handler; mod process_handler; +mod state; +mod state_manager; mod track; use std::sync::Arc; @@ -12,54 +15,76 @@ use std::sync::Arc; use allocator::Allocator; use audio_chunk::AudioChunk; use chunk_factory::ChunkFactory; +use connection_manager::ConnectionManager; use looper_error::LooperError; use looper_error::Result; use notification_handler::JackNotification; use notification_handler::NotificationHandler; use process_handler::ProcessHandler; +use state::State; +use state_manager::StateManager; use track::Track; use track::TrackState; pub struct JackPorts { pub audio_in: jack::Port, - pub audio_out: jack::Port, + pub audio_out: jack::Port, pub midi_in: jack::Port, } #[tokio::main] async fn main() { - simple_logger::SimpleLogger::new().init().expect("Could not initialize logger"); + simple_logger::SimpleLogger::new() + .init() + .expect("Could not initialize logger"); let (jack_client, ports) = setup_jack(); - let (allocator, factory_handle) = Allocator::spawn(jack_client.sample_rate(), 3); + let allocator = Allocator::spawn(jack_client.sample_rate(), 3); - let process_handler = ProcessHandler::new(ports, allocator) - .expect("Could not create process handler"); + let process_handler = + ProcessHandler::new(ports, allocator).expect("Could not create process handler"); let notification_handler = NotificationHandler::new(); let mut notification_channel = notification_handler.subscribe(); - let _async_client = jack_client + let (mut state_manager, state_rx) = StateManager::new(notification_handler.subscribe()); + + let mut connection_manager = ConnectionManager::new( + state_rx, + notification_handler.subscribe(), + jack_client.name().to_string(), + ) + .expect("Could not create connection manager"); + + let _active_client = jack_client .activate_async(notification_handler, process_handler) .expect("Could not activate Jack"); - tokio::select! { - notification = notification_channel.recv() => { - match notification { - Ok(JackNotification::Shutdown {reason, status}) => { + loop { + tokio::select! { + notification = notification_channel.recv() => { + if let Ok(JackNotification::Shutdown {reason, status}) = notification { log::error!("Jack shutdown: {reason} {status:?}"); - } - Err(e) => { - log::error!("NotificationHandler stopped: {e}"); + break; } } - } - _ = factory_handle => { - log::error!("Allocator was dropped"); - } - _ = tokio::signal::ctrl_c() => { - log::info!("Stopping"); + result = state_manager.run() => { + if let Err(e) = result { + log::error!("StateManager task failed: {}", e); + } + break; + } + result = connection_manager.run() => { + if let Err(e) = result { + log::error!("ConnectionManager task failed: {}", e); + } + break; + } + _ = tokio::signal::ctrl_c() => { + log::info!("Stopping"); + break; + } } } } @@ -80,12 +105,12 @@ fn setup_jack() -> (jack::Client, JackPorts) { let midi_in = jack_client .register_port("midi_in", jack::MidiIn::default()) .expect("Could not create midi_in port"); - + let ports = JackPorts { audio_in, audio_out, midi_in, }; - + (jack_client, ports) -} \ No newline at end of file +} diff --git a/src/connection_manager.rs b/src/connection_manager.rs new file mode 100644 index 0000000..1678cb4 --- /dev/null +++ b/src/connection_manager.rs @@ -0,0 +1,80 @@ +use crate::*; +use tokio::sync::{broadcast, watch}; + +pub struct ConnectionManager { + state_rx: watch::Receiver, + notification_rx: broadcast::Receiver, + jack_client: jack::Client, + jack_client_name: String, +} + +impl ConnectionManager { + pub fn new( + state_rx: watch::Receiver, + notification_rx: broadcast::Receiver, + jack_client_name: String, + ) -> Result { + // Create a dedicated client for making connections + let (jack_client, _status) = jack::Client::new( + &format!("{}_connector", jack_client_name), + jack::ClientOptions::NO_START_SERVER, + ) + .map_err(|_| LooperError::JackConnection(std::panic::Location::caller()))?; + + Ok(Self { + state_rx, + notification_rx, + jack_client, + jack_client_name, + }) + } + + pub async fn run(&mut self) -> Result<()> { + self.restore_connections().await; + while let Ok(notification) = self.notification_rx.recv().await { + if let JackNotification::PortRegistered { .. } = notification { + self.restore_connections().await; + } + } + Ok(()) + } + + async fn restore_connections(&self) { + let state = self.state_rx.borrow(); + + // MIDI and audio inputs: connect FROM external TO our port + for external_port in &state.connections.midi_in { + let our_port = format!("{}:midi_in", self.jack_client_name); + let result = self + .jack_client + .connect_ports_by_name(external_port, &our_port); + match result { + Ok(_) => log::info!("Connected {} -> {}", external_port, our_port), + Err(_) => log::debug!("Could not connect {} -> {}", external_port, our_port), + } + } + + for external_port in &state.connections.audio_in { + let our_port = format!("{}:audio_in", self.jack_client_name); + let result = self + .jack_client + .connect_ports_by_name(external_port, &our_port); + match result { + Ok(_) => log::info!("Connected {} -> {}", external_port, our_port), + Err(_) => log::debug!("Could not connect {} -> {}", external_port, our_port), + } + } + + // Audio output: connect FROM our port TO external + for external_port in &state.connections.audio_out { + let our_port = format!("{}:audio_out", self.jack_client_name); + let result = self + .jack_client + .connect_ports_by_name(&our_port, external_port); + match result { + Ok(_) => log::info!("Connected {} -> {}", our_port, external_port), + Err(_) => log::debug!("Could not connect {} -> {}", our_port, external_port), + } + } + } +} diff --git a/src/looper_error.rs b/src/looper_error.rs index b86291a..26f54ef 100644 --- a/src/looper_error.rs +++ b/src/looper_error.rs @@ -8,6 +8,15 @@ pub enum LooperError { #[error("Index out of bounds")] OutOfBounds(&'static std::panic::Location<'static>), + + #[error("Failed to load state")] + StateLoad(&'static std::panic::Location<'static>), + + #[error("Failed to save state")] + StateSave(&'static std::panic::Location<'static>), + + #[error("Failed to connect to JACK")] + JackConnection(&'static std::panic::Location<'static>), } pub type Result = std::result::Result; diff --git a/src/midi.rs b/src/midi.rs index 425a9c5..4f97f11 100644 --- a/src/midi.rs +++ b/src/midi.rs @@ -10,7 +10,7 @@ pub fn process_events( const MAX_EVENTS: usize = 16; // Reasonable limit for real-time processing let mut raw_events = [[0u8; 3]; MAX_EVENTS]; let mut event_count = 0; - + // Collect events from the MIDI input iterator let midi_input = process_handler.ports.midi_in.iter(ps); for midi_event in midi_input { @@ -23,12 +23,12 @@ pub fn process_events( return Err(LooperError::OutOfBounds(std::panic::Location::caller())); } } - + // Now process the collected events using wmidi // The iterator borrow is dropped, so we can mutably borrow process_handler for i in 0..event_count { let event_bytes = &raw_events[i]; - + // Use wmidi for cleaner MIDI parsing instead of manual byte checking match wmidi::MidiMessage::try_from(&event_bytes[..]) { Ok(message) => { @@ -62,6 +62,6 @@ pub fn process_events( } } } - + Ok(()) -} \ No newline at end of file +} diff --git a/src/notification_handler.rs b/src/notification_handler.rs index b1c4058..36c9286 100644 --- a/src/notification_handler.rs +++ b/src/notification_handler.rs @@ -4,18 +4,24 @@ pub struct NotificationHandler { #[derive(Clone, Debug)] pub enum JackNotification { - Shutdown{ + Shutdown { status: jack::ClientStatus, - reason: String + reason: String, + }, + PortConnect { + port_a: String, + port_b: String, + connected: bool, + }, + PortRegistered { + port_name: String, }, } impl NotificationHandler { pub fn new() -> Self { - let (channel, _) = tokio::sync::broadcast::channel(1); - Self { - channel, - } + let (channel, _) = tokio::sync::broadcast::channel(16); + Self { channel } } pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { @@ -26,6 +32,52 @@ impl NotificationHandler { impl jack::NotificationHandler for NotificationHandler { unsafe fn shutdown(&mut self, status: jack::ClientStatus, reason: &str) { let reason = reason.to_string(); - self.channel.send(JackNotification::Shutdown { status, reason }).expect("Could not send shutdown notification"); + self.channel + .send(JackNotification::Shutdown { status, reason }) + .expect("Could not send shutdown notification"); } -} \ No newline at end of file + + fn ports_connected( + &mut self, + client: &jack::Client, + port_id_a: jack::PortId, + port_id_b: jack::PortId, + connect: bool, + ) { + // Convert port IDs to port names + let Some(port_a) = client.port_by_id(port_id_a) else { + return; + }; + let Ok(port_a) = port_a.name() else { return }; + let Some(port_b) = client.port_by_id(port_id_b) else { + return; + }; + let Ok(port_b) = port_b.name() else { return }; + + let notification = JackNotification::PortConnect { + port_a, + port_b, + connected: connect, + }; + + self.channel + .send(notification) + .expect("Could not send port connection notification"); + } + + 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 { + let notification = JackNotification::PortRegistered { port_name }; + self.channel + .send(notification) + .expect("Could not send port registration notification"); + }; + } +} diff --git a/src/process_handler.rs b/src/process_handler.rs index 9c202df..d85513f 100644 --- a/src/process_handler.rs +++ b/src/process_handler.rs @@ -8,10 +8,7 @@ pub struct ProcessHandler { } impl ProcessHandler { - pub fn new( - ports: JackPorts, - mut chunk_factory: F, - ) -> Result { + pub fn new(ports: JackPorts, mut chunk_factory: F) -> Result { Ok(Self { track: Track::new(&mut chunk_factory)?, playback_position: 0, @@ -63,7 +60,7 @@ impl jack::ProcessHandler for ProcessHandler { if midi::process_events(self, ps).is_err() { return jack::Control::Quit; } - + let input_buffer = self.ports.audio_in.as_slice(ps); let output_buffer = self.ports.audio_out.as_mut_slice(ps); @@ -117,4 +114,4 @@ impl jack::ProcessHandler for ProcessHandler { } jack::Control::Continue } -} \ No newline at end of file +} diff --git a/src/sendmidi.rs b/src/sendmidi.rs index e87869d..e83b165 100644 --- a/src/sendmidi.rs +++ b/src/sendmidi.rs @@ -1,6 +1,6 @@ -use std::io::{self, Write}; use jack::RawMidi; use kanal::{Receiver, Sender}; +use std::io::{self, Write}; #[derive(Debug)] enum MidiCommand { @@ -14,7 +14,10 @@ struct MidiSender { } impl MidiSender { - fn new(client: &jack::Client, command_receiver: Receiver) -> Result> { + fn new( + client: &jack::Client, + command_receiver: Receiver, + ) -> Result> { let midi_out = client .register_port("midi_out", jack::MidiOut::default()) .map_err(|e| format!("Could not create MIDI output port: {}", e))?; @@ -29,20 +32,23 @@ impl MidiSender { impl jack::ProcessHandler for MidiSender { fn process(&mut self, _client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control { let mut midi_writer = self.midi_out.writer(ps); - + // Process all pending commands while let Ok(Some(command)) = self.command_receiver.try_recv() { match command { MidiCommand::ControlChange { cc, value } => { let midi_data = [0xB0, cc, value]; // Control Change on channel 1 - if let Err(e) = midi_writer.write(&RawMidi { time: 0, bytes: &midi_data }) { + if let Err(e) = midi_writer.write(&RawMidi { + time: 0, + bytes: &midi_data, + }) { eprintln!("Failed to send MIDI: {}", e); } } MidiCommand::Quit => return jack::Control::Quit, } } - + jack::Control::Continue } } @@ -69,37 +75,28 @@ fn show_help() { println!("q - Quit\n"); } -fn auto_connect(active_client: &jack::AsyncClient<(), MidiSender>) { - // Try to connect our MIDI output to the looper's MIDI input - let our_port = "sendmidi:midi_out"; - let target_port = "looper:midi_in"; - - match active_client.as_client().connect_ports_by_name(our_port, target_port) { - Ok(_) => println!("✓ Auto-connected to looper:midi_in"), - Err(e) => println!("⚠ Could not auto-connect to looper:midi_in: {}\n Make sure the looper is running, then connect manually.", e), - } -} - fn get_expression_value(prompt: &str) -> Result> { print!("{}", prompt); io::stdout().flush()?; - + let mut input = String::new(); io::stdin().read_line(&mut input)?; - - let value: u8 = input.trim().parse() + + let value: u8 = input + .trim() + .parse() .map_err(|_| "Invalid number. Please enter 0-127")?; - + if value > 127 { return Err("Value must be 0-127".into()); } - + Ok(value) } fn main() -> Result<(), Box> { println!("Starting FCB1010 MIDI Simulator..."); - + // Create JACK client let (client, status) = jack::Client::new("sendmidi", jack::ClientOptions::NO_START_SERVER)?; if !status.is_empty() { @@ -107,17 +104,14 @@ fn main() -> Result<(), Box> { } // Create command channel - let (command_sender, command_receiver): (Sender, Receiver) = kanal::bounded(32); + let (command_sender, command_receiver): (Sender, Receiver) = + kanal::bounded(32); // Create MIDI sender let midi_sender = MidiSender::new(&client, command_receiver)?; // Activate client - let active_client = client.activate_async((), midi_sender)?; - - // Auto-connect after a brief delay to let JACK settle - std::thread::sleep(std::time::Duration::from_millis(100)); - auto_connect(&active_client); + let _active_client = client.activate_async((), midi_sender)?; show_help(); @@ -135,7 +129,7 @@ fn main() -> Result<(), Box> { } let first_char = input.chars().next().unwrap(); - + let command = match first_char { '1' => Some(MidiCommand::ControlChange { cc: 20, value: 127 }), '2' => Some(MidiCommand::ControlChange { cc: 21, value: 127 }), @@ -149,33 +143,29 @@ fn main() -> Result<(), Box> { '0' => Some(MidiCommand::ControlChange { cc: 29, value: 127 }), 'u' => Some(MidiCommand::ControlChange { cc: 30, value: 127 }), 'd' => Some(MidiCommand::ControlChange { cc: 31, value: 127 }), - 'e' => { - match get_expression_value("Expression A value (0-127): ") { - Ok(value) => Some(MidiCommand::ControlChange { cc: 1, value }), - Err(e) => { - eprintln!("Error: {}", e); - None - } + 'e' => match get_expression_value("Expression A value (0-127): ") { + Ok(value) => Some(MidiCommand::ControlChange { cc: 1, value }), + Err(e) => { + eprintln!("Error: {}", e); + None } }, - 'm' => { - match get_expression_value("Master volume value (0-127): ") { - Ok(value) => Some(MidiCommand::ControlChange { cc: 7, value }), - Err(e) => { - eprintln!("Error: {}", e); - None - } + 'm' => match get_expression_value("Master volume value (0-127): ") { + Ok(value) => Some(MidiCommand::ControlChange { cc: 7, value }), + Err(e) => { + eprintln!("Error: {}", e); + None } }, 'h' => { show_help(); None - }, + } 'q' => { println!("Exiting..."); command_sender.send(MidiCommand::Quit)?; break; - }, + } _ => { println!("Unknown command '{}'. Press 'h' for help.", first_char); None @@ -191,4 +181,4 @@ fn main() -> Result<(), Box> { } Ok(()) -} \ No newline at end of file +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..f75fafc --- /dev/null +++ b/src/state.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct State { + pub connections: ConnectionState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionState { + pub midi_in: Vec, + pub audio_in: Vec, + pub audio_out: Vec, +} + +impl Default for State { + fn default() -> Self { + Self { + connections: ConnectionState { + midi_in: Vec::new(), + audio_in: Vec::new(), + audio_out: Vec::new(), + }, + } + } +} diff --git a/src/state_manager.rs b/src/state_manager.rs new file mode 100644 index 0000000..795109f --- /dev/null +++ b/src/state_manager.rs @@ -0,0 +1,131 @@ +use crate::*; +use std::path::PathBuf; +use tokio::sync::{broadcast, watch}; + +pub struct StateManager { + state: State, + state_tx: watch::Sender, + notification_rx: broadcast::Receiver, + state_file_path: PathBuf, +} + +impl StateManager { + pub fn new( + notification_rx: broadcast::Receiver, + ) -> (Self, watch::Receiver) { + let state_file_path = Self::get_state_file_path(); + let initial_state = Self::load_from_disk(&state_file_path).unwrap_or_default(); + let (state_tx, state_rx) = watch::channel(initial_state.clone()); + + let manager = Self { + state: initial_state, + state_tx, + notification_rx, + state_file_path, + }; + + (manager, state_rx) + } + + pub async fn run(&mut self) -> Result<()> { + while let Ok(notification) = self.notification_rx.recv().await { + if let JackNotification::PortConnect { + port_a, + port_b, + connected, + } = notification + { + self.handle_port_connection(port_a, port_b, connected)?; + } + } + + Ok(()) + } + + fn handle_port_connection( + &mut self, + port_a: String, + port_b: String, + connected: bool, + ) -> Result<()> { + // Determine which port is ours and which is external + let (our_port, external_port) = if port_a.starts_with("looper:") { + (port_a, port_b) + } else if port_b.starts_with("looper:") { + (port_b, port_a) + } else { + // Neither port is ours, ignore + return Ok(()); + }; + + // Update the connections state + let our_port_name = our_port.strip_prefix("looper:").unwrap_or(&our_port); + let connections = &mut self.state.connections; + + let port_list = match our_port_name { + "midi_in" => &mut connections.midi_in, + "audio_in" => &mut connections.audio_in, + "audio_out" => &mut connections.audio_out, + _ => { + log::warn!("Unknown port: {}", our_port_name); + return Ok(()); + } + }; + + if connected { + // Add connection if not already present + if !port_list.contains(&external_port) { + port_list.push(external_port.clone()); + log::info!("Added connection: {} -> {}", our_port, external_port); + } + } else { + // Remove connection + port_list.retain(|p| p != &external_port); + log::info!("Removed connection: {} -> {}", our_port, external_port); + } + + // Broadcast state change + if let Err(e) = self.state_tx.send(self.state.clone()) { + log::error!("Failed to broadcast state change: {}", e); + } + + // Save to disk + self.save_to_disk()?; + + 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) => { + let state: State = serde_json::from_str(&content) + .map_err(|_| LooperError::StateLoad(std::panic::Location::caller()))?; + log::info!("Loaded state from {:?}", path); + Ok(state) + } + Err(_) => { + log::info!("Could not load state from {:?}. Using defaults.", path); + Err(LooperError::StateLoad(std::panic::Location::caller())) + } + } + } + + fn save_to_disk(&self) -> Result<()> { + let json = serde_json::to_string_pretty(&self.state) + .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; + + std::fs::write(&self.state_file_path, json) + .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; + + log::debug!("Saved state to {:?}", self.state_file_path); + Ok(()) + } +} diff --git a/src/track.rs b/src/track.rs index b4a97d5..b29c363 100644 --- a/src/track.rs +++ b/src/track.rs @@ -54,4 +54,4 @@ impl Track { pub fn set_state(&mut self, state: TrackState) { self.state = state; } -} \ No newline at end of file +}