timed metronome beep

This commit is contained in:
Geens 2025-06-07 15:14:52 +02:00
parent 77b09b385b
commit 9e6bd37b8f
7 changed files with 126 additions and 72 deletions

View File

@ -48,14 +48,21 @@ async fn main() {
let beep_samples = generate_beep(jack_client.sample_rate() as u32, &mut allocator) let beep_samples = generate_beep(jack_client.sample_rate() as u32, &mut allocator)
.expect("Could not generate beep samples"); .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 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 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( let mut connection_manager = ConnectionManager::new(
state_rx, state_rx,
notification_handler.subscribe(), notification_handler.subscribe(),

View File

@ -46,9 +46,8 @@ impl ConnectionManager {
let result = self let result = self
.jack_client .jack_client
.connect_ports_by_name(external_port, &our_port); .connect_ports_by_name(external_port, &our_port);
match result { if let Ok(_) = result {
Ok(_) => log::info!("Connected {} -> {}", external_port, our_port), log::info!("Connected {} -> {}", external_port, our_port);
Err(_) => log::debug!("Could not connect {} -> {}", external_port, our_port),
} }
} }
@ -57,9 +56,8 @@ impl ConnectionManager {
let result = self let result = self
.jack_client .jack_client
.connect_ports_by_name(external_port, &our_port); .connect_ports_by_name(external_port, &our_port);
match result { if let Ok(_) = result {
Ok(_) => log::info!("Connected {} -> {}", external_port, our_port), log::info!("Connected {} -> {}", external_port, our_port);
Err(_) => log::debug!("Could not connect {} -> {}", external_port, our_port),
} }
} }
@ -68,9 +66,8 @@ impl ConnectionManager {
let result = self let result = self
.jack_client .jack_client
.connect_ports_by_name(&our_port, external_port); .connect_ports_by_name(&our_port, external_port);
match result { if let Ok(_) = result {
Ok(_) => log::info!("Connected {} -> {}", our_port, external_port), log::info!("Connected {} -> {}", our_port, external_port);
Err(_) => log::debug!("Could not connect {} -> {}", our_port, external_port),
} }
} }
@ -79,9 +76,8 @@ impl ConnectionManager {
let result = self let result = self
.jack_client .jack_client
.connect_ports_by_name(&our_port, external_port); .connect_ports_by_name(&our_port, external_port);
match result { if let Ok(_) = result {
Ok(_) => log::info!("Connected {} -> {}", our_port, external_port), log::info!("Connected {} -> {}", our_port, external_port);
Err(_) => log::debug!("Could not connect {} -> {}", our_port, external_port),
} }
} }
} }

View File

@ -17,6 +17,9 @@ pub enum LooperError {
#[error("Failed to connect to JACK")] #[error("Failed to connect to JACK")]
JackConnection(&'static std::panic::Location<'static>), JackConnection(&'static std::panic::Location<'static>),
#[error("Irrecoverable XRUN")]
Xrun(&'static std::panic::Location<'static>),
} }
pub type Result<T> = std::result::Result<T, LooperError>; pub type Result<T> = std::result::Result<T, LooperError>;

View File

@ -2,61 +2,95 @@ use crate::*;
use std::f32::consts::TAU; use std::f32::consts::TAU;
pub struct Metronome { pub struct Metronome {
// Audio playback
beep_samples: Arc<AudioChunk>, beep_samples: Arc<AudioChunk>,
is_playing: bool, click_volume: f32, // 0.0 to 1.0
playback_position: usize,
// Timing state
samples_per_beat: u32,
beat_time: u32,
} }
impl Metronome { impl Metronome {
pub fn new(beep_samples: Arc<AudioChunk>) -> Self { pub fn new(beep_samples: Arc<AudioChunk>, state: &State) -> Self {
Self { Self {
beep_samples, beep_samples,
is_playing: false, click_volume: state.metronome.click_volume,
playback_position: 0, 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 /// Process audio for current buffer, writing to output slice
pub fn process_audio(&mut self, output: &mut [f32]) -> Result<()> { /// Returns the sample index where a beat occurred, if any
// Clear output buffer first pub fn process(&mut self, ps: &jack::ProcessScope, ports: &mut JackPorts) -> Result<Option<u32>> {
for sample in output.iter_mut() { if 0 == self.beat_time {
*sample = 0.0; self.beat_time = ps.last_frame_time();
} }
// If not playing, output silence let time_since_last_beat = ps.last_frame_time() - self.beat_time;
if !self.is_playing {
return Ok(()); 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 beat_sample_index = if time_since_last_beat >= self.samples_per_beat {
let beep_length = self.beep_samples.as_ref().sample_count; self.beat_time += self.samples_per_beat;
let output_length = output.len(); Some(self.beat_time - ps.last_frame_time())
} else {
None
};
let mut output_index = 0; // Get output buffer for click track
while output_index < output_length && self.playback_position < beep_length { let click_output = ports.click_track_out.as_mut_slice(ps);
// Copy one sample at a time let buffer_size = click_output.len();
let mut temp_buffer = [0.0f32; 1];
self.beep_samples.copy_samples(&mut temp_buffer, self.playback_position)?;
output[output_index] = temp_buffer[0];
output_index += 1; // Calculate current position within the beep (frames since last beat started)
self.playback_position += 1; 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;
// 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 Ok(beat_sample_index)
if self.playback_position >= beep_length {
self.is_playing = false;
}
// TODO: Update playback_position
// TODO: Stop when beep is complete
Ok(())
} }
} }

View File

@ -13,9 +13,7 @@ pub enum JackNotification {
port_b: String, port_b: String,
connected: bool, connected: bool,
}, },
PortRegistered { PortRegistered {},
port_name: String,
},
} }
impl NotificationHandler { impl NotificationHandler {
@ -74,7 +72,7 @@ impl jack::NotificationHandler for NotificationHandler {
}; };
if register { if register {
let notification = JackNotification::PortRegistered { port_name }; let notification = JackNotification::PortRegistered {};
self.channel self.channel
.send(notification) .send(notification)
.expect("Could not send port registration notification"); .expect("Could not send port registration notification");

View File

@ -10,13 +10,18 @@ pub struct ProcessHandler<F: ChunkFactory> {
} }
impl<F: ChunkFactory> ProcessHandler<F> { impl<F: ChunkFactory> ProcessHandler<F> {
pub fn new(ports: JackPorts, mut chunk_factory: F, beep_samples: Arc<AudioChunk>) -> Result<Self> { pub fn new(
ports: JackPorts,
mut chunk_factory: F,
beep_samples: Arc<AudioChunk>,
state: &State,
) -> Result<Self> {
Ok(Self { Ok(Self {
track: Track::new(&mut chunk_factory)?, track: Track::new(&mut chunk_factory)?,
playback_position: 0, playback_position: 0,
ports, ports,
chunk_factory, chunk_factory,
metronome: Metronome::new(beep_samples), metronome: Metronome::new(beep_samples, state),
}) })
} }
@ -42,9 +47,6 @@ impl<F: ChunkFactory> ProcessHandler<F> {
/// Handle play/mute toggle button (Button 2) /// Handle play/mute toggle button (Button 2)
pub fn play_toggle(&mut self) -> Result<()> { pub fn play_toggle(&mut self) -> Result<()> {
// Trigger beep for testing
self.metronome.trigger_beep();
match self.track.state() { match self.track.state() {
TrackState::Idle | TrackState::Recording => { TrackState::Idle | TrackState::Recording => {
if self.track.len() > 0 { if self.track.len() > 0 {
@ -63,21 +65,21 @@ impl<F: ChunkFactory> ProcessHandler<F> {
impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> { impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
fn process(&mut self, client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control { fn process(&mut self, client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control {
// Process MIDI first // 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; return jack::Control::Quit;
} }
let input_buffer = self.ports.audio_in.as_slice(ps); let input_buffer = self.ports.audio_in.as_slice(ps);
let output_buffer = self.ports.audio_out.as_mut_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; 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; let mut index = 0;
while index < jack_buffer_size { while index < jack_buffer_size {
@ -86,11 +88,11 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
// Recording - continue until manually stopped // Recording - continue until manually stopped
let sample_count_to_append = jack_buffer_size - index; let sample_count_to_append = jack_buffer_size - index;
let samples_to_append = &input_buffer[index..index + sample_count_to_append]; let samples_to_append = &input_buffer[index..index + sample_count_to_append];
if self if let Err(e) = self
.track .track
.append_samples(samples_to_append, &mut self.chunk_factory) .append_samples(samples_to_append, &mut self.chunk_factory)
.is_err()
{ {
log::error!("Error appending samples: {}", e);
return jack::Control::Quit; return jack::Control::Quit;
}; };
output_buffer[index..(index + sample_count_to_append)].fill(0.0); output_buffer[index..(index + sample_count_to_append)].fill(0.0);
@ -101,14 +103,14 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
let sample_count_to_play = jack_buffer_size - index; let sample_count_to_play = jack_buffer_size - index;
let sample_count_to_play = let sample_count_to_play =
sample_count_to_play.min(self.track.len() - self.playback_position); sample_count_to_play.min(self.track.len() - self.playback_position);
if self if let Err(e) = self
.track .track
.copy_samples( .copy_samples(
&mut output_buffer[index..(index + sample_count_to_play)], &mut output_buffer[index..(index + sample_count_to_play)],
self.playback_position, self.playback_position,
) )
.is_err()
{ {
log::error!("Error copying samples: {}", e);
return jack::Control::Quit; return jack::Control::Quit;
} }
index += sample_count_to_play; index += sample_count_to_play;

View File

@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State { pub struct State {
pub connections: ConnectionState, pub connections: ConnectionState,
pub metronome: MetronomeState,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -13,6 +14,12 @@ pub struct ConnectionState {
pub click_track_out: Vec<String>, pub click_track_out: Vec<String>,
} }
#[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 { impl Default for State {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -22,6 +29,13 @@ impl Default for State {
audio_out: Vec::new(), audio_out: Vec::new(),
click_track_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
},
} }
} }
} }