OSC messages for column beat tracking

This commit is contained in:
Geens 2025-06-22 18:52:57 +02:00
parent cfa2f1709e
commit b4f51548b3
16 changed files with 441 additions and 272 deletions

View File

@ -118,14 +118,17 @@ impl<const ROWS: usize> Column<ROWS> {
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
controllers: &ColumnControllers, controllers: &ColumnControllers,
) -> Result<()> { ) -> Result<()> {
let len = self.len(); let old_len = self.len();
if self.idle() { if self.idle() {
if let Some(beat_index) = timing.beat_in_buffer { if let Some(beat_index) = timing.beat_in_buffer {
let idle_time = input_buffer.len() - beat_index as usize; // When starting from idle, always start at beat 0 (beginning of first beat)
if len == 0 { // regardless of where in the buffer the beat occurs
self.playback_position = self.frames_per_beat - idle_time; if old_len == 0 {
self.playback_position = 0; // Start at beat 0 for first recording
} else { } else {
self.playback_position = len - idle_time; let idle_time = input_buffer.len() - beat_index as usize;
self.playback_position = old_len - idle_time;
} }
} }
} }
@ -147,13 +150,38 @@ impl<const ROWS: usize> Column<ROWS> {
} }
} }
let len = self.len(); let new_len = self.len();
if len > 0 {
self.playback_position = (self.playback_position + input_buffer.len()) % self.len(); if old_len == 0 && new_len > 0 {
} else { let beats = new_len / self.frames_per_beat;
self.playback_position = controllers.send_column_beats_changed(beats)?;
(self.playback_position + input_buffer.len()) % self.frames_per_beat;
} }
// Update playback position
if new_len > 0 {
self.playback_position = (self.playback_position + input_buffer.len()) % new_len;
} else {
// During recording of first track, don't use modulo - let position grow
// so that beat calculation works correctly
self.playback_position += input_buffer.len();
}
// Send beat change message if a beat occurred in this buffer
if timing.beat_in_buffer.is_some() {
// Calculate which beat we're entering (after the beat that just occurred)
let current_beat = if new_len > 0 {
// When column has defined length, calculate beat within that loop
let column_beats = new_len / self.frames_per_beat;
(self.playback_position / self.frames_per_beat) % column_beats
} else {
// During recording of first track, calculate beat based on absolute position
// The beat that just occurred is the beat we're now in
self.playback_position / self.frames_per_beat
};
controllers.send_column_beat_changed(current_beat)?;
}
Ok(()) Ok(())
} }
} }

View File

@ -65,6 +65,18 @@ impl<'a> ColumnControllers<'a> {
row, row,
} }
} }
pub fn send_column_beats_changed(&self, beats: usize) -> Result<()> {
self.osc.column_beats_changed(self.column, beats)
}
pub fn send_column_beat_changed(&self, beat: usize) -> Result<()> {
self.osc.column_beat_changed(self.column, beat)
}
pub fn column(&self) -> usize {
self.column
}
} }
impl<'a> TrackControllers<'a> { impl<'a> TrackControllers<'a> {

View File

@ -84,10 +84,10 @@ async fn main() {
let state = persistence_manager.state(); let state = persistence_manager.state();
// Calculate tempo from state and jack client // Calculate tempo from state and jack client
let tempo_bpm = (jack_client.sample_rate() as f32 * 60.0) let tempo_bpm =
/ state.borrow().metronome.frames_per_beat as f32; (jack_client.sample_rate() as f32 * 60.0) / state.borrow().metronome.frames_per_beat as f32;
let (mut osc, osc_controller) = Osc::new(&args.socket, COLS, ROWS, tempo_bpm) let (mut osc, osc_controller) = Osc::<COLS, ROWS>::new(&args.socket, tempo_bpm)
.await .await
.expect("Could not create OSC server"); .expect("Could not create OSC server");

View File

@ -62,4 +62,22 @@ impl OscController {
Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed
} }
} }
pub fn column_beats_changed(&self, column: usize, beats: usize) -> Result<()> {
let message = osc::Message::ColumnBeatsChanged { column, beats };
match self.sender.try_send(message) {
Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full
Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed
}
}
pub fn column_beat_changed(&self, column: usize, beat: usize) -> Result<()> {
let message = osc::Message::ColumnBeatChanged { column, beat };
match self.sender.try_send(message) {
Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full
Err(_) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel closed
}
}
} }

