Added track volume control to audio engine

This commit is contained in:
Geens 2025-06-19 19:48:43 +02:00
parent b68e3af28a
commit 70b2f92a8d
15 changed files with 595 additions and 248 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
config/
collect.txt collect.txt

View File

@ -10,8 +10,8 @@ pub struct Args {
#[arg(short = 's', long = "socket", env = "SOCKET", default_value = "fcb_looper.sock")] #[arg(short = 's', long = "socket", env = "SOCKET", default_value = "fcb_looper.sock")]
pub socket: String, pub socket: String,
/// Path to configuration file /// Path to configuration dir
#[arg(short = 'c', long = "config-file", env = "CONFIG_FILE", default_value = default_config_path())] #[arg(short = 'c', long = "config-path", env = "CONFIG_PATH", default_value = default_config_path())]
pub config_file: PathBuf, pub config_file: PathBuf,
} }
@ -26,8 +26,11 @@ impl Args {
} }
fn default_config_path() -> String { fn default_config_path() -> String {
/*
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".fcb_looper"); path.push(".fcb_looper");
path.push("state.json"); path.push("state.json");
path.to_string_lossy().to_string() path.to_string_lossy().to_string()
*/
"../config".to_string()
} }

View File

@ -37,9 +37,14 @@ impl<const ROWS: usize> Column<ROWS> {
return true; return true;
} }
pub fn handle_record_button(&mut self, row: usize) -> Result<()> { pub fn handle_record_button(
&mut self,
last_volume_setting: f32,
controllers: &TrackControllers,
) -> Result<()> {
let len = self.len(); let len = self.len();
let track = &mut self.tracks[row]; let track = &mut self.tracks[controllers.row()];
if track.is_recording() { if track.is_recording() {
if len > 0 { if len > 0 {
track.clear(); track.clear();
@ -49,71 +54,76 @@ impl<const ROWS: usize> Column<ROWS> {
} else { } else {
if len > 0 { if len > 0 {
let sync_offset = len - self.playback_position; let sync_offset = len - self.playback_position;
track.record_auto_stop(len, sync_offset); track.record_auto_stop(len, sync_offset, last_volume_setting);
} else { } else {
track.record(); track.record(last_volume_setting);
} }
} }
Ok(()) Ok(())
} }
pub fn handle_play_button(&mut self, row: usize) -> Result<()> { pub fn handle_play_button(
let track = &mut self.tracks[row]; &mut self,
if track.len() > 0 && track.is_idle() { controllers: &TrackControllers,
) -> Result<()> {
let track = &mut self.tracks[controllers.row()];
if track.len() > 0 && track.is_idle() {
track.play(); track.play();
} else if ! track.is_idle() { } else if !track.is_idle() {
track.stop(); track.stop();
} }
Ok(()) Ok(())
} }
pub fn handle_clear_button(&mut self, row: usize) -> Result<()> { pub fn handle_clear_button(
let track = &mut self.tracks[row]; &mut self,
controllers: &TrackControllers,
) -> Result<()> {
let track = &mut self.tracks[controllers.row()];
track.clear(); track.clear();
Ok(()) Ok(())
} }
pub fn handle_volume_update(
&mut self,
new_volume: f32,
controllers: &TrackControllers,
) -> Result<()> {
self.tracks[controllers.row()].set_volume(new_volume, controllers)
}
pub fn set_consolidated_buffer(&mut self, row: usize, buffer: Box<[f32]>) -> Result<()> { pub fn set_consolidated_buffer(&mut self, row: usize, buffer: Box<[f32]>) -> Result<()> {
self.tracks[row].set_consolidated_buffer(buffer) self.tracks[row].set_consolidated_buffer(buffer)
} }
pub fn handle_xrun<PRC, OC>( pub fn handle_xrun(
&mut self, &mut self,
timing: &BufferTiming, timing: &BufferTiming,
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
post_record_controller: PRC, controllers: &ColumnControllers,
osc_controller: OC, ) -> Result<()> {
) -> Result<()>
where
PRC: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(usize, TrackState) -> Result<()>,
{
for (row, track) in self.tracks.iter_mut().enumerate() { for (row, track) in self.tracks.iter_mut().enumerate() {
let track_controllers = controllers.to_track_controllers(row);
track.handle_xrun( track.handle_xrun(
timing.beat_in_missed, timing.beat_in_missed,
timing.missed_frames, timing.missed_frames,
chunk_factory, chunk_factory,
|chunk, sync_offset| post_record_controller(row, chunk, sync_offset), &track_controllers,
|state| osc_controller(row, state),
)?; )?;
} }
Ok(()) Ok(())
} }
pub fn process<PRC, OC>( pub fn process(
&mut self, &mut self,
timing: &BufferTiming, timing: &BufferTiming,
input_buffer: &[f32], input_buffer: &[f32],
output_buffer: &mut [f32], output_buffer: &mut [f32],
scratch_pad: &mut [f32], scratch_pad: &mut [f32],
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
post_record_controller: PRC, controllers: &ColumnControllers,
osc_controller: OC, ) -> Result<()> {
) -> Result<()>
where
PRC: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(usize, TrackState) -> Result<()>,
{
let len = self.len(); let 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 {
@ -125,20 +135,24 @@ impl<const ROWS: usize> Column<ROWS> {
} }
} }
} }
for (row, track) in self.tracks.iter_mut().enumerate() { for (row, track) in self.tracks.iter_mut().enumerate() {
let track_controllers = controllers.to_track_controllers(row);
track.process( track.process(
self.playback_position, self.playback_position,
timing.beat_in_buffer, timing.beat_in_buffer,
input_buffer, input_buffer,
scratch_pad, scratch_pad,
chunk_factory, chunk_factory,
|chunk, sync_offset| post_record_controller(row, chunk, sync_offset), &track_controllers,
|state| osc_controller(row, state),
)?; )?;
for (output_val, scratch_pad_val) in output_buffer.iter_mut().zip(scratch_pad.iter()) { for (output_val, scratch_pad_val) in output_buffer.iter_mut().zip(scratch_pad.iter()) {
*output_val += *scratch_pad_val; *output_val += *scratch_pad_val;
} }
} }
let len = self.len(); let len = self.len();
if len > 0 { if len > 0 {
self.playback_position = (self.playback_position + input_buffer.len()) % self.len(); self.playback_position = (self.playback_position + input_buffer.len()) % self.len();

View File

@ -0,0 +1,118 @@
use crate::*;
pub struct MatrixControllers<'a> {
post_record: &'a PostRecordController,
osc: &'a OscController,
persistence: &'a PersistenceManagerController,
sample_rate: usize,
}
pub struct ColumnControllers<'a> {
post_record: &'a PostRecordController,
osc: &'a OscController,
persistence: &'a PersistenceManagerController,
sample_rate: usize,
column: usize,
}
pub struct TrackControllers<'a> {
post_record: &'a PostRecordController,
osc: &'a OscController,
persistence: &'a PersistenceManagerController,
sample_rate: usize,
column: usize,
row: usize,
}
impl<'a> MatrixControllers<'a> {
pub fn new(
post_record: &'a PostRecordController,
osc: &'a OscController,
persistence: &'a PersistenceManagerController,
sample_rate: usize,
) -> Self {
Self {
post_record,
osc,
persistence,
sample_rate,
}
}
pub fn to_column_controllers(&self, column: usize) -> ColumnControllers<'a> {
ColumnControllers {
post_record: self.post_record,
osc: self.osc,
persistence: self.persistence,
sample_rate: self.sample_rate,
column,
}
}
pub fn try_recv_post_record_response(&self) -> Option<PostRecordResponse> {
self.post_record.try_recv_response()
}
}
impl<'a> ColumnControllers<'a> {
pub fn to_track_controllers(&self, row: usize) -> TrackControllers<'a> {
TrackControllers {
post_record: self.post_record,
osc: self.osc,
persistence: self.persistence,
sample_rate: self.sample_rate,
column: self.column,
row,
}
}
}
impl<'a> TrackControllers<'a> {
pub fn new(
post_record: &'a PostRecordController,
osc: &'a OscController,
persistence: &'a PersistenceManagerController,
sample_rate: usize,
column: usize,
row: usize,
) -> Self {
Self {
post_record,
osc,
persistence,
sample_rate,
column,
row,
}
}
pub fn send_post_record_request(&self, chunk: Arc<AudioChunk>, sync_offset: usize) -> Result<()> {
self.post_record.send_request(
self.column,
self.row,
chunk,
sync_offset,
self.sample_rate,
)
}
pub fn send_state_update(&self, state: TrackState) -> Result<()> {
self.osc.track_state_changed(self.column, self.row, state)
}
pub fn send_volume_update(&self, volume: f32) -> Result<()> {
self.osc.track_volume_changed(self.column, self.row, volume)
}
pub fn update_track_volume(&self, volume: f32) -> Result<()> {
self.persistence.update_track_volume(self.column, self.row, volume)
}
pub fn column(&self) -> usize {
self.column
}
pub fn row(&self) -> usize {
self.row
}
}

