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)
.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(),

View File

@ -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);
}
}
}

View File

@ -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<T> = std::result::Result<T, LooperError>;

View File

@ -2,61 +2,95 @@ use crate::*;
use std::f32::consts::TAU;
pub struct Metronome {
// Audio playback
beep_samples: Arc<AudioChunk>,
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<AudioChunk>) -> Self {
pub fn new(beep_samples: Arc<AudioChunk>, 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<Option<u32>> {
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)
}
}

View File

@ -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");

View File

@ -10,13 +10,18 @@ pub struct ProcessHandler<F: ChunkFactory> {
}
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 {
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<F: ChunkFactory> ProcessHandler<F> {
/// 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<F: ChunkFactory> ProcessHandler<F> {
impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
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<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
// 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<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
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;

View File

@ -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<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 {
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
},
}
}
}