per track volume

This commit is contained in:
Geens 2025-06-21 11:00:04 +02:00
parent 70b2f92a8d
commit 7b7ebb8c0e
29 changed files with 4989 additions and 309 deletions

3
.gitignore vendored
View File

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

4606
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

17
Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[workspace]
resolver = "3"
members = [
"audio_engine",
"gui",
"osc",
]
[workspace.dependencies]
bytes = "1.0"
futures = "0.3"
log = "0.4"
osc = { path = "osc" }
rosc = "0.10"
thiserror = "1"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }

View File

@ -79,6 +79,7 @@ dependencies = [
"jack", "jack",
"kanal", "kanal",
"log", "log",
"osc",
"rosc", "rosc",
"serde", "serde",
"serde_json", "serde_json",
@ -519,6 +520,14 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "osc"
version = "0.1.0"
dependencies = [
"rosc",
"thiserror",
]
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.4" version = "0.12.4"

View File

@ -4,19 +4,21 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
bytes = "1.0"
clap = { version = "4", features = ["derive", "env", "string"] } clap = { version = "4", features = ["derive", "env", "string"] }
dirs = "5" dirs = "5"
futures = "0.3"
hound = "3.5" hound = "3.5"
jack = "0.13" jack = "0.13"
kanal = "0.1" kanal = "0.1"
log = "0.4"
rosc = "0.10"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
simple_logger = "5" simple_logger = "5"
thiserror = "1" wmidi = "4"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] } bytes.workspace = true
wmidi = "4" futures.workspace = true
log.workspace = true
osc.workspace = true
rosc.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true

View File

@ -11,7 +11,7 @@ mod looper_error;
mod metronome; mod metronome;
mod midi; mod midi;
mod notification_handler; mod notification_handler;
mod osc; mod osc_server;
mod persistence_manager; mod persistence_manager;
mod post_record_handler; mod post_record_handler;
mod process_handler; mod process_handler;
@ -38,8 +38,8 @@ use metronome::BufferTiming;
use metronome::Metronome; use metronome::Metronome;
use notification_handler::JackNotification; use notification_handler::JackNotification;
use notification_handler::NotificationHandler; use notification_handler::NotificationHandler;
use osc::OscController; use osc_server::OscController;
use osc::Osc; use osc_server::Osc;
use persistence_manager::PersistenceManager; use persistence_manager::PersistenceManager;
use persistence_manager::PersistenceManagerController; use persistence_manager::PersistenceManagerController;
use post_record_handler::PostRecordController; use post_record_handler::PostRecordController;

View File

@ -1,13 +1,12 @@
use crate::{LooperError, Result, TrackState}; use crate::*;
use super::message::OscMessage;
#[derive(Debug)] #[derive(Debug)]
pub struct OscController { pub struct OscController {
sender: kanal::Sender<OscMessage>, sender: kanal::Sender<osc::Message>,
} }
impl OscController { impl OscController {
pub(crate) fn new(sender: kanal::Sender<OscMessage>) -> Self { pub(crate) fn new(sender: kanal::Sender<osc::Message>) -> Self {
Self { sender } Self { sender }
} }
@ -17,7 +16,7 @@ impl OscController {
row: usize, row: usize,
state: TrackState, state: TrackState,
) -> Result<()> { ) -> Result<()> {
let message = OscMessage::TrackStateChanged { column, row, state }; let message = osc::Message::TrackStateChanged { column, row, state: state.into() };
match self.sender.try_send(message) { match self.sender.try_send(message) {
Ok(true) => Ok(()), Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full
@ -31,7 +30,7 @@ impl OscController {
row: usize, row: usize,
volume: f32, volume: f32,
) -> Result<()> { ) -> Result<()> {
let message = OscMessage::TrackVolumeChanged { column, row, volume }; let message = osc::Message::TrackVolumeChanged { column, row, volume };
match self.sender.try_send(message) { match self.sender.try_send(message) {
Ok(true) => Ok(()), Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full
@ -40,7 +39,7 @@ impl OscController {
} }
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 = osc::Message::SelectedColumnChanged { column };
match self.sender.try_send(message) { match self.sender.try_send(message) {
Ok(true) => Ok(()), Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full
@ -49,7 +48,7 @@ impl OscController {
} }
pub fn selected_row_changed(&self, row: usize) -> Result<()> { pub fn selected_row_changed(&self, row: usize) -> Result<()> {
let message = OscMessage::SelectedRowChanged { row }; let message = osc::Message::SelectedRowChanged { row };
match self.sender.try_send(message) { match self.sender.try_send(message) {
Ok(true) => Ok(()), Ok(true) => Ok(()),
Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full Ok(false) => Err(LooperError::Osc(std::panic::Location::caller())), // Channel full

View File

@ -1,5 +1,4 @@
mod controller; mod controller;
mod message;
mod platform; mod platform;
mod server; mod server;

View File

@ -1,33 +1,25 @@
use crate::Result; use crate::*;
use super::{
controller::OscController,
message::{OscMessage, ShadowState},
platform::{create_listener, PlatformListener, PlatformStream},
};
use bytes::Bytes;
use futures::SinkExt; use futures::SinkExt;
use tokio::sync::broadcast;
use tokio_util::codec::{Framed, LengthDelimitedCodec};
const CLIENT_BUFFER_SIZE: usize = 32; const CLIENT_BUFFER_SIZE: usize = 32;
pub struct Osc { pub struct Osc {
receiver: kanal::AsyncReceiver<OscMessage>, receiver: kanal::AsyncReceiver<osc::Message>,
listener: PlatformListener, listener: osc_server::platform::PlatformListener,
broadcaster: broadcast::Sender<OscMessage>, broadcaster: tokio::sync::broadcast::Sender<osc::Message>,
shadow_state: ShadowState, shadow_state: osc::State,
} }
impl Osc { impl Osc {
/// Create new OSC server and controller with same interface as before /// Create new OSC server and controller with same interface as before
pub async fn new(socket_path: &str) -> Result<(Self, OscController)> { pub async fn new(socket_path: &str) -> Result<(Self, OscController)> {
// Create platform listener (server) // Create platform listener (server)
let listener = create_listener(socket_path).await?; let listener = osc_server::platform::create_listener(socket_path).await?;
// Create communication channels // Create communication channels
let (sender, receiver) = kanal::bounded(64); let (sender, receiver) = kanal::bounded(64);
let receiver = receiver.to_async(); let receiver = receiver.to_async();
let (broadcaster, _) = broadcast::channel(CLIENT_BUFFER_SIZE); let (broadcaster, _) = tokio::sync::broadcast::channel(CLIENT_BUFFER_SIZE);
let controller = OscController::new(sender); let controller = OscController::new(sender);
@ -35,7 +27,7 @@ impl Osc {
receiver, receiver,
listener, listener,
broadcaster, broadcaster,
shadow_state: ShadowState::new(5, 5), // Default 5x5 matrix shadow_state: osc::State::new(5, 5), // Default 5x5 matrix
}; };
Ok((server, controller)) Ok((server, controller))
@ -68,12 +60,12 @@ impl Osc {
Ok(()) Ok(())
} }
fn spawn_client_task(&self, stream: PlatformStream) { fn spawn_client_task(&self, stream: osc_server::platform::PlatformStream) {
let mut receiver = self.broadcaster.subscribe(); let mut receiver = self.broadcaster.subscribe();
let state_dump = self.shadow_state.create_state_dump(); let state_dump = self.shadow_state.create_state_dump();
tokio::spawn(async move { tokio::spawn(async move {
let mut framed = Framed::new(stream, LengthDelimitedCodec::new()); let mut framed = tokio_util::codec::Framed::new(stream, tokio_util::codec::LengthDelimitedCodec::new());
// Send current state dump immediately // Send current state dump immediately
for msg in state_dump { for msg in state_dump {
@ -92,8 +84,8 @@ impl Osc {
} }
async fn send_osc_message( async fn send_osc_message(
framed: &mut Framed<PlatformStream, LengthDelimitedCodec>, framed: &mut tokio_util::codec::Framed<osc_server::platform::PlatformStream, tokio_util::codec::LengthDelimitedCodec>,
message: OscMessage, message: osc::Message,
) -> Result<()> { ) -> Result<()> {
let osc_packet = Self::message_to_osc_packet(message)?; let osc_packet = Self::message_to_osc_packet(message)?;
@ -101,25 +93,24 @@ impl Osc {
.map_err(|_| crate::LooperError::StateSave(std::panic::Location::caller()))?; .map_err(|_| crate::LooperError::StateSave(std::panic::Location::caller()))?;
framed framed
.send(Bytes::from(osc_bytes)) .send(bytes::Bytes::from(osc_bytes))
.await .await
.map_err(|_| crate::LooperError::StateSave(std::panic::Location::caller()))?; .map_err(|_| crate::LooperError::StateSave(std::panic::Location::caller()))?;
Ok(()) Ok(())
} }
fn message_to_osc_packet(message: OscMessage) -> Result<rosc::OscPacket> { fn message_to_osc_packet(message: osc::Message) -> Result<rosc::OscPacket> {
match message { match message {
OscMessage::TrackStateChanged { column, row, state } => { osc::Message::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 let address = format!("/looper/cell/{}/{}/state", column + 1, row + 1); // 1-based indexing
Ok(rosc::OscPacket::Message(rosc::OscMessage { Ok(rosc::OscPacket::Message(rosc::OscMessage {
addr: address, addr: address,
args: vec![rosc::OscType::String(osc_state)], args: vec![rosc::OscType::String(state.to_string())],
})) }))
} }
OscMessage::TrackVolumeChanged { column, row, volume } => { osc::Message::TrackVolumeChanged { column, row, volume } => {
let address = format!("/looper/cell/{}/{}/volume", column + 1, row + 1); // 1-based indexing let address = format!("/looper/cell/{}/{}/volume", column + 1, row + 1); // 1-based indexing
Ok(rosc::OscPacket::Message(rosc::OscMessage { Ok(rosc::OscPacket::Message(rosc::OscMessage {
@ -127,7 +118,7 @@ impl Osc {
args: vec![rosc::OscType::Float(volume)], args: vec![rosc::OscType::Float(volume)],
})) }))
} }
OscMessage::SelectedColumnChanged { column } => { osc::Message::SelectedColumnChanged { column } => {
let address = "/looper/selected/column".to_string(); let address = "/looper/selected/column".to_string();
Ok(rosc::OscPacket::Message(rosc::OscMessage { Ok(rosc::OscPacket::Message(rosc::OscMessage {
@ -135,7 +126,7 @@ impl Osc {
args: vec![rosc::OscType::Int((column + 1) as i32)], // 1-based indexing args: vec![rosc::OscType::Int((column + 1) as i32)], // 1-based indexing
})) }))
} }
OscMessage::SelectedRowChanged { row } => { osc::Message::SelectedRowChanged { row } => {
let address = "/looper/selected/row".to_string(); let address = "/looper/selected/row".to_string();
Ok(rosc::OscPacket::Message(rosc::OscMessage { Ok(rosc::OscPacket::Message(rosc::OscMessage {
@ -145,14 +136,4 @@ impl Osc {
} }
} }
} }
fn track_state_to_osc_string(state: crate::TrackState) -> String {
match state {
crate::TrackState::Empty => "empty".to_string(),
crate::TrackState::Idle => "ready".to_string(),
crate::TrackState::Recording { .. } => "recording".to_string(),
crate::TrackState::RecordingAutoStop { .. } => "recording".to_string(),
crate::TrackState::Playing => "playing".to_string(),
}
}
} }

View File

@ -327,4 +327,16 @@ impl Track {
Ok(()) Ok(())
} }
}
impl Into<osc::TrackState> for TrackState {
fn into(self) -> osc::TrackState {
match self {
TrackState::Empty => osc::TrackState::Empty,
TrackState::Idle => osc::TrackState::Idle,
TrackState::Recording { .. } => osc::TrackState::Recording,
TrackState::RecordingAutoStop { .. } => osc::TrackState::Recording,
TrackState::Playing => osc::TrackState::Playing,
}
}
} }

9
gui/Cargo.lock generated
View File

@ -1097,6 +1097,7 @@ dependencies = [
"egui", "egui",
"futures", "futures",
"log", "log",
"osc",
"rosc", "rosc",
"simple_logger", "simple_logger",
"tokio", "tokio",
@ -2309,6 +2310,14 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "osc"
version = "0.1.0"
dependencies = [
"rosc",
"thiserror",
]
[[package]] [[package]]
name = "owned_ttf_parser" name = "owned_ttf_parser"
version = "0.25.0" version = "0.25.0"

View File

@ -1,16 +1,18 @@
[package] [package]
name = "fcb-looper-gui" name = "gui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
bytes = "1.0"
eframe = { version = "0.29", default-features = true, features = [ "default_fonts", "glow" ] } eframe = { version = "0.29", default-features = true, features = [ "default_fonts", "glow" ] }
egui = "0.29" egui = "0.29"
futures = "0.3"
log = "0.4"
rosc = "0.10"
simple_logger = "5" simple_logger = "5"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] } bytes.workspace = true
futures.workspace = true
log.workspace = true
osc.workspace = true
rosc.workspace = true
tokio.workspace = true
tokio-util.workspace = true

View File

@ -1,72 +0,0 @@
#[derive(Debug, Clone, PartialEq)]
pub enum TrackState {
Empty,
Ready,
Recording,
Playing,
}
impl TrackState {
pub fn display_str(&self) -> &'static str {
match self {
TrackState::Empty => "---",
TrackState::Ready => "READY",
TrackState::Recording => "REC",
TrackState::Playing => "PLAY",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColumnState {
pub track_states: Vec<TrackState>,
}
impl ColumnState {
pub fn new(rows: usize) -> Self {
Self {
track_states: vec![TrackState::Empty; rows],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DisplayState {
pub selected_column: usize, // 0-based internally
pub selected_row: usize, // 0-based internally
pub column_states: Vec<ColumnState>,
pub columns: usize,
pub rows: usize,
}
impl DisplayState {
pub fn new(columns: usize, rows: usize) -> Self {
let column_states = (0..columns).map(|_| ColumnState::new(rows)).collect();
Self {
selected_column: 0,
selected_row: 0,
column_states,
columns,
rows,
}
}
pub fn update_track_state(&mut self, column: usize, row: usize, state: TrackState) {
if column < self.columns && row < self.rows {
self.column_states[column].track_states[row] = state;
}
}
pub fn update_selected_column(&mut self, column: usize) {
if column < self.columns {
self.selected_column = column;
}
}
pub fn update_selected_row(&mut self, row: usize) {
if row < self.rows {
self.selected_row = row;
}
}
}

View File

@ -1,11 +1,23 @@
mod display_state; mod osc_client;
mod osc;
mod ui; mod ui;
use anyhow::anyhow;
use anyhow::Result; use anyhow::Result;
use display_state::ColumnState;
use display_state::DisplayState; trait DisplayStr {
use display_state::TrackState; fn display_str(&self) -> &'static str;
}
impl DisplayStr for osc::TrackState {
fn display_str(&self) -> &'static str {
match self {
osc::TrackState::Empty => "---",
osc::TrackState::Idle => "READY",
osc::TrackState::Recording => "REC",
osc::TrackState::Playing => "PLAY",
}
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -17,12 +29,12 @@ async fn main() -> Result<()> {
// Logger // Logger
simple_logger::SimpleLogger::new() simple_logger::SimpleLogger::new()
.with_level(log::LevelFilter::Error) .with_level(log::LevelFilter::Error)
.with_module_level("fcb_looper_gui::osc::client", log::LevelFilter::Off) .with_module_level("gui::osc_client", log::LevelFilter::Off)
.init() .init()
.expect("Could not initialize logger"); .expect("Could not initialize logger");
// Create OSC client // Create OSC client
let osc_client = osc::Client::new(socket_path, columns, rows)?; let osc_client = osc_client::Client::new(socket_path, columns, rows)?;
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

View File

@ -1,91 +0,0 @@
use crate::display_state::{DisplayState, TrackState};
use anyhow::{anyhow, Result};
use rosc::{OscMessage, OscPacket, OscType};
#[derive(Debug, Clone)]
pub enum StateUpdate {
TrackStateChanged {
column: usize, // 0-based
row: usize, // 0-based
state: TrackState,
},
SelectedColumnChanged {
column: usize, // 0-based
},
SelectedRowChanged {
row: usize, // 0-based
},
}
impl StateUpdate {
pub fn from_osc_packet(packet: OscPacket) -> Result<Option<Self>> {
match packet {
OscPacket::Message(msg) => Self::from_osc_message(msg),
OscPacket::Bundle(_) => {
// For now, we don't handle bundles
Ok(None)
}
}
}
pub fn apply_to_state(&self, state: &mut DisplayState) {
match self {
StateUpdate::TrackStateChanged {
column,
row,
state: track_state,
} => {
state.update_track_state(*column, *row, track_state.clone());
}
StateUpdate::SelectedColumnChanged { column } => {
state.update_selected_column(*column);
}
StateUpdate::SelectedRowChanged { row } => {
state.update_selected_row(*row);
}
}
}
fn from_osc_message(msg: OscMessage) -> Result<Option<Self>> {
let OscMessage { addr, args } = msg;
if addr.starts_with("/looper/cell/") && addr.ends_with("/state") {
// Parse: /looper/cell/{column}/{row}/state
let parts: Vec<&str> = addr.split('/').collect();
if parts.len() != 6 {
return Err(anyhow!("Invalid cell state address format: {}", addr));
}
let column: usize = parts[3].parse::<usize>()? - 1; // Convert to 0-based
let row: usize = parts[4].parse::<usize>()? - 1; // Convert to 0-based
if let Some(OscType::String(state_str)) = args.first() {
let state = Self::track_state_from_osc_string(state_str);
return Ok(Some(StateUpdate::TrackStateChanged { column, row, state }));
}
} else if addr == "/looper/selected/column" {
if let Some(OscType::Int(column_1based)) = args.first() {
let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based
return Ok(Some(StateUpdate::SelectedColumnChanged { column }));
}
} else if addr == "/looper/selected/row" {
if let Some(OscType::Int(row_1based)) = args.first() {
let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based
return Ok(Some(StateUpdate::SelectedRowChanged { row }));
}
}
// Unknown or unsupported message
Ok(None)
}
fn track_state_from_osc_string(s: &str) -> TrackState {
match s {
"empty" => TrackState::Empty,
"ready" => TrackState::Ready,
"recording" => TrackState::Recording,
"playing" => TrackState::Playing,
_ => TrackState::Empty, // Default fallback
}
}
}

View File

@ -1,4 +0,0 @@
mod client;
mod message;
pub use client::Client;

View File

@ -1,10 +1,8 @@
use anyhow::{anyhow, Result};
use bytes::Bytes; use bytes::Bytes;
use futures::TryStreamExt; use futures::TryStreamExt;
use tokio::sync::watch; use tokio::sync::watch;
use tokio_util::codec::{Framed, LengthDelimitedCodec}; use tokio_util::codec::{Framed, LengthDelimitedCodec};
use super::message::*;
use crate::*; use crate::*;
#[cfg(windows)] #[cfg(windows)]
@ -19,24 +17,30 @@ type PlatformStream = NamedPipeClient;
pub struct Client { pub struct Client {
socket_path: String, socket_path: String,
state_sender: watch::Sender<DisplayState>, state_sender: watch::Sender<osc::State>,
state_receiver: watch::Receiver<DisplayState>, state_receiver: watch::Receiver<osc::State>,
} }
impl Client { impl Client {
pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> { pub fn new(socket_path: &str, columns: usize, rows: usize) -> Result<Self> {
let initial_state = DisplayState::new(columns, rows); let initial_state = osc::State::new(columns, rows);
// 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 = 2;
initial_state.selected_row = 3; initial_state.selected_row = 3;
initial_state.column_states[0].track_states[0] = TrackState::Playing; initial_state.cells[0][0].state = osc::TrackState::Playing;
initial_state.column_states[0].track_states[1] = TrackState::Ready; initial_state.cells[0][0].volume = 0.3;
initial_state.column_states[1].track_states[0] = TrackState::Ready; initial_state.cells[0][1].state = osc::TrackState::Idle;
initial_state.column_states[1].track_states[1] = TrackState::Playing; initial_state.cells[0][1].volume = 1.0;
initial_state.column_states[2].track_states[0] = TrackState::Ready; initial_state.cells[1][0].state = osc::TrackState::Idle;
initial_state.column_states[2].track_states[3] = TrackState::Recording; initial_state.cells[1][0].volume = 0.9;
initial_state.cells[1][1].state = osc::TrackState::Playing;
initial_state.cells[1][1].volume = 0.8;
initial_state.cells[2][0].state = osc::TrackState::Idle;
initial_state.cells[2][0].volume = 0.7;
initial_state.cells[2][3].state = osc::TrackState::Recording;
initial_state.cells[2][3].volume = 0.6;
initial_state initial_state
}; };
@ -49,7 +53,7 @@ impl Client {
}) })
} }
pub fn state_receiver(&self) -> watch::Receiver<DisplayState> { pub fn state_receiver(&self) -> watch::Receiver<osc::State> {
self.state_receiver.clone() self.state_receiver.clone()
} }
@ -82,11 +86,11 @@ impl Client {
Ok(()) Ok(())
} }
async fn handle_osc_data(&self, data: Bytes, current_state: &mut DisplayState) -> Result<()> { async fn handle_osc_data(&self, data: Bytes, current_state: &mut osc::State) -> 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) = StateUpdate::from_osc_packet(osc_packet)? { if let Some(update) = osc::Message::from_osc_packet(osc_packet)? {
update.apply_to_state(current_state); update.apply_to_state(current_state);
// Send updated state through watch channel // Send updated state through watch channel

View File

@ -1,11 +1,9 @@
use crate::*;
pub struct App { pub struct App {
state_receiver: tokio::sync::watch::Receiver<DisplayState>, state_receiver: tokio::sync::watch::Receiver<osc::State>,
} }
impl App { impl App {
pub fn new(state_receiver: tokio::sync::watch::Receiver<DisplayState>) -> Self { pub fn new(state_receiver: tokio::sync::watch::Receiver<osc::State>) -> Self {
Self { state_receiver } Self { state_receiver }
} }
} }
@ -20,7 +18,7 @@ impl eframe::App for App {
}); });
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
ui.columns(state.columns, |ui| { ui.columns(state.columns, |ui| {
for (i, column) in state.column_states.iter().enumerate() { for (i, column) in state.cells.iter().enumerate() {
let selected_row = if i == state.selected_column { let selected_row = if i == state.selected_column {
Some(state.selected_row) Some(state.selected_row)
} else { } else {

View File

@ -1,13 +1,11 @@
use super::ui_rows_ext::UiRowsExt; use super::ui_rows_ext::UiRowsExt;
use crate::*;
pub struct Column<'a> { pub struct Column<'a> {
state: &'a ColumnState, state: &'a Vec<osc::Track>,
selected_row: Option<usize>, selected_row: Option<usize>,
} }
impl<'a> Column<'a> { impl<'a> Column<'a> {
pub fn new(state: &'a ColumnState, selected_row: Option<usize>) -> Self { pub fn new(state: &'a Vec<osc::Track>, selected_row: Option<usize>) -> Self {
Self { Self {
state, state,
selected_row, selected_row,
@ -17,8 +15,8 @@ impl<'a> Column<'a> {
impl<'a> egui::Widget for Column<'a> { impl<'a> egui::Widget for Column<'a> {
fn ui(self, ui: &mut egui::Ui) -> egui::Response { fn ui(self, ui: &mut egui::Ui) -> egui::Response {
ui.rows(self.state.track_states.len(), |ui| { ui.rows(self.state.len(), |ui| {
for (i, track) in self.state.track_states.iter().enumerate() { for (i, track) in self.state.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,31 +1,31 @@
use crate::*; use crate::*;
pub struct Track<'a> { pub struct Track<'a> {
state: &'a TrackState, state: &'a osc::Track,
selected: bool, selected: bool,
} }
impl<'a> Track<'a> { impl<'a> Track<'a> {
pub fn new(state: &'a TrackState, selected: bool) -> Self { pub fn new(state: &'a osc::Track, selected: bool) -> Self {
Self { state, selected } Self { state, selected }
} }
fn get_colors(&self) -> (egui::Color32, egui::Color32) { fn get_colors(&self) -> (egui::Color32, egui::Color32) {
// Returns (background_color, text_color) // Returns (background_color, text_color)
let (dark, bright) = match self.state { let (dark, bright) = match self.state.state {
TrackState::Empty => ( osc::TrackState::Empty => (
egui::Color32::from_rgb(26, 26, 26), // #1a1a1a egui::Color32::from_rgb(26, 26, 26), // #1a1a1a
egui::Color32::from_rgb(102, 102, 102), // #666666 egui::Color32::from_rgb(102, 102, 102), // #666666
), ),
TrackState::Ready => ( osc::TrackState::Idle => (
egui::Color32::from_rgb(10, 48, 32), // #0a3020 egui::Color32::from_rgb(10, 48, 32), // #0a3020
egui::Color32::from_rgb(0, 170, 102), // #00aa66 egui::Color32::from_rgb(0, 170, 102), // #00aa66
), ),
TrackState::Playing => ( osc::TrackState::Playing => (
egui::Color32::from_rgb(0, 42, 64), // #002a40 egui::Color32::from_rgb(0, 42, 64), // #002a40
egui::Color32::from_rgb(0, 136, 204), // #0088cc egui::Color32::from_rgb(0, 136, 204), // #0088cc
), ),
TrackState::Recording => ( osc::TrackState::Recording => (
egui::Color32::from_rgb(68, 10, 10), // #440a0a egui::Color32::from_rgb(68, 10, 10), // #440a0a
egui::Color32::from_rgb(204, 68, 68), // #cc4444 egui::Color32::from_rgb(204, 68, 68), // #cc4444
), ),
@ -47,7 +47,7 @@ impl<'a> egui::Widget for Track<'a> {
let (bg_color, fg_color) = self.get_colors(); let (bg_color, fg_color) = self.get_colors();
egui::Frame::none() let frame_response = egui::Frame::none()
.fill(bg_color) .fill(bg_color)
.rounding(egui::Rounding::same(4.0 * sf)) .rounding(egui::Rounding::same(4.0 * sf))
.stroke(egui::Stroke::new(2.5 * sf, fg_color)) .stroke(egui::Stroke::new(2.5 * sf, fg_color))
@ -55,12 +55,55 @@ impl<'a> egui::Widget for Track<'a> {
.show(ui, |ui| { .show(ui, |ui| {
ui.centered_and_justified(|ui| { ui.centered_and_justified(|ui| {
ui.add(egui::Label::new( ui.add(egui::Label::new(
egui::RichText::new(self.state.display_str()) egui::RichText::new(self.state.state.display_str())
.font(font_id) .font(font_id)
.color(fg_color), .color(fg_color),
)); ));
}); });
}) });
.response
// Draw volume bar overlay at the bottom
let volume_bar_height = 2.0 * sf;
let margin = 4.0 * sf;
let frame_rect = frame_response.response.rect;
// Calculate volume bar position (bottom of the frame, inside the margins)
let inner_rect = frame_rect.shrink(5.0 * sf); // Match the outer_margin
let volume_bar_rect = egui::Rect::from_min_max(
egui::pos2(
inner_rect.min.x + margin,
inner_rect.max.y - volume_bar_height - margin
),
egui::pos2(
inner_rect.max.x - margin,
inner_rect.max.y - margin
)
);
// Draw volume bar background
ui.painter().rect_filled(
volume_bar_rect,
egui::Rounding::same(1.0 * sf),
egui::Color32::from_rgb(51, 51, 51) // #333333
);
// Draw volume bar fill (only if volume > 0)
if self.state.volume > 0.0 {
let fill_width = volume_bar_rect.width() * self.state.volume;
if fill_width > 0.0 {
let fill_rect = egui::Rect::from_min_size(
volume_bar_rect.min,
egui::vec2(fill_width, volume_bar_rect.height())
);
ui.painter().rect_filled(
fill_rect,
egui::Rounding::same(1.0 * sf),
egui::Color32::from_rgb(0, 255, 136) // #00ff88
);
}
}
frame_response.response
} }
} }

2
osc/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
Cargo.lock

10
osc/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "osc"
version = "0.1.0"
edition = "2021"
[dependencies]
strum = { version = "0.23", features = ["derive"] }
rosc.workspace = true
thiserror.workspace = true

17
osc/src/error.rs Normal file
View File

@ -0,0 +1,17 @@
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Failed to parse osc address")]
AddressParseError(String),
}
pub trait AddressParseResult<T> {
fn address_part_result(self, context: &'static str) -> Result<T>;
}
impl <T> AddressParseResult <T> for std::result::Result<T, std::num::ParseIntError> {
fn address_part_result(self, context: &'static str) -> Result<T> {
self.map_err(|e| Error::AddressParseError(format!("failed to parse number in address part {}: {}", context, e)))
}
}

11
osc/src/lib.rs Normal file
View File

@ -0,0 +1,11 @@
mod error;
mod message;
mod state;
use error::AddressParseResult;
pub use error::Error;
pub use error::Result;
pub use message::Message;
pub use state::State;
pub use state::Track;
pub use state::TrackState;

101
osc/src/message.rs Normal file
View File

@ -0,0 +1,101 @@
use crate::*;
#[derive(Debug, Clone)]
pub enum Message {
TrackStateChanged {
column: usize,
row: usize,
state: TrackState,
},
TrackVolumeChanged {
column: usize,
row: usize,
volume: f32,
},
SelectedColumnChanged {
column: usize,
},
SelectedRowChanged {
row: usize,
},
}
impl Message {
pub fn from_osc_packet(packet: rosc::OscPacket) -> Result<Option<Self>> {
match packet {
rosc::OscPacket::Message(msg) => Self::from_osc_message(msg),
rosc::OscPacket::Bundle(_) => {
// For now, we don't handle bundles
Ok(None)
}
}
}
pub fn apply_to_state(&self, state: &mut State) {
match self {
Message::TrackStateChanged {
column,
row,
state: track_state,
} => {
state.set_track_state(*column, *row, track_state.clone());
}
Message::SelectedColumnChanged { column } => {
state.set_selected_column(*column);
}
Message::SelectedRowChanged { row } => {
state.set_selected_row(*row);
}
Message::TrackVolumeChanged {
column,
row,
volume,
} => {
state.set_track_volume(*column, *row, *volume);
}
}
}
fn from_osc_message(msg: rosc::OscMessage) -> Result<Option<Self>> {
let rosc::OscMessage { addr, args } = msg;
if addr.starts_with("/looper/cell/") && addr.ends_with("/state") {
// Parse: /looper/cell/{column}/{row}/state
let parts: Vec<&str> = addr.split('/').collect();
if parts.len() != 6 {
return Err(Error::AddressParseError(addr));
}
let column: usize = parts[3].parse::<usize>().address_part_result("column")? - 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::String(state_str)) = args.first() {
let state = Self::track_state_from_osc_string(state_str);
return Ok(Some(Message::TrackStateChanged { column, row, state }));
}
} else if addr == "/looper/selected/column" {
if let Some(rosc::OscType::Int(column_1based)) = args.first() {
let column = (*column_1based as usize).saturating_sub(1); // Convert to 0-based
return Ok(Some(Message::SelectedColumnChanged { column }));
}
} else if addr == "/looper/selected/row" {
if let Some(rosc::OscType::Int(row_1based)) = args.first() {
let row = (*row_1based as usize).saturating_sub(1); // Convert to 0-based
return Ok(Some(Message::SelectedRowChanged { row }));
}
}
// Unknown or unsupported message
Ok(None)
}
fn track_state_from_osc_string(s: &str) -> TrackState {
match s {
"empty" => TrackState::Empty,
"idle" => TrackState::Idle,
"recording" => TrackState::Recording,
"playing" => TrackState::Playing,
_ => TrackState::Empty, // Default fallback
}
}
}

View File

@ -1,33 +1,23 @@
use crate::TrackState; use strum::Display;
#[derive(Debug, Clone)] use crate::*;
pub(crate) enum OscMessage {
TrackStateChanged { #[derive(Clone, Debug, Display, PartialEq)]
column: usize, pub enum TrackState {
row: usize, Empty,
state: TrackState, Idle,
}, Playing,
TrackVolumeChanged { Recording,
column: usize,
row: usize,
volume: f32,
},
SelectedColumnChanged {
column: usize,
},
SelectedRowChanged {
row: usize,
},
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct Track { pub struct Track {
pub state: TrackState, pub state: TrackState,
pub volume: f32, pub volume: f32,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ShadowState { pub struct State {
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<Track>>, pub cells: Vec<Vec<Track>>,
@ -35,7 +25,7 @@ pub(crate) struct ShadowState {
pub rows: usize, pub rows: usize,
} }
impl ShadowState { impl State {
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![Track { state: TrackState::Empty, volume: 1.0 }; rows]) .map(|_| vec![Track { state: TrackState::Empty, volume: 1.0 }; rows])
@ -50,24 +40,24 @@ impl ShadowState {
} }
} }
pub fn update(&mut self, message: &OscMessage) { pub fn update(&mut self, message: &Message) {
match message { match message {
OscMessage::TrackStateChanged { column, row, state } => { Message::TrackStateChanged { column, row, state } => {
if *column < self.columns && *row < self.rows { if *column < self.columns && *row < self.rows {
self.cells[*column][*row].state = state.clone(); self.cells[*column][*row].state = state.clone();
} }
} }
OscMessage::TrackVolumeChanged { column, row, volume } => { Message::TrackVolumeChanged { column, row, volume } => {
if *column < self.columns && *row < self.rows { if *column < self.columns && *row < self.rows {
self.cells[*column][*row].volume = *volume; self.cells[*column][*row].volume = *volume;
} }
} }
OscMessage::SelectedColumnChanged { column } => { Message::SelectedColumnChanged { column } => {
if *column < self.columns { if *column < self.columns {
self.selected_column = *column; self.selected_column = *column;
} }
} }
OscMessage::SelectedRowChanged { row } => { Message::SelectedRowChanged { row } => {
if *row < self.rows { if *row < self.rows {
self.selected_row = *row; self.selected_row = *row;
} }
@ -75,26 +65,26 @@ impl ShadowState {
} }
} }
pub fn create_state_dump(&self) -> Vec<OscMessage> { pub fn create_state_dump(&self) -> Vec<Message> {
let mut messages = Vec::new(); let mut messages = Vec::new();
// Send current selections // Send current selections
messages.push(OscMessage::SelectedColumnChanged { messages.push(Message::SelectedColumnChanged {
column: self.selected_column, column: self.selected_column,
}); });
messages.push(OscMessage::SelectedRowChanged { messages.push(Message::SelectedRowChanged {
row: self.selected_row, row: self.selected_row,
}); });
// Send all cell states and volumes // 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, track) in column_cells.iter().enumerate() { for (row, track) in column_cells.iter().enumerate() {
messages.push(OscMessage::TrackStateChanged { messages.push(Message::TrackStateChanged {
column, column,
row, row,
state: track.state.clone(), state: track.state.clone(),
}); });
messages.push(OscMessage::TrackVolumeChanged { messages.push(Message::TrackVolumeChanged {
column, column,
row, row,
volume: track.volume, volume: track.volume,
@ -104,4 +94,28 @@ impl ShadowState {
messages messages
} }
pub fn set_track_state(&mut self, column: usize, row: usize, state: TrackState) {
if column < self.columns && row < self.rows {
self.cells[column][row].state = state;
}
}
pub fn set_selected_column(&mut self, column: usize) {
if column < self.columns {
self.selected_column = column;
}
}
pub fn set_selected_row(&mut self, row: usize) {
if row < self.rows {
self.selected_row = row;
}
}
pub fn set_track_volume(&mut self, column: usize, row: usize, volume: f32) {
if column < self.columns && row < self.rows {
self.cells[column][row].volume = volume;
}
}
} }