View File

@ -3,21 +3,16 @@ use futures::SinkExt;
const CLIENT_BUFFER_SIZE: usize = 32; const CLIENT_BUFFER_SIZE: usize = 32;
pub struct Osc { pub struct Osc<const COLS: usize, const ROWS: usize> {
receiver: kanal::AsyncReceiver<osc::Message>, receiver: kanal::AsyncReceiver<osc::Message>,
listener: osc_server::platform::PlatformListener, listener: osc_server::platform::PlatformListener,
broadcaster: tokio::sync::broadcast::Sender<osc::Message>, broadcaster: tokio::sync::broadcast::Sender<osc::Message>,
shadow_state: osc::State, shadow_state: osc::State<COLS, ROWS>,
} }
impl Osc { impl<const COLS: usize, const ROWS: usize> Osc<COLS, ROWS> {
/// Create new OSC server and controller with configurable matrix size and tempo /// Create new OSC server and controller with configurable tempo
pub async fn new( pub async fn new(socket_path: &str, tempo: f32) -> Result<(Self, OscController)> {
socket_path: &str,
columns: usize,
rows: usize,
tempo: f32,
) -> Result<(Self, OscController)> {
// Create platform listener (server) // Create platform listener (server)
let listener = osc_server::platform::create_listener(socket_path).await?; let listener = osc_server::platform::create_listener(socket_path).await?;
@ -32,7 +27,7 @@ impl Osc {
receiver, receiver,
listener, listener,
broadcaster, broadcaster,
shadow_state: osc::State::new(columns, rows, tempo), shadow_state: osc::State::new(tempo),
}; };
Ok((server, controller)) Ok((server, controller))

View File

@ -150,7 +150,9 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> ProcessHandler<F, CO
} }
} }
impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> jack::ProcessHandler for ProcessHandler<F, COLS, ROWS> { impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> jack::ProcessHandler
for ProcessHandler<F, COLS, ROWS>
{
fn process(&mut self, _client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control { fn process(&mut self, _client: &jack::Client, ps: &jack::ProcessScope) -> jack::Control {
if let Err(e) = self.process_with_error_handling(ps) { if let Err(e) = self.process_with_error_handling(ps) {
log::error!("Error processing audio: {}", e); log::error!("Error processing audio: {}", e);

View File

@ -26,7 +26,11 @@ impl Interpolation {
} }
/// Update interpolation with current state and return interpolated values /// Update interpolation with current state and return interpolated values
pub fn update(&mut self, state: &osc::State, current_time: Instant) -> Interpolated { pub fn update<const COLS: usize, const ROWS: usize>(
&mut self,
state: &osc::State<COLS, ROWS>,
current_time: Instant,
) -> Interpolated {
// The OSC state is the source of truth for the base position and tempo. // The OSC state is the source of truth for the base position and tempo.
let base_position = state.metronome_position; let base_position = state.metronome_position;
let base_timestamp = state.metronome_timestamp; let base_timestamp = state.metronome_timestamp;
@ -82,7 +86,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -111,7 +115,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -146,7 +150,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -177,7 +181,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.8; state.metronome_position = 0.8;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -205,7 +209,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.8; state.metronome_position = 0.8;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -227,7 +231,7 @@ mod interpolation_without_state_update_tests {
let result3 = interpolation.update(&state, slightly_later_time); let result3 = interpolation.update(&state, slightly_later_time);
assert!(!result3.beat_boundary); // Boundary already detected, don't fire again assert!(!result3.beat_boundary); // Boundary already detected, don't fire again
// Position should advance only slightly: 1ms / 500ms = 0.002 // Position should advance only slightly: 1ms / 500ms = 0.002
let expected_position_3 = 0.1 + 0.002; let expected_position_3 = 0.1 + 0.002;
assert!((result3.position_in_beat - expected_position_3).abs() < 0.001); assert!((result3.position_in_beat - expected_position_3).abs() < 0.001);
} }
@ -238,7 +242,7 @@ mod interpolation_without_state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.8; state.metronome_position = 0.8;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -259,7 +263,8 @@ mod interpolation_without_state_update_tests {
let result3 = interpolation.update(&state, later_time); let result3 = interpolation.update(&state, later_time);
assert!(!result3.beat_boundary); // Boundary already detected, don't fire again assert!(!result3.beat_boundary); // Boundary already detected, don't fire again
assert!((result3.position_in_beat - result2.position_in_beat).abs() < 0.001); // Same position assert!((result3.position_in_beat - result2.position_in_beat).abs() < 0.001);
// Same position
} }
} }
@ -274,7 +279,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -321,7 +326,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.3; state.metronome_position = 0.3;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -367,7 +372,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.9; state.metronome_position = 0.9;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -410,7 +415,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.85; state.metronome_position = 0.85;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -453,7 +458,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.92; state.metronome_position = 0.92;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -488,7 +493,11 @@ mod state_update_tests {
// Should show current position: OSC 0.98 at 80ms + 20ms interpolation // Should show current position: OSC 0.98 at 80ms + 20ms interpolation
// 20ms = 0.02 seconds = 0.02 / 0.5 = 0.04 beat progress // 20ms = 0.02 seconds = 0.02 / 0.5 = 0.04 beat progress
let expected_position = 0.98 + 0.04; // = 1.02 → wraps to 0.02 let expected_position = 0.98 + 0.04; // = 1.02 → wraps to 0.02
let wrapped_position = if expected_position >= 1.0 { expected_position - 1.0 } else { expected_position }; let wrapped_position = if expected_position >= 1.0 {
expected_position - 1.0
} else {
expected_position
};
assert!((result3.position_in_beat - wrapped_position).abs() < 0.001); assert!((result3.position_in_beat - wrapped_position).abs() < 0.001);
} }
@ -498,7 +507,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -549,7 +558,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM // Create initial state with 120 BPM
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.2; state.metronome_position = 0.2;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -595,7 +604,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.8; state.metronome_position = 0.8;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -644,7 +653,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.85; state.metronome_position = 0.85;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;
@ -701,7 +710,7 @@ mod state_update_tests {
let mut interpolation = Interpolation::new(start_time); let mut interpolation = Interpolation::new(start_time);
// Create initial state with 120 BPM, starting near end of beat // Create initial state with 120 BPM, starting near end of beat
let mut state = osc::State::new(5, 5, 120.0); let mut state = osc::State::<5, 5>::new(120.0);
state.metronome_position = 0.9; state.metronome_position = 0.9;
state.metronome_timestamp = start_time; state.metronome_timestamp = start_time;

View File

@ -6,6 +6,9 @@ mod ui;
use anyhow::anyhow; use anyhow::anyhow;
use anyhow::Result; use anyhow::Result;
const COLUMNS: usize = 5;
const ROWS: usize = 5;
trait DisplayStr { trait DisplayStr {
fn display_str(&self) -> &'static str; fn display_str(&self) -> &'static str;
} }
@ -25,18 +28,17 @@ impl DisplayStr for osc::TrackState {
async fn main() -> Result<()> { async fn main() -> Result<()> {
// Configuration // Configuration
let socket_path = "fcb_looper.sock"; let socket_path = "fcb_looper.sock";
let columns = 5;
let rows = 5;
// Logger // Logger
simple_logger::SimpleLogger::new() simple_logger::SimpleLogger::new()
.with_level(log::LevelFilter::Info) .with_level(log::LevelFilter::Info)
.with_module_level("gui::osc_client", log::LevelFilter::Off) .with_module_level("gui::osc_client", log::LevelFilter::Off)
.with_module_level("osc", log::LevelFilter::Debug)
.init() .init()
.expect("Could not initialize logger"); .expect("Could not initialize logger");
// Create OSC client // Create OSC client
let osc_client = osc_client::Client::new(socket_path, columns, rows)?; let osc_client = osc_client::Client::<COLUMNS, ROWS>::new(socket_path)?;
let state_receiver = osc_client.state_receiver(); let state_receiver = osc_client.state_receiver();
// Spawn OSC receiver thread on tokio runtime // Spawn OSC receiver thread on tokio runtime
@ -58,7 +60,7 @@ async fn main() -> Result<()> {
eframe::run_native( eframe::run_native(
"FCB Looper", "FCB Looper",
options, options,
Box::new(|_cc| Ok(Box::new(ui::App::new(state_receiver)))), Box::new(|_cc| Ok(Box::new(ui::App::<COLUMNS, ROWS>::new(state_receiver)))),
) )
.map_err(|e| anyhow::anyhow!("Failed to run egui: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to run egui: {}", e))?;

View File

@ -15,32 +15,33 @@ type PlatformStream = UnixStream;
#[cfg(windows)] #[cfg(windows)]
type PlatformStream = NamedPipeClient; type PlatformStream = NamedPipeClient;
pub struct Client { pub struct Client<const COLS: usize, const ROWS: usize> {
socket_path: String, socket_path: String,
state_sender: watch::Sender<osc::State>, state_sender: watch::Sender<osc::State<COLS, ROWS>>,
state_receiver: watch::Receiver<osc::State>, state_receiver: watch::Receiver<osc::State<COLS, ROWS>>,
} }
impl Client { impl<const COLS: usize, const ROWS: usize> Client<COLS, ROWS> {
pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> { pub fn new(socket_path: &str) -> Result<Self> {
let initial_state = osc::State::new(columns, rows, 120.0); let initial_state = osc::State::new(120.0);
// Test data // Test data
let initial_state = { let initial_state = {
let mut initial_state = initial_state; let mut initial_state = initial_state;
initial_state.selected_column = 2; initial_state.selected_column = if COLS > 2 { 2 } else { 0 };
initial_state.selected_row = 3; initial_state.selected_row = if ROWS > 3 { 3 } else { 0 };
initial_state.cells[0][0].state = osc::TrackState::Playing;
initial_state.cells[0][0].volume = 0.3; initial_state.columns[0].tracks[0].state = osc::TrackState::Playing;
initial_state.cells[0][1].state = osc::TrackState::Idle; initial_state.columns[0].tracks[0].volume = 0.3;
initial_state.cells[0][1].volume = 1.0; initial_state.columns[0].tracks[1].state = osc::TrackState::Idle;
initial_state.cells[1][0].state = osc::TrackState::Idle; initial_state.columns[0].tracks[1].volume = 1.0;
initial_state.cells[1][0].volume = 0.9; initial_state.columns[1].tracks[0].state = osc::TrackState::Idle;
initial_state.cells[1][1].state = osc::TrackState::Playing; initial_state.columns[1].tracks[0].volume = 0.9;
initial_state.cells[1][1].volume = 0.8; initial_state.columns[1].tracks[1].state = osc::TrackState::Playing;
initial_state.cells[2][0].state = osc::TrackState::Idle; initial_state.columns[1].tracks[1].volume = 0.8;
initial_state.cells[2][0].volume = 0.7; initial_state.columns[2].tracks[0].state = osc::TrackState::Idle;
initial_state.cells[2][3].state = osc::TrackState::Recording; initial_state.columns[2].tracks[0].volume = 0.7;
initial_state.cells[2][3].volume = 0.6; initial_state.columns[2].tracks[3].state = osc::TrackState::Recording;
initial_state.columns[2].tracks[3].volume = 0.6;
initial_state initial_state
}; };
@ -53,7 +54,7 @@ impl Client {
}) })
} }
pub fn state_receiver(&self) -> watch::Receiver<osc::State> { pub fn state_receiver(&self) -> watch::Receiver<osc::State<COLS, ROWS>> {
self.state_receiver.clone() self.state_receiver.clone()
} }
@ -86,12 +87,16 @@ impl Client {
Ok(()) Ok(())
} }
async fn handle_osc_data(&self, data: Bytes, current_state: &mut osc::State) -> Result<()> { async fn handle_osc_data(
&self,
data: Bytes,
current_state: &mut osc::State<COLS, ROWS>,
) -> Result<()> {
let (_, osc_packet) = rosc::decoder::decode_udp(&data) let (_, osc_packet) = rosc::decoder::decode_udp(&data)
.map_err(|e| anyhow!("Failed to decode OSC packet: {}", e))?; .map_err(|e| anyhow!("Failed to decode OSC packet: {}", e))?;
if let Some(update) = osc::Message::from_osc_packet(osc_packet)? { if let Some(update) = osc::Message::from_osc_packet(osc_packet)? {
update.apply_to_state(current_state); current_state.update(&update);
// Send updated state through watch channel // Send updated state through watch channel
if let Err(_) = self.state_sender.send(current_state.clone()) { if let Err(_) = self.state_sender.send(current_state.clone()) {

View File

@ -1,11 +1,11 @@
pub struct App { pub struct App<const COLS: usize, const ROWS: usize> {
state_receiver: tokio::sync::watch::Receiver<osc::State>, state_receiver: tokio::sync::watch::Receiver<osc::State<COLS, ROWS>>,
interpolation: crate::interpolation::Interpolation, interpolation: crate::interpolation::Interpolation,
pendulum: crate::pendulum::Pendulum, pendulum: crate::pendulum::Pendulum,
} }
impl App { impl<const COLS: usize, const ROWS: usize> App<COLS, ROWS> {
pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State>) -> Self { pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State<COLS, ROWS>>) -> Self {
let interpolation = crate::interpolation::Interpolation::new(std::time::Instant::now()); let interpolation = crate::interpolation::Interpolation::new(std::time::Instant::now());
let pendulum = crate::pendulum::Pendulum::new(); let pendulum = crate::pendulum::Pendulum::new();
Self { Self {
@ -16,7 +16,7 @@ impl App {
} }
} }
impl eframe::App for App { impl<const COLS: usize, const ROWS: usize> eframe::App for App<COLS, ROWS> {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let state = self.state_receiver.borrow_and_update().clone(); let state = self.state_receiver.borrow_and_update().clone();
let interpolated = self.interpolation.update(&state, std::time::Instant::now()); let interpolated = self.interpolation.update(&state, std::time::Instant::now());
@ -31,8 +31,8 @@ impl eframe::App for App {
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.add(super::Metronome::new(pendulum_position)); ui.add(super::Metronome::new(pendulum_position));
ui.add(super::Matrix::new( ui.add(super::Matrix::<COLS, ROWS>::new(
&state.cells, &state.columns,
state.selected_column, state.selected_column,
state.selected_row, state.selected_row,
)); ));

View File

@ -1,22 +1,23 @@
use super::ui_rows_ext::UiRowsExt; use super::ui_rows_ext::UiRowsExt;
pub struct Column<'a> {
state: &'a Vec<osc::Track>, pub struct Column<'a, const ROWS: usize> {
tracks: &'a [osc::Track; ROWS],
selected_row: Option<usize>, selected_row: Option<usize>,
} }
impl<'a> Column<'a> { impl<'a, const ROWS: usize> Column<'a, ROWS> {
pub fn new(state: &'a Vec<osc::Track>, selected_row: Option<usize>) -> Self { pub fn new(tracks: &'a [osc::Track; ROWS], selected_row: Option<usize>) -> Self {
Self { Self {
state, tracks,
selected_row, selected_row,
} }
} }
} }
impl<'a> egui::Widget for Column<'a> { impl<'a, const ROWS: usize> egui::Widget for Column<'a, ROWS> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response { fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.rows(self.state.len(), |ui| { ui.rows(self.tracks.len(), |ui| {
for (i, track) in self.state.iter().enumerate() { for (i, track) in self.tracks.iter().enumerate() {
ui[i].add(super::Track::new(track, Some(i) == self.selected_row)); ui[i].add(super::Track::new(track, Some(i) == self.selected_row));
} }
}); });

View File

@ -1,33 +1,33 @@
pub struct Matrix<'a> { pub struct Matrix<'a, const COLS: usize, const ROWS: usize> {
cells: &'a Vec<Vec<osc::Track>>, columns: &'a [osc::Column<ROWS>; COLS],
selected_column: usize, selected_column: usize,
selected_row: usize, selected_row: usize,
} }
impl<'a> Matrix<'a> { impl<'a, const COLS: usize, const ROWS: usize> Matrix<'a, COLS, ROWS> {
pub fn new( pub fn new(
cells: &'a Vec<Vec<osc::Track>>, columns: &'a [osc::Column<ROWS>; COLS],
selected_column: usize, selected_column: usize,
selected_row: usize, selected_row: usize,
) -> Self { ) -> Self {
Self { Self {
cells, columns,
selected_column, selected_column,
selected_row, selected_row,
} }
} }
} }
impl<'a> egui::Widget for Matrix<'a> { impl<'a, const COLS: usize, const ROWS: usize> egui::Widget for Matrix<'a, COLS, ROWS> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response { fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.columns(self.cells.len(), |ui| { ui.columns(self.columns.len(), |ui| {
for (i, column) in self.cells.iter().enumerate() { for (i, column) in self.columns.iter().enumerate() {
let selected_row = if i == self.selected_column { let selected_row = if i == self.selected_column {
Some(self.selected_row) Some(self.selected_row)
} else { } else {
None None
}; };
ui[i].add(super::Column::new(column, selected_row)); ui[i].add(super::Column::new(&column.tracks, selected_row));
} }
}); });
ui.response() ui.response()

View File

@ -6,6 +6,7 @@ use error::AddressParseResult;
pub use error::Error; pub use error::Error;
pub use error::Result; pub use error::Result;
pub use message::Message; pub use message::Message;
pub use state::Column;
pub use state::State; pub use state::State;
pub use state::Track; pub use state::Track;
pub use state::TrackState; pub use state::TrackState;

View File

@ -24,6 +24,14 @@ pub enum Message {
Tempo { Tempo {
bpm: f32, bpm: f32,
}, },
ColumnBeatsChanged {
column: usize,
beats: usize,
},
ColumnBeatChanged {
column: usize,
beat: usize,
},
} }
impl Message { impl Message {
@ -37,7 +45,10 @@ impl Message {
} }
} }
pub fn apply_to_state(&self, state: &mut State) { pub fn apply_to_state<const COLS: usize, const ROWS: usize>(
&self,
state: &mut State<COLS, ROWS>,
) {
match self { match self {
Message::TrackStateChanged { Message::TrackStateChanged {
column, column,
@ -65,6 +76,12 @@ impl Message {
Message::Tempo { bpm } => { Message::Tempo { bpm } => {
state.set_tempo(*bpm); state.set_tempo(*bpm);
} }
Message::ColumnBeatsChanged { column, beats } => {
state.set_column_beats(*column, *beats);
}
Message::ColumnBeatChanged { column, beat } => {
state.set_column_beat(*column, *beat);
}
} }
} }
@ -83,7 +100,7 @@ impl Message {
if let Some(rosc::OscType::String(state_str)) = args.first() { if let Some(rosc::OscType::String(state_str)) = args.first() {
let state = state_str.parse::<TrackState>().unwrap_or(TrackState::Empty); let state = state_str.parse::<TrackState>().unwrap_or(TrackState::Empty);
log::trace!("TrackStateChanged: column={column}, row={row}, state={state}"); log::debug!("TrackStateChanged: column={column}, row={row}, state={state}");
return Ok(Some(Message::TrackStateChanged { column, row, state })); return Ok(Some(Message::TrackStateChanged { column, row, state }));
} }
} else if addr.starts_with("/looper/cell/") && addr.ends_with("/volume") { } else if addr.starts_with("/looper/cell/") && addr.ends_with("/volume") {
@ -97,23 +114,53 @@ impl Message {
let row: usize = parts[4].parse::<usize>().address_part_result("row")? - 1; // Convert to 0-based let row: usize = parts[4].parse::<usize>().address_part_result("row")? - 1; // Convert to 0-based
if let Some(rosc::OscType::Float(volume)) = args.first() { if let Some(rosc::OscType::Float(volume)) = args.first() {
log::trace!("TrackVolumeChanged: column={column}, row={row}, volume={volume}"); log::debug!("TrackVolumeChanged: column={column}, row={row}, volume={volume}");
return Ok(Some(Message::TrackVolumeChanged { return Ok(Some(Message::TrackVolumeChanged {
column, column,
row, row,
volume: *volume, volume: *volume,
})); }));
} }
} else if addr.starts_with("/looper/column/") && addr.ends_with("/beats") {
// Parse: /looper/column/{column}/beats
let parts: Vec<&str> = addr.split('/').collect();
if parts.len() != 5 {
return Err(Error::AddressParseError(addr));
}
let column: usize = parts[3].parse::<usize>().address_part_result("column")? - 1; // Convert to 0-based
if let Some(rosc::OscType::Int(beats)) = args.first() {
log::debug!("ColumnBeatsChanged: column={column}, beats={beats}");
return Ok(Some(Message::ColumnBeatsChanged {
column,
beats: *beats as usize,
}));
}
} else if addr.starts_with("/looper/column/") && addr.ends_with("/beat") {
// Parse: /looper/column/{column}/beat
let parts: Vec<&str> = addr.split('/').collect();
if parts.len() != 5 {
return Err(Error::AddressParseError(addr));
}
let column: usize = parts[3].parse::<usize>().address_part_result("column")? - 1; // Convert to 0-based
if let Some(rosc::OscType::Int(beat)) = args.first() {
let beat = (*beat as usize).saturating_sub(1); // Convert to 0-based
log::debug!("ColumnBeatChanged: column={column}, beat={beat}");
return Ok(Some(Message::ColumnBeatChanged { column, beat }));
}
} else if addr == "/looper/selected/column" { } else if addr == "/looper/selected/column" {
if let Some(rosc::OscType::Int(column_1based)) = args.first() { if let Some(rosc::OscType::Int(column_1based)) = args.first() {
log::trace!("SelectedColumnChanged: column={column_1based}");
let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based
log::debug!("SelectedColumnChanged: column={column}");
return Ok(Some(Message::SelectedColumnChanged { column })); return Ok(Some(Message::SelectedColumnChanged { column }));
} }
} else if addr == "/looper/selected/row" { } else if addr == "/looper/selected/row" {
if let Some(rosc::OscType::Int(row_1based)) = args.first() { if let Some(rosc::OscType::Int(row_1based)) = args.first() {
log::trace!("SelectedRowChanged: row={row_1based}");
let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based
log::debug!("SelectedRowChanged: row={row}");
return Ok(Some(Message::SelectedRowChanged { row })); return Ok(Some(Message::SelectedRowChanged { row }));
} }
} else if addr == "/looper/metronome/position" { } else if addr == "/looper/metronome/position" {
@ -125,7 +172,7 @@ impl Message {
} }
} else if addr == "/looper/tempo" { } else if addr == "/looper/tempo" {
if let Some(rosc::OscType::Float(bpm)) = args.first() { if let Some(rosc::OscType::Float(bpm)) = args.first() {
log::trace!("Tempo: bpm={bpm}"); log::debug!("Tempo: bpm={bpm}");
return Ok(Some(Message::Tempo { bpm: *bpm })); return Ok(Some(Message::Tempo { bpm: *bpm }));
} }
} }
@ -187,6 +234,22 @@ impl Message {
args: vec![rosc::OscType::Float(bpm)], args: vec![rosc::OscType::Float(bpm)],
}) })
} }
Message::ColumnBeatsChanged { column, beats } => {
let address = format!("/looper/column/{}/beats", column + 1); // 1-based indexing
rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::Int(beats as i32)],
})
}
Message::ColumnBeatChanged { column, beat } => {
let address = format!("/looper/column/{}/beat", column + 1); // 1-based indexing
rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::Int((beat + 1) as i32)], // Convert to 1-based
})
}
} }
} }
} }

