Skip to content

Commit

Permalink
WIP: MIDI
Browse files Browse the repository at this point in the history
  • Loading branch information
tsionyx committed Feb 19, 2024
1 parent 9a70868 commit 16e22a5
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 9 deletions.
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
208 changes: 208 additions & 0 deletions src/io/midi/codec.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
use std::{borrow::Cow, collections::HashMap, iter};

use itertools::Itertools as _;

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

use super::{Channel, UserPatchMap};

#[derive(Debug, Eq, PartialEq)]
pub struct Midi {
file_type: FileType,
time_div: TimeDiv,
tracks: Vec<Track<Ticks>>,
}

#[derive(Debug, Eq, PartialEq)]
pub enum FileType {
SingleTrack,
MultiTrack,
MultiPattern,
}

#[derive(Debug, Eq, PartialEq)]
enum TimeDiv {
/// Ticks per quarter note
TicksPerBeat(u16),
}

impl Default for TimeDiv {
fn default() -> Self {
Self::TicksPerBeat(96)
}
}

#[derive(Debug, Eq, PartialEq)]
pub struct Track<T> {
repr: Vec<(T, Message)>,
}

impl Track<Ticks> {
pub fn into_relative_time(self) -> Self {
let (ts, msgs): (Vec<_>, Vec<_>) = self.repr.into_iter().unzip();
let repr = ts
.into_iter()
.scan(0, |acc, t| {
let rel_time = t - *acc;
*acc = t;
Some(rel_time)
})
.zip(msgs)
.collect();
Self { repr }
}
}

type Ticks = u32; // 0..2^28

#[derive(Debug, Eq, PartialEq)]
pub enum Message {
/// Turns off the key on the given MIDI channel.
NoteOff {
channel: Channel,
key: Key,
velocity: Velocity,
},

/// Turns on the key on the given MIDI channel.
NoteOn {
channel: Channel,
/// Pitch
key: Key,
velocity: Velocity,
},

/// Select an instrument for a given channel.
ProgramChange { channel: Channel, preset: Preset },

/// The time, in microseconds, of one whole note.
///
/// E.g. using 120 beats per minute (2 beats per second),
/// that works out to 500_000 microseconds per beat,
/// which is the default value that we will use.
TempoChange(Tempo),
}

type Key = u8; // 0..=127
type Velocity = Volume;
type Preset = u8; // 0..=127
type Tempo = u32; // microseconds per beat, 1..2^24

impl Performance {
pub fn into_midi(self, user_patch: Option<&UserPatchMap>) -> Result<Midi, 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 {
FileType::SingleTrack
} else {
FileType::MultiTrack
};

let tracks = split
.iter()
.map(|(i, p)| p.as_midi_track(i, &user_patch).into_relative_time())
.collect();
Ok(Midi {
file_type,
time_div: TimeDiv::default(),
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,
) -> Track<Ticks> {
let (channel, preset) = match user_patch.lookup(instrument) {
Some(x) => x,
None => {
// TODO: Error
return Track { repr: vec![] };
}
};

let setup_instrument = (0, Message::ProgramChange { channel, preset });
let tempo = 1_000_000 / BEATS_PER_SECOND;
let set_tempo = (0, Message::TempoChange(tempo));

let messages = self
.iter()
.flat_map(|e| {
// TODO: sort the off properly for the infinite events
let (on, off) = e.as_midi(channel);
iter::once(on).chain(iter::once(off))
})
.sorted_by_key(|(t, _)| *t);
Track {
repr: iter::once(setup_instrument)
.chain(iter::once(set_tempo))
.chain(messages)
.collect(),
}
}
}

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

type TimedMessage = (u32, Message);
type Pair<T> = (T, T);

impl Event {
fn as_midi(&self, channel: Channel) -> Pair<TimedMessage> {
let ticks_per_second = match TimeDiv::default() {
TimeDiv::TicksPerBeat(x) => u32::from(x) * 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 = self.pitch.get_inner().try_into().expect("Bad pitch");
let velocity = self.volume.clamp(Volume::softest(), Volume::loudest());
(
(
start,
Message::NoteOn {
channel,
key,
velocity,
},
),
(
end,
Message::NoteOff {
channel,
key,
velocity,
},
),
)
}
}
62 changes: 62 additions & 0 deletions src/io/midi/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
mod codec;

use std::collections::HashMap;

use enum_map::Enum;

use crate::instruments::InstrumentName;

pub use self::codec::{FileType, Message, Midi, Track};

// 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 16e22a5

Please sign in to comment.