From 9e6bd37b8f27bc5b28e4b5466ba64133a69b2ac0 Mon Sep 17 00:00:00 2001 From: Geens Date: Sat, 7 Jun 2025 15:14:52 +0200 Subject: [PATCH] timed metronome beep --- src/audio_engine.rs | 13 ++++- src/connection_manager.rs | 20 +++---- src/looper_error.rs | 3 + src/metronome.rs | 108 ++++++++++++++++++++++++------------ src/notification_handler.rs | 6 +- src/process_handler.rs | 34 ++++++------ src/state.rs | 14 +++++ 7 files changed, 126 insertions(+), 72 deletions(-) diff --git a/src/audio_engine.rs b/src/audio_engine.rs index d77b54a..11df979 100644 --- a/src/audio_engine.rs +++ b/src/audio_engine.rs @@ -48,14 +48,21 @@ async fn main() { let beep_samples = generate_beep(jack_client.sample_rate() as u32, &mut allocator) .expect("Could not generate beep samples"); - let process_handler = - ProcessHandler::new(ports, allocator, beep_samples).expect("Could not create process handler"); - let notification_handler = NotificationHandler::new(); let mut notification_channel = notification_handler.subscribe(); let (mut state_manager, state_rx) = StateManager::new(notification_handler.subscribe()); + // Load state values for metronome configuration + let initial_state = state_rx.borrow().clone(); + + let process_handler = ProcessHandler::new( + ports, + allocator, + beep_samples, + &initial_state, + ).expect("Could not create process handler"); + let mut connection_manager = ConnectionManager::new( state_rx, notification_handler.subscribe(), diff --git a/src/connection_manager.rs b/src/connection_manager.rs index 66b2e84..6c947d0 100644 --- a/src/connection_manager.rs +++ b/src/connection_manager.rs @@ -46,9 +46,8 @@ impl ConnectionManager { 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), + if let Ok(_) = result { + log::info!("Connected {} -> {}", external_port, our_port); } } @@ -57,9 +56,8 @@ impl ConnectionManager { 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), + if let Ok(_) = result { + log::info!("Connected {} -> {}", external_port, our_port); } } @@ -68,9 +66,8 @@ impl ConnectionManager { 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), + if let Ok(_) = result { + log::info!("Connected {} -> {}", our_port, external_port); } } @@ -79,9 +76,8 @@ impl ConnectionManager { 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), + if let Ok(_) = result { + log::info!("Connected {} -> {}", our_port, external_port); } } } diff --git a/src/looper_error.rs b/src/looper_error.rs index 26f54ef..ef4d3c7 100644 --- a/src/looper_error.rs +++ b/src/looper_error.rs @@ -17,6 +17,9 @@ pub enum LooperError { #[error("Failed to connect to JACK")] JackConnection(&'static std::panic::Location<'static>), + + #[error("Irrecoverable XRUN")] + Xrun(&'static std::panic::Location<'static>), } pub type Result = std::result::Result; diff --git a/src/metronome.rs b/src/metronome.rs index efd61cf..a5fb7e6 100644 --- a/src/metronome.rs +++ b/src/metronome.rs @@ -2,61 +2,95 @@ use crate::*; use std::f32::consts::TAU; pub struct Metronome { + // Audio playback beep_samples: Arc, - is_playing: bool, - playback_position: usize, + click_volume: f32, // 0.0 to 1.0 + + // Timing state + samples_per_beat: u32, + beat_time: u32, } impl Metronome { - pub fn new(beep_samples: Arc) -> Self { + pub fn new(beep_samples: Arc, state: &State) -> Self { Self { beep_samples, - is_playing: false, - playback_position: 0, + click_volume: state.metronome.click_volume, + samples_per_beat: state.metronome.samples_per_beat, + beat_time: 0, } } - /// Trigger a single beep - pub fn trigger_beep(&mut self) { - self.is_playing = true; - self.playback_position = 0; - } - /// Process audio for current buffer, writing to output slice - pub fn process_audio(&mut self, output: &mut [f32]) -> Result<()> { - // Clear output buffer first - for sample in output.iter_mut() { - *sample = 0.0; + /// Returns the sample index where a beat occurred, if any + pub fn process(&mut self, ps: &jack::ProcessScope, ports: &mut JackPorts) -> Result> { + if 0 == self.beat_time { + self.beat_time = ps.last_frame_time(); } - // If not playing, output silence - if !self.is_playing { - return Ok(()); + let time_since_last_beat = ps.last_frame_time() - self.beat_time; + + if time_since_last_beat >= (2 * self.samples_per_beat) && self.beat_time != 0 { + return Err(LooperError::Xrun(std::panic::Location::caller())); } - // Copy samples from beep_samples to output - let beep_length = self.beep_samples.as_ref().sample_count; - let output_length = output.len(); + let beat_sample_index = if time_since_last_beat >= self.samples_per_beat { + self.beat_time += self.samples_per_beat; + Some(self.beat_time - ps.last_frame_time()) + } else { + None + }; + + // Get output buffer for click track + let click_output = ports.click_track_out.as_mut_slice(ps); + let buffer_size = click_output.len(); - let mut output_index = 0; - while output_index < output_length && self.playback_position < beep_length { - // Copy one sample at a time - let mut temp_buffer = [0.0f32; 1]; - self.beep_samples.copy_samples(&mut temp_buffer, self.playback_position)?; - output[output_index] = temp_buffer[0]; + // Calculate current position within the beep (frames since last beat started) + let frames_since_beat_start = ps.last_frame_time() - self.beat_time; + let beep_length = self.beep_samples.sample_count as u32; + + if let Some(beat_offset) = beat_sample_index { + let beat_offset = beat_offset as usize; - output_index += 1; - self.playback_position += 1; + // Write silence up to beat boundary + let silence_end = beat_offset.min(buffer_size); + click_output[0..silence_end].fill(0.0); + + // Write beep samples from boundary onward + if beat_offset < buffer_size { + let remaining_buffer = buffer_size - beat_offset; + let samples_to_write = remaining_buffer.min(beep_length as usize); + + // Copy beep samples in bulk + let dest = &mut click_output[beat_offset..beat_offset + samples_to_write]; + if self.beep_samples.copy_samples(dest, 0).is_ok() { + // Apply volume scaling with iterators + dest.iter_mut().for_each(|sample| *sample *= self.click_volume); + } + + // Fill remaining buffer with silence + click_output[(beat_offset + samples_to_write)..].fill(0.0); + } + } else if frames_since_beat_start < beep_length { + // Continue playing beep from previous beat if still within beep duration + let beep_start_offset = frames_since_beat_start as usize; + let remaining_beep_samples = beep_length as usize - beep_start_offset; + let samples_to_write = buffer_size.min(remaining_beep_samples); + + // Copy remaining beep samples in bulk + let dest = &mut click_output[0..samples_to_write]; + if self.beep_samples.copy_samples(dest, beep_start_offset).is_ok() { + // Apply volume scaling with iterators + dest.iter_mut().for_each(|sample| *sample *= self.click_volume); + } + + // Fill remaining buffer with silence + click_output[samples_to_write..].fill(0.0); + } else { + click_output.fill(0.0); } - - // Stop playing if we've reached the end of the beep - if self.playback_position >= beep_length { - self.is_playing = false; - } - // TODO: Update playback_position - // TODO: Stop when beep is complete - Ok(()) + Ok(beat_sample_index) } } diff --git a/src/notification_handler.rs b/src/notification_handler.rs index 36c9286..badbe11 100644 --- a/src/notification_handler.rs +++ b/src/notification_handler.rs @@ -13,9 +13,7 @@ pub enum JackNotification { port_b: String, connected: bool, }, - PortRegistered { - port_name: String, - }, + PortRegistered {}, } impl NotificationHandler { @@ -74,7 +72,7 @@ impl jack::NotificationHandler for NotificationHandler { }; if register { - let notification = JackNotification::PortRegistered { port_name }; + let notification = JackNotification::PortRegistered {}; self.channel .send(notification) .expect("Could not send port registration notification"); diff --git a/src/process_handler.rs b/src/process_handler.rs index 9203dba..6065b7c 100644 --- a/src/process_handler.rs +++ b/src/process_handler.rs @@ -10,13 +10,18 @@ pub struct ProcessHandler { } impl ProcessHandler { - pub fn new(ports: JackPorts, mut chunk_factory: F, beep_samples: Arc) -> Result { + pub fn new( + ports: JackPorts, + mut chunk_factory: F, + beep_samples: Arc, + state: &State, + ) -> Result { Ok(Self { track: Track::new(&mut chunk_factory)?, playback_position: 0, ports, chunk_factory, - metronome: Metronome::new(beep_samples), + metronome: Metronome::new(beep_samples, state), }) } @@ -42,9 +47,6 @@ impl ProcessHandler { /// Handle play/mute toggle button (Button 2) pub fn play_toggle(&mut self) -> Result<()> { - // Trigger beep for testing - self.metronome.trigger_beep(); - match self.track.state() { TrackState::Idle | TrackState::Recording => { if self.track.len() > 0 { @@ -63,21 +65,21 @@ impl ProcessHandler { impl jack::ProcessHandler for ProcessHandler { fn process(&mut self, client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control { // Process MIDI first - if midi::process_events(self, ps).is_err() { + if let Err(e) = midi::process_events(self, ps) { + log::error!("Error processing MIDI events: {}", e); + return jack::Control::Quit; + } + + if let Err(e) = self.metronome.process(ps, &mut self.ports) { + log::error!("Error processing metronome: {}", e); 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); - let click_track_buffer = self.ports.click_track_out.as_mut_slice(ps); let jack_buffer_size = client.buffer_size() as usize; - // Process metronome/click track - if self.metronome.process_audio(click_track_buffer).is_err() { - return jack::Control::Quit; - } - let mut index = 0; while index < jack_buffer_size { @@ -86,11 +88,11 @@ impl jack::ProcessHandler for ProcessHandler { // Recording - continue until manually stopped let sample_count_to_append = jack_buffer_size - index; let samples_to_append = &input_buffer[index..index + sample_count_to_append]; - if self + if let Err(e) = self .track .append_samples(samples_to_append, &mut self.chunk_factory) - .is_err() { + log::error!("Error appending samples: {}", e); return jack::Control::Quit; }; output_buffer[index..(index + sample_count_to_append)].fill(0.0); @@ -101,14 +103,14 @@ impl jack::ProcessHandler for ProcessHandler { let sample_count_to_play = jack_buffer_size - index; let sample_count_to_play = sample_count_to_play.min(self.track.len() - self.playback_position); - if self + if let Err(e) = self .track .copy_samples( &mut output_buffer[index..(index + sample_count_to_play)], self.playback_position, ) - .is_err() { + log::error!("Error copying samples: {}", e); return jack::Control::Quit; } index += sample_count_to_play; diff --git a/src/state.rs b/src/state.rs index ff0cb7b..bc67b4c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct State { pub connections: ConnectionState, + pub metronome: MetronomeState, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -13,6 +14,12 @@ pub struct ConnectionState { pub click_track_out: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetronomeState { + pub samples_per_beat: u32, + pub click_volume: f32, // 0.0 to 1.0 +} + impl Default for State { fn default() -> Self { Self { @@ -22,6 +29,13 @@ impl Default for State { audio_out: Vec::new(), click_track_out: Vec::new(), }, + metronome: MetronomeState { + samples_per_beat: 96000, // 120 BPM at 192kHz sample rate + click_volume: 0.5, // Default 50% volume + }, } } } + + +