implement basic typing via midi
This commit is contained in:
parent
64adf8136e
commit
dfb4c93e4e
8 changed files with 291 additions and 20 deletions
|
@ -6,8 +6,8 @@ edition = "2021"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.86"
|
anyhow = "1.0.86"
|
||||||
crossbeam = "0.8.4"
|
crossbeam = "0.8.4"
|
||||||
eframe = "0.29.1"
|
eframe = "0"
|
||||||
egui = "0.29.1"
|
egui = "0"
|
||||||
enigo = { version = "0.2.1", features = ["serde"] }
|
enigo = { version = "0.2.1", features = ["serde"] }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
midir = "0.10.0"
|
midir = "0.10.0"
|
||||||
|
|
61
doc/typing.md
Normal file
61
doc/typing.md
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
|
||||||
|
# Typing system design
|
||||||
|
|
||||||
|
|
||||||
|
This document talks about how data flows through the system, and how it's
|
||||||
|
displayed, so that users can enter text using a MIDI device.
|
||||||
|
|
||||||
|
There are a few distinct pieces:
|
||||||
|
- **data flow**: how do we get from MIDI to the display and to the typing portion?
|
||||||
|
- **encoding**: how do we represent text so that we can enter it?
|
||||||
|
- **parsing**: how do we read a MIDI stream to convert it into our text encoding?
|
||||||
|
- **display**: how do we present the state to a user so that they can see what they're doing?
|
||||||
|
|
||||||
|
|
||||||
|
## Data flow and architecture
|
||||||
|
|
||||||
|
We already have the ability for data to flow in, and for devices to be added
|
||||||
|
and removed. What we need here is another piece that will keep track of state
|
||||||
|
for the typing daemon.
|
||||||
|
|
||||||
|
The typing daemon will be a state machine, with state transitions for entering
|
||||||
|
new bits and for emitting text (which will then move back to a clear state).
|
||||||
|
|
||||||
|
It will be subscribed to all incoming MIDI messages, and it will store a
|
||||||
|
separate state for each connection ID, so things you enter on the drums will
|
||||||
|
not alter what you're entering on the wind synth, for example.
|
||||||
|
|
||||||
|
It will emit text directly, via `enigo` (or, in debug mode, to the console).
|
||||||
|
And it will emit its current state on another queue, which can be used to
|
||||||
|
display the characters which can be typed.
|
||||||
|
|
||||||
|
|
||||||
|
## Encoding
|
||||||
|
|
||||||
|
Each MIDI device will be in a given *base*. Wind synth will be in base 11 by
|
||||||
|
default, so we can read the semitone and ignore which octave it's in. We could
|
||||||
|
expand this to use a wider range, or narrow it to belong to a particular key,
|
||||||
|
but this is the starting point. Drums will be in base 2 by default, with the
|
||||||
|
snare and kick drum providing the bits and everything else being ignored.
|
||||||
|
|
||||||
|
We will support a given range of characters, and some modifiers as well. Other
|
||||||
|
keys can be added, but this is where we'll start: just text! the characters
|
||||||
|
supported initially will be 'a'..'z', 'A'..'Z', '0'..'9', '.', and ','.
|
||||||
|
|
||||||
|
Each of these will be assigned a number, which will then be converted to base 2
|
||||||
|
for entry.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Display
|
||||||
|
|
||||||
|
Given a list of
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,20 +4,17 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
use enigo::{Enigo, Keyboard, Settings};
|
||||||
use midi_keys::{
|
use midi_keys::{
|
||||||
log::load_raw_log,
|
log::load_raw_log,
|
||||||
midi::daemon::Category,
|
midi::daemon::Category,
|
||||||
parser::parse_message,
|
parser::parse_message,
|
||||||
|
typing::{base_values, encode, midi_to_scale_degree, TypingState},
|
||||||
ui::{display_state_daemon, DisplayQueues, DisplayState},
|
ui::{display_state_daemon, DisplayQueues, DisplayState},
|
||||||
};
|
};
|
||||||
//use enigo::{Direction, Enigo, Key, Keyboard, Settings};
|
|
||||||
use midir::{MidiInput, MidiInputPort};
|
use midir::{MidiInput, MidiInputPort};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
//let mut enigo = Enigo::new(&Settings::default()).unwrap();
|
|
||||||
//enigo.text("echo \"hello world\"").unwrap();
|
|
||||||
//enigo.key(Key::Return, Direction::Press).unwrap();
|
|
||||||
|
|
||||||
let (dbg_send, dbg_recv) = crossbeam::channel::unbounded();
|
let (dbg_send, dbg_recv) = crossbeam::channel::unbounded();
|
||||||
let (ws_send, ws_recv) = crossbeam::channel::unbounded();
|
let (ws_send, ws_recv) = crossbeam::channel::unbounded();
|
||||||
let (drum_send, drum_recv) = crossbeam::channel::unbounded();
|
let (drum_send, drum_recv) = crossbeam::channel::unbounded();
|
||||||
|
@ -37,17 +34,47 @@ fn main() {
|
||||||
let _handle = display_state_daemon(queues, state.clone());
|
let _handle = display_state_daemon(queues, state.clone());
|
||||||
println!("started daemon");
|
println!("started daemon");
|
||||||
|
|
||||||
std::thread::spawn(move || loop {
|
for c in "hello, world".chars() {
|
||||||
match dbg_recv.recv() {
|
let v = encode(c).unwrap();
|
||||||
Ok(m) => println!("debug: {m:?}"),
|
println!("{c}: {:?} - {:?}", v, base_values(v, 7, 3));
|
||||||
Err(err) => println!("err: {err:?}"),
|
|
||||||
}
|
}
|
||||||
});
|
for c in "Hello, WORLD!".chars() {
|
||||||
std::thread::spawn(move || loop {
|
let v = encode(c).unwrap();
|
||||||
|
println!("{c}: {:?} - {:?}", v, base_values(v, 7, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
//std::thread::spawn(move || loop {
|
||||||
|
// match dbg_recv.recv() {
|
||||||
|
// Ok(m) => println!("debug: {m:?}"),
|
||||||
|
// Err(err) => println!("err: {err:?}"),
|
||||||
|
// }
|
||||||
|
//});
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut steps = 0;
|
||||||
|
let mut state = TypingState::new(7);
|
||||||
|
let mut enigo = Enigo::new(&Settings::default()).unwrap();
|
||||||
|
loop {
|
||||||
match ws_recv.recv() {
|
match ws_recv.recv() {
|
||||||
Ok(m) => println!("windsynth: {m:?}"),
|
Ok((_cid, _ts, m)) => {
|
||||||
|
if let Some((note, _velocity)) = m.note_on_values() {
|
||||||
|
if let Some(degree) = midi_to_scale_degree(60, note) {
|
||||||
|
steps += 1;
|
||||||
|
println!("got degree: {degree}, steps: {steps}, state: {state:?}");
|
||||||
|
state.enter(degree);
|
||||||
|
|
||||||
|
if steps % 3 == 0 {
|
||||||
|
let (b, c) = state.emit();
|
||||||
|
println!("got {b}, {c:?}");
|
||||||
|
if let Some(c) = c {
|
||||||
|
enigo.text(&format!("{c}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(err) => println!("err(ws): {err:?}"),
|
Err(err) => println!("err(ws): {err:?}"),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
match drum_recv.recv() {
|
match drum_recv.recv() {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod midi;
|
pub mod midi;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
|
pub mod typing;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
13
src/midi.rs
13
src/midi.rs
|
@ -10,6 +10,19 @@ impl Message {
|
||||||
pub fn new(raw: Vec<u8>, parsed: ParsedMessage) -> Self {
|
pub fn new(raw: Vec<u8>, parsed: ParsedMessage) -> Self {
|
||||||
Message { raw, parsed }
|
Message { raw, parsed }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracts the note value and velocity for any NoteOn messages.
|
||||||
|
/// This is a convenience function to let us avoid a match in all the places
|
||||||
|
/// we need to extract this.
|
||||||
|
pub fn note_on_values(&self) -> Option<(u8, u8)> {
|
||||||
|
match &self.parsed {
|
||||||
|
ParsedMessage::Voice(VoiceMessage {
|
||||||
|
category: VoiceCategory::NoteOn { note, velocity },
|
||||||
|
..
|
||||||
|
}) => Some((*note, *velocity)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||||
|
|
|
@ -149,11 +149,11 @@ mod tests {
|
||||||
|
|
||||||
let note = 0x24;
|
let note = 0x24;
|
||||||
let velocity = 0x51;
|
let velocity = 0x51;
|
||||||
let expected = Message::Voice(VoiceMessage::new(
|
let expected = ParsedMessage::Voice(VoiceMessage::new(
|
||||||
VoiceCategory::NoteOn { note, velocity },
|
VoiceCategory::NoteOn { note, velocity },
|
||||||
0,
|
0,
|
||||||
));
|
));
|
||||||
assert_eq!(parsed, expected,);
|
assert_eq!(parsed.parsed, expected,);
|
||||||
assert_eq!(remainder.len(), 0);
|
assert_eq!(remainder.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
169
src/typing.rs
Normal file
169
src/typing.rs
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
#[derive(Debug, Copy, Clone)]
|
||||||
|
pub struct TypingState {
|
||||||
|
base: u8,
|
||||||
|
current: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypingState {
|
||||||
|
pub fn new(base: u8) -> TypingState {
|
||||||
|
TypingState { base, current: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add the value into the current accumulator.
|
||||||
|
pub fn enter(&mut self, value: u8) {
|
||||||
|
self.current = self.current.saturating_mul(self.base).saturating_add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the current accumulator as a character, or None if it's invalid.
|
||||||
|
/// This resets the accumulator to 0!
|
||||||
|
pub fn emit(&mut self) -> (u8, Option<char>) {
|
||||||
|
let x = self.current;
|
||||||
|
let c = decode(x);
|
||||||
|
|
||||||
|
self.current = 0;
|
||||||
|
|
||||||
|
(x, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chunks a u8 into a sequence of values in a given base.
|
||||||
|
pub fn base_values(mut x: u8, base: u8, len: usize) -> Vec<u8> {
|
||||||
|
let mut vals = vec![];
|
||||||
|
for _ in 0..len {
|
||||||
|
vals.push(x % base);
|
||||||
|
x = x / base;
|
||||||
|
}
|
||||||
|
vals.reverse();
|
||||||
|
|
||||||
|
vals
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines the scale degree of a given MIDI note relative to the provided
|
||||||
|
/// scale root.
|
||||||
|
pub fn midi_to_scale_degree(root: u8, note: u8) -> Option<u8> {
|
||||||
|
let semitones = (note as i32 - root as i32).rem_euclid(12);
|
||||||
|
Some(match semitones {
|
||||||
|
0 => 0,
|
||||||
|
2 => 1,
|
||||||
|
4 => 2,
|
||||||
|
5 => 3,
|
||||||
|
7 => 4,
|
||||||
|
9 => 5,
|
||||||
|
11 => 6,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a character to its assigned number for use in text entry. This is
|
||||||
|
/// stores them as u8 to keep things simple and fixed length. If we want to
|
||||||
|
/// support the full Unicode space, we can have expand this to u32.
|
||||||
|
pub fn encode(c: char) -> Option<u8> {
|
||||||
|
Some(match c {
|
||||||
|
'a'..='z' => c as u8 - 'a' as u8,
|
||||||
|
'A'..='Z' => c as u8 - 'A' as u8 + 26,
|
||||||
|
'0'..='9' => c as u8 - '0' as u8 + 26 * 2,
|
||||||
|
'.' => 62,
|
||||||
|
',' => 63,
|
||||||
|
'!' => 64,
|
||||||
|
' ' => 65,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a number to its corresponding character for use in text entry.
|
||||||
|
/// If a character isn't implemented, it will return None.
|
||||||
|
pub fn decode(x: u8) -> Option<char> {
|
||||||
|
let c = match x {
|
||||||
|
0..=25 => 'a' as u8 + x,
|
||||||
|
26..=51 => 'A' as u8 + x - 26,
|
||||||
|
52..=61 => '0' as u8 + x - 26 * 2,
|
||||||
|
62 => '.' as u8,
|
||||||
|
63 => ',' as u8,
|
||||||
|
64 => '!' as u8,
|
||||||
|
65 => ' ' as u8,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(char::from(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_lowercase() {
|
||||||
|
assert_eq!(encode('a'), Some(0));
|
||||||
|
assert_eq!(encode('b'), Some(1));
|
||||||
|
assert_eq!(encode('z'), Some(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_uppercase() {
|
||||||
|
assert_eq!(encode('A'), Some(26));
|
||||||
|
assert_eq!(encode('B'), Some(27));
|
||||||
|
assert_eq!(encode('Z'), Some(51));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_numbers() {
|
||||||
|
assert_eq!(encode('0'), Some(52));
|
||||||
|
assert_eq!(encode('1'), Some(53));
|
||||||
|
assert_eq!(encode('9'), Some(61));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encodes_punctuation() {
|
||||||
|
assert_eq!(encode('.'), Some(62));
|
||||||
|
assert_eq!(encode(','), Some(63));
|
||||||
|
assert_eq!(encode('!'), Some(64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_lowercase() {
|
||||||
|
assert_eq!(decode(0), Some('a'));
|
||||||
|
assert_eq!(decode(1), Some('b'));
|
||||||
|
assert_eq!(decode(25), Some('z'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_uppercase() {
|
||||||
|
assert_eq!(decode(26), Some('A'));
|
||||||
|
assert_eq!(decode(27), Some('B'));
|
||||||
|
assert_eq!(decode(51), Some('Z'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_numbers() {
|
||||||
|
assert_eq!(decode(52), Some('0'));
|
||||||
|
assert_eq!(decode(53), Some('1'));
|
||||||
|
assert_eq!(decode(61), Some('9'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_punctuation() {
|
||||||
|
assert_eq!(decode(62), Some('.'));
|
||||||
|
assert_eq!(decode(63), Some(','));
|
||||||
|
assert_eq!(decode(64), Some('!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn converts_to_scale_degree() {
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 58), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 59), Some(6));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 60), Some(0));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 61), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 62), Some(1));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 63), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 64), Some(2));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 65), Some(3));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 66), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 67), Some(4));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 68), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 69), Some(5));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 70), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 71), Some(6));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 72), Some(0));
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 73), None);
|
||||||
|
assert_eq!(midi_to_scale_degree(60, 74), Some(1));
|
||||||
|
}
|
||||||
|
}
|
|
@ -232,7 +232,7 @@ impl eframe::App for MidiKeysApp {
|
||||||
|
|
||||||
frame.content_ui.label(RichText::new(port_name).strong());
|
frame.content_ui.label(RichText::new(port_name).strong());
|
||||||
|
|
||||||
display_midi_message(idx, msg, &mut frame.content_ui, show_raw);
|
//display_midi_message(idx, msg, &mut frame.content_ui, show_raw);
|
||||||
frame.end(ui);
|
frame.end(ui);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue