I am working on a small MIDI router application for the Jack audio connection kit in Rust. The goal of the application is to route MIDI signals from an instrument to one or multiple applications in real-time based on conditions.
For example, I have a piano VST and a strings VST, and I want to control both of them simultaneously with the same MIDI keyboard. So, I could decide to send every MIDI note below C3 to the strings VST and every MIDI note above C3 to the piano. This is akin to the split keyboard common in physical keyboards. Alternatively, I also could send every signal to both VSTs simultaneously.
I designed my (barebone) MIDI implementation based on the MIDI reference tables by the MIDI Association. My router supports routing rules based on the MIDI event type (e.g., note-on/off), channel (0-15), value, velocity and controller signal. Which conditions are available depends on the MIDI event type.
I hope, 300 lines isn't too much for StackExchange CodeReview. Since I am a beginner in Rust, I am grateful for any learning that I can take away from this. Performance-related issues, better design choices, readability/maintainability and best practices are of particular interest to me, but I'd also love to learn about general implementation/coding/style related issues.
The core code is split across three files. The following code snippet shows my main entry point main.rs
:
fn main() -> Result<(), Box<dyn Error>> {
env_logger::init();
info!("Starting Jack MIDI router");
let rules = load_rules_from_file("test.config")?;
debug!("{:?}", rules);
let routing_table = RoutingTable { rules, };
let router = JackRouter::new(routing_table, "midi_router")?;
wait_for_keypress();
router.stop()?;
Ok(())
}
fn wait_for_keypress() {
println!("Press any key to quit");
let mut user_input = String::new();
io::stdin().read_line(&mut user_input).ok();
}
The rules are defined in a custom config file. The code for parsing this config file is about 350 lines long and thus not part of this code review, I will open another code review post for that.
First, I create a RoutingTable
object that the router uses to look up the port a MIDI event is routed to. The JackRouter
creates the necessary Jack client, input and output ports and registers an event handler for handling MIDI events.
In routing.rs
, I define RoutingTable
and the structs used to define my rules:
#[derive(Debug, PartialEq)]
pub struct NumericRange<T> {
pub start: T,
pub end: T,
}
impl<T: PartialOrd> NumericRange<T> {
pub fn is_within(&self, value: T) -> bool {
value >= self.start && value <= self.end
}
}
#[derive(Debug)]
pub struct Condition {
pub event_pattern: Option<Regex>,
pub channel_pattern: Option<NumericRange<u8>>,
pub value_pattern: Option<NumericRange<i16>>,
pub velocity_pattern: Option<NumericRange<u8>>,
pub controller_pattern: Option<NumericRange<u8>>,
}
impl Condition {
pub fn matches(&self, midi_event: &MidiEvent) -> bool {
let event_name: &'static str = midi_event.into();
if !self.event_pattern.as_ref().map(|p| p.is_match(event_name)).unwrap_or(true) {
return false
}
match midi_event {
MidiEvent::NoteOff { channel, note, velocity } |
MidiEvent::NoteOn { channel, note, velocity } |
MidiEvent::PolyphonicAftertouch { channel, note, pressure: velocity } => {
self.match_velocity(*velocity)
&& self.match_value_u8(*note)
&& self.match_channel(*channel)
},
MidiEvent::ControlChange { channel, control_no, value } => {
self.match_channel(*channel)
&& self.match_control_no(*control_no)
&& self.match_value_u8(*value)
},
MidiEvent::ProgramChange { channel, program: value } |
MidiEvent::ChannelAftertouch {channel, pressure: value}=> {
self.match_channel(*channel) && self.match_value_u8(*value)
},
MidiEvent::PitchBendChange { channel, value } => {
self.match_channel(*channel) && self.match_value(*value)
},
MidiEvent::SongSelect { song_num } => {
self.match_value_u8(*song_num)
},
_ => true,
}
}
fn match_channel(&self, channel: u8) -> bool {
self.match_range(&self.channel_pattern, channel)
}
fn match_value(&self, value: i16) -> bool {
self.match_range(&self.value_pattern, value)
}
fn match_value_u8(&self, value: u8) -> bool {
self.match_value(value as i16)
}
fn match_velocity(&self, velocity: u8) -> bool {
self.match_range(&self.velocity_pattern, velocity)
}
fn match_control_no(&self, controller: u8) -> bool {
self.match_range(&self.controller_pattern, controller)
}
fn match_range<T: PartialOrd>(&self, range: &Option<NumericRange<T>>, value: T) -> bool {
range.as_ref().map(|c| c.is_within(value)).unwrap_or(true)
}
}
#[derive(Debug, PartialEq)]
pub enum Action {
ForwardTo {
output_port: String,
},
}
#[derive(Debug)]
pub struct Rule {
pub condition: Condition,
pub actions: Vec<Action>,
}
pub struct RoutingTable {
pub rules: Vec<Rule>,
}
impl RoutingTable {
pub fn get_all_output_ports(&self) -> HashSet<&String> {
let output_port_names = self.rules.iter()
.flat_map(|rule| &rule.actions)
.map(|action| match action {
Action::ForwardTo { output_port } => output_port,
});
HashSet::from_iter(output_port_names)
}
pub fn get_output_ports(&self, midi_event: MidiEvent) -> Vec<&str> {
let mut ports = Vec::new();
for rule in &self.rules {
if rule.condition.matches(&midi_event) {
debug!("Rule {:?} matches event {:?}", rule, midi_event);
let p = self.get_ports_from_actions(&rule.actions);
ports.extend(p);
} else {
debug!("Rule {:?} does not match event {:?}", rule, midi_event);
}
}
ports
}
fn get_ports_from_actions<'a>(&self, actions: &'a Vec<Action>) -> Vec<&'a str> {
let mut ports = Vec::new();
for action in actions {
if let Some(port) = self.get_port_from_action(action) {
ports.push(port);
}
}
ports
}
fn get_port_from_action<'a>(&self, action: &'a Action) -> Option<&'a str> {
match action {
Action::ForwardTo { output_port } => {
Some(&output_port)
},
}
}
}
A rule has a condition and a list of actions. Conditions contain patterns for an event name, channel, value, velocity and controller. The first is a regular expression, the latter four are numeric ranges with a start and an end. All of these are optional and if there's no condition on an attribute, it will be regarded as true. The matches
function simply decides in a giant match
statement whether the condition matches a given MidiEvent
object. Currently, the only action supported is the forward action that defines an output port name. The RoutingTable
struct offers methods to get all output port names and to select output ports based on a MIDI event.
The next code snippet introduces the JackRouter
:
pub struct JackRouter {
client: AsyncClient<(), JackRouterProcessHandler>,
}
impl JackRouter {
pub fn new(routing_table: RoutingTable,
router_name: &str) -> Result<JackRouter, JackRouterError> {
let (client, _status) = Self::create_client(router_name)?;
let midi_input_port = Self::register_midi_input_port(&client)?;
let midi_output_ports = Self::register_midi_output_ports(&client, &routing_table)?;
let process_handler = JackRouterProcessHandler {
midi_input_port,
midi_output_ports,
routing_table,
};
let async_client = JackRouter::create_active_client(client, process_handler)?;
Ok(JackRouter {
client: async_client,
})
}
fn create_client(router_name: &str) -> Result<(Client, ClientStatus), JackRouterError> {
info!("Creating Jack client {}", router_name);
Client::new(router_name, ClientOptions::default())
.map_err(|err| JackRouterError { reasons: vec![err] })
}
fn register_midi_input_port(client: &Client) -> Result<Port<MidiIn>, JackRouterError> {
let port_name = "midi_in";
info!("Registering midi input port {}", port_name);
client.register_port(port_name, MidiIn::default())
.map_err(|err| JackRouterError { reasons: vec![err] })
}
fn register_midi_output_ports(client: &Client, routing_table: &RoutingTable) -> Result<HashMap<String, Port<MidiOut>>, JackRouterError> {
let output_port_names = routing_table.get_all_output_ports();
let mut midi_output_ports = HashMap::with_capacity(output_port_names.len());
let mut errors = Vec::new();
for port_name in output_port_names {
info!("Registering midi output port {}", port_name);
match client.register_port(port_name.as_str(), MidiOut::default()) {
Ok(output_port) => {
midi_output_ports.insert(port_name.into(), output_port);
},
Err(error) => errors.push(error),
}
}
if !errors.is_empty() {
Err(JackRouterError {
reasons: errors,
})?
}
Ok(midi_output_ports)
}
fn create_active_client(client: Client, process_handler: JackRouterProcessHandler) -> Result<AsyncClient<(), JackRouterProcessHandler>, JackRouterError> {
info!("Activating Jack client {}", client.name());
client.activate_async((), process_handler)
.map_err(|err| JackRouterError { reasons: vec![err] })
}
pub fn stop(self) -> Result<(), Box<dyn Error>> {
info!("Deactivating Jack client");
if let Err(err) = self.client.deactivate() {
Err(JackRouterError { reasons: vec![err] })?
};
Ok(())
}
}
pub struct JackRouterProcessHandler {
midi_input_port: Port<MidiIn>,
midi_output_ports: HashMap<String, Port<MidiOut>>,
routing_table: RoutingTable,
}
impl JackRouterProcessHandler {
fn send_event_out(raw_event: RawMidi,
output_port_names: Vec<&str>,
output_port_writers: &mut HashMap<String, MidiWriter>) {
for port_name in output_port_names {
if let Some(writer) = output_port_writers.get_mut(port_name) {
writer.write(&raw_event).unwrap()
} else {
error!("Could not find output port writer: {}. Ignore this rule.", port_name);
}
}
}
fn create_output_port_writers<'a>(ps: &'a ProcessScope, output_ports: &'a mut HashMap<String, Port<MidiOut>>) -> HashMap<String, MidiWriter<'a>> {
let mut output_writers = HashMap::with_capacity(output_ports.len());
for (port_name, port) in output_ports {
let writer = port.writer(ps);
output_writers.insert(port_name.into(), writer);
}
output_writers
}
}
impl ProcessHandler for JackRouterProcessHandler {
fn process(&mut self, _: &Client, ps: &ProcessScope) -> Control {
let mut output_port_writers = Self::create_output_port_writers(ps, &mut self.midi_output_ports);
for raw_event in self.midi_input_port.iter(ps) {
let midi_event = match decode_raw_midi(raw_event) {
Ok(event) => {
event
},
Err(err) => {
error!("Error decoding midi event: {}", err);
continue;
},
};
let output_port_names = self.routing_table.get_output_ports(midi_event);
Self::send_event_out(raw_event, output_port_names, &mut output_port_writers);
}
Control::Continue
}
}
////////////////////////////////////////////////////////////////////////////////
// Errors //
////////////////////////////////////////////////////////////////////////////////
#[derive(Debug)]
pub struct JackRouterError {
reasons: Vec<JackError>,
}
impl Display for JackRouterError {
fn fmt(&self, _: &mut Formatter<'_>) -> std::fmt::Result {
todo!() // not so important for now...
}
}
impl Error for JackRouterError {}
As mentioned above, this struct interfaces with the Jack public API to create a Jack client, an input port, multiple output ports and register the signal process handler. The JackRouterProcessHandler
helper class processes the MIDI signals by decoding each signal, looking up the destination in above lookup table, and send it to the appropriate output ports (identified by name). This handler's process
method is a callback that runs within its own thread (managed by Jack). The Jack client initializes everything in its new
method and then stores the ActiveClient
object to keep it alive and cleanup later. Errors are collected in a vector and bundled within a JackRouterError
instance.
-
1\$\begingroup\$ I am not a Rust expert so wont post an answer. But this looks very readable/maintainable, just not enough comments. There is nothing obviously wrong to me. \$\endgroup\$konijn– konijn2024年11月21日 09:30:37 +00:00Commented Nov 21, 2024 at 9:30
-
\$\begingroup\$ Honestly, I'm trying to write code in a way that it explicitly doesn't need comments. :) I will add documentation when the design is unlikely to change. \$\endgroup\$Green 绿色– Green 绿色2024年11月21日 11:50:32 +00:00Commented Nov 21, 2024 at 11:50
-
1\$\begingroup\$ No starting a whole thread. But sufficiently complex code requires design decisions with trade-offs. To me, those merit a few comments. \$\endgroup\$konijn– konijn2024年11月21日 12:29:52 +00:00Commented Nov 21, 2024 at 12:29
-
\$\begingroup\$ Thanks for this hint. I'll revise my code accordingly. \$\endgroup\$Green 绿色– Green 绿色2024年11月22日 01:17:53 +00:00Commented Nov 22, 2024 at 1:17