Added tap tempo

This commit is contained in:
Niels Geens
2025-08-05 12:03:26 +02:00
parent 0dd4387917
commit 872f933506
17 changed files with 416 additions and 160 deletions

View File

@@ -90,6 +90,14 @@ impl<const ROWS: usize> Column<ROWS> {
self.tracks[row].set_consolidated_buffer(buffer)
}
pub fn is_all_tracks_cleared(&self) -> bool {
self.tracks.iter().all(|track| track.is_empty())
}
pub fn set_frames_per_beat(&mut self, new_frames_per_beat: usize) {
self.frames_per_beat = new_frames_per_beat;
}
pub fn handle_xrun(
&mut self,
timing: &BufferTiming,

View File

@@ -16,6 +16,7 @@ mod persistence_manager;
mod post_record_handler;
mod process_handler;
mod state;
mod tap_tempo;
mod track;
mod track_matrix;
@@ -47,6 +48,7 @@ use post_record_handler::PostRecordHandler;
use post_record_handler::PostRecordResponse;
use process_handler::ProcessHandler;
use state::State;
use tap_tempo::TapTempo;
use track::Track;
use track::TrackState;
use track_matrix::TrackMatrix;

View File

@@ -68,13 +68,22 @@ impl Metronome {
Ok(timing)
}
pub fn set_click_volume(&mut self, click_volume: f32, persistence: &mut PersistenceManagerController) -> Result<()> {
pub fn set_click_volume(
&mut self,
click_volume: f32,
persistence: &mut PersistenceManagerController,
) -> Result<()> {
let clamped = click_volume.clamp(0.0, 1.0);
persistence.update_metronome_volume(clamped)?;
self.click_volume = clamped;
Ok(())
}
pub fn set_frames_per_beat(&mut self, frames_per_beat: usize) {
self.frames_per_beat = frames_per_beat;
self.frames_since_last_beat = self.frames_since_last_beat % self.frames_per_beat;
}
fn process_ppqn_ticks(
&mut self,
ps: &jack::ProcessScope,

View File

@@ -59,7 +59,7 @@ pub fn process_events<F: ChunkFactory, const COLS: usize, const ROWS: usize>(
process_handler.handle_button_4()?;
}
24 if value_num > 0 => {
process_handler.handle_button_5()?;
process_handler.handle_button_5(ps)?;
}
25 if value_num > 0 => {
process_handler.handle_button_6()?;

View File

@@ -16,7 +16,10 @@ pub enum PersistenceUpdate {
},
UpdateMetronomeVolume {
volume: f32,
}
},
UpdateMetronomeFramesPerBeat {
frames_per_beat: usize,
},
}
impl PersistenceManagerController {
@@ -34,7 +37,16 @@ impl PersistenceManagerController {
}
pub fn update_metronome_volume(&self, volume: f32) -> Result<()> {
let update = PersistenceUpdate::UpdateMetronomeVolume { volume: volume };
let update = PersistenceUpdate::UpdateMetronomeVolume { volume };
match self.sender.try_send(update) {
Ok(true) => Ok(()),
Ok(false) => Err(LooperError::StateSave(std::panic::Location::caller())), // Channel full
Err(_) => Err(LooperError::StateSave(std::panic::Location::caller())), // Channel closed
}
}
pub fn update_metronome_frames_per_beat(&self, frames_per_beat: usize) -> Result<()> {
let update = PersistenceUpdate::UpdateMetronomeFramesPerBeat { frames_per_beat };
match self.sender.try_send(update) {
Ok(true) => Ok(()),
Ok(false) => Err(LooperError::StateSave(std::panic::Location::caller())), // Channel full
@@ -120,6 +132,12 @@ impl PersistenceManager {
PersistenceUpdate::UpdateMetronomeVolume { volume } => {
self.state
.send_modify(|state| state.metronome.click_volume = volume);
self.save_to_disk()?;
}
PersistenceUpdate::UpdateMetronomeFramesPerBeat { frames_per_beat } => {
self.state
.send_modify(|state| state.metronome.frames_per_beat = frames_per_beat);
self.save_to_disk()?;
}
}

View File

@@ -11,6 +11,7 @@ pub struct ProcessHandler<F: ChunkFactory, const COLS: usize, const ROWS: usize>
selected_column: usize,
last_volume_setting: f32,
sample_rate: usize,
tap_tempo: TapTempo,
}
impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, COLS, ROWS> {
@@ -36,6 +37,7 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, CO
selected_column: 0,
last_volume_setting: 1.0,
sample_rate: client.sample_rate(),
tap_tempo: TapTempo::new(client.sample_rate()),
})
}
@@ -59,7 +61,8 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, CO
}
pub fn handle_pedal_b(&mut self, value: u8) -> Result<()> {
self.metronome.set_click_volume(value as f32 / 127.0, &mut self.persistence)
self.metronome
.set_click_volume(value as f32 / 127.0, &mut self.persistence)
}
pub fn handle_button_1(&mut self) -> Result<()> {
@@ -92,10 +95,6 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, CO
}
pub fn handle_button_4(&mut self) -> Result<()> {
Ok(())
}
pub fn handle_button_5(&mut self) -> Result<()> {
let controllers = TrackControllers::new(
&self.post_record_controller,
&self.osc,
@@ -107,6 +106,27 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, CO
self.track_matrix.handle_clear_button(&controllers)
}
pub fn handle_button_5(&mut self, ps: &jack::ProcessScope) -> Result<()> {
if !self.track_matrix.is_all_tracks_cleared() {
// Ignore tap if not all tracks are clear
return Ok(());
}
let current_frame_time = ps.last_frame_time();
let tempo_updated = self.tap_tempo.handle_tap(current_frame_time);
if let Some(frames_per_beat) = tempo_updated {
// If tempo was updated, we need to update all columns with new frames_per_beat
self.metronome.set_frames_per_beat(frames_per_beat);
self.persistence
.update_metronome_frames_per_beat(frames_per_beat)?;
self.track_matrix.set_frames_per_beat(frames_per_beat);
}
Ok(())
}
pub fn handle_button_6(&mut self) -> Result<()> {
self.selected_column = 0;
self.osc.selected_column_changed(self.selected_column)?;

View File

@@ -0,0 +1,65 @@
/// Collects tap timestamps and calculates tempo when enough taps are received.
#[derive(Debug)]
pub struct TapTempo {
sample_rate: usize,
tap_times: [u32; Self::MAX_TAPS],
tap_count: usize,
}
impl TapTempo {
const MAX_TAPS: usize = 8;
const MIN_TAPS: usize = 4;
const MIN_INTERVAL_MS: usize = 100;
const MAX_INTERVAL_MS: usize = 2000;
pub fn new(sample_rate: usize) -> Self {
Self {
sample_rate,
tap_times: [0; Self::MAX_TAPS],
tap_count: 0,
}
}
pub fn handle_tap(&mut self, current_frame_time: u32) -> Option<usize> {
self.record_tap(current_frame_time);
self.calculate_tempo()
}
fn record_tap(&mut self, frame_time: u32) {
// Check if valid interval range
if self.tap_count > 0 {
let last_tap = self.tap_times[self.tap_count - 1];
if frame_time - last_tap < (Self::MIN_INTERVAL_MS * self.sample_rate / 1000) as u32 {
return;
}
if frame_time - last_tap > (Self::MAX_INTERVAL_MS * self.sample_rate / 1000) as u32 {
self.tap_count = 0;
}
}
// Add new entry
if self.tap_count < Self::MAX_TAPS {
self.tap_times[self.tap_count] = frame_time;
self.tap_count += 1;
} else {
self.tap_times[0] = frame_time;
self.tap_times.rotate_left(1);
}
}
fn calculate_tempo(&self) -> Option<usize> {
if self.tap_count < Self::MIN_TAPS {
return None;
}
let mut total_interval = 0u64;
let interval_count = self.tap_count - 1;
for i in 1..self.tap_count {
let interval = self.tap_times[i] - self.tap_times[i - 1];
total_interval += interval as u64;
}
let average_interval = (total_interval / interval_count as u64) as usize;
Some(average_interval)
}
}

View File

@@ -107,6 +107,10 @@ impl Track {
self.audio_data.set_consolidated_buffer(buffer)
}
pub fn is_empty(&self) -> bool {
matches!(self.current_state, TrackState::Empty) && self.audio_data.is_empty()
}
pub fn handle_xrun(
&mut self,
beat_in_missed: Option<u32>,

View File

@@ -40,6 +40,18 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
self.columns[controllers.column()].handle_volume_update(new_volume, controllers)
}
pub fn is_all_tracks_cleared(&self) -> bool {
self.columns
.iter()
.all(|column| column.is_all_tracks_cleared())
}
pub fn set_frames_per_beat(&mut self, new_frames_per_beat: usize) {
for column in &mut self.columns {
column.set_frames_per_beat(new_frames_per_beat);
}
}
pub fn process(
&mut self,
ps: &jack::ProcessScope,