Making a MIDI Piano using Rust
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!