Skip to content

Commit

Permalink
add text notation parser (#154)
Browse files Browse the repository at this point in the history
* add parse voice

* del intervalset

* index_abs_messages -> abs_messages. Change sort order note_off,pitchwheel,note_on

* add notation files player cli

usage:
python -m musiclib.midi.notation notes/file.txt


* simplify Notation.to_midi

* play voices using separate midi channels

https://gitlab.tandav.me/tandav/notes/-/issues/5893

* use mido.midifiles.tracks._to_reltime for midiobj_to_midifile

* fix to_midi by sending voices to corresponding midi channels

* fix pre-commit

* change cli arg order
  • Loading branch information
tandav authored Jan 20, 2024
1 parent e46082c commit 4341e73
Show file tree
Hide file tree
Showing 15 changed files with 454 additions and 67 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ repos:
- id: autoflake

- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.1.11
rev: v0.1.14
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]

- repo: https://github.com/PyCQA/flake8
rev: 6.1.0
rev: 7.0.0
hooks:
- id: flake8
additional_dependencies: [Flake8-pyproject, flake8-functions-names]
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ ignore = [
"FIX002",
"FIX004",
"B028",
"PTH123",
]

[tool.ruff.per-file-ignores]
Expand Down
192 changes: 192 additions & 0 deletions src/musiclib/midi/notation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import argparse
import bisect
import collections
import json
import operator
import pathlib
from typing import NamedTuple

import mido
from mido.midifiles.tracks import _to_reltime

import musiclib
from musiclib.midi.parse import Midi
from musiclib.midi.parse import MidiNote
from musiclib.midi.parse import abs_messages
from musiclib.midi.player import Player
from musiclib.note import SpecificNote


class IntervalEvent(NamedTuple):
interval: int
on: int
off: int


class Event:
def __init__(self, code: str) -> None:
self.type, *_kw = code.splitlines()
self.kw = dict(kv.split(maxsplit=1) for kv in _kw)


class Header(Event):
def __init__(self, code: str) -> None:
super().__init__(code)
self.version = self.kw['version']
if musiclib.__version__ != self.version:
raise ValueError(f'musiclib must be exact version {self.version} to parse notation')
self.root = SpecificNote.from_str(self.kw['root'])
self.ticks_per_beat = int(self.kw['ticks_per_beat'])
self.channel_map = json.loads(self.kw['channels'])


class Modulation(Event):
def __init__(self, code: str) -> None:
super().__init__(code)
self.root = SpecificNote.from_str(self.kw['root'])


class Voice:
def __init__(self, code: str) -> None:
self.channel, intervals_str = code.split(maxsplit=1)
self.interval_events = self.parse_interval_events(intervals_str)

def parse_interval_events(self, intervals_str: str, ticks_per_beat: int = 96) -> list[IntervalEvent]:
interval: int | None = None
on = 0
off = 0
interval_events = []
for interval_str in intervals_str.split():
if interval_str == '..':
if interval is None:
on += ticks_per_beat
else:
off += ticks_per_beat
elif interval_str == '--':
if interval is None:
raise ValueError('Cannot have -- in the beginning of a voice')
off += ticks_per_beat
else:
if interval is not None:
interval_events.append(IntervalEvent(interval, on, off))
on = off
off += ticks_per_beat
interval = int(interval_str, base=12)
if interval is None:
raise ValueError('Cannot have empty voice')
interval_events.append(IntervalEvent(interval, on, off))
return interval_events


class Bar:
def __init__(self, code: str) -> None:
self.voices = [Voice(voice_code) for voice_code in code.splitlines()]

def to_midi(self, root: SpecificNote, *, figured_bass: bool = True) -> dict[str, list[MidiNote]]:
if not isinstance(root, SpecificNote):
raise TypeError(f'root must be SpecificNote, got {root}')
channels = collections.defaultdict(list)
if not figured_bass:
for voice in self.voices:
for interval_event in voice.interval_events:
channels[voice.channel].append(
MidiNote(
note=root + interval_event.interval,
on=interval_event.on,
off=interval_event.off,
),
)
return dict(channels)
*voices, bass = self.voices
for bass_interval_event in bass.interval_events:
bass_note = root + bass_interval_event.interval
bass_on = bass_interval_event.on
bass_off = bass_interval_event.off
channels[bass.channel].append(MidiNote(bass_note, bass_on, bass_off))

for voice in voices:
above_bass_events = voice.interval_events[
bisect.bisect_left(voice.interval_events, bass_interval_event.on, key=operator.attrgetter('on')):
bisect.bisect_left(voice.interval_events, bass_interval_event.off, key=operator.attrgetter('on'))
]

for interval_event in above_bass_events:
channels[voice.channel].append(
MidiNote(
note=bass_note + interval_event.interval,
on=interval_event.on,
off=interval_event.off,
),
)
return dict(channels)


class Notation:
def __init__(self, code: str) -> None:
self.parse(code)
self.ticks_per_beat = self.header.ticks_per_beat
self.channel_map = self.header.channel_map