View File

@ -6,6 +6,7 @@ mod beep;
mod chunk_factory; mod chunk_factory;
mod column; mod column;
mod connection_manager; mod connection_manager;
mod controllers;
mod looper_error; mod looper_error;
mod metronome; mod metronome;
mod midi; mod midi;
@ -28,6 +29,9 @@ use beep::generate_beep;
use chunk_factory::ChunkFactory; use chunk_factory::ChunkFactory;
use column::Column; use column::Column;
use connection_manager::ConnectionManager; use connection_manager::ConnectionManager;
use controllers::ColumnControllers;
use controllers::MatrixControllers;
use controllers::TrackControllers;
use looper_error::LooperError; use looper_error::LooperError;
use looper_error::Result; use looper_error::Result;
use metronome::BufferTiming; use metronome::BufferTiming;
@ -37,8 +41,10 @@ use notification_handler::NotificationHandler;
use osc::OscController; use osc::OscController;
use osc::Osc; use osc::Osc;
use persistence_manager::PersistenceManager; use persistence_manager::PersistenceManager;
use persistence_manager::PersistenceManagerController;
use post_record_handler::PostRecordController; use post_record_handler::PostRecordController;
use post_record_handler::PostRecordHandler; use post_record_handler::PostRecordHandler;
use post_record_handler::PostRecordResponse;
use process_handler::ProcessHandler; use process_handler::ProcessHandler;
use state::State; use state::State;
use track::Track; use track::Track;
@ -71,29 +77,28 @@ async fn main() {
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 persistence_manager, state_watch) = let (mut persistence_manager, persistence_controller) =
PersistenceManager::new(&args, notification_handler.subscribe()); PersistenceManager::new(&args, notification_handler.subscribe());
let state = persistence_manager.state();
// Load state values for metronome configuration
let initial_state = state_watch.borrow().clone();
// Create post-record handler and get controller for ProcessHandler // Create post-record handler and get controller for ProcessHandler
let (mut post_record_handler, post_record_controller) = let (mut post_record_handler, post_record_controller) =
PostRecordHandler::new().expect("Could not create post-record handler"); PostRecordHandler::new(&args).expect("Could not create post-record handler");
let process_handler = ProcessHandler::new( let process_handler = ProcessHandler::new(
&jack_client, &jack_client,
ports, ports,
allocator, allocator,
beep_samples, beep_samples,
&initial_state, &state.borrow(),
osc_controller,
post_record_controller, post_record_controller,
osc_controller,
persistence_controller,
) )
.expect("Could not create process handler"); .expect("Could not create process handler");
let mut connection_manager = ConnectionManager::new( let mut connection_manager = ConnectionManager::new(
state_watch, persistence_manager.state(),
notification_handler.subscribe(), notification_handler.subscribe(),
jack_client.name().to_string(), jack_client.name().to_string(),
) )
@ -171,4 +176,4 @@ fn setup_jack() -> (jack::Client, JackPorts) {
}; };
(jack_client, ports) (jack_client, ports)
} }

