summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md133
-rw-r--r--config.rhai26
-rw-r--r--src/main.rs126
-rw-r--r--src/soundfont.rs14
5 files changed, 256 insertions, 45 deletions
diff --git a/.gitignore b/.gitignore
index 2800a93..642ff81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ TAGS
scratch
*.mid
*.wav
+*.sf2
+*.sf3
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c2f0452
--- /dev/null
+++ b/README.md
@@ -0,0 +1,133 @@
+# progmidi
+
+A programmable MIDI keyboard audio synthesizer.
+
+Check out the [releases](https://github.com/pommicket/progmidi/releases)
+for Windows and Linux executables.
+
+## what
+
+If you have a MIDI keyboard, or other MIDI input device,
+you can use progmidi to play sound with it and generate
+.wav and .mid files.
+
+progmidi's behavior is controlled through the config script `config.rhai`,
+written in the [Rhai scripting language](https://rhai.rs/book/language/).
+
+You will need a soundfont file. Soundfonts contain samples of various "presets" (instruments).
+Both .sf2 and .sf3 files are supported.
+
+Musescore has a very extensive soundfont file.
+According to [this web page](https://musescore.org/en/handbook/3/soundfonts-and-sfz-files),
+after installing musescore, the soundfont will be located here:
+
+Windows x86 (32-bit) / MuseScore x86: `%ProgramFiles%\MuseScore 3\sound\MuseScore_General.sf3`
+
+Windows x64 (64-bit) / MuseScore x86: `%ProgramFiles(x86)%\MuseScore 3\sound\MuseScore_General.sf3`
+
+Windows x64 (64-bit) / MuseScore x86\_64: `%ProgramFiles%\MuseScore 3\sound\MuseScore_General.sf3`
+
+macOS: `/Applications/MuseScore 3.app/Contents/Resources/sound/MuseScore_General.sf3`
+
+Linux (Ubuntu): `/usr/share/mscore-xxx/sounds/MuseScore_General.sf3` (with xxx being the MuseScore version)
+
+You can either change the path in the `config.rhai` file, or copy (or link) the
+file to `soundfont.sf3` in the same directory as `progmidi`.
+
+## scripting
+
+The best way to learn how to use progmidi is to check out the default script
+`config.rhai`.
+
+Here is a full description of how your script will interact with progmidi.
+Note that these functions and constants all begin with
+`pm_` or `PM_`. For compatibility with future versions of progmidi,
+it's recommended that you don't define any other functions/variables/etc.
+starting with `pm_` or `PM_`.
+
+Below, `i64` is an integer, and `f64` is a floating-point number (number with decimals).
+`bool`s are either `true` or `false`.
+
+### user-supplied constants
+
+- `PM_DEVICE_ID: i64` - define this to control which MIDI device is used.
+If this is -1 or undefined, then you will be asked which device
+to use when you run progmidi. If this is 0, the default device
+will be used. Otherwise, the device with ID `PM_DEVICE_ID` will be used.
+
+### user-supplied functions
+
+- `pm_note_played(channel: i64, note: i64, velocity: i64)` - Called
+when a note is played (MIDI "note on" event, or "note on" with velocity 0).
+`note` is a number from 0 to 127 — 60±n indicates n semitones above/below middle C.
+`velocity` is a number from 1 to 127, indicating how forcefully the
+key was struck.
+`channel` ranges from 0 to 15, and indicates which portion of the MIDI device
+was used (e.g. on my MIDI keyboard, the piano keys are channel 0 and the
+drum pad is channel 9).
+
+- `pm_note_released(channel: i64, note: i64, velocity: i64)` - Called
+when a note is released (MIDI "note off" event). `note` and `channel` are as in `pm_note_played`.
+`velocity` (0 to 127) indicates how forcefully the note was released.
+For most keyboards, the `velocity` is always just 0, so it can be ignored.
+
+- `pm_pitch_bent(amount: f32)` - Called when the pitch wheel
+is changed. `amount` ranges from -1 to 1.
+
+- `pm_control_changed(channel: i64, controller: i64, value: i64)` - Called
+when a "controller" value is changed. Typically these are buttons, pedals,
+etc. `controller` ranges from 0 to 127; different buttons have different
+`controller` numbers, but typically everything is `channel` 0.
+
+### built-in functions
+
+- `pm_load_soundfont(filename: string)` - Load the sound font with the given file name.
+
+- `pm_print_presets()` - Print a list of presets from the soundfont, together with their indices.
+
+- `pm_load_preset(channel: i64, name: string)` - Load the preset whose name closestly matches
+`name` to channel `channel`. If `channel` is -1, the command applies to all channels.
+
+- `pm_load_preset(channel: i64, index: i64)` - Load the preset with the given index.
+
+- `pm_play_note(channel: i64, note: i64, velocity: i64)` - Play the
+given note on the given channel with the given velocity.
+
+- `pm_set_pedal(down: bool)` - Set `down` to `true`/`false` to enable/disable the sustain pedal.
+
+- `pm_bend_pitch(amount: f64)` - Bend pitch by `amount` cents (100 cents = 1 semitones).
+
+- `pm_set_volume(channel: i64, volume: f64)` - Set the volume (0-1) for the given channel.
+If `channel` is -1, the master volume is set.
+
+- `pm_set_metronome(key: i64, bpm: f64)` - Set the metronome to play the given key,
+every `60/bpm` seconds. Use `pm_load_preset`/`pm_set_volume` with channel 16 to set
+the preset and volume of the metronome.
+
+- `pm_set_release_falloff(falloff: i64)` - When a key is released, the note's volume
+decreases according to the release falloff (default 0.1). e.g. if the
+release falloff is 0.5, the volume of the note goes down by 50% every second.
+If `falloff` = 0, the note immediately cuts off when it is released.
+If `falloff` = 1, the note's volume is not affected when it is released.
+
+- `pm_start_midi_recording()` - Start a .mid recording.
+
+- `pm_stop_midi_recording()` - Stop the current .mid recording if there is one.
+
+- `pm_start_midi_recording()` - Start a .wav recording.
+
+- `pm_stop_midi_recording()` - Stop the current .wav recording if there is one.
+
+## building from source
+
+You can build progmidi with `cargo build --release`,
+or run it with `cargo run --release` (it is *way* slower
+without the `--release`, and you may get audio glitches as a result).
+
+## bugs
+
+if you find a bug, please create a github issue.
+
+## license
+
+`progmidi` is hereby dedicated to the public domain .
diff --git a/config.rhai b/config.rhai
index 7ed1edc..4ff9ea6 100644
--- a/config.rhai
+++ b/config.rhai
@@ -1,16 +1,10 @@
const PM_DEVICE_ID = 0;
fn pm_note_played(channel, note, vel) {
- if channel <= 4 {
- channel = 0;
- }
pm_play_note(channel, note, vel);
}
fn pm_note_released(channel, note, vel) {
- if channel <= 4 {
- channel = 0;
- }
pm_release_note(channel, note);
}
@@ -19,26 +13,30 @@ fn pm_pitch_bent(channel, amount) {
}
fn pm_control_changed(channel, controller, value) {
- print(channel + " " + controller + " " + value);
+ print(`controller change: channel=${channel} controller=${controller} value=${value}`);
if controller == 64 {
// pedal down if value < 127.
pm_set_pedal(value < 127);
} else if controller == 1 {
+ // set master volume
pm_set_volume(-1, value / 127.0);
} else if controller == 20 {
- let bpm = 0;
+ // set metronome
+ let bpm = 0.0;
if value != 0 {
bpm = round(30.0 + 1.5 * value);
}
- print("setting metronome to " + bpm);
- pm_set_metronome(60, bpm, 1.0);
+ print("setting metronome to " + bpm.to_int());
+ pm_set_metronome(60, bpm, 1.5);
} else if controller == 50 {
+ // start/stop .wav recording
if value == 127 {
pm_start_wav_recording();
} else {
pm_stop_wav_recording();
}
} else if controller == 51 {
+ // start/stop .mid recording
if value == 127 {
pm_start_midi_recording();
} else {
@@ -47,7 +45,7 @@ fn pm_control_changed(channel, controller, value) {
}
}
-pm_load_soundfont("/etc/alternatives/default-GM.sf3");
-pm_load_preset(-1, 299); // default = piano
-pm_load_preset(9, 102); // drum pad
-pm_load_preset(16, 102); // metronome
+pm_load_soundfont("soundfont.sf3");
+pm_load_preset(-1, "grand piano"); // default = piano
+pm_load_preset(9, "standard"); // drum pad
+pm_load_preset(16, "standard"); // metronome
diff --git a/src/main.rs b/src/main.rs
index 9e05761..7bbeb51 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,7 +13,6 @@ use std::io::Write;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
-const NOTE_FALLOFF: f32 = 0.1; // falloff when note is released
const CHANNEL_COUNT: usize = 17; // 16 MIDI channels + metronome
const METRONOME_CHANNEL: i64 = 16;
@@ -21,6 +20,9 @@ struct Note {
key: u8,
req: soundfont::SamplesRequest,
down: bool,
+ // if user plays A4, then A4 again, the first A4 will get this (it will be cut out quickly,
+ // but not instantaneously to avoid clicking)
+ cut: bool,
kill: bool, // only used briefly
}
@@ -28,7 +30,6 @@ struct Note {
struct Metronome {
bpm: f32,
key: i64,
- volume: f32,
}
struct MidiRecording {
@@ -51,6 +52,7 @@ struct NoteInfo {
channel_volumes: [f32; CHANNEL_COUNT],
master_volume: f32,
metronome: Metronome,
+ release_falloff: f32, // falloff when a note is released
}
static NOTE_INFO: Mutex<NoteInfo> = Mutex::new(NoteInfo {
@@ -82,13 +84,17 @@ static NOTE_INFO: Mutex<NoteInfo> = Mutex::new(NoteInfo {
presets: [0; CHANNEL_COUNT],
channel_volumes: [1.0; CHANNEL_COUNT],
master_volume: 1.0,
+ release_falloff: 0.1,
metronome: Metronome {
bpm: 0.0,
key: 0,
- volume: 0.0,
},
});
+// unfortunately, this needs to be a separate mutex because otherwise
+// we would get double mutable borrows. sometimes we only need one of
+// the note_info / soundfont, so it might actually be slightly better
+// this way.
static SOUNDFONT: Mutex<Option<SoundFont>> = Mutex::new(None);
impl MidiRecording {
@@ -351,6 +357,9 @@ fn get_audio_stream() -> Result<(cpal::Stream, u32), String> {
for note in notes.iter_mut() {
note.req.set_tune(pitch_bend as i32);
note.req.set_volume(volume);
+ if note.cut {
+ note.req.set_falloff(0.01);
+ }
match sf.add_samples_interlaced(&mut note.req, data, sample_rate) {
Ok(true) => {}
Ok(false) => note.kill = true,
@@ -400,7 +409,12 @@ fn play_note(channel: i64, note: i64, vel: i64) {
recording.write_event(0b1001_0000 | (channel as u8), Some(note), Some(vel));
}
}
- note_info.notes[channel].retain(|n| n.key != note);
+ for n in note_info.notes[channel].iter_mut() {
+ if n.key == note {
+ n.cut = true;
+ }
+ }
+
let preset = note_info.presets[channel];
if let Some(sf) = maybe_sf.as_mut() {
match sf.request(preset, note, vel) {
@@ -409,6 +423,7 @@ fn play_note(channel: i64, note: i64, vel: i64) {
key: note,
req,
down: true,
+ cut: false,
kill: false,
});
}
@@ -434,11 +449,14 @@ fn release_note(channel: i64, note: i64) {
}
let pedal_down = note_info.pedal_down;
+ let release_falloff = note_info.release_falloff;
let notes = &mut note_info.notes[channel];
- if let Some(n) = notes.iter_mut().find(|n| n.key == note && n.down) {
- n.down = false;
- if !pedal_down {
- n.req.set_falloff(NOTE_FALLOFF);
+ for n in notes.iter_mut() {
+ if n.key == note {
+ n.down = false;
+ if !pedal_down {
+ n.req.set_falloff(release_falloff);
+ }
}
}
}
@@ -447,6 +465,7 @@ fn set_pedal_down(down: bool) {
let mut note_info = lock_note_info();
note_info.pedal_down = down;
for channel in 0..CHANNEL_COUNT {
+ let release_falloff = note_info.release_falloff;
let notes = &mut note_info.notes[channel];
if down {
// disable falloff for all notes
@@ -457,7 +476,7 @@ fn set_pedal_down(down: bool) {
// start falloff for all non-down notes
for note in notes.iter_mut() {
if !note.down {
- note.req.set_falloff(NOTE_FALLOFF);
+ note.req.set_falloff(release_falloff);
}
}
}
@@ -470,6 +489,11 @@ fn set_pitch_bend(amount: f64) {
note_info.pitch_bend = amount;
}
+fn set_release_falloff(falloff: f64) {
+ let mut note_info = lock_note_info();
+ note_info.release_falloff = falloff as f32;
+}
+
fn load_soundfont(filename: &str) {
if let Ok(sf) = SoundFont::open(filename) {
let mut sflock = lock_soundfont();
@@ -491,24 +515,64 @@ fn load_preset(channel: i64, preset: i64) {
let preset = preset as usize;
let (mut note_info, mut soundfont) = lock_note_info_and_soundfont();
- if channel == -1 {
- for p in note_info.presets.iter_mut() {
- *p = preset;
+
+ if let Some(sf) = soundfont.as_mut() {
+ if preset >= sf.preset_count() {
+ eprintln!("preset {} out of range", preset);
+ } else {
+ if let Err(e) = sf.load_samples_for_preset(preset) {
+ eprintln!("error loading preset {}: {}", preset, e);
+ }
+
+ if channel == -1 {
+ for p in note_info.presets.iter_mut() {
+ *p = preset;
+ }
+ } else {
+ let channel = channel as usize;
+ if !check_channel(channel) {
+ return;
+ }
+ note_info.presets[channel] = preset;
+ }
}
} else {
- let channel = channel as usize;
- if !check_channel(channel) {
+ eprintln!("Can't load preset {} since no soundfont is loaded", preset);
+ }
+}
+
+fn load_preset_by_name(channel: i64, name: &str) {
+ let mut preset_names;
+ {
+ if let Some(soundfont) = lock_soundfont().as_ref() {
+ preset_names = Vec::with_capacity(soundfont.preset_count());
+ for i in 0..soundfont.preset_count() {
+ let pname = soundfont.preset_name(i).unwrap().to_lowercase();
+ if pname.contains(name) {
+ preset_names.push((i, pname));
+ }
+ }
+ } else {
+ eprintln!("Can't load preset \"{}\", since no soundfont is loaded.", name);
return;
}
- note_info.presets[channel] = preset;
}
-
- if let Some(sf) = soundfont.as_mut() {
- if preset >= sf.preset_count() {
- eprintln!("preset {} out of range", preset);
- } else if let Err(e) = sf.load_samples_for_preset(preset) {
- eprintln!("error loading preset {}: {}", preset, e);
+ preset_names.sort_by(|(_, a),(_, b)| {
+ use std::cmp::Ordering::*;
+ if a.len() < b.len() {
+ Less
+ } else if a.len() > b.len() {
+ Greater
+ } else if a < b {
+ Less
+ } else if a > b {
+ Greater
+ } else {
+ Equal
}
+ });
+ if !preset_names.is_empty() {
+ load_preset(channel, preset_names[0].0 as i64);
}
}
@@ -526,12 +590,11 @@ fn set_volume(channel: i64, volume: f64) {
note_info.channel_volumes[channel] = volume as f32;
}
-fn set_metronome(key: i64, bpm: f64, volume: f64) {
+fn set_metronome(key: i64, bpm: f64) {
let mut note_info = lock_note_info();
note_info.metronome = Metronome {
key,
bpm: bpm as f32,
- volume: volume as f32,
}
}
@@ -624,15 +687,26 @@ fn main() {
engine.register_fn("pm_load_soundfont", load_soundfont);
engine.register_fn("pm_print_presets", print_presets);
engine.register_fn("pm_load_preset", load_preset);
+ engine.register_fn("pm_load_preset", load_preset_by_name);
engine.register_fn("pm_play_note", play_note);
engine.register_fn("pm_release_note", release_note);
engine.register_fn("pm_set_pedal", set_pedal_down);
engine.register_fn("pm_bend_pitch", set_pitch_bend);
+ engine.register_fn("pm_bend_pitch", |bend: i64| {
+ set_pitch_bend(bend as f64);
+ });
engine.register_fn("pm_set_volume", set_volume);
+ engine.register_fn("pm_set_volume", |channel: i64, volume: i64| {
+ set_volume(channel, volume as f64);
+ });
engine.register_fn("pm_set_metronome", set_metronome);
// allow integer bpm as well
- engine.register_fn("pm_set_metronome", |key: i64, bpm: i64, volume: f64| {
- set_metronome(key, bpm as f64, volume);
+ engine.register_fn("pm_set_metronome", |key: i64, bpm: i64| {
+ set_metronome(key, bpm as f64);
+ });
+ engine.register_fn("pm_set_release_falloff", set_release_falloff);
+ engine.register_fn("pm_set_release_falloff", |f: i64| {
+ set_release_falloff(f as f64);
});
engine.register_fn("pm_start_midi_recording", start_midi_recording);
engine.register_fn("pm_stop_midi_recording", stop_midi_recording);
@@ -724,7 +798,7 @@ fn main() {
play_note(
METRONOME_CHANNEL,
metronome.key,
- (metronome.volume * 127.0) as i64,
+ 127
);
last_metronome_tick = Instant::now();
}
diff --git a/src/soundfont.rs b/src/soundfont.rs
index efa60dd..35a24aa 100644
--- a/src/soundfont.rs
+++ b/src/soundfont.rs
@@ -191,7 +191,7 @@ mod vorbis {
Err("bad vorbis file".to_string())
} else {
let samples = samples as usize;
- let mut vec = Vec::with_capacity(samples);
+ let mut vec = Vec::with_capacity(samples + 1);
for i in 0..samples {
vec.push(unsafe { *output.add(i) });
}
@@ -289,15 +289,17 @@ impl Sample {
file.read_exact(&mut data8)?;
match file_type {
FileType::SF2 => {
- self.data = vec![0i16; len];
+ self.data = Vec::with_capacity(len + 1);
for i in 0..len as usize {
- self.data[i] = i16::from_le_bytes([data8[2 * i], data8[2 * i + 1]]);
+ self.data.push(i16::from_le_bytes([data8[2 * i], data8[2 * i + 1]]));
}
}
FileType::SF3 => {
self.data = vorbis::decode(&data8).map_err(SampleError::Vorbis)?;
}
}
+ // (***) add an extra sample to the end to prevent OOB
+ self.data.push(self.data[self.data.len() - 1]);
Ok(())
}
}
@@ -948,10 +950,10 @@ impl SoundFont {
let start = read_u32(&mut file)?;
let end = read_u32(&mut file)?;
- if end < start {
+ if end <= start {
return Err(OpenError::BadSoundFont(format!(
"sample starts at {}, and ends before then (at {})",
- start, end
+ start, end as i64 - 1
)));
}
let mut startloop = read_u32(&mut file)?;
@@ -1314,6 +1316,8 @@ impl SoundFont {
break;
}
// interpolate between one sample and the next
+ // note: it's okay to do this even for samples where endloop = end, because
+ // we added an additional sample to the end -- see (***)
let sample1 = data[s] as f32;
let sample2 = data[s + 1] as f32;
let mut sample = sample1 + (sample2 - sample1) * (s_frac as f32);