per track volume
This commit is contained in:
9
gui/Cargo.lock
generated
9
gui/Cargo.lock
generated
@@ -1097,6 +1097,7 @@ dependencies = [
|
||||
"egui",
|
||||
"futures",
|
||||
"log",
|
||||
"osc",
|
||||
"rosc",
|
||||
"simple_logger",
|
||||
"tokio",
|
||||
@@ -2309,6 +2310,14 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osc"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"rosc",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owned_ttf_parser"
|
||||
version = "0.25.0"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
[package]
|
||||
name = "fcb-looper-gui"
|
||||
name = "gui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
bytes = "1.0"
|
||||
eframe = { version = "0.29", default-features = true, features = [ "default_fonts", "glow" ] }
|
||||
egui = "0.29"
|
||||
futures = "0.3"
|
||||
log = "0.4"
|
||||
rosc = "0.10"
|
||||
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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,23 @@
|
||||
mod display_state;
|
||||
mod osc;
|
||||
mod osc_client;
|
||||
mod ui;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use display_state::ColumnState;
|
||||
use display_state::DisplayState;
|
||||
use display_state::TrackState;
|
||||
|
||||
trait DisplayStr {
|
||||
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]
|
||||
async fn main() -> Result<()> {
|
||||
@@ -17,12 +29,12 @@ async fn main() -> Result<()> {
|
||||
// Logger
|
||||
simple_logger::SimpleLogger::new()
|
||||
.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()
|
||||
.expect("Could not initialize logger");
|
||||
|
||||
// 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();
|
||||
|
||||
// Spawn OSC receiver thread on tokio runtime
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
mod client;
|
||||
mod message;
|
||||
|
||||
pub use client::Client;
|
||||
@@ -1,10 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use bytes::Bytes;
|
||||
use futures::TryStreamExt;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::codec::{Framed, LengthDelimitedCodec};
|
||||
|
||||
use super::message::*;
|
||||
use crate::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -19,24 +17,30 @@ type PlatformStream = NamedPipeClient;
|
||||
|
||||
pub struct Client {
|
||||
socket_path: String,
|
||||
state_sender: watch::Sender<DisplayState>,
|
||||
state_receiver: watch::Receiver<DisplayState>,
|
||||
state_sender: watch::Sender<osc::State>,
|
||||
state_receiver: watch::Receiver<osc::State>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
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
|
||||
let initial_state = {
|
||||
let mut initial_state = initial_state;
|
||||
initial_state.selected_column = 2;
|
||||
initial_state.selected_row = 3;
|
||||
initial_state.column_states[0].track_states[0] = TrackState::Playing;
|
||||
initial_state.column_states[0].track_states[1] = TrackState::Ready;
|
||||
initial_state.column_states[1].track_states[0] = TrackState::Ready;
|
||||
initial_state.column_states[1].track_states[1] = TrackState::Playing;
|
||||
initial_state.column_states[2].track_states[0] = TrackState::Ready;
|
||||
initial_state.column_states[2].track_states[3] = TrackState::Recording;
|
||||
initial_state.cells[0][0].state = osc::TrackState::Playing;
|
||||
initial_state.cells[0][0].volume = 0.3;
|
||||
initial_state.cells[0][1].state = osc::TrackState::Idle;
|
||||
initial_state.cells[0][1].volume = 1.0;
|
||||
initial_state.cells[1][0].state = osc::TrackState::Idle;
|
||||
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
|
||||
};
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -82,11 +86,11 @@ impl Client {
|
||||
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)
|
||||
.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);
|
||||
|
||||
// Send updated state through watch channel
|
||||
@@ -1,11 +1,9 @@
|
||||
use crate::*;
|
||||
|
||||
pub struct App {
|
||||
state_receiver: tokio::sync::watch::Receiver<DisplayState>,
|
||||
state_receiver: tokio::sync::watch::Receiver<osc::State>,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -20,7 +18,7 @@ impl eframe::App for App {
|
||||
});
|
||||
egui::CentralPanel::default().show(ctx, |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 {
|
||||
Some(state.selected_row)
|
||||
} else {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use super::ui_rows_ext::UiRowsExt;
|
||||
use crate::*;
|
||||
|
||||
pub struct Column<'a> {
|
||||
state: &'a ColumnState,
|
||||
state: &'a Vec<osc::Track>,
|
||||
selected_row: Option<usize>,
|
||||
}
|
||||
|
||||
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 {
|
||||
state,
|
||||
selected_row,
|
||||
@@ -17,8 +15,8 @@ impl<'a> Column<'a> {
|
||||
|
||||
impl<'a> egui::Widget for Column<'a> {
|
||||
fn ui(self, ui: &mut egui::Ui) -> egui::Response {
|
||||
ui.rows(self.state.track_states.len(), |ui| {
|
||||
for (i, track) in self.state.track_states.iter().enumerate() {
|
||||
ui.rows(self.state.len(), |ui| {
|
||||
for (i, track) in self.state.iter().enumerate() {
|
||||
ui[i].add(super::Track::new(track, Some(i) == self.selected_row));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
use crate::*;
|
||||
|
||||
pub struct Track<'a> {
|
||||
state: &'a TrackState,
|
||||
state: &'a osc::Track,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
fn get_colors(&self) -> (egui::Color32, egui::Color32) {
|
||||
// Returns (background_color, text_color)
|
||||
let (dark, bright) = match self.state {
|
||||
TrackState::Empty => (
|
||||
let (dark, bright) = match self.state.state {
|
||||
osc::TrackState::Empty => (
|
||||
egui::Color32::from_rgb(26, 26, 26), // #1a1a1a
|
||||
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(0, 170, 102), // #00aa66
|
||||
),
|
||||
TrackState::Playing => (
|
||||
osc::TrackState::Playing => (
|
||||
egui::Color32::from_rgb(0, 42, 64), // #002a40
|
||||
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(204, 68, 68), // #cc4444
|
||||
),
|
||||
@@ -47,7 +47,7 @@ impl<'a> egui::Widget for Track<'a> {
|
||||
|
||||
let (bg_color, fg_color) = self.get_colors();
|
||||
|
||||
egui::Frame::none()
|
||||
let frame_response = egui::Frame::none()
|
||||
.fill(bg_color)
|
||||
.rounding(egui::Rounding::same(4.0 * sf))
|
||||
.stroke(egui::Stroke::new(2.5 * sf, fg_color))
|
||||
@@ -55,12 +55,55 @@ impl<'a> egui::Widget for Track<'a> {
|
||||
.show(ui, |ui| {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.add(egui::Label::new(
|
||||
egui::RichText::new(self.state.display_str())
|
||||
egui::RichText::new(self.state.state.display_str())
|
||||
.font(font_id)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user