def parse(self, code: str) -> None:
self.events: list[Event | Bar] = []
for event_code in code.strip().split('\n\n'):
if event_code.startswith('header'):
self.header = Header(event_code)
elif event_code.startswith('modulation'):
self.events.append(Modulation(event_code))
else:
self.events.append(Bar(event_code))

def _to_midi(self) -> list[Midi]:
channels: list[list[MidiNote]] = [[] for _ in range(len(self.channel_map))]
root = self.header.root
t = 0
for event in self.events:
if isinstance(event, Modulation):
root = event.root
elif isinstance(event, Bar):
bar_midi = event.to_midi(root)
bar_off_channels = {channel: notes[-1].off for channel, notes in bar_midi.items()}
if len(set(bar_off_channels.values())) != 1:
raise ValueError(f'all channels in the bar must have equal length, got {bar_off_channels}')
bar_off = next(iter(bar_off_channels.values()))
for channel, notes in bar_midi.items():
channel_id = self.channel_map[channel]
channels[channel_id] += [
MidiNote(
note=note.note,
on=t + note.on,
off=t + note.off,
channel=channel_id,
)
for note in notes
]
t += bar_off
else:
raise TypeError(f'unknown event type: {event}')

return [Midi(notes=v, ticks_per_beat=self.ticks_per_beat) for v in channels]

def to_midi(self) -> mido.MidiFile:
return mido.MidiFile(
type=1,
ticks_per_beat=self.ticks_per_beat,
tracks=[mido.MidiTrack(_to_reltime(abs_messages(midi))) for midi in self._to_midi()],
)


def play_file() -> None:
parser = argparse.ArgumentParser()
parser.add_argument('--bpm', type=float, default=120)
parser.add_argument('--midiport', type=str, default='IAC Driver Bus 1')
parser.add_argument('filepath', type=pathlib.Path)
args = parser.parse_args()
code = args.filepath.read_text()
nt = Notation(code)
midi = nt.to_midi()
player = Player(args.midiport)
player.play_midi(midi, beats_per_minute=args.bpm)


if __name__ == '__main__':
play_file()
48 changes: 22 additions & 26 deletions src/musiclib/midi/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Literal

import mido
from mido.midifiles.tracks import _to_reltime

from musiclib.note import SpecificNote
from musiclib.noteset import SpecificNoteSet
Expand All @@ -19,6 +20,16 @@ class MidiNote:
note: SpecificNote
on: int
off: int
channel: int = 0
velocity: int = 100

def __post_init__(self) -> None:
if self.off <= self.on:
raise ValueError('off must be > on')
if self.channel not in range(16):
raise ValueError('channel must be in 0..15')
if self.velocity not in range(128):
raise ValueError('velocity must be in 0..127')

def __eq__(self, other: object) -> bool:
if not isinstance(other, MidiNote):
Expand Down Expand Up @@ -53,12 +64,6 @@ def __len__(self) -> int:
return max(note.off for note in self.notes)


@dataclasses.dataclass
class IndexedMessage:
message: mido.Message
index: int


def is_note(type_: Literal['on', 'off'], message: mido.Message) -> bool:
"""https://stackoverflow.com/a/43322203/4204843"""
if type_ == 'on':
Expand Down Expand Up @@ -91,29 +96,20 @@ def parse_midi(midi: mido.MidiFile) -> Midi:


def midiobj_to_midifile(midi: Midi) -> mido.MidiFile:
abs_messages = index_abs_messages(midi)
t = 0
messages = []
for im in abs_messages:
m = im.message.copy()
m.time = im.message.time - t
messages.append(m)
t = im.message.time
track = mido.MidiTrack(messages)
track = mido.MidiTrack(_to_reltime(abs_messages(midi)))
return mido.MidiFile(type=0, tracks=[track], ticks_per_beat=midi.ticks_per_beat)


def index_abs_messages(midi: Midi) -> list[IndexedMessage]:
def abs_messages(midi: Midi) -> list[mido.Message]:
"""this are messages with absolute time, note real midi messages"""
abs_messages = []
for i, note in enumerate(midi.notes):
abs_messages.append(IndexedMessage(message=mido.Message(type='note_on', time=note.on, note=note.note.i, velocity=100), index=i))
abs_messages.append(IndexedMessage(message=mido.Message(type='note_off', time=note.off, note=note.note.i, velocity=100), index=i))
for i, pitch in enumerate(midi.pitchbend):
abs_messages.append(IndexedMessage(message=mido.Message(type='pitchwheel', time=pitch.time, pitch=pitch.pitch), index=i))
# Sort by time. If time is equal sort using type priority in following order: note_on, pitchwheel, note_off
abs_messages.sort(key=lambda m: (m.message.time, {'note_on': 0, 'pitchwheel': 1, 'note_off': 2}[m.message.type]))
return abs_messages
out = []
for note in midi.notes:
out.append(mido.Message(type='note_on', time=note.on, note=note.note.i, velocity=note.velocity, channel=note.channel))
out.append(mido.Message(type='note_off', time=note.off, note=note.note.i, velocity=note.velocity, channel=note.channel))
for pitch in midi.pitchbend:
out.append(mido.Message(type='pitchwheel', time=pitch.time, pitch=pitch.pitch)) # noqa: PERF401
out.sort(key=lambda m: (m.time, {'note_off': 0, 'pitchwheel': 1, 'note_on': 2}[m.type]))
return out


def specific_note_set_to_midi(
Expand Down Expand Up @@ -234,7 +230,7 @@ def to_dict(midi: mido.MidiFile) -> dict: # type: ignore[type-arg]
return {
'type': midi.type,
'ticks_per_beat': midi.ticks_per_beat,
'tracks': [[message.dict() | {'is_meta': message.is_meta} for message in track]for track in midi.tracks],
'tracks': [[message.dict() | {'is_meta': message.is_meta} for message in track] for track in midi.tracks],
}


Expand Down
37 changes: 19 additions & 18 deletions src/musiclib/midi/pitchbend.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import collections
import bisect
import dataclasses
import itertools
import operator
from typing import no_type_check

import numpy as np

from musiclib.midi.parse import Midi
from musiclib.midi.parse import MidiNote
from musiclib.midi.parse import MidiPitch
from musiclib.midi.parse import index_abs_messages
from musiclib.tempo import Tempo
from musiclib.util.etc import increment_duplicates

Expand Down Expand Up @@ -77,25 +77,26 @@ def insert_pitch_pattern(

def make_notes_pitchbends(midi: Midi) -> dict[MidiNote, list[MidiPitch]]:
T, P = zip(*[(e.time, e.pitch) for e in midi.pitchbend], strict=True) # noqa: N806
T_set = set(T) # noqa: N806
interp_t = []
for note in midi.notes:
interp_t += [note.on, note.off]
for t in (note.on, note.off):
if t in T_set:
continue
interp_t.append(t)
T_set.add(t)
interp_p = np.interp(interp_t, T, P, left=0).astype(int).tolist() # https://docs.scipy.org/doc/scipy/tutorial/interpolate/1D.html#piecewise-linear-interpolation
interp_pitches = sorted(midi.pitchbend + [MidiPitch(time=t, pitch=p) for t, p in zip(interp_t, interp_p, strict=True)], key=lambda p: p.time)
midi_tmp = Midi(notes=midi.notes, pitchbend=interp_pitches)
notes_pitchbends = collections.defaultdict(list)
playing_notes = set()
for im in index_abs_messages(midi_tmp):
if im.message.type in {'note_on', 'note_off'}:
note = midi.notes[im.index]
if im.message.type == 'note_on':
playing_notes.add(note)
elif im.message.type == 'note_off':
playing_notes.remove(note)
elif im.message.type == 'pitchwheel':
for note in playing_notes:
notes_pitchbends[note].append(midi_tmp.pitchbend[im.index])
return dict(notes_pitchbends)
interp_pitches = sorted(
midi.pitchbend + [MidiPitch(time=t, pitch=p) for t, p in zip(interp_t, interp_p, strict=True)],
key=operator.attrgetter('time'),
)
notes_pitchbends = {}
for note in midi.notes:
notes_pitchbends[note] = interp_pitches[
bisect.bisect_left(interp_pitches, note.on, key=operator.attrgetter('time')):
bisect.bisect_right(interp_pitches, note.off, key=operator.attrgetter('time'))
]
return notes_pitchbends


@no_type_check
Expand Down
18 changes: 9 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ def mido_midifile():
return mido.MidiFile(
type=0, ticks_per_beat=96, tracks=[
mido.MidiTrack([
mido.Message('note_on', note=60, time=0, velocity=100),
mido.Message('note_off', note=60, time=24, velocity=100),
mido.Message('pitchwheel', pitch=0, time=69),
mido.Message('note_on', note=64, time=3, velocity=100),
mido.Message('note_on', note=67, time=96, velocity=100),
mido.Message('pitchwheel', pitch=8191, time=5),
mido.Message('note_off', note=64, time=5, velocity=100),
mido.Message('pitchwheel', pitch=0, time=14),
mido.Message('note_off', note=67, time=0, velocity=100),
mido.Message('note_on', channel=0, note=60, velocity=100, time=0),
mido.Message('note_off', channel=0, note=60, velocity=100, time=24),
mido.Message('pitchwheel', channel=0, pitch=0, time=69),
mido.Message('note_on', channel=0, note=64, velocity=100, time=3),
mido.Message('note_on', channel=0, note=67, velocity=100, time=96),
mido.Message('pitchwheel', channel=0, pitch=8191, time=5),
mido.Message('note_off', channel=0, note=64, velocity=100, time=5),
mido.Message('note_off', channel=0, note=67, velocity=100, time=14),
mido.Message('pitchwheel', channel=0, pitch=0, time=0),
]),
],
)
Expand Down
10 changes: 10 additions & 0 deletions tests/midi/data/notation/0/code.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
header
version 2.2.1
root C3
ticks_per_beat 96
channels {"bass": 0, "piano": 1, "flute": 2}

flute 17 14 10 17
flute 14 10 07 14
piano 10 07 04 10
bass 00 05 07 00
Loading

0 comments on commit 4341e73

Please sign in to comment.