View File

@ -15,37 +15,41 @@ pub struct Track {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct State { pub struct Column<const ROWS: usize> {
pub selected_column: usize, // 0-based (converted to 1-based for OSC) pub tracks: [Track; ROWS],
pub selected_row: usize, // 0-based (converted to 1-based for OSC) pub beat_count: usize, // total beats in column (0 = no beats set)
pub cells: Vec<Vec<Track>>, pub current_beat: usize, // current beat position (0-based internally)
pub columns: usize,
pub rows: usize,
pub metronome_position: f32, // 0.0 - 1.0 position within current beat
pub metronome_timestamp: std::time::Instant, // When position was last updated
pub tempo: f32, // BPM
} }
impl State { #[derive(Debug, Clone)]
pub fn new(columns: usize, rows: usize, tempo: f32) -> Self { pub struct State<const COLS: usize, const ROWS: usize> {
let cells = (0..columns) pub selected_column: usize, // 0-based (converted to 1-based for OSC)
.map(|_| { pub selected_row: usize, // 0-based (converted to 1-based for OSC)
vec![ pub columns: [Column<ROWS>; COLS],
Track { pub metronome_position: f32, // 0.0 - 1.0 position within current beat
state: TrackState::Empty, pub metronome_timestamp: std::time::Instant, // When position was last updated
volume: 1.0 pub tempo: f32, // BPM
}; }
rows
]
})
.collect();
impl<const ROWS: usize> Column<ROWS> {
pub fn new() -> Self {
Self {
tracks: std::array::from_fn(|_| Track {
state: TrackState::Empty,
volume: 1.0,
}),
beat_count: 0,
current_beat: 0,
}
}
}
impl<const COLS: usize, const ROWS: usize> State<COLS, ROWS> {
pub fn new(tempo: f32) -> Self {
Self { Self {
selected_column: 0, selected_column: 0,
selected_row: 0, selected_row: 0,
cells, columns: std::array::from_fn(|_| Column::new()),
columns,
rows,
metronome_position: 0.0, metronome_position: 0.0,
metronome_timestamp: std::time::Instant::now(), metronome_timestamp: std::time::Instant::now(),
tempo, tempo,
@ -55,8 +59,8 @@ impl State {
pub fn update(&mut self, message: &Message) { pub fn update(&mut self, message: &Message) {
match message { match message {
Message::TrackStateChanged { column, row, state } => { Message::TrackStateChanged { column, row, state } => {
if *column < self.columns && *row < self.rows { if *column < COLS && *row < ROWS {
self.cells[*column][*row].state = state.clone(); self.columns[*column].tracks[*row].state = state.clone();
} }
} }
Message::TrackVolumeChanged { Message::TrackVolumeChanged {
@ -64,17 +68,17 @@ impl State {
row, row,
volume, volume,
} => { } => {
if *column < self.columns && *row < self.rows { if *column < COLS && *row < ROWS {
self.cells[*column][*row].volume = *volume; self.columns[*column].tracks[*row].volume = *volume;
} }
} }
Message::SelectedColumnChanged { column } => { Message::SelectedColumnChanged { column } => {
if *column < self.columns { if *column < COLS {
self.selected_column = *column; self.selected_column = *column;
} }
} }
Message::SelectedRowChanged { row } => { Message::SelectedRowChanged { row } => {
if *row < self.rows { if *row < ROWS {
self.selected_row = *row; self.selected_row = *row;
} }
} }
@ -84,6 +88,12 @@ impl State {
Message::Tempo { bpm } => { Message::Tempo { bpm } => {
self.set_tempo(*bpm); self.set_tempo(*bpm);
} }
Message::ColumnBeatsChanged { column, beats } => {
self.set_column_beats(*column, *beats);
}
Message::ColumnBeatChanged { column, beat } => {
self.set_column_beat(*column, *beat);
}
} }
} }
@ -101,9 +111,20 @@ impl State {
row: self.selected_row, row: self.selected_row,
}); });
// Send all cell states and volumes // Send column beat information and cell states/volumes
for (column, column_cells) in self.cells.iter().enumerate() { for (column, column_data) in self.columns.iter().enumerate() {
for (row, track) in column_cells.iter().enumerate() { // Send column beat info
messages.push(Message::ColumnBeatsChanged {
column,
beats: column_data.beat_count,
});
messages.push(Message::ColumnBeatChanged {
column,
beat: column_data.current_beat,
});
// Send all cell states and volumes for this column
for (row, track) in column_data.tracks.iter().enumerate() {
messages.push(Message::TrackStateChanged { messages.push(Message::TrackStateChanged {
column, column,
row, row,
@ -121,26 +142,26 @@ impl State {
} }
pub fn set_track_state(&mut self, column: usize, row: usize, state: TrackState) { pub fn set_track_state(&mut self, column: usize, row: usize, state: TrackState) {
if column < self.columns && row < self.rows { if column < COLS && row < ROWS {
self.cells[column][row].state = state; self.columns[column].tracks[row].state = state;
} }
} }
pub fn set_selected_column(&mut self, column: usize) { pub fn set_selected_column(&mut self, column: usize) {
if column < self.columns { if column < COLS {
self.selected_column = column; self.selected_column = column;
} }
} }
pub fn set_selected_row(&mut self, row: usize) { pub fn set_selected_row(&mut self, row: usize) {
if row < self.rows { if row < ROWS {
self.selected_row = row; self.selected_row = row;
} }
} }
pub fn set_track_volume(&mut self, column: usize, row: usize, volume: f32) { pub fn set_track_volume(&mut self, column: usize, row: usize, volume: f32) {
if column < self.columns && row < self.rows { if column < COLS && row < ROWS {
self.cells[column][row].volume = volume; self.columns[column].tracks[row].volume = volume;
} }
} }
@ -152,4 +173,16 @@ impl State {
pub fn set_tempo(&mut self, bpm: f32) { pub fn set_tempo(&mut self, bpm: f32) {
self.tempo = bpm; self.tempo = bpm;
} }
pub fn set_column_beats(&mut self, column: usize, beats: usize) {
if column < COLS {
self.columns[column].beat_count = beats;
}
}
pub fn set_column_beat(&mut self, column: usize, beat: usize) {
if column < COLS {
self.columns[column].current_beat = beat;
}
}
} }