Skip to content

Commit

Permalink
WIP: MIDI
Browse files Browse the repository at this point in the history
  • Loading branch information
tsionyx committed Feb 21, 2024
1 parent 772b7c3 commit 8fadde3
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 10 deletions.
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ enum-map ="2.6"
enum-iterator = "1.4"
ordered-float = "4.2"
itertools = "0.12"

# MIDI stuff
midly = "0.5"
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,21 @@ It is mostly expired by [HSoM](https://www.euterpea.com/haskell-school-of-music/

- On MIDI:
- http://tedfelix.com/linux/linux-midi.html
- https://sndrtj.eu/2019/10/19/Using-a-USB-midi-keyboard-on-Ubuntu/
- https://askubuntu.com/q/572120
- https://linuxaudio.github.io/libremusicproduction/html/workflow.html

- On signals:
- https://www.youtube.com/watch?v=odeWLp96fdo
- https://crates.io/crates/hound
- https://crates.io/crates/rodio
- https://crates.io/crates/fundsp


### Similar crates

- https://crates.io/crates/rust-music-theory
- https://crates.io/crates/rust-music
- https://crates.io/crates/tune-cli
- https://crates.io/crates/tonal
- search crates.io: https://crates.io/search?q=WORD where WORD: {music, sound, melody, chord, harmony, note, pitch, octave}
29 changes: 28 additions & 1 deletion examples/hsom-exercises/ch6.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,6 @@ mod shepard_scale {
}
}

// TODO: test it with delta=+1,-1 and 3..5 Instruments
fn music(delta: Interval, lines: &[(StandardMidiInstrument, u16)]) -> Music<(Pitch, Volume)> {
Music::chord(
lines
Expand All @@ -703,4 +702,32 @@ mod shepard_scale {
.collect(),
)
}

#[test]
fn test_save() {
use musik::Performable as _;
use StandardMidiInstrument::*;

let m = music(
-Interval::semi_tone(),
&[
(AcousticGrandPiano, 2323),
(ElectricGuitarClean, 9940),
(Flute, 7899),
(Cello, 15000),
],
);
m.perform_default().save_to_file("desc.mid").unwrap();

let m = music(
Interval::semi_tone(),
&[
(AcousticGrandPiano, 18774),
(ElectricGuitarClean, 33300),
(Flute, 19231),
(Cello, 99),
],
);
m.perform_default().save_to_file("asc.mid").unwrap();
}
}
4 changes: 2 additions & 2 deletions src/instruments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use enum_map::Enum;
use crate::{AbsPitch, Dur, Music};

