Making a MIDI Piano using Rust

Youichi Fujimoto
ITNEXT
Published in
3 min readApr 10, 2024

--

As you know, Rust is a great language especially for system programming. Assuming it is true, how can we know it’s greatness without struggling with system calls?

Let’s try it by implementing a simple MIDI Piano app.

Preparation

First of all, you need latest cargo command installed.

Once installed, type cargo init within a shell to create an app.

$ mkdir rs-midi
$ cd rs-midi
$ cargo init .

It creates a default Cargo.toml. Open it and replace [dependencies] block as following:

[dependencies]
eframe = “0.27.2”
itertools = “0.12.1”
phf = { version = “0.11”, features = [“macros”] }
rustysynth = “1.3.1”
tinyaudio = “0.1.3”

NOTE: Use newer versions if available.

Create an app

Let’s proceed to src/main.rs.

This app is going to open an egui window which receives key events, they are sent to rustysynth as midi notes and output sound via tinyaudio.

First, put these lines at the beginning of main.rs to use these crates later.

use eframe::egui;
use itertools::Itertools;
use phf::{phf_map, Map};
use rustysynth::{SoundFont, Synthesizer, SynthesizerSettings};
use std::{
fs::File,
sync::{Arc, Mutex},
};
use tinyaudio::prelude::*;

Next, put these static and constant.

const OUTPUT_PARAMS: OutputDeviceParameters = OutputDeviceParameters {
channels_count: 2,
sample_rate: 44100,
channel_sample_count: 441, // サンプルのmaxの長さ
};

#[derive(Debug)]
pub struct MidiNote {
pub note: i32,
pub velocity: i32,
}

pub static NOTE_KEY_MAP: Map<&'static str, MidiNote> = phf_map! {
"A" => MidiNote {
note: 60,
velocity: 100,
},
"S" => MidiNote {
note: 62,
velocity: 100,
},
"D" => MidiNote {
note: 64,
velocity: 100,
},
"F" => MidiNote {
note: 65,
velocity: 100,
},
"G" => MidiNote {
note: 67,
velocity: 100,
},
};

OUTPUT_PARAMS is a parameter for tinyaudio. MidiNote holds a note number and velocity of MIDI note to play it with rustysynth. They are held in a static map using a macro of phf indexed by keys.

Coding the app

Let’s proceed to SynthApp which is an application of egui.

It has synthesizer object and methods to perform note on/off, processing key events within the update method of eframe::App.

struct SynthApp {
synthesizer: Arc<Mutex<Synthesizer>>,
midi_channel: i32,
}

impl SynthApp {
fn note_on(&mut self, key: &str) {
let note = match NOTE_KEY_MAP.get(key) {
Some(note) => note,
None => return,
};
self.synthesizer
.lock()
.unwrap()
.note_on(self.midi_channel, note.note, note.velocity)
}

fn note_off(&mut self, key: &str) {
let note = match NOTE_KEY_MAP.get(key) {
Some(note) => note,
None => return,
};
self.synthesizer
.lock()
.unwrap()
.note_off(self.midi_channel, note.note);
}
}

impl eframe::App for SynthApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.input(|i| {
for key_str in NOTE_KEY_MAP.keys() {
if let Some(key) = egui::Key::from_name(key_str) {
if i.key_pressed(key) {
self.note_on(key_str);
} else if i.key_released(key) {
self.note_off(key_str);
}
}
}
});

egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("My egui Application");
ui.label(format!("Midi channel {}", self.midi_channel));
});
}
}

Combining the app and synthesizer

Lastly, we write the main function as shown below.

As you can see, synthesizer is held in Arc<Mutex<…>> so that it can be accessed by both run_output_device and SynthApp. Because Arc is a smart-pointer, it can be cloned and moved.

fn main() -> Result<(), eframe::Error> {
// Load the SoundFont.
let mut sf2 = File::open("your_soundfont.sf2").unwrap();
let sound_font = Arc::new(SoundFont::new(&mut sf2).unwrap());

// Create the MIDI file sequencer.
let settings = SynthesizerSettings::new(OUTPUT_PARAMS.sample_rate as i32);
let synthesizer = Arc::new(Mutex::new(
Synthesizer::new(&sound_font, &settings).unwrap(),
));

// Run output device.
let synth_c = synthesizer.clone();
let mut left: Vec<f32> = vec![0_f32; OUTPUT_PARAMS.channel_sample_count];
let mut right: Vec<f32> = vec![0_f32; OUTPUT_PARAMS.channel_sample_count];
let _device = run_output_device(OUTPUT_PARAMS, move |data| {
synth_c
.lock()
.unwrap()
.render(&mut left[..], &mut right[..]);
for (i, value) in left.iter().interleave(right.iter()).enumerate() {
data[i] = *value;
}
})
.unwrap();

// eframe
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([640.0, 480.0]),
..Default::default()
};
eframe::run_native(
"My egui App",
options,
Box::new(|_cc| {
Box::new(SynthApp {
synthesizer,
midi_channel: 0,
})
}),
)
}

Here, please don’t forget to rename the filename of SoundFont.
There are a lot of nice SoundFonts on internet but TimGM6mb.sf2 could be a good starting point.

Now, you are ready to run! Try hitting cargo run.
Once a window appeared, ASDFG of keyboard will play CDEFG notes.

Conclusion

Thanks to great crates of Rust, it is very straightforward to make an app like this. This app is very primitive but uses common Rust features like mut, ref, struct, iter, macro, closure and Arc<Mutex<...>>.

You might feel them unfamiliar but still application code is simple enough. That’s why Rust is great, I think.

For further exploration, you can try various functionality of egui and rustysynth by adding UIs and instruments.

Happy Hacking!

--

--

Software Engineer living in Japan. Love Golang, Rust, TypeScript, React, GCP and flavour of functional programming. Twitter/Instagram: @_fp