From 4341e730dd1c6ebb7842a4ef0bed8c6849ea4382 Mon Sep 17 00:00:00 2001 From: Alexander Rodionov Date: Sat, 20 Jan 2024 18:05:21 +0300 Subject: [PATCH] add text notation parser (#154) * 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 --- .pre-commit-config.yaml | 4 +- pyproject.toml | 1 + src/musiclib/midi/notation.py | 192 +++++++++++++++++++++++++++ src/musiclib/midi/parse.py | 48 +++---- src/musiclib/midi/pitchbend.py | 37 +++--- tests/conftest.py | 18 +-- tests/midi/data/notation/0/code.txt | 10 ++ tests/midi/data/notation/0/midi.json | 44 ++++++ tests/midi/data/notation/1/code.txt | 10 ++ tests/midi/data/notation/1/midi.json | 40 ++++++ tests/midi/data/notation/2/code.txt | 9 ++ tests/midi/data/notation/2/midi.json | 24 ++++ tests/midi/notation_test.py | 61 +++++++++ tests/midi/parse_test.py | 22 +-- tests/midi/pitchbend_test.py | 1 - 15 files changed, 454 insertions(+), 67 deletions(-) create mode 100644 src/musiclib/midi/notation.py create mode 100644 tests/midi/data/notation/0/code.txt create mode 100644 tests/midi/data/notation/0/midi.json create mode 100644 tests/midi/data/notation/1/code.txt create mode 100644 tests/midi/data/notation/1/midi.json create mode 100644 tests/midi/data/notation/2/code.txt create mode 100644 tests/midi/data/notation/2/midi.json create mode 100644 tests/midi/notation_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ff990a3..253500d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/pyproject.toml b/pyproject.toml index 7641f6da..96ce6ed3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ ignore = [ "FIX002", "FIX004", "B028", + "PTH123", ] [tool.ruff.per-file-ignores] diff --git a/src/musiclib/midi/notation.py b/src/musiclib/midi/notation.py new file mode 100644 index 00000000..bf4ea510 --- /dev/null +++ b/src/musiclib/midi/notation.py @@ -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() diff --git a/src/musiclib/midi/parse.py b/src/musiclib/midi/parse.py index cfa9a69a..ca1727a5 100644 --- a/src/musiclib/midi/parse.py +++ b/src/musiclib/midi/parse.py @@ -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 @@ -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): @@ -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': @@ -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( @@ -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], } diff --git a/src/musiclib/midi/pitchbend.py b/src/musiclib/midi/pitchbend.py index 1e4ec732..73d7f536 100644 --- a/src/musiclib/midi/pitchbend.py +++ b/src/musiclib/midi/pitchbend.py @@ -1,6 +1,7 @@ -import collections +import bisect import dataclasses import itertools +import operator from typing import no_type_check import numpy as np @@ -8,7 +9,6 @@ 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 @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 40c00357..5a5560f7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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), ]), ], ) diff --git a/tests/midi/data/notation/0/code.txt b/tests/midi/data/notation/0/code.txt new file mode 100644 index 00000000..522d533e --- /dev/null +++ b/tests/midi/data/notation/0/code.txt @@ -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 diff --git a/tests/midi/data/notation/0/midi.json b/tests/midi/data/notation/0/midi.json new file mode 100644 index 00000000..ae3148c6 --- /dev/null +++ b/tests/midi/data/notation/0/midi.json @@ -0,0 +1,44 @@ +{ + "type": 1, + "ticks_per_beat": 96, + "tracks": [ + [ + {"type": "note_on", "time": 0, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 53, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 53, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 55, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 55, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 48, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 1, "note": 59, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 59, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 60, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 69, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 65, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 69, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 65, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 62, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 62, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false} + ] + ] +} diff --git a/tests/midi/data/notation/1/code.txt b/tests/midi/data/notation/1/code.txt new file mode 100644 index 00000000..487d007a --- /dev/null +++ b/tests/midi/data/notation/1/code.txt @@ -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 20 +flute 14 10 07 17 +piano 10 -- -- 14 +bass 00 05 07 00 diff --git a/tests/midi/data/notation/1/midi.json b/tests/midi/data/notation/1/midi.json new file mode 100644 index 00000000..51b5f14a --- /dev/null +++ b/tests/midi/data/notation/1/midi.json @@ -0,0 +1,40 @@ +{ + "type": 1, + "ticks_per_beat": 96, + "tracks": [ + [ + {"type": "note_on", "time": 0, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 53, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 53, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 55, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 55, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 48, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 48, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 288, "channel": 1, "note": 60, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 1, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 64, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 69, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 65, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 69, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 65, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 62, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 62, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 72, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 72, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false} + ] + ] +} diff --git a/tests/midi/data/notation/2/code.txt b/tests/midi/data/notation/2/code.txt new file mode 100644 index 00000000..a01549be --- /dev/null +++ b/tests/midi/data/notation/2/code.txt @@ -0,0 +1,9 @@ +header +version 2.2.1 +root E3 +ticks_per_beat 96 +channels {"bass": 0, "piano": 1, "flute": 2} + +flute 17 13 +piano 13 10 +bass 0 0 diff --git a/tests/midi/data/notation/2/midi.json b/tests/midi/data/notation/2/midi.json new file mode 100644 index 00000000..832bc79c --- /dev/null +++ b/tests/midi/data/notation/2/midi.json @@ -0,0 +1,24 @@ +{ + "type": 1, + "ticks_per_beat": 96, + "tracks": [ + [ + {"type": "note_on", "time": 0, "channel": 0, "note": 52, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 52, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 0, "note": 52, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 0, "note": 52, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 1, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 1, "note": 64, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 1, "note": 64, "velocity": 100, "is_meta": false} + ], + [ + {"type": "note_on", "time": 0, "channel": 2, "note": 71, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 71, "velocity": 100, "is_meta": false}, + {"type": "note_on", "time": 0, "channel": 2, "note": 67, "velocity": 100, "is_meta": false}, + {"type": "note_off", "time": 96, "channel": 2, "note": 67, "velocity": 100, "is_meta": false} + ] + ] +} diff --git a/tests/midi/notation_test.py b/tests/midi/notation_test.py new file mode 100644 index 00000000..5c3e33f4 --- /dev/null +++ b/tests/midi/notation_test.py @@ -0,0 +1,61 @@ +import json +from pathlib import Path + +import pytest +from musiclib.midi import notation +from musiclib.midi import parse +from musiclib.midi.notation import IntervalEvent + + +@pytest.mark.parametrize( + ('code', 'channel'), [ + ('flute 1 2 3 4', 'flute'), + ('bass 9 8 -7 17 4 -12 0', 'bass'), + ], +) +def test_voice_channel(code, channel): + assert notation.Voice(code).channel == channel + + +@pytest.mark.parametrize( + ('code', 'interval_events'), [ + ( + 'flute 1 2 3 4', + [ + IntervalEvent(interval=1, on=0, off=96), + IntervalEvent(interval=2, on=96, off=192), + IntervalEvent(interval=3, on=192, off=288), + IntervalEvent(interval=4, on=288, off=384), + ], + ), + ( + 'flute 15 -3 .. -10 26 17 28 -- 17 29 -15 27 -8 -5 25 23', + [ + IntervalEvent(interval=17, on=0, off=96), + IntervalEvent(interval=-3, on=96, off=288), + IntervalEvent(interval=-12, on=288, off=384), + IntervalEvent(interval=30, on=384, off=480), + IntervalEvent(interval=19, on=480, off=576), + IntervalEvent(interval=32, on=576, off=768), + IntervalEvent(interval=19, on=768, off=864), + IntervalEvent(interval=33, on=864, off=960), + IntervalEvent(interval=-17, on=960, off=1056), + IntervalEvent(interval=31, on=1056, off=1152), + IntervalEvent(interval=-8, on=1152, off=1248), + IntervalEvent(interval=-5, on=1248, off=1344), + IntervalEvent(interval=29, on=1344, off=1440), + IntervalEvent(interval=27, on=1440, off=1536), + ], + ), + ], +) +def test_voice(code, interval_events): + assert notation.Voice(code).interval_events == interval_events + + +@pytest.mark.parametrize('example_dir', (Path(__file__).parent / 'data/notation').iterdir()) +def test_to_midi(example_dir): + code = (example_dir / 'code.txt').read_text() + with open(example_dir / 'midi.json') as f: + midi_dict = json.load(f) + assert parse.to_dict(notation.Notation(code).to_midi()) == midi_dict diff --git a/tests/midi/parse_test.py b/tests/midi/parse_test.py index fcb2baf1..b4495c3b 100644 --- a/tests/midi/parse_test.py +++ b/tests/midi/parse_test.py @@ -30,17 +30,17 @@ def test_midiobj_to_midifile(midi, mido_midifile): assert is_midi_equal(check, mido_midifile) -def test_index_abs_messages(midi): - assert parse.index_abs_messages(midi) == [ - parse.IndexedMessage(message=mido.Message('note_on', channel=0, note=60, velocity=100, time=0), index=0), - parse.IndexedMessage(message=mido.Message('note_off', channel=0, note=60, velocity=100, time=24), index=0), - parse.IndexedMessage(message=mido.Message('pitchwheel', channel=0, pitch=0, time=93), index=0), - parse.IndexedMessage(message=mido.Message('note_on', channel=0, note=64, velocity=100, time=96), index=1), - parse.IndexedMessage(message=mido.Message('note_on', channel=0, note=67, velocity=100, time=192), index=2), - parse.IndexedMessage(message=mido.Message('pitchwheel', channel=0, pitch=8191, time=197), index=1), - parse.IndexedMessage(message=mido.Message('note_off', channel=0, note=64, velocity=100, time=202), index=1), - parse.IndexedMessage(message=mido.Message('pitchwheel', channel=0, pitch=0, time=216), index=2), - parse.IndexedMessage(message=mido.Message('note_off', channel=0, note=67, velocity=100, time=216), index=2), +def test_abs_messages(midi): + assert parse.abs_messages(midi) == [ + 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=93), + mido.Message('note_on', channel=0, note=64, velocity=100, time=96), + mido.Message('note_on', channel=0, note=67, velocity=100, time=192), + mido.Message('pitchwheel', channel=0, pitch=8191, time=197), + mido.Message('note_off', channel=0, note=64, velocity=100, time=202), + mido.Message('note_off', channel=0, note=67, velocity=100, time=216), + mido.Message('pitchwheel', channel=0, pitch=0, time=216), ] diff --git a/tests/midi/pitchbend_test.py b/tests/midi/pitchbend_test.py index 2c6f0748..a7940eb3 100644 --- a/tests/midi/pitchbend_test.py +++ b/tests/midi/pitchbend_test.py @@ -84,7 +84,6 @@ def test_make_notes_pitchbends(midi): MidiPitch(time=197, pitch=8191), MidiPitch(time=202, pitch=6035), MidiPitch(time=216, pitch=0), - MidiPitch(time=216, pitch=0), ], }