use std::{ collections::{HashMap, VecDeque}, sync::{ atomic::{AtomicBool, Ordering}, Arc, }, thread::JoinHandle, }; use itertools::Itertools; use crossbeam::{channel::Receiver, select}; use egui::{mutex::Mutex, Color32, Frame, Grid, RichText, ScrollArea, SelectableLabel}; use midir::{MidiInput, MidiInputPort}; use crate::{ midi::{daemon::CTM, Message, ParsedMessage, VoiceCategory, VoiceMessage}, typing::{c_scale_note_name, standard_melodic_mapping, Keystroke, TypingState}, }; /// 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>>, pub midi_messages: Arc>>, pub selected_ports: HashMap, pub max_messages: usize, pub only_note_on_off: Arc, pub show_raw: Arc, pub selected_tab: Tabs, pub melodic_typing_state: Arc>, pub scratchpad: String, } impl DisplayState { pub fn new() -> DisplayState { let melodic_mapping = standard_melodic_mapping(); let melodic_typing_state = TypingState::new(melodic_mapping); 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(true)), show_raw: Arc::new(AtomicBool::new(true)), selected_tab: Tabs::WindSynthTyping, melodic_typing_state: Arc::new(Mutex::new(melodic_typing_state)), scratchpad: String::new(), } } pub fn set_ports(&self, updated_ports: Vec) { 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, pub ports: Receiver>, // TODO: conn mapping } impl DisplayQueues { pub fn new(messages: Receiver, ports: Receiver>) -> 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. /// /// 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, state.clone())))), ); } #[derive(Debug, PartialEq, Copy, Clone)] pub enum Tabs { Messages, WindSynthTyping, } struct MidiKeysApp { midi_in: MidiInput, ports: Vec, messages: Vec, state: DisplayState, first_render: bool, } impl MidiKeysApp { fn new(cc: &eframe::CreationContext<'_>, state: DisplayState) -> Self { // this is where to hook in for customizing egui, like fonts and visuals. let midi_in: MidiInput = MidiInput::new("midi-keys").expect("could not connect to system MIDI"); let ports = vec![]; let messages = vec![]; cc.egui_ctx.set_zoom_factor(1.25); MidiKeysApp { midi_in, ports, messages, state, first_render: true, } } pub fn instrument_panel(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { egui::SidePanel::left("instrument_panel").show(ctx, |ui| { ui.heading("Connections"); for port in self.ports.iter() { let port_name = self .midi_in .port_name(port) .unwrap_or("unknown".to_string()); let conn_id = port.id(); let selected = self.state.selected_ports.get(&conn_id).unwrap_or(&true); if ui .add(SelectableLabel::new(*selected, &port_name)) .clicked() { self.state.selected_ports.insert(conn_id, !selected); } } }); } pub fn messages_tab(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.instrument_panel(ctx, frame); let mut only_note_on_off = self.state.only_note_on_off.load(Ordering::Relaxed); let mut show_raw = self.state.show_raw.load(Ordering::Relaxed); egui::TopBottomPanel::bottom("filter_panel").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { ui.checkbox(&mut only_note_on_off, "Only note on/off"); ui.checkbox(&mut show_raw, "Display raw bytes"); }); }); self.state .only_note_on_off .store(only_note_on_off, Ordering::Relaxed); self.state.show_raw.store(show_raw, Ordering::Relaxed); egui::CentralPanel::default().show(ctx, |ui| { ScrollArea::vertical() .max_width(f32::INFINITY) .show(ui, |ui| { ui.vertical_centered_justified(|ui| { for (idx, (conn, _ts, msg)) in self.messages.iter().rev().enumerate() { if only_note_on_off && !matches!( &msg.parsed, ParsedMessage::Voice(VoiceMessage { category: VoiceCategory::NoteOn { .. } | VoiceCategory::NoteOff { .. }, .. }) ) { continue; } if !self .state .selected_ports .get(conn.as_str()) .unwrap_or(&true) { continue; } let port = match self.ports.iter().find(|p| &p.id() == conn) { Some(p) => p, None => continue, }; ui.set_width(ui.available_width()); let mut frame = Frame::default() .inner_margin(4.0) .outer_margin(4.0) .stroke((1.0, Color32::BLACK)) .corner_radius(2) .begin(ui); let port_name = self .midi_in .port_name(port) .unwrap_or("(disconnected_device)".into()); frame.content_ui.label(RichText::new(port_name).strong()); display_midi_message(idx, msg, &mut frame.content_ui, show_raw); frame.end(ui); } }); ctx.request_repaint(); }); }); } pub fn typing_tab(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { self.instrument_panel(ctx, frame); egui::CentralPanel::default().show(ctx, |ui| { let typing_state = self.state.melodic_typing_state.lock(); let current_notes = typing_state.current_notes(); let options = typing_state.mapping().collated_remaining(¤t_notes); Grid::new("typing_melodic_grid".to_string()).show(ui, |ui| { ui.label("Current notes:"); for note in ¤t_notes { ui.label(c_scale_note_name(*note)); } ui.end_row(); }); if let Some(options) = options { Grid::new("typing_melodic_options_grid".to_string()).show(ui, |ui| { for (next_note, keys) in options { ui.label(c_scale_note_name(next_note)); let keys_disp = keys.iter().map(|k| match k { Keystroke::Char(' ') => format!(""), Keystroke::Char('\n') => format!(""), Keystroke::Char(c) => format!("{c}"), Keystroke::Modifier(modifier) => format!("<{modifier:?}>"), Keystroke::Special(special) => format!("<{special:?}>"), }).join(", "); ui.label(format!("{keys_disp}")); ui.end_row(); } }); } else { ui.label("Nothing you can do from here. Sorry."); } ctx.request_repaint(); }); } } impl eframe::App for MidiKeysApp { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { if self.first_render { let window_size = (1200., 800.).into(); ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(window_size)); self.first_render = false; } ctx.set_theme(egui::Theme::Light); self.ports = self.state.midi_input_ports.lock().clone(); self.messages = self.state.midi_messages.lock().clone().into(); egui::TopBottomPanel::top("menu_bar_panel").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { if ui.button("Quit").clicked() { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } }); ui.menu_button("Help", |ui| { if ui.button("About").clicked() { // TODO: implement something } }); }); }); egui::TopBottomPanel::top("tab_selector").show(ctx, |ui| { ui.horizontal_wrapped(|ui| { ui.radio_value(&mut self.state.selected_tab, Tabs::Messages, "Messages"); ui.radio_value( &mut self.state.selected_tab, Tabs::WindSynthTyping, "Typing", ); }); }); if self.state.selected_tab == Tabs::Messages { self.messages_tab(ctx, frame); } else if self.state.selected_tab == Tabs::WindSynthTyping { self.typing_tab(ctx, frame); } } } fn display_midi_message(idx: usize, msg: &Message, ui: &mut egui::Ui, raw: bool) { match &msg.parsed { ParsedMessage::Voice(vm) => { Grid::new(format!("message_grid_{idx}")).show(ui, |ui| { let (name, fields) = match vm.category { VoiceCategory::NoteOff { note, velocity } => ( "NoteOff", vec![ ("note", midi_note_name(note)), ("velocity", format!("{}", velocity)), ], ), VoiceCategory::NoteOn { note, velocity } => ( "NoteOn", vec![ ("note", midi_note_name(note)), ("velocity", format!("{}", velocity)), ], ), VoiceCategory::AfterTouch { note, pressure } => ( "AfterTouch", vec![ ("note", midi_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![]), }; let voice_label = format!("Voice:{}", name); ui.label(RichText::new(voice_label).italics()); ui.label(format!("channel = {}", vm.channel)); for (name, value) in fields { ui.label(format!("{name} = {value}")); } }); } ParsedMessage::System(_system_common) => {} ParsedMessage::Realtime(_system_realtime) => {} } if raw { ui.horizontal_top(|ui| { ui.label("Bytes:"); Frame::new().show(ui, |ui| { ui.spacing_mut().item_spacing = (0.0, 0.0).into(); for byte in &msg.raw { Frame::new() .inner_margin(2.0) .stroke((0.5, Color32::GRAY)) .outer_margin(0.0) .show(ui, |ui| { ui.label(format!("{:02X}", byte)); }); } }); }); } } pub fn midi_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::midi_note_name; #[test] pub fn names_of_notes() { assert_eq!(midi_note_name(60), "C4"); assert_eq!(midi_note_name(36), "C2"); assert_eq!(midi_note_name(57), "A3"); assert_eq!(midi_note_name(35), "B1"); } }