3
\$\begingroup\$

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.

asked Nov 17, 2024 at 4:15
\$\endgroup\$
4
  • 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\$ Commented 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\$ Commented 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\$ Commented Nov 21, 2024 at 12:29
  • \$\begingroup\$ Thanks for this hint. I'll revise my code accordingly. \$\endgroup\$ Commented Nov 22, 2024 at 1:17

0

Know someone who can answer? Share a link to this question via email, Twitter, or Facebook.

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.