From a96285501d2801211fc11a9f207a301437950805 Mon Sep 17 00:00:00 2001 From: Nicole Tietz-Sokolskaya <me@ntietz.com> Date: Mon, 13 Jan 2025 03:31:12 +0000 Subject: [PATCH] feat: implement MIDI routing, improve UI (#1) Implements the main MIDI daemon and the routing system. Makes a few improvements to the UI: - Allows selecting the devices to show messages from - Provides meager filtering - Prettier output of messages Reviewed-on: https://git.kittencollective.com/nicole/midi-keys/pulls/1 --- Cargo.toml | 2 +- src/bin/main.rs | 47 ++++-- src/midi/daemon.rs | 149 ++++++++++++------- src/ui.rs | 361 ++++++++++++++++++++++++++++++++------------- 4 files changed, 392 insertions(+), 167 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 970c60e..b6add1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,4 +15,4 @@ nom = "7.1.3" thiserror = "1.0.63" [lints.clippy] -unwrap_used = "deny" +#unwrap_used = "deny" diff --git a/src/bin/main.rs b/src/bin/main.rs index a6b8d89..de8176d 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -4,7 +4,12 @@ use std::{ }; use anyhow::{anyhow, Result}; -use midi_keys::{log::load_raw_log, midi::daemon::Category, parser::parse_message}; +use midi_keys::{ + log::load_raw_log, + midi::daemon::Category, + parser::parse_message, + ui::{display_state_daemon, DisplayQueues, DisplayState}, +}; //use enigo::{Direction, Enigo, Key, Keyboard, Settings}; use midir::{MidiInput, MidiInputPort}; @@ -13,21 +18,45 @@ fn main() { //enigo.text("echo \"hello world\"").unwrap(); //enigo.key(Key::Return, Direction::Press).unwrap(); - let (send, receive) = crossbeam::channel::unbounded(); - let all = Category::All; + let (dbg_send, dbg_recv) = crossbeam::channel::unbounded(); + let (ws_send, ws_recv) = crossbeam::channel::unbounded(); + let (drum_send, drum_recv) = crossbeam::channel::unbounded(); - std::thread::spawn(move || { - midi_keys::midi::daemon::run_daemon(vec![(all, send)]); - }); + let (msgs_send, msgs_recv) = crossbeam::channel::unbounded(); + + let (_handle, ports_recv) = midi_keys::midi::daemon::run_daemon(vec![ + (Category::All, dbg_send), + (Category::All, msgs_send), + (Category::WindSynth, ws_send), + (Category::Drums, drum_send), + ]); + + let queues = DisplayQueues::new(msgs_recv, ports_recv); + let state = DisplayState::new(); + + let _handle = display_state_daemon(queues, state.clone()); + println!("started daemon"); std::thread::spawn(move || loop { - match receive.recv() { - Ok(m) => println!("got: {m:?}"), + match dbg_recv.recv() { + Ok(m) => println!("debug: {m:?}"), Err(err) => println!("err: {err:?}"), } }); + std::thread::spawn(move || loop { + match ws_recv.recv() { + Ok(m) => println!("windsynth: {m:?}"), + Err(err) => println!("err(ws): {err:?}"), + } + }); + std::thread::spawn(move || loop { + match drum_recv.recv() { + Ok(m) => println!("drum: {m:?}"), + Err(err) => println!("err(drum): {err:?}"), + } + }); - midi_keys::ui::run(); + midi_keys::ui::run(state); } pub fn run() -> Result<()> { diff --git a/src/midi/daemon.rs b/src/midi/daemon.rs index a703b2c..cdaf67d 100644 --- a/src/midi/daemon.rs +++ b/src/midi/daemon.rs @@ -1,4 +1,20 @@ -use std::{collections::HashMap, time::Duration}; +//! Architecture for the MIDI connectivity: +//! +//! State is what's held by the daemon, and is used in an event loop. +//! - queues are passed in which are what we use to route messages +//! - this state can only be accessed directly by the event loop, as it owns the state +//! - does a `select!` (crossbeam) on the timer tick and on other channels +//! - each tick of the timer (on a timer rx channel), call `state.refresh_ports()` and assign +//! ports if they're mapped automatically (drums / windsynth / ignore) for anything that +//! isn't yet mapped (users can override in UI) +//! - listens to a queue of routing updates ("set connectionid to drums / windsynth / ignore") +//! from the UI +//! - receive from the message_receive queue and route the messages +//! +//! ideal would be to do the port refresh on a separate thread. but! it's relatively cheap (250 +//! microseconds per refresh) so it's not a big deal + +use std::{collections::HashMap, thread::JoinHandle, time::Duration}; use crossbeam::{ channel::{Receiver, Sender}, @@ -10,6 +26,7 @@ use crate::parser::parse_message; use super::Message; +#[derive(PartialEq, Eq, Debug)] pub enum Category { All, Drums, @@ -18,80 +35,79 @@ pub enum Category { Ignore, } -/// Architecture for the MIDI connectivity: -/// -/// State is what's held by the daemon, and is used in an event loop. -/// - queues are passed in which are what we use to route messages -/// - this state can only be accessed directly by the event loop, as it owns the state -/// - does a `select!` (crossbeam) on the timer tick and on other channels -/// - each tick of the timer (on a timer rx channel), call `state.refresh_ports()` and assign -/// ports if they're mapped automatically (drums / windsynth / ignore) for anything that -/// isn't yet mapped (users can override in UI) -/// - listens to a queue of routing updates ("set connectionid to drums / windsynth / ignore") -/// from the UI -/// - receive from the message_receive queue and route the messages -/// -/// ideal would be to do the port refresh on a separate thread. but! it's relatively cheap (250 -/// microseconds per refresh) so it's not a big deal -type ConnectionId = String; -type Timestamp = u64; -type TimestampedMessage = (Timestamp, Message); -type CTM = (ConnectionId, Timestamp, Message); +pub type ConnectionId = String; +pub type Timestamp = u64; +pub type CTM = (ConnectionId, Timestamp, Message); -type MidiSend = crossbeam::channel::Sender<CTM>; -type MidiRecv = crossbeam::channel::Receiver<CTM>; - -pub fn run_daemon(routes: Vec<(Category, MidiSend)>) { +pub fn run_daemon( + routes: Vec<(Category, Sender<CTM>)>, +) -> (JoinHandle<usize>, Receiver<Vec<MidiInputPort>>) { let mut state = State::new(); - let messages = state.message_queue(); + let ports_receiver = state.ports_queue(); - let ticker = crossbeam::channel::tick(Duration::from_millis(250)); + let handle = std::thread::spawn(move || { + let messages_q = state.message_queue(); + let ticker = crossbeam::channel::tick(Duration::from_millis(250)); - loop { - select! { - recv(ticker) -> m => match m { - Ok(ts) => { - println!("refreshing ports at {ts:?}"); - state.refresh_ports(); - } - Err(e) => { - println!("borken {e:?}"); - } - }, - recv(messages) -> m => match m { - Ok(ctm) => { - for (_cat, q) in routes.iter() { - q.send(ctm.clone()); + loop { + select! { + recv(ticker) -> m => match m { + Ok(_ts) => { + state.refresh_ports(); } - } - Err(e) => { - println!("borken2 {e:?}"); - } - }, + Err(e) => { + println!("borken {e:?}"); + } + }, + recv(messages_q) -> m => match m { + Ok(ctm) => { + let (cid, _ts, _msg) = &ctm; + let msg_cat = state.conn_mapping.get(cid).unwrap_or(&Category::Ignore); + for (route_cat, q) in routes.iter() { + if route_cat == msg_cat || *route_cat == Category::All { + let _ = q.send(ctm.clone()); + } + } + } + Err(e) => { + println!("borken2 {e:?}"); + } + }, + } } - } + }); + + (handle, ports_receiver) } pub struct State { midi: MidiInput, ports: Vec<MidiInputPort>, connections: HashMap<String, MidiInputConnection<(ConnectionId, Sender<CTM>)>>, + conn_mapping: HashMap<ConnectionId, Category>, message_receive: Receiver<CTM>, message_send: Sender<CTM>, + + ports_receive: Receiver<Vec<MidiInputPort>>, + ports_send: Sender<Vec<MidiInputPort>>, } impl State { pub fn new() -> State { - let (tx, rx) = crossbeam::channel::unbounded(); + let (msg_tx, msg_rx) = crossbeam::channel::unbounded(); + let (ports_tx, ports_rx) = crossbeam::channel::unbounded(); State { midi: MidiInput::new("midi-keys daemon").expect("could not connect to system MIDI"), ports: vec![], connections: HashMap::new(), - message_receive: rx, - message_send: tx, + conn_mapping: HashMap::new(), + message_receive: msg_rx, + message_send: msg_tx, + ports_receive: ports_rx, + ports_send: ports_tx, } } @@ -99,11 +115,17 @@ impl State { self.message_receive.clone() } + pub fn ports_queue(&self) -> Receiver<Vec<MidiInputPort>> { + self.ports_receive.clone() + } + pub fn refresh_ports(&mut self) { let ports = self.midi.ports(); for port in ports.iter() { self.refresh_port(port); } + // TODO: don't ignore + let _ = self.ports_send.send(ports.clone()); self.ports = ports; } @@ -129,18 +151,41 @@ impl State { let send_queue = self.message_send.clone(); if let Ok(conn) = midi.connect( - &port, + port, &name, |timestamp, bytes, (conn_id, send_queue)| { - handle_message(timestamp, bytes, &conn_id, &send_queue); + handle_message(timestamp, bytes, conn_id, send_queue); }, (port.id(), send_queue), ) { + let category = guess_catgory(&name); + println!("guessing category:"); + println!(" name> {name}"); + println!(" id> {}", port.id()); + println!(" cat> {category:?}"); + + self.conn_mapping.insert(port.id(), category); self.connections.insert(port.id(), conn); } } } +impl Default for State { + fn default() -> Self { + State::new() + } +} + +fn guess_catgory(name: &str) -> Category { + if name.contains("TravelSax2") || name.contains("AE-20") { + Category::WindSynth + } else if name.contains("MultiPad") { + Category::Drums + } else { + Category::Ignore + } +} + fn handle_message(ts: Timestamp, bytes: &[u8], conn_id: &ConnectionId, tx: &Sender<CTM>) { if let Ok((_rem, msg)) = parse_message(bytes) { let _ = tx.send((conn_id.clone(), ts, msg)); diff --git a/src/ui.rs b/src/ui.rs index 552d06b..200a62e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,102 +1,128 @@ -use std::{sync::Arc, thread::JoinHandle}; +use std::{ + collections::{HashMap, VecDeque}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::JoinHandle, +}; -use egui::{mutex::Mutex, Color32, Frame, Rounding, SelectableLabel}; +use crossbeam::{channel::Receiver, select}; +use egui::{mutex::Mutex, Color32, Frame, Grid, RichText, Rounding, ScrollArea, SelectableLabel}; use midir::{MidiInput, MidiInputPort}; -use crate::midi::Message; +use crate::midi::{daemon::CTM, Message, VoiceCategory, VoiceMessage}; + +/// State used to display the UI. It's intended to be shared between the +/// renderer and the daemon which updates the state. +#[derive(Clone)] +pub struct DisplayState { + pub midi_input_ports: Arc<Mutex<Vec<MidiInputPort>>>, + pub midi_messages: Arc<Mutex<VecDeque<CTM>>>, + pub selected_ports: HashMap<String, bool>, + pub max_messages: usize, + pub only_note_on_off: Arc<AtomicBool>, +} + +impl DisplayState { + pub fn new() -> DisplayState { + DisplayState { + midi_input_ports: Arc::new(Mutex::new(vec![])), + midi_messages: Arc::new(Mutex::new(VecDeque::new())), + selected_ports: HashMap::new(), + max_messages: 10_000_usize, + only_note_on_off: Arc::new(AtomicBool::new(false)), + } + } + + pub fn set_ports(&self, updated_ports: Vec<MidiInputPort>) { + let mut ports = self.midi_input_ports.lock(); + *ports = updated_ports; + } + + pub fn store_message(&self, message: CTM) { + let mut messages = self.midi_messages.lock(); + messages.push_back(message); + if messages.len() > self.max_messages { + messages.pop_front(); + } + } +} + +impl Default for DisplayState { + fn default() -> Self { + DisplayState::new() + } +} + +/// Queues we receive from to refresh and update the UI. +#[derive(Debug, Clone)] +pub struct DisplayQueues { + pub messages: Receiver<CTM>, + pub ports: Receiver<Vec<MidiInputPort>>, + // TODO: conn mapping +} + +impl DisplayQueues { + pub fn new(messages: Receiver<CTM>, ports: Receiver<Vec<MidiInputPort>>) -> DisplayQueues { + DisplayQueues { messages, ports } + } +} + +pub fn display_state_daemon(queues: DisplayQueues, state: DisplayState) -> JoinHandle<()> { + std::thread::spawn(move || loop { + select! { + recv(queues.messages) -> m => match m { + Ok(ctm) => { + state.store_message(ctm); + } + Err(e) => { + println!("borken {e:?}"); + } + }, + + recv(queues.ports) -> ports => match ports { + Ok(ports) => { + state.set_ports(ports) + } + Err(e) => { + println!("borken {e:?}"); + } + } + + + } + }) +} /// Launches the UI and runs it until it's done executing. -pub fn run() { +/// +/// Accepts a VecDeque as input, which is used as shared state to provide the +/// messages +pub fn run(state: DisplayState) { let native_options = eframe::NativeOptions::default(); // TODO: don't ignore result let _ = eframe::run_native( "Midi Keys", native_options, - Box::new(|cc| Ok(Box::new(MidiKeysApp::new(cc)))), + Box::new(|cc| Ok(Box::new(MidiKeysApp::new(cc, state.clone())))), ); } -const MAX_MESSAGES: usize = 1_000; -type Timestamp = u64; - -struct MidiMessageBuffer { - messages: Vec<(Timestamp, Message)>, - start: usize, - max: usize, -} - -impl MidiMessageBuffer { - pub fn new(max: usize) -> MidiMessageBuffer { - Self { - messages: vec![], - start: 0, - max, - } - } - - pub fn insert(&mut self, m: (Timestamp, Message)) { - if self.messages.len() < self.max { - self.messages.push(m); - } else { - self.messages[self.start] = m; - self.start = (self.start + 1) % self.messages.len(); - } - } - - pub fn iter(&self) -> BufferIter { - BufferIter { - elems: self.messages.as_slice(), - start: self.start, - index: 0, - } - } -} - -struct BufferIter<'a> { - elems: &'a [(Timestamp, Message)], - start: usize, - index: usize, -} - -impl<'a> Iterator for BufferIter<'a> { - type Item = (Timestamp, Message); - - fn next(&mut self) -> Option<Self::Item> { - if self.index >= self.elems.len() { - None - } else { - let elem = self.elems[(self.start + self.index) % self.elems.len()].clone(); - self.index += 1; - Some(elem) - } - } -} - struct MidiKeysApp { - midi_input_ports: Arc<Mutex<Vec<MidiInputPort>>>, - midi_messages: Arc<Mutex<MidiMessageBuffer>>, midi_in: MidiInput, + state: DisplayState, } impl MidiKeysApp { - fn new(_cc: &eframe::CreationContext<'_>) -> Self { + fn new(_cc: &eframe::CreationContext<'_>, state: DisplayState) -> Self { // this is where to hook in for customizing eguji, like fonts and visuals. let midi_in: MidiInput = MidiInput::new("midi-keys").expect("could not connect to system MIDI"); - let midi_input_ports = Arc::new(Mutex::new(Vec::new())); - let midi_messages = Arc::new(Mutex::new(MidiMessageBuffer::new(MAX_MESSAGES))); - - // TODO: have a way to shut down the midi daemon? - let _ = launch_midi_daemon(midi_input_ports.clone()); - - MidiKeysApp { - midi_input_ports, - midi_messages, - midi_in, - } + MidiKeysApp { midi_in, state } } } @@ -104,7 +130,8 @@ impl eframe::App for MidiKeysApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.set_theme(egui::Theme::Light); - let ports = self.midi_input_ports.lock().clone(); + let ports = self.state.midi_input_ports.lock().clone(); + let messages = self.state.midi_messages.lock().clone(); egui::TopBottomPanel::top("menu_bar_panel").show(ctx, |ui| { egui::menu::bar(ui, |ui| { @@ -124,54 +151,178 @@ impl eframe::App for MidiKeysApp { egui::SidePanel::left("instrument_panel").show(ctx, |ui| { ui.heading("Connections"); - for (idx, port) in ports.iter().enumerate() { + for port in ports.iter() { let port_name = self .midi_in .port_name(port) .unwrap_or("unknown".to_string()); - ui.add(SelectableLabel::new(idx == 0, port_name)); + let conn_id = port.id(); + + let selected = self.state.selected_ports.get(&conn_id).unwrap_or(&false); + + if ui + .add(SelectableLabel::new(*selected, &port_name)) + .clicked() + { + self.state.selected_ports.insert(conn_id, !selected); + } } }); + let mut only_note_on_off = self.state.only_note_on_off.load(Ordering::Relaxed); + + egui::TopBottomPanel::top("filter_panel").show(ctx, |ui| { + ui.checkbox(&mut only_note_on_off, "Only note on/off"); + }); + + self.state + .only_note_on_off + .store(only_note_on_off, Ordering::Relaxed); + egui::CentralPanel::default().show(ctx, |ui| { - for port in ports.iter() { - let mut frame = Frame::default() - .inner_margin(4.0) - .stroke((1.0, Color32::BLACK)) - .rounding(Rounding::same(2.0)) - .begin(ui); + ScrollArea::vertical().show(ui, |ui| { + for (idx, (conn, _ts, msg)) in messages.iter().rev().enumerate() { + if only_note_on_off + && !matches!( + msg, + Message::Voice(VoiceMessage { + category: VoiceCategory::NoteOn { .. } + | VoiceCategory::NoteOff { .. }, + .. + }) + ) + { + continue; + } + if !self + .state + .selected_ports + .get(conn.as_str()) + .unwrap_or(&false) + { + continue; + } + let mut frame = Frame::default() + .inner_margin(4.0) + .stroke((1.0, Color32::BLACK)) + .rounding(Rounding::same(2.0)) + .begin(ui); - let port_name = self - .midi_in - .port_name(port) - .unwrap_or("unknown".to_string()); - frame.content_ui.label(port_name); + let port = match ports.iter().find(|p| &p.id() == conn) { + Some(p) => p, + None => continue, + }; + let port_name = self + .midi_in + .port_name(port) + .unwrap_or("(disconnected_device)".into()); - frame.end(ui); - } + frame.content_ui.label(RichText::new(port_name).strong()); + + display_midi_message(idx, msg, &mut frame.content_ui); + + frame.end(ui); + } + }); ctx.request_repaint(); }); } } -struct MidiDaemon { +fn display_midi_message(idx: usize, msg: &Message, ui: &mut egui::Ui) { + match msg { + Message::Voice(vm) => { + Grid::new(format!("message_grid_{idx}")).show(ui, |ui| { + let voice_label = format!("Voice (channel={})", vm.channel); + ui.label(RichText::new(voice_label).italics()); -} + let (name, fields) = match vm.category { + VoiceCategory::NoteOff { note, velocity } => ( + "NoteOff", + vec![ + ("note", note_name(note)), + ("velocity", format!("{}", velocity)), + ], + ), + VoiceCategory::NoteOn { note, velocity } => ( + "NoteOn", + vec![ + ("note", note_name(note)), + ("velocity", format!("{}", velocity)), + ], + ), + VoiceCategory::AfterTouch { note, pressure } => ( + "AfterTouch", + vec![ + ("note", note_name(note)), + ("pressure", format!("{}", pressure)), + ], + ), + VoiceCategory::ControlChange { controller, value } => ( + "ControlChange", + vec![ + ("controller", format!("{}", controller)), + ("value", format!("{}", value)), + ], + ), + VoiceCategory::ProgramChange { value } => { + ("ProgramChange", vec![("value", format!("{}", value))]) + } + VoiceCategory::ChannelPressure { pressure } => ( + "ChannelPressure", + vec![("pressure", format!("{}", pressure))], + ), + VoiceCategory::PitchWheel { value } => { + ("PitchWheel", vec![("value", format!("{}", value))]) + } + VoiceCategory::Unknown => ("Unknown", vec![]), + }; -pub fn launch_midi_daemon(target_field: Arc<Mutex<Vec<MidiInputPort>>>) -> JoinHandle<()> { - let daemon_handle = std::thread::spawn(move || midi_daemon(target_field)); + ui.label(name); + ui.end_row(); - daemon_handle -} - -pub fn midi_daemon(target_field: Arc<Mutex<Vec<MidiInputPort>>>) { - let midi_in: MidiInput = MidiInput::new("midi-keys").expect("could not connect to system MIDI"); - - loop { - *target_field.lock() = midi_in.ports(); - - std::thread::sleep(std::time::Duration::from_millis(100)); + for (name, value) in fields { + ui.label(format!("{name} = {value}")); + } + }); + } + Message::System(_system_common) => {} + Message::Realtime(_system_realtime) => {} + } +} + +fn note_name(midi_note: u8) -> String { + let octave = ((midi_note as i32) - 12) / 12; + let note = match midi_note % 12 { + 0 => "C", + 1 => "C#", + 2 => "D", + 3 => "Eb", + 4 => "E", + 5 => "F#", + 6 => "F#", + 7 => "G", + 8 => "Ab", + 9 => "A", + 10 => "Bb", + 11 => "B", + _ => panic!("somehow we broke modular arithmetic rules i guess"), + }; + + format!("{note}{octave}") +} + +#[cfg(test)] +mod tests { + use crate::ui::note_name; + + #[test] + pub fn names_of_notes() { + assert_eq!(note_name(60), "C4"); + assert_eq!(note_name(36), "C2"); + assert_eq!(note_name(57), "A3"); + assert_eq!(note_name(35), "B1"); } }