Implemented osc as client

This commit is contained in:
2025-06-13 16:35:19 +02:00
parent d5665b841c
commit 1bbb020566
12 changed files with 575 additions and 33 deletions

33
audio_engine/src/args.rs Normal file
View File

@@ -0,0 +1,33 @@
use clap::Parser;
use std::path::PathBuf;
use crate::*;
#[derive(Parser)]
#[command(name = "audio_engine")]
#[command(about = "FCB1010 Looper Pedal Audio Engine")]
pub struct Args {
/// Path to Unix socket or named pipe name
#[arg(short = 's', long = "socket", env = "SOCKET", default_value = "/tmp/fcb_looper.sock")]
pub socket: String,
/// Path to configuration file
#[arg(short = 'c', long = "config-file", env = "CONFIG_FILE", default_value = default_config_path())]
pub config_file: PathBuf,
}
impl Args {
pub fn new() -> Result<Self> {
let res = Self::try_parse();
if let Err(res) = &res {
log::error!("{res}");
}
res.map_err(|_| LooperError::ArgsParse(std::panic::Location::caller()))
}
}
fn default_config_path() -> String {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".fcb_looper");
path.push("state.json");
path.to_string_lossy().to_string()
}

View File

@@ -77,37 +77,42 @@ impl<const ROWS: usize> Column<ROWS> {
self.tracks[row].set_consolidated_buffer(buffer)
}
pub fn handle_xrun<H>(
pub fn handle_xrun<PRC, OC>(
&mut self,
timing: &BufferTiming,
chunk_factory: &mut impl ChunkFactory,
post_record_handler: H,
post_record_controller: PRC,
osc_controller: OC,
) -> Result<()>
where
H: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
PRC: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(usize, TrackState) -> Result<()>,
{
for (row, track) in self.tracks.iter_mut().enumerate() {
track.handle_xrun(
timing.beat_in_missed,
timing.missed_frames,
chunk_factory,
|chunk, sync_offset| post_record_handler(row, chunk, sync_offset),
|chunk, sync_offset| post_record_controller(row, chunk, sync_offset),
|state| osc_controller(row, state),
)?;
}
Ok(())
}
pub fn process<H>(
pub fn process<PRC, OC>(
&mut self,
timing: &BufferTiming,
input_buffer: &[f32],
output_buffer: &mut [f32],
scratch_pad: &mut [f32],
chunk_factory: &mut impl ChunkFactory,
post_record_handler: H,
post_record_controller: PRC,
osc_controller: OC,
) -> Result<()>
where
H: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
PRC: Fn(usize, Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(usize, TrackState) -> Result<()>,
{
let len = self.len();
if self.idle() {
@@ -127,7 +132,8 @@ impl<const ROWS: usize> Column<ROWS> {
input_buffer,
scratch_pad,
chunk_factory,
|chunk, sync_offset| post_record_handler(row, chunk, sync_offset),
|chunk, sync_offset| post_record_controller(row, chunk, sync_offset),
|state| osc_controller(row, state),
)?;
for (output_val, scratch_pad_val) in output_buffer.iter_mut().zip(scratch_pad.iter()) {
*output_val += *scratch_pad_val;

View File

@@ -1,5 +1,8 @@
#[derive(Debug, thiserror::Error)]
pub enum LooperError {
#[error("Failed to parse command line arguments")]
ArgsParse(&'static std::panic::Location<'static>),
#[error("Failed to allocate new audio chunk")]
ChunkAllocation(&'static std::panic::Location<'static>),
@@ -9,6 +12,9 @@ pub enum LooperError {
#[error("Index out of bounds")]
OutOfBounds(&'static std::panic::Location<'static>),
#[error("Failed to send OSC message")]
Osc(&'static std::panic::Location<'static>),
#[error("Failed to load state")]
StateLoad(&'static std::panic::Location<'static>),

View File

@@ -1,4 +1,5 @@
mod allocator;
mod args;
mod audio_chunk;
mod audio_data;
mod beep;
@@ -9,6 +10,7 @@ mod looper_error;
mod metronome;
mod midi;
mod notification_handler;
mod osc;
mod persistence_manager;
mod post_record_handler;
mod process_handler;
@@ -19,6 +21,7 @@ mod track_matrix;
use std::sync::Arc;
use allocator::Allocator;
use args::Args;
use audio_chunk::AudioChunk;
use audio_data::AudioData;
use beep::generate_beep;
@@ -31,12 +34,15 @@ use metronome::BufferTiming;
use metronome::Metronome;
use notification_handler::JackNotification;
use notification_handler::NotificationHandler;
use osc::OscController;
use osc::Osc;
use persistence_manager::PersistenceManager;
use post_record_handler::PostRecordController;
use post_record_handler::PostRecordHandler;
use process_handler::ProcessHandler;
use state::State;
use track::Track;
use track::TrackState;
use track_matrix::TrackMatrix;
pub struct JackPorts {
@@ -48,10 +54,13 @@ pub struct JackPorts {
#[tokio::main]
async fn main() {
let args = Args::new().expect("Could not parse arguments");
simple_logger::SimpleLogger::new()
.init()
.expect("Could not initialize logger");
let (mut osc, osc_controller) = Osc::new(&args.socket).await.expect("Could not create OSC server");
let (jack_client, ports) = setup_jack();
let mut allocator = Allocator::spawn(jack_client.sample_rate(), 3);
@@ -63,7 +72,7 @@ async fn main() {
let mut notification_channel = notification_handler.subscribe();
let (mut persistence_manager, state_watch) =
PersistenceManager::new(notification_handler.subscribe());
PersistenceManager::new(&args, notification_handler.subscribe());
// Load state values for metronome configuration
let initial_state = state_watch.borrow().clone();
@@ -78,6 +87,7 @@ async fn main() {
allocator,
beep_samples,
&initial_state,
osc_controller,
post_record_controller,
)
.expect("Could not create process handler");
@@ -95,6 +105,12 @@ async fn main() {
loop {
tokio::select! {
result = osc.run() => {
if let Err(e) = result {
log::error!("OSC task failed: {}", e);
}
break;
}
notification = notification_channel.recv() => {
if let Ok(JackNotification::Shutdown {reason, status}) = notification {
log::error!("Jack shutdown: {reason} {status:?}");

View File

@@ -12,7 +12,7 @@ pub fn process_events<F: ChunkFactory>(
let mut event_count = 0;
// Collect events from the MIDI input iterator
let midi_input = process_handler.ports.midi_in.iter(ps);
let midi_input = process_handler.ports().midi_in.iter(ps);
for midi_event in midi_input {
if event_count < MAX_EVENTS && midi_event.bytes.len() >= 3 {
raw_events[event_count][0] = midi_event.bytes[0];

175
audio_engine/src/osc.rs Normal file
View File

@@ -0,0 +1,175 @@
use crate::*;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
use futures::SinkExt;
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(windows)]
use tokio::net::windows::named_pipe::ClientOptions;
#[cfg(unix)]
type PlatformStream = UnixStream;
#[cfg(windows)]
type PlatformStream = tokio::net::windows::named_pipe::NamedPipeClient;
#[derive(Debug, Clone)]
enum OscMessage {
TrackStateChanged {
column: usize,
row: usize,
state: TrackState,
},
SelectedColumnChanged {
column: usize,
},
SelectedRowChanged {
row: usize,
},
}
#[derive(Debug)]
pub struct OscController {
sender: kanal::Sender<OscMessage>,
}
impl OscController {
pub fn track_state_changed(
&self,
column: usize,
row: usize,
state: TrackState,
) -> Result<()> {
let message = OscMessage::TrackStateChanged { column, row, state };
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<()> {
let message = OscMessage::SelectedColumnChanged { column };
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_row_changed(&self, row: usize) -> Result<()> {
let message = OscMessage::SelectedRowChanged { row };
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 struct Osc {
receiver: kanal::AsyncReceiver<OscMessage>,
framed_socket: Framed<PlatformStream, LengthDelimitedCodec>,
}
impl Osc {
/// # Platform Notes
/// - Unix: socket_path should be a file path (e.g., "/tmp/fcb_looper.sock")
/// - Windows: socket_path will be converted to named pipe format (e.g., "fcb_looper" -> "\\.\pipe\fcb_looper")
pub async fn new(socket_path: &str) -> Result<(Self, OscController)> {
// Connect to platform-specific IPC
let stream = Self::connect_platform_stream(socket_path).await?;
// Wrap with length-delimited codec
let framed_socket = Framed::new(stream, LengthDelimitedCodec::new());
// Create communication channel
let (sender, receiver) = kanal::bounded(64);
let receiver = receiver.to_async();
let controller = OscController { sender };
let server = Self {
receiver,
framed_socket,
};
Ok((server, controller))
}
#[cfg(unix)]
async fn connect_platform_stream(socket_path: &str) -> Result<PlatformStream> {
UnixStream::connect(socket_path)
.await
.map_err(|_| LooperError::JackConnection(std::panic::Location::caller()))
}
#[cfg(windows)]
async fn connect_platform_stream(pipe_name: &str) -> Result<PlatformStream> {
ClientOptions::new()
.open(pipe_name)
.map_err(|_| LooperError::JackConnection(std::panic::Location::caller()))
}
pub async fn run(&mut self) -> Result<()> {
while let Ok(message) = self.receiver.recv().await {
if let Err(e) = self.send_osc_message(message).await {
log::error!("Failed to send OSC message: {}", e);
// Continue processing other messages
}
}
Ok(())
}
async fn send_osc_message(&mut self, message: OscMessage) -> Result<()> {
let osc_packet = self.message_to_osc_packet(message)?;
let osc_bytes = rosc::encoder::encode(&osc_packet)
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
self.framed_socket
.send(bytes::Bytes::from(osc_bytes))
.await
.map_err(|_| LooperError::StateSave(std::panic::Location::caller()))?;
Ok(())
}
fn message_to_osc_packet(&self, message: OscMessage) -> Result<rosc::OscPacket> {
match message {
OscMessage::TrackStateChanged { column, row, state } => {
let osc_state = self.track_state_to_osc_string(state);
let address = format!("/looper/cell/{}/{}/state", column + 1, row + 1); // 1-based indexing
Ok(rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::String(osc_state)],
}))
}
OscMessage::SelectedColumnChanged { column } => {
let address = "/looper/selected/column".to_string();
Ok(rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::Int((column + 1) as i32)], // 1-based indexing
}))
}
OscMessage::SelectedRowChanged { row } => {
let address = "/looper/selected/row".to_string();
Ok(rosc::OscPacket::Message(rosc::OscMessage {
addr: address,
args: vec![rosc::OscType::Int((row + 1) as i32)], // 1-based indexing
}))
}
}
}
fn track_state_to_osc_string(&self, state: TrackState) -> String {
match state {
TrackState::Empty => "empty".to_string(),
TrackState::Idle => "ready".to_string(), // Assuming track has audio data
TrackState::Recording | TrackState::RecordingAutoStop { .. } => "recording".to_string(),
TrackState::Playing => "playing".to_string(),
}
}
}

View File

@@ -11,9 +11,13 @@ pub struct PersistenceManager {
impl PersistenceManager {
pub fn new(
args: &Args,
notification_rx: broadcast::Receiver<JackNotification>,
) -> (Self, watch::Receiver<State>) {
let state_file_path = Self::get_state_file_path();
let state_file_path = args.config_file.clone();
if let Some(parent) = state_file_path.parent() {
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 (state_tx, state_rx) = watch::channel(initial_state.clone());
@@ -96,14 +100,6 @@ impl PersistenceManager {
Ok(())
}
fn get_state_file_path() -> PathBuf {
let mut path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
path.push(".fcb_looper");
std::fs::create_dir_all(&path).ok(); // Create directory if it doesn't exist
path.push("state.json");
path
}
fn load_from_disk(path: &PathBuf) -> Result<State> {
match std::fs::read_to_string(path) {
Ok(content) => {

View File

@@ -4,7 +4,8 @@ const COLS: usize = 5;
const ROWS: usize = 5;
pub struct ProcessHandler<F: ChunkFactory> {
pub ports: JackPorts,
osc: OscController,
ports: JackPorts,
metronome: Metronome,
track_matrix: TrackMatrix<F, COLS, ROWS>,
selected_row: usize,
@@ -18,6 +19,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
chunk_factory: F,
beep_samples: Arc<AudioChunk>,
state: &State,
osc: OscController,
post_record_controller: PostRecordController,
) -> Result<Self> {
let track_matrix = TrackMatrix::new(
@@ -27,6 +29,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
post_record_controller,
)?;
Ok(Self {
osc,
ports,
metronome: Metronome::new(beep_samples, state),
track_matrix,
@@ -35,6 +38,10 @@ impl<F: ChunkFactory> ProcessHandler<F> {
})
}
pub fn ports(&self) -> &JackPorts {
&self.ports
}
pub fn handle_button_1(&mut self) -> Result<()> {
self.track_matrix.handle_record_button(self.selected_column, self.selected_row)
}
@@ -57,26 +64,31 @@ impl<F: ChunkFactory> ProcessHandler<F> {
pub fn handle_button_6(&mut self) -> Result<()> {
self.selected_column = 0;
self.osc.selected_column_changed(self.selected_column)?;
Ok(())
}
pub fn handle_button_7(&mut self) -> Result<()> {
self.selected_column = 1;
self.osc.selected_column_changed(self.selected_column)?;
Ok(())
}
pub fn handle_button_8(&mut self) -> Result<()> {
self.selected_column = 2;
self.osc.selected_column_changed(self.selected_column)?;
Ok(())
}
pub fn handle_button_9(&mut self) -> Result<()> {
self.selected_column = 3;
self.osc.selected_column_changed(self.selected_column)?;
Ok(())
}
pub fn handle_button_10(&mut self) -> Result<()> {
self.selected_column = 4;
self.osc.selected_column_changed(self.selected_column)?;
Ok(())
}
@@ -86,11 +98,13 @@ impl<F: ChunkFactory> ProcessHandler<F> {
} else {
self.selected_row -= 1;
}
self.osc.selected_row_changed(self.selected_row)?;
Ok(())
}
pub fn handle_button_down(&mut self) -> Result<()> {
self.selected_row = (self.selected_row + 1) % ROWS;
self.osc.selected_row_changed(self.selected_row)?;
Ok(())
}
}
@@ -124,6 +138,7 @@ impl<F: ChunkFactory> ProcessHandler<F> {
ps,
&mut self.ports,
&timing,
&mut self.osc,
)?;
Ok(())

View File

@@ -8,7 +8,7 @@ pub struct Track {
}
#[derive(Debug, Clone, PartialEq)]
enum TrackState {
pub enum TrackState {
Empty,
Idle,
Playing,
@@ -83,15 +83,17 @@ impl Track {
self.audio_data.set_consolidated_buffer(buffer)
}
pub fn handle_xrun<H>(
pub fn handle_xrun<PRC, OC>(
&mut self,
beat_in_missed: Option<u32>,
missed_frames: usize,
chunk_factory: &mut impl ChunkFactory,
post_record_handler: H,
post_record_controller: PRC,
osc_controller: OC,
) -> Result<()>
where
H: Fn(Arc<AudioChunk>, usize) -> Result<()>,
PRC: Fn(Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(TrackState) -> Result<()>,
{
match beat_in_missed {
None => {
@@ -109,7 +111,7 @@ impl Track {
.append_silence(beat_offset as _, chunk_factory)?;
}
// Apply state transition at beat boundary
self.apply_state_transition(chunk_factory, post_record_handler)?;
self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?;
// Insert silence after beat with new state
let frames_after_beat = missed_frames - beat_offset as usize;
if frames_after_beat > 0 && self.is_recording() {
@@ -122,17 +124,19 @@ impl Track {
}
/// Audio processing
pub fn process<H>(
pub fn process<PRC, OC>(
&mut self,
playback_position: usize,
beat_in_buffer: Option<u32>,
input_buffer: &[f32],
output_buffer: &mut [f32],
chunk_factory: &mut impl ChunkFactory,
post_record_handler: H,
post_record_controller: PRC,
osc_controller: OC,
) -> Result<()>
where
H: Fn(Arc<AudioChunk>, usize) -> Result<()>,
PRC: Fn(Arc<AudioChunk>, usize) -> Result<()>,
OC: Fn(TrackState) -> Result<()>,
{
match beat_in_buffer {
None => {
@@ -156,7 +160,7 @@ impl Track {
}
// Apply state transition at beat boundary
self.apply_state_transition(chunk_factory, post_record_handler)?;
self.apply_state_transition(chunk_factory, post_record_controller, osc_controller)?;
// Process samples after beat with new current state
if (beat_index_in_buffer as usize) < output_buffer.len() {
@@ -235,13 +239,15 @@ impl Track {
/// Apply state transition from next_state to current_state
/// Returns true if track should be consolidated and saved
fn apply_state_transition<H>(
fn apply_state_transition<PRC, OC>(
&mut self,
chunk_factory: &mut impl ChunkFactory,
post_record_handler: H,
post_record_controller: PRC,
osc_controller: OC,
) -> Result<()>
where
H: Fn(Arc<AudioChunk>, usize) -> Result<()>,
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
if self.current_state == self.next_state {
@@ -286,12 +292,15 @@ impl Track {
}
// Apply the state transition
if self.current_state != self.next_state {
osc_controller(self.next_state.clone())?;
}
self.current_state = self.next_state.clone();
// Handle post-record processing
if was_recording && !self.is_recording() {
let (chunk, sync_offset) = self.audio_data.get_chunk_for_processing()?;
post_record_handler(chunk, sync_offset)?;
post_record_controller(chunk, sync_offset)?;
}
Ok(())
}

View File

@@ -41,6 +41,7 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
ps: &jack::ProcessScope,
ports: &mut JackPorts,
timing: &BufferTiming,
osc_controller: &mut OscController,
) -> Result<()> {
// Check for consolidation response
if let Some(response) = self.post_record_controller.try_recv_response() {
@@ -63,6 +64,9 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
client.sample_rate(),
)
},
|row, state| {
osc_controller.track_state_changed(i, row, state)
},
)?;
}
}
@@ -88,6 +92,9 @@ impl<F: ChunkFactory, const COLS: usize, const ROWS: usize> TrackMatrix<F, COLS,
client.sample_rate(),
)
},
|row, state| {
osc_controller.track_state_changed(i, row, state)
},
)?;
}