// https://github.com/rust-lang/rfcs/issues/284#issuecomment-1592343574
#[derive(Debug, PartialEq, Eq, Copy, Clone, PartialOrd, Ord, Enum, Sequence)]
#[derive(Debug, PartialEq, Eq, Copy, Clone, PartialOrd, Ord, Hash, Enum, Sequence)]
pub enum StandardMidiInstrument {
AcousticGrandPiano,
BrightAcousticPiano,
Expand Down Expand Up @@ -141,7 +141,7 @@ pub enum StandardMidiInstrument {
Gunshot,
}

#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord)]
#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord, Hash)]
pub enum InstrumentName {
Standard(StandardMidiInstrument),
/// Marks the pitches in the [`Music`] as the specific [`PercussionSound`].
Expand Down
185 changes: 185 additions & 0 deletions src/io/midi/codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
use std::{borrow::Cow, collections::HashMap, iter, path::Path};

use itertools::Itertools as _;
use midly::{
num::u15, Format, Header, MetaMessage, MidiMessage, Smf, Timing, Track, TrackEvent,
TrackEventKind,
};

use crate::{
instruments::InstrumentName,
music::{
performance::{Event, Performance},
Volume,
},
};

use super::{Channel, UserPatchMap};

fn into_relative_time(track: AbsTimeTrack) -> Track<'static> {
let (ts, msgs): (Vec<_>, Vec<_>) = track.into_iter().unzip();
ts.into_iter()
.scan(0, |acc, t| {
let rel_time = t - *acc;
*acc = t;
Some(rel_time)
})
.zip(msgs)
.map(|(delta, kind)| {
TrackEvent {
delta: delta.into(),
kind,
}
.to_static()
})
.collect()
}

impl Performance {
pub fn into_midi(self, user_patch: Option<&UserPatchMap>) -> Result<Smf, String> {
let split = self.split_by_instruments();
let instruments: Vec<_> = split.keys().cloned().collect();
let user_patch = user_patch.and_then(|user_patch| {
user_patch
.contains_all(&instruments)
.then_some(Cow::Borrowed(user_patch))
});

let user_patch = match user_patch {
Some(x) => x,
None => Cow::Owned(UserPatchMap::with_instruments(instruments)?),
};

let file_type = if split.len() == 1 {
Format::SingleTrack
} else {
Format::Parallel
};

let tracks: Result<Vec<_>, String> = split
.iter()
.map(|(i, p)| {
let mut track = into_relative_time(p.as_midi_track(i, &user_patch)?);
track.push(TrackEvent {
delta: 0.into(),
kind: TrackEventKind::Meta(MetaMessage::EndOfTrack),
});
Ok(track)
})
.collect();

tracks.map(|tracks| Smf {
header: Header::new(file_type, Timing::Metrical(DEFAULT_TIME_DIV)),
tracks,
})
}

fn split_by_instruments(self) -> HashMap<InstrumentName, Self> {
self.into_events()
.into_iter()
.map(|e| (e.instrument.clone(), e))
.into_group_map()
.into_iter()
.map(|(k, v)| (k, Self::with_events(v)))
.collect()
}

fn as_midi_track(
&self,
instrument: &InstrumentName,
user_patch: &UserPatchMap,
) -> Result<AbsTimeTrack, String> {
let (channel, program) = user_patch
.lookup(instrument)
.ok_or_else(|| format!("Not found instrument {:?}", instrument))?;

let tempo = 1_000_000 / BEATS_PER_SECOND;
let set_tempo = TrackEventKind::Meta(MetaMessage::Tempo(tempo.into()));
let setup_instrument = TrackEventKind::Midi {
channel: channel.into(),
message: MidiMessage::ProgramChange {
program: program.into(),
},
};

let messages = self
.iter()
.flat_map(|e| {
// TODO: sort the NoteOff more effective for the infinite `Performance`
let (on, off) = e.as_midi(channel);
iter::once(on).chain(iter::once(off))
})
.sorted_by_key(|(t, _)| *t);
Ok(iter::once((0, set_tempo))
.chain(iter::once((0, setup_instrument)))
.chain(messages)
.collect())
}
}

const DEFAULT_TIME_DIV: u15 = u15::new(96);

// beat is a quarter note
const BEATS_PER_SECOND: u32 = 2;

type TimedMessage<'a> = (u32, TrackEventKind<'a>);
type AbsTimeTrack<'a> = Vec<TimedMessage<'a>>;
type Pair<T> = (T, T);

impl Event {
fn as_midi(&self, channel: Channel) -> Pair<TimedMessage> {
let ticks_per_second = u32::from(u16::from(DEFAULT_TIME_DIV)) * BEATS_PER_SECOND;

let start = (self.start_time * ticks_per_second).to_integer();
let end = ((self.start_time + self.duration) * ticks_per_second).to_integer();
let key: u8 = self.pitch.get_inner().try_into().expect("Bad pitch");
let vel = self.volume.clamp(Volume::softest(), Volume::loudest());
(
(
start,
TrackEventKind::Midi {
channel: channel.into(),
message: MidiMessage::NoteOn {
key: key.into(),
vel: vel.0.into(),
},
},
),
(
end,
TrackEventKind::Midi {
channel: channel.into(),
message: MidiMessage::NoteOff {
key: key.into(),
vel: vel.0.into(),
},
},
),
)
}
}

