diff options
-rw-r--r-- | .gitignore | 2 | ||||
-rw-r--r-- | README.md | 133 | ||||
-rw-r--r-- | config.rhai | 26 | ||||
-rw-r--r-- | src/main.rs | 126 | ||||
-rw-r--r-- | src/soundfont.rs | 14 |
5 files changed, 256 insertions, 45 deletions
@@ -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); |