View File

@ -34,48 +34,53 @@ pub fn process_events<F: ChunkFactory>(
Ok(message) => { Ok(message) => {
match message { match message {
wmidi::MidiMessage::ControlChange(_, controller, value) => { wmidi::MidiMessage::ControlChange(_, controller, value) => {
// Only process button presses (value > 0) let controller_num = u8::from(controller);
if u8::from(value) > 0 { let value_num = u8::from(value);
match u8::from(controller) {
20 => { match controller_num {
process_handler.handle_button_1()?; // Expression Pedal A - Track Volume
} 1 => {
21 => { process_handler.handle_pedal_a(value_num)?;
process_handler.handle_button_2()?; }
} // Button mappings (only process button presses - value > 0)
22 => { 20 if value_num > 0 => {
process_handler.handle_button_3()?; process_handler.handle_button_1()?;
} }
23 => { 21 if value_num > 0 => {
process_handler.handle_button_4()?; process_handler.handle_button_2()?;
} }
24 => { 22 if value_num > 0 => {
process_handler.handle_button_5()?; process_handler.handle_button_3()?;
} }
25 => { 23 if value_num > 0 => {
process_handler.handle_button_6()?; process_handler.handle_button_4()?;
} }
26 => { 24 if value_num > 0 => {
process_handler.handle_button_7()?; process_handler.handle_button_5()?;
} }
27 => { 25 if value_num > 0 => {
process_handler.handle_button_8()?; process_handler.handle_button_6()?;
} }
28 => { 26 if value_num > 0 => {
process_handler.handle_button_9()?; process_handler.handle_button_7()?;
} }
29 => { 27 if value_num > 0 => {
process_handler.handle_button_10()?; process_handler.handle_button_8()?;
} }
30 => { 28 if value_num > 0 => {
process_handler.handle_button_up()?; process_handler.handle_button_9()?;
} }
31 => { 29 if value_num > 0 => {
process_handler.handle_button_down()?; process_handler.handle_button_10()?;
} }
_ => { 30 if value_num > 0 => {
// Other CC messages - ignore process_handler.handle_button_up()?;
} }
31 if value_num > 0 => {
process_handler.handle_button_down()?;
}
_ => {
// Other CC messages - ignore
} }
} }
} }
@ -91,4 +96,4 @@ pub fn process_events<F: ChunkFactory>(
} }
Ok(()) Ok(())
} }

View File

@ -25,6 +25,20 @@ impl OscController {
} }
} }
pub fn track_volume_changed(
&self,
column: usize,
row: usize,
volume: f32,
) -> Result<()> {
let message = OscMessage::TrackVolumeChanged { column, row, volume };
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 selected_column_changed(&self, column: usize) -> Result<()> { pub fn selected_column_changed(&self, column: usize) -> Result<()> {
let message = OscMessage::SelectedColumnChanged { column }; let message = OscMessage::SelectedColumnChanged { column };
match self.sender.try_send(message) { match self.sender.try_send(message) {

View File

@ -7,6 +7,11 @@ pub(crate) enum OscMessage {
row: usize, row: usize,
state: TrackState, state: TrackState,
}, },
TrackVolumeChanged {
column: usize,
row: usize,
volume: f32,
},
SelectedColumnChanged { SelectedColumnChanged {
column: usize, column: usize,
}, },
@ -15,11 +20,17 @@ pub(crate) enum OscMessage {
}, },
} }
#[derive(Debug, Clone)]
pub(crate) struct Track {
pub state: TrackState,
pub volume: f32,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ShadowState { pub(crate) struct ShadowState {
pub selected_column: usize, // 0-based (converted to 1-based for OSC) pub selected_column: usize, // 0-based (converted to 1-based for OSC)
pub selected_row: usize, // 0-based (converted to 1-based for OSC) pub selected_row: usize, // 0-based (converted to 1-based for OSC)
pub cells: Vec<Vec<TrackState>>, pub cells: Vec<Vec<Track>>,
pub columns: usize, pub columns: usize,
pub rows: usize, pub rows: usize,
} }
@ -27,7 +38,7 @@ pub(crate) struct ShadowState {
impl ShadowState { impl ShadowState {
pub fn new(columns: usize, rows: usize) -> Self { pub fn new(columns: usize, rows: usize) -> Self {
let cells = (0..columns) let cells = (0..columns)
.map(|_| vec![TrackState::Empty; rows]) .map(|_| vec![Track { state: TrackState::Empty, volume: 1.0 }; rows])
.collect(); .collect();
Self { Self {
@ -43,7 +54,12 @@ impl ShadowState {
match message { match message {
OscMessage::TrackStateChanged { column, row, state } => { OscMessage::TrackStateChanged { column, row, state } => {
if *column < self.columns && *row < self.rows { if *column < self.columns && *row < self.rows {
self.cells[*column][*row] = state.clone(); self.cells[*column][*row].state = state.clone();
}
}
OscMessage::TrackVolumeChanged { column, row, volume } => {
if *column < self.columns && *row < self.rows {
self.cells[*column][*row].volume = *volume;
} }
} }
OscMessage::SelectedColumnChanged { column } => { OscMessage::SelectedColumnChanged { column } => {
@ -70,13 +86,18 @@ impl ShadowState {
row: self.selected_row, row: self.selected_row,
}); });
// Send all cell states // Send all cell states and volumes
for (column, column_cells) in self.cells.iter().enumerate() { for (column, column_cells) in self.cells.iter().enumerate() {
for (row, cell_state) in column_cells.iter().enumerate() { for (row, track) in column_cells.iter().enumerate() {
messages.push(OscMessage::TrackStateChanged { messages.push(OscMessage::TrackStateChanged {
column, column,
row, row,
state: cell_state.clone(), state: track.state.clone(),
});
messages.push(OscMessage::TrackVolumeChanged {
column,
row,
volume: track.volume,
}); });
} }
} }

View File

@ -119,6 +119,14 @@ impl Osc {
args: vec![rosc::OscType::String(osc_state)], args: vec![rosc::OscType::String(osc_state)],
})) }))
} }
OscMessage::TrackVolumeChanged { column, row, volume } => {
let address = format!("/looper/cell/{}/{}/volume", column + 1, row + 1); // 1-based indexing
Ok(rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::Float(volume)],
}))
}
OscMessage::SelectedColumnChanged { column } => { OscMessage::SelectedColumnChanged { column } => {
let address = "/looper/selected/column".to_string(); let address = "/looper/selected/column".to_string();
@ -142,7 +150,8 @@ impl Osc {
match state { match state {
crate::TrackState::Empty => "empty".to_string(), crate::TrackState::Empty => "empty".to_string(),
crate::TrackState::Idle => "ready".to_string(), crate::TrackState::Idle => "ready".to_string(),
crate::TrackState::Recording | crate::TrackState::RecordingAutoStop { .. } => "recording".to_string(), crate::TrackState::Recording { .. } => "recording".to_string(),
crate::TrackState::RecordingAutoStop { .. } => "recording".to_string(),
crate::TrackState::Playing => "playing".to_string(), crate::TrackState::Playing => "playing".to_string(),
} }
} }

View File

@ -2,10 +2,31 @@ use crate::*;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
#[derive(Debug)]
pub struct PersistenceManagerController {
sender: kanal::Sender<PersistenceUpdate>,
}
#[derive(Debug)]
pub enum PersistenceUpdate {
UpdateTrackVolume { column: usize, row: usize, volume: f32 },
}
impl PersistenceManagerController {
pub fn update_track_volume(&self, column: usize, row: usize, volume: f32) -> Result<()> {
let update = PersistenceUpdate::UpdateTrackVolume { column, row, 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 struct PersistenceManager { pub struct PersistenceManager {
state: State, state: watch::Sender<State>,
state_tx: watch::Sender<State>,
notification_rx: broadcast::Receiver<JackNotification>, notification_rx: broadcast::Receiver<JackNotification>,
update_rx: kanal::AsyncReceiver<PersistenceUpdate>,
state_file_path: PathBuf, state_file_path: PathBuf,
} }
@ -13,33 +34,65 @@ impl PersistenceManager {
pub fn new( pub fn new(
args: &Args, args: &Args,
notification_rx: broadcast::Receiver<JackNotification>, notification_rx: broadcast::Receiver<JackNotification>,
) -> (Self, watch::Receiver<State>) { ) -> (Self, PersistenceManagerController) {
let state_file_path = args.config_file.clone(); let state_file_path = args.config_file.join("state.json");
if let Some(parent) = state_file_path.parent() { std::fs::create_dir_all(&args.config_file).ok(); // Create directory if it doesn't exist
std::fs::create_dir_all(parent).ok(); // Create directory if it doesn't exist
}
let initial_state = Self::load_from_disk(&state_file_path).unwrap_or_default(); let initial_state = Self::load_from_disk(&state_file_path).unwrap_or_default();
let (state_tx, state_rx) = watch::channel(initial_state.clone()); let (state, _) = watch::channel(initial_state);
let (update_tx, update_rx) = kanal::bounded(64);
let update_rx = update_rx.to_async();
let controller = PersistenceManagerController {
sender: update_tx,
};
let manager = Self { let manager = Self {
state: initial_state, state,
state_tx,
notification_rx, notification_rx,
update_rx,
state_file_path, state_file_path,
}; };
(manager, state_rx) (manager, controller)
}
pub fn state(&self) -> watch::Receiver<State> {
self.state.subscribe()
} }
pub async fn run(&mut self) -> Result<()> { pub async fn run(&mut self) -> Result<()> {
while let Ok(notification) = self.notification_rx.recv().await { loop {
if let JackNotification::PortConnect { tokio::select! {
port_a, notification = self.notification_rx.recv() => {
port_b, match notification {
connected, Ok(JackNotification::PortConnect { port_a, port_b, connected }) => {
} = notification self.handle_port_connection(port_a, port_b, connected)?;
{ }
self.handle_port_connection(port_a, port_b, connected)?; Ok(_) => {}
Err(_) => break,
}
}
// Handle state updates
update = self.update_rx.recv() => {
match update {
Ok(update) => {
self.handle_state_update(update)?;
}
Err(_) => break,
}
}
}
}
Ok(())
}
fn handle_state_update(&mut self, update: PersistenceUpdate) -> Result<()> {
match update {
PersistenceUpdate::UpdateTrackVolume { column, row, volume } => {
self.state.send_modify(|state| state.track_volumes.set_volume(column, row, volume));
self.save_to_disk()?;
} }
} }
@ -64,37 +117,36 @@ impl PersistenceManager {
// Update the connections state // Update the connections state
let our_port_name = our_port.strip_prefix("looper:").unwrap_or(&our_port); let our_port_name = our_port.strip_prefix("looper:").unwrap_or(&our_port);
let connections = &mut self.state.connections; self.state.send_if_modified(|state| {
let connections = &mut state.connections;
let port_list = match our_port_name { let port_list = match our_port_name {
"midi_in" => &mut connections.midi_in, "midi_in" => &mut connections.midi_in,
"audio_in" => &mut connections.audio_in, "audio_in" => &mut connections.audio_in,
"audio_out" => &mut connections.audio_out, "audio_out" => &mut connections.audio_out,
"click_track" => &mut connections.click_track_out, "click_track" => &mut connections.click_track_out,
_ => { _ => {
log::warn!("Unknown port: {}", our_port_name); log::warn!("Unknown port: {}", our_port_name);
return Ok(()); return false;
}
};
if connected {
// Add connection if not already present
if !port_list.contains(&external_port) {
port_list.push(external_port.clone());
log::info!("Added connection: {} -> {}", our_port, external_port);
true
} else {
false
}
} else {
// Remove connection
port_list.retain(|p| p != &external_port);
log::info!("Removed connection: {} -> {}", our_port, external_port);
true
} }
}; });
if connected {
// Add connection if not already present
if !port_list.contains(&external_port) {
port_list.push(external_port.clone());
log::info!("Added connection: {} -> {}", our_port, external_port);
}
} else {
// Remove connection
port_list.retain(|p| p != &external_port);
log::info!("Removed connection: {} -> {}", our_port, external_port);
}
// Broadcast state change
if let Err(e) = self.state_tx.send(self.state.clone()) {
log::error!("Failed to broadcast state change: {}", e);
}
// Save to disk
self.save_to_disk()?; self.save_to_disk()?;
Ok(()) Ok(())
@ -116,7 +168,7 @@ impl PersistenceManager {
} }
fn save_to_disk(&self) -> Result<()> { fn save_to_disk(&self) -> Result<()> {
let json = serde_json::to_string_pretty(&self.state) let json = serde_json::to_string_pretty(&*self.state.borrow())
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?; .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
std::fs::write(&self.state_file_path, json) std::fs::write(&self.state_file_path, json)
@ -125,4 +177,4 @@ impl PersistenceManager {
log::debug!("Saved state to {:?}", self.state_file_path); log::debug!("Saved state to {:?}", self.state_file_path);
Ok(()) Ok(())
} }
} }

View File

@ -69,7 +69,7 @@ pub struct PostRecordHandler {
impl PostRecordHandler { impl PostRecordHandler {
/// Create new handler and return RT controller /// Create new handler and return RT controller
pub fn new() -> Result<(Self, PostRecordController)> { pub fn new(args: &Args) -> Result<(Self, PostRecordController)> {
// Create channels for bidirectional communication // Create channels for bidirectional communication
let (request_sender, request_receiver) = kanal::bounded(16); let (request_sender, request_receiver) = kanal::bounded(16);
let (response_sender, response_receiver) = kanal::bounded(16); let (response_sender, response_receiver) = kanal::bounded(16);
@ -85,7 +85,7 @@ impl PostRecordHandler {
let handler = Self { let handler = Self {
request_receiver, request_receiver,
response_sender, response_sender,
directory: Self::create_directory()?, directory: args.config_file.clone(),
}; };
Ok((handler, controller)) Ok((handler, controller))
@ -219,18 +219,6 @@ impl PostRecordHandler {
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))? .map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?
} }
/// Create save directory and return path
fn create_directory() -> Result<PathBuf> {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".fcb_looper");
std::fs::create_dir_all(&path)
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
Ok(path)
}
/// Get file path for track recording
fn get_file_path(&self, row: usize) -> PathBuf { fn get_file_path(&self, row: usize) -> PathBuf {
self.directory.join(format!("row_{row}.wav")) self.directory.join(format!("row_{row}.wav"))
} }

View File

@ -4,12 +4,16 @@ const COLS: usize = 5;
const ROWS: usize = 5; const ROWS: usize = 5;
pub struct ProcessHandler<F: ChunkFactory> { pub struct ProcessHandler<F: ChunkFactory> {
post_record_controller: PostRecordController,
osc: OscController, osc: OscController,
persistence: PersistenceManagerController,
ports: JackPorts, ports: JackPorts,
metronome: Metronome, metronome: Metronome,
track_matrix: TrackMatrix<F, COLS, ROWS>, track_matrix: TrackMatrix<F, COLS, ROWS>,
selected_row: usize, selected_row: usize,
selected_column: usize, selected_column: usize,
last_volume_setting: f32,
sample_rate: usize,
} }
impl<F: ChunkFactory> ProcessHandler<F> { impl<F: ChunkFactory> ProcessHandler<F> {
@ -19,22 +23,26 @@ impl<F: ChunkFactory> ProcessHandler<F> {
chunk_factory: F, chunk_factory: F,
beep_samples: Arc<AudioChunk>, beep_samples: Arc<AudioChunk>,
state: &State, state: &State,
osc: OscController,
post_record_controller: PostRecordController, post_record_controller: PostRecordController,
osc: OscController,
persistence: PersistenceManagerController,
) -> Result<Self> { ) -> Result<Self> {
let track_matrix = TrackMatrix::new( let track_matrix = TrackMatrix::new(
client, client,
chunk_factory, chunk_factory,
state, state,
post_record_controller,
)?; )?;
Ok(Self { Ok(Self {
post_record_controller,
osc, osc,
persistence,
ports, ports,
metronome: Metronome::new(beep_samples, state), metronome: Metronome::new(beep_samples, state),
track_matrix, track_matrix,
selected_row: 0, selected_row: 0,
selected_column: 0, selected_column: 0,
last_volume_setting: 1.0,
sample_rate: client.sample_rate(),
}) })
} }
@ -42,12 +50,50 @@ impl<F: ChunkFactory> ProcessHandler<F> {
&self.ports &self.ports
} }
pub fn handle_pedal_a(&mut self, value: u8) -> Result<()> {
self.last_volume_setting = (value as f32) / 127.0;
let controllers = TrackControllers::new(
&self.post_record_controller,
&self.osc,
&self.persistence,
self.sample_rate,
self.selected_column,
self.selected_row,
);
self.track_matrix.handle_volume_update(
self.last_volume_setting,
&controllers,
)
}
pub fn handle_button_1(&mut self) -> Result<()> { pub fn handle_button_1(&mut self) -> Result<()> {
self.track_matrix.handle_record_button(self.selected_column, self.selected_row) let controllers = TrackControllers::new(
&self.post_record_controller,
&self.osc,
&self.persistence,
self.sample_rate,
self.selected_column,
self.selected_row,
);
self.track_matrix.handle_record_button(
self.last_volume_setting,
&controllers,
)
} }
pub fn handle_button_2(&mut self) -> Result<()> { pub fn handle_button_2(&mut self) -> Result<()> {
self.track_matrix.handle_play_button(self.selected_column, self.selected_row) let controllers = TrackControllers::new(
&self.post_record_controller,
&self.osc,
&self.persistence,
self.sample_rate,
self.selected_column,
self.selected_row,
);
self.track_matrix.handle_play_button(
&controllers,
)
} }
pub fn handle_button_3(&mut self) -> Result<()> { pub fn handle_button_3(&mut self) -> Result<()> {
@ -59,7 +105,17 @@ impl<F: ChunkFactory> ProcessHandler<F> {
} }
pub fn handle_button_5(&mut self) -> Result<()> { pub fn handle_button_5(&mut self) -> Result<()> {
self.track_matrix.handle_clear_button(self.selected_column, self.selected_row) let controllers = TrackControllers::new(
&self.post_record_controller,
&self.osc,
&self.persistence,
self.sample_rate,
self.selected_column,
self.selected_row,
);
self.track_matrix.handle_clear_button(
&controllers,
)
} }
pub fn handle_button_6(&mut self) -> Result<()> { pub fn handle_button_6(&mut self) -> Result<()> {
@ -110,8 +166,8 @@ 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 {
if let Err(e) = self.process_with_error_handling(client, ps) { if let Err(e) = self.process_with_error_handling(ps) {
log::error!("Error processing audio: {}", e); log::error!("Error processing audio: {}", e);
jack::Control::Quit jack::Control::Quit
} else { } else {
@ -123,7 +179,6 @@ impl<F: ChunkFactory> jack::ProcessHandler for ProcessHandler<F> {
impl<F: ChunkFactory> ProcessHandler<F> { impl<F: ChunkFactory> ProcessHandler<F> {
fn process_with_error_handling( fn process_with_error_handling(
&mut self, &mut self,
client: &jack::Client,
ps: &jack::ProcessScope, ps: &jack::ProcessScope,
) -> Result<()> { ) -> Result<()> {
// Process metronome and get beat timing information // Process metronome and get beat timing information
@ -133,14 +188,19 @@ impl<F: ChunkFactory> ProcessHandler<F> {
midi::process_events(self, ps)?; midi::process_events(self, ps)?;
// Process audio // Process audio
let controllers = MatrixControllers::new(
&self.post_record_controller,
&self.osc,
&self.persistence,
self.sample_rate,
);
self.track_matrix.process( self.track_matrix.process(
client,
ps, ps,
&mut self.ports, &mut self.ports,
&timing, &timing,
&mut self.osc, &controllers,
)?; )?;
Ok(()) Ok(())
} }
} }

View File

@ -1,9 +1,11 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[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, pub metronome: MetronomeState,
pub track_volumes: TrackVolumes,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -20,6 +22,35 @@ pub struct MetronomeState {
pub click_volume: f32, // 0.0 to 1.0 pub click_volume: f32, // 0.0 to 1.0
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackVolumes {
volumes: HashMap<String, f32>, // "col_1_row_1" → volume (1-based naming)
}
impl TrackVolumes {
pub fn new() -> Self {
Self {
volumes: HashMap::new(),
}
}
pub fn set_volume(&mut self, column: usize, row: usize, volume: f32) {
let key = Self::make_key(column, row);
let clamped_volume = volume.clamp(0.0, 1.0);
self.volumes.insert(key, clamped_volume);
}
fn make_key(column: usize, row: usize) -> String {
format!("col_{}_row_{}", column + 1, row + 1)
}
}
impl Default for TrackVolumes {
fn default() -> Self {
Self::new()
}
}
impl Default for State { impl Default for State {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -33,6 +64,7 @@ impl Default for State {
frames_per_beat: 96000, // 120 BPM at 192kHz sample rate frames_per_beat: 96000, // 120 BPM at 192kHz sample rate
click_volume: 0.5, // Default 50% volume click_volume: 0.5, // Default 50% volume
}, },
track_volumes: TrackVolumes::new(),
} }
} }
} }

View File

@ -12,8 +12,11 @@ pub enum TrackState {
Empty, Empty,
Idle, Idle,
Playing, Playing,
Recording, Recording {
volume: f32,
},
RecordingAutoStop { RecordingAutoStop {
volume: f32,
target_samples: usize, target_samples: usize,
sync_offset: usize, sync_offset: usize,
}, },
@ -32,7 +35,7 @@ impl Track {
pub fn is_recording(&self) -> bool { pub fn is_recording(&self) -> bool {
matches!( matches!(
self.current_state, self.current_state,
TrackState::Recording | TrackState::RecordingAutoStop { .. } TrackState::Recording { .. } | TrackState::RecordingAutoStop { .. }
) )
} }
@ -48,20 +51,40 @@ impl Track {
self.audio_data.len() self.audio_data.len()
} }
pub fn volume(&self) -> f32 { pub fn set_volume(
self.volume &mut self,
new_volume: f32,
controllers: &TrackControllers,
) -> Result<()> {
let new_volume = new_volume.clamp(0.0, 1.0);
match &mut self.current_state {
TrackState::Recording { volume } => {
*volume = new_volume; // Update recording volume only
controllers.send_volume_update(new_volume)?;
}
TrackState::RecordingAutoStop { volume, .. } => {
*volume = new_volume; // Update recording volume only
controllers.send_volume_update(new_volume)?;
}
_ => {
self.volume = new_volume; // Update committed volume
controllers.update_track_volume(new_volume)?; // Update State
controllers.send_volume_update(new_volume)?; // Update GUI
}
}
Ok(())
} }
pub fn set_volume(&mut self, volume: f32) { pub fn record(&mut self, last_volume_setting: f32) {
self.volume = volume.clamp(0.0, 1.0); self.next_state = TrackState::Recording {
volume: last_volume_setting,
};
} }
pub fn record(&mut self) { pub fn record_auto_stop(&mut self, target_samples: usize, sync_offset: usize, last_volume_setting: f32) {
self.next_state = TrackState::Recording;
}
pub fn record_auto_stop(&mut self, target_samples: usize, sync_offset: usize) {
self.next_state = TrackState::RecordingAutoStop { self.next_state = TrackState::RecordingAutoStop {
volume: last_volume_setting,
target_samples, target_samples,
sync_offset, sync_offset,
}; };
@ -83,18 +106,13 @@ impl Track {
self.audio_data.set_consolidated_buffer(buffer) self.audio_data.set_consolidated_buffer(buffer)
} }
pub fn handle_xrun<PRC, OC>( pub fn handle_xrun(
&mut self, &mut self,
beat_in_missed: Option<u32>, beat_in_missed: Option<u32>,
missed_frames: usize, missed_frames: usize,
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
post_record_controller: PRC, controllers: &TrackControllers,
osc_controller: OC, ) -> Result<()> {
) -> Result<()>
where
PRC: Fn(Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(TrackState) -> Result<()>,
{
match beat_in_missed { match beat_in_missed {
None => { None => {
if self.is_recording() { if self.is_recording() {
@ -111,7 +129,7 @@ impl Track {
.append_silence(beat_offset as _, chunk_factory)?; .append_silence(beat_offset as _, chunk_factory)?;
} }
// Apply state transition at beat boundary // Apply state transition at beat boundary
self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?; self.apply_state_transition(chunk_factory, controllers)?;
// Insert silence after beat with new state // Insert silence after beat with new state
let frames_after_beat = missed_frames - beat_offset as usize; let frames_after_beat = missed_frames - beat_offset as usize;
if frames_after_beat > 0 && self.is_recording() { if frames_after_beat > 0 && self.is_recording() {
@ -124,20 +142,15 @@ impl Track {
} }
/// Audio processing /// Audio processing
pub fn process<PRC, OC>( pub fn process(
&mut self, &mut self,
playback_position: usize, playback_position: usize,
beat_in_buffer: Option<u32>, beat_in_buffer: Option<u32>,
input_buffer: &[f32], input_buffer: &[f32],
output_buffer: &mut [f32], output_buffer: &mut [f32],
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
post_record_controller: PRC, controllers: &TrackControllers,
osc_controller: OC, ) -> Result<()> {
) -> Result<()>
where
PRC: Fn(Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(TrackState) -> Result<()>,
{
match beat_in_buffer { match beat_in_buffer {
None => { None => {
// No beat in this buffer - process entire buffer with current state // No beat in this buffer - process entire buffer with current state
@ -160,7 +173,7 @@ impl Track {
} }
// Apply state transition at beat boundary // Apply state transition at beat boundary
self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?; self.apply_state_transition(chunk_factory, controllers)?;
// Process samples after beat with new current state // Process samples after beat with new current state
if (beat_index_in_buffer as usize) < output_buffer.len() { if (beat_index_in_buffer as usize) < output_buffer.len() {
@ -198,7 +211,7 @@ impl Track {
TrackState::Empty | TrackState::Idle => { TrackState::Empty | TrackState::Idle => {
output_buffer.fill(0.0); output_buffer.fill(0.0);
} }
TrackState::Recording => { TrackState::Recording { .. } => {
self.audio_data self.audio_data
.append_samples(input_buffer, chunk_factory)?; .append_samples(input_buffer, chunk_factory)?;
output_buffer.fill(0.0); output_buffer.fill(0.0);
@ -239,16 +252,11 @@ impl Track {
/// Apply state transition from next_state to current_state /// Apply state transition from next_state to current_state
/// Returns true if track should be consolidated and saved /// Returns true if track should be consolidated and saved
fn apply_state_transition<PRC, OC>( fn apply_state_transition(
&mut self, &mut self,
chunk_factory: &mut impl ChunkFactory, chunk_factory: &mut impl ChunkFactory,
post_record_controller: PRC, controllers: &TrackControllers,
osc_controller: OC, ) -> Result<()> {
) -> Result<()>
where
PRC: Fn(Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(TrackState) -> Result<()>,
{
// Check for auto-stop recording completion and transition to playing if no other state transition // Check for auto-stop recording completion and transition to playing if no other state transition
if self.current_state == self.next_state { if self.current_state == self.next_state {
if let TrackState::RecordingAutoStop { target_samples, .. } = &self.current_state { if let TrackState::RecordingAutoStop { target_samples, .. } = &self.current_state {
@ -260,11 +268,16 @@ impl Track {
// remember recording before transition // remember recording before transition
let was_recording = self.is_recording(); let was_recording = self.is_recording();
let recording_volume = match &self.current_state {
TrackState::Recording { volume } => Some(*volume),
TrackState::RecordingAutoStop { volume, .. } => Some(*volume),
_ => None,
};
// Handle transitions that require setup // Handle transitions that require setup
match (&self.current_state, &self.next_state) { match (&self.current_state, &self.next_state) {
(current_state, TrackState::Recording) (current_state, TrackState::Recording { .. })
if !matches!(current_state, TrackState::Recording) => if !matches!(current_state, TrackState::Recording { .. }) =>
{ {
// Starting manual recording - clear previous data and create new unconsolidated data // Starting manual recording - clear previous data and create new unconsolidated data
self.audio_data = AudioData::new_unconsolidated(chunk_factory, 0)?; self.audio_data = AudioData::new_unconsolidated(chunk_factory, 0)?;
@ -293,15 +306,25 @@ impl Track {
// Apply the state transition // Apply the state transition
if self.current_state != self.next_state { if self.current_state != self.next_state {
osc_controller(self.next_state.clone())?; controllers.send_state_update(self.next_state.clone())?;
} }
self.current_state = self.next_state.clone(); self.current_state = self.next_state.clone();
// Handle post-record processing // Handle post-record processing and volume updates
if was_recording && !self.is_recording() { if was_recording && !self.is_recording() {
// Recording accepted - commit recording volume to track volume and update state
if let Some(recording_vol) = recording_volume {
self.volume = recording_vol;
controllers.update_track_volume(recording_vol)?;
}
let (chunk, sync_offset) = self.audio_data.get_chunk_for_processing()?; let (chunk, sync_offset) = self.audio_data.get_chunk_for_processing()?;
post_record_controller(chunk, sync_offset)?; controllers.send_post_record_request(chunk, sync_offset)?;
} else if was_recording && self.current_state == TrackState::Empty {
// Recording cancelled - send OSC with committed volume (Track.volume unchanged)
controllers.send_volume_update(self.volume)?;
} }
Ok(()) Ok(())
} }
} }

View File

@ -2,7 +2,6 @@ use crate::*;
pub struct TrackMatrix<F: ChunkFactory, const COLS: usize, const ROWS: usize> { pub struct TrackMatrix<F: ChunkFactory, const COLS: usize, const ROWS: usize> {
chunk_factory: F, chunk_factory: F,
post_record_controller: PostRecordController,
columns: [Column<ROWS>; COLS], columns: [Column<ROWS>; COLS],
scratch_pad: Box<[f32]>, scratch_pad: Box<[f32]>,
} }
@ -12,39 +11,60 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
client: &jack::Client, client: &jack::Client,
chunk_factory: F, chunk_factory: F,
state: &State, state: &State,
post_record_controller: PostRecordController,
) -> Result<Self> { ) -> Result<Self> {
let columns = std::array::from_fn(|_| Column::new(state.metronome.frames_per_beat)); let columns = std::array::from_fn(|_| Column::new(state.metronome.frames_per_beat));
Ok(Self { Ok(Self {
chunk_factory, chunk_factory,
post_record_controller,
columns, columns,
scratch_pad: vec![0.0; client.buffer_size() as usize].into_boxed_slice(), scratch_pad: vec![0.0; client.buffer_size() as usize].into_boxed_slice(),
}) })
} }
pub fn handle_record_button(&mut self, selected_column: usize, selected_row: usize) -> Result<()> { pub fn handle_record_button(
self.columns[selected_column].handle_record_button(selected_row) &mut self,
last_volume_setting: f32,
controllers: &TrackControllers,
) -> Result<()> {
self.columns[controllers.column()].handle_record_button(
last_volume_setting,
controllers,
)
} }
pub fn handle_play_button(&mut self, selected_column: usize, selected_row: usize) -> Result<()> { pub fn handle_play_button(
self.columns[selected_column].handle_play_button(selected_row) &mut self,
controllers: &TrackControllers,
) -> Result<()> {
self.columns[controllers.column()].handle_play_button(controllers)
} }
pub fn handle_clear_button(&mut self, selected_column: usize, selected_row: usize) -> Result<()> { pub fn handle_clear_button(
self.columns[selected_column].handle_clear_button(selected_row) &mut self,
controllers: &TrackControllers,
) -> Result<()> {
self.columns[controllers.column()].handle_clear_button(controllers)
}
pub fn handle_volume_update(
&mut self,
new_volume: f32,
controllers: &TrackControllers,
) -> Result<()> {
self.columns[controllers.column()].handle_volume_update(
new_volume,
controllers,
)
} }
pub fn process( pub fn process(
&mut self, &mut self,
client: &jack::Client,
ps: &jack::ProcessScope, ps: &jack::ProcessScope,
ports: &mut JackPorts, ports: &mut JackPorts,
timing: &BufferTiming, timing: &BufferTiming,
osc_controller: &mut OscController, controllers: &MatrixControllers,
) -> Result<()> { ) -> Result<()> {
// Check for consolidation response // Check for consolidation response
if let Some(response) = self.post_record_controller.try_recv_response() { if let Some(response) = controllers.try_recv_post_record_response() {
self.columns[response.column] self.columns[response.column]
.set_consolidated_buffer(response.row, response.consolidated_buffer)?; .set_consolidated_buffer(response.row, response.consolidated_buffer)?;
} }
@ -52,21 +72,12 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
// Handle xruns // Handle xruns
if timing.missed_frames > 0 { if timing.missed_frames > 0 {
for (i, column) in &mut self.columns.iter_mut().enumerate() { for (i, column) in &mut self.columns.iter_mut().enumerate() {
let controllers = controllers.to_column_controllers(i);
column.handle_xrun( column.handle_xrun(
&timing, &timing,
&mut self.chunk_factory, &mut self.chunk_factory,
|row, chunk, sync_offset| { &controllers,
self.post_record_controller.send_request(
i,
row,
chunk,
sync_offset,
client.sample_rate(),
)
},
|row, state| {
osc_controller.track_state_changed(i, row, state)
},
)?; )?;
} }
} }
@ -77,27 +88,18 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
output_buffer.fill(0.0); output_buffer.fill(0.0);
for (i, column) in &mut self.columns.iter_mut().enumerate() { for (i, column) in &mut self.columns.iter_mut().enumerate() {
let controllers = controllers.to_column_controllers(i);
column.process( column.process(
&timing, &timing,
input_buffer, input_buffer,
output_buffer, output_buffer,
&mut self.scratch_pad, &mut self.scratch_pad,
&mut self.chunk_factory, &mut self.chunk_factory,
|row, chunk, sync_offset| { &controllers,
self.post_record_controller.send_request(
i,
row,
chunk,
sync_offset,
client.sample_rate(),
)
},
|row, state| {
osc_controller.track_state_changed(i, row, state)
},
)?; )?;
} }
Ok(()) Ok(())
} }
} }