midi-keys/src/ui.rs

460 lines
16 KiB
Rust

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<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>,
pub show_raw: Arc<AtomicBool>,
pub selected_tab: Tabs,
pub melodic_typing_state: Arc<Mutex<TypingState>>,
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<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.
///
/// 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<MidiInputPort>,
messages: Vec<CTM>,
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(&current_notes);
Grid::new("typing_melodic_grid".to_string()).show(ui, |ui| {
ui.label("Current notes:");
for note in &current_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!("<space>"),
Keystroke::Char('\n') => format!("<newline>"),
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");
}
}