type AnyError = Box<dyn std::error::Error>;

impl Performance {
pub fn save_to_file<P: AsRef<Path>>(self, path: P) -> Result<(), AnyError> {
let midi = self.into_midi(None)?;
midi.save(path)?;
Ok(())
}

pub fn play() -> Result<(), AnyError> {
// TODO:
// 1. join all tracks into a single track with absolute time:
// a. convert to absolute time (remove TrackEnd)
// b. merge (https://hackage.haskell.org/package/HCodecs-0.5.2/docs/src/Codec.Midi.html#merge)
// d. add TrackEnd
// 2. convert absolute time into Instant points using the predefined TimeDiv
// https://hackage.haskell.org/package/HCodecs-0.5.2/docs/src/Codec.Midi.html#toRealTime
// 3. run a thread by sleeping in a loop until the new event is received
// https://github.com/insomnimus/nodi/blob/main/src/player.rs#L40
// 4. take care of stopping all notes on Ctrl-C:
// https://github.com/insomnimus/nodi/blob/main/src/player.rs#L91
Ok(())
}
}
60 changes: 60 additions & 0 deletions src/io/midi/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
mod codec;

use std::collections::HashMap;

use enum_map::Enum;

use crate::instruments::InstrumentName;

// up to 16 channels
type Channel = u8;

// up to 128 instruments
type ProgNum = u8;

#[derive(Debug, Clone)]
pub struct UserPatchMap {
repr: HashMap<InstrumentName, Channel>,
}

impl UserPatchMap {
const PERCUSSION: Channel = 9;
// all but Percussion
const AVAILABLE_CHANNELS: [Channel; 15] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15];

pub fn with_instruments(instruments: Vec<InstrumentName>) -> Result<Self, String> {
// TODO: extend the range of instruments by combining non-overlapping tracks
if instruments.len() > Self::AVAILABLE_CHANNELS.len() {
return Err(format!("Too many instruments: {}", instruments.len()));
}

let map = instruments.into_iter().scan(0, |idx, instrument| {
if instrument == InstrumentName::Percussion {
Some((instrument, Self::PERCUSSION))
} else {
let channel = Self::AVAILABLE_CHANNELS[*idx];
*idx += 1;
Some((instrument, channel))
}
});

Ok(Self {
repr: map.collect(),
})
}

pub fn lookup(&self, instrument: &InstrumentName) -> Option<(Channel, ProgNum)> {
self.repr.get(instrument).map(|x| {
let prog_num = match instrument {
InstrumentName::Standard(i) => i.into_usize(),
InstrumentName::Percussion => 0,
InstrumentName::Custom(_) => 0,
};
(*x, u8::try_from(prog_num).expect("128 instruments only"))
})
}

pub fn contains_all(&self, instruments: &[InstrumentName]) -> bool {
instruments.iter().all(|i| self.lookup(i).is_some())
}
}
1 change: 1 addition & 0 deletions src/io/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod midi;
18 changes: 11 additions & 7 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
pub mod instruments;
mod io;
pub mod music;

pub use music::{
duration::Dur,
interval::{AbsPitch, Interval, Octave},
performance::{self, Performable, Performance, Player},
phrases::{PhraseAttribute, TrillOptions},
pitch::{Pitch, PitchClass},
rests, Music,
pub use self::{
io::midi,
music::{
duration::Dur,
interval::{AbsPitch, Interval, Octave},
performance::{self, Performable, Performance, Player},
phrases::{PhraseAttribute, TrillOptions},
pitch::{Pitch, PitchClass},
rests, Music,
},
};
4 changes: 4 additions & 0 deletions src/music/performance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ impl Performance {
pub fn into_events(self) -> Vec<Event> {
self.repr
}

pub(crate) fn iter(&self) -> impl Iterator<Item = &Event> {
self.repr.iter()
}
}

pub trait Performable<P> {
Expand Down

0 comments on commit 8fadde3

Please sign in to comment.