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]
|
||||
anyhow = "1.0.86"
|
||||
crossbeam = "0.8.4"
|
||||
eframe = "0.29.1"
|
||||
egui = "0.29.1"
|
||||
eframe = "0"
|
||||
egui = "0"
|
||||
enigo = { version = "0.2.1", features = ["serde"] }
|
||||
hex = "0.4.3"
|
||||
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 enigo::{Enigo, Keyboard, Settings};
|
||||
use midi_keys::{
|
||||
log::load_raw_log,
|
||||
midi::daemon::Category,
|
||||
parser::parse_message,
|
||||
typing::{base_values, encode, midi_to_scale_degree, TypingState},
|
||||
ui::{display_state_daemon, DisplayQueues, DisplayState},
|
||||
};
|
||||
//use enigo::{Direction, Enigo, Key, Keyboard, Settings};
|
||||
use midir::{MidiInput, MidiInputPort};
|
||||
|
||||
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 (ws_send, ws_recv) = crossbeam::channel::unbounded();
|
||||
let (drum_send, drum_recv) = crossbeam::channel::unbounded();
|
||||
|
@ -37,16 +34,46 @@ fn main() {
|
|||
let _handle = display_state_daemon(queues, state.clone());
|
||||
println!("started daemon");
|
||||
|
||||
std::thread::spawn(move || loop {
|
||||
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:?}"),
|
||||
for c in "hello, world".chars() {
|
||||
let v = encode(c).unwrap();
|
||||
println!("{c}: {:?} - {:?}", v, base_values(v, 7, 3));
|
||||
}
|
||||
for c in "Hello, WORLD!".chars() {
|
||||
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() {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || loop {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
pub mod log;
|
||||
pub mod midi;
|
||||
pub mod parser;
|
||||
pub mod typing;
|
||||
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 {
|
||||
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)]
|
||||
|
|
|
@ -149,11 +149,11 @@ mod tests {
|
|||
|
||||
let note = 0x24;
|
||||
let velocity = 0x51;
|
||||
let expected = Message::Voice(VoiceMessage::new(
|
||||
let expected = ParsedMessage::Voice(VoiceMessage::new(
|
||||
VoiceCategory::NoteOn { note, velocity },
|
||||
0,
|
||||
));
|
||||
assert_eq!(parsed, expected,);
|
||||
assert_eq!(parsed.parsed, expected,);
|
||||
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());
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue