Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Melodie v A–dur pythonization #17

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ out
*.xml

.idea

*.ipynb
.ipynb_checkpoints/
*.pyc
4 changes: 2 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ install:
- pip install -r requirements.txt
script:
- py.test
- mkdir out && python -c 'import make; make.python()'
- python midi_diff.py
- ./make.py python
- ./midi_diff.py
33 changes: 23 additions & 10 deletions make.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#!/usr/bin/env python3

# TODO use pathlib
# TODO use f'' string formatting

import importlib.util
import os

import click
import colorama


Expand All @@ -11,6 +15,11 @@
OUT_DIR = 'out'


@click.group()
def cli():
pass


def info(msg):
try:
print(colorama.Fore.BLUE + msg + colorama.Fore.RESET)
Expand Down Expand Up @@ -43,6 +52,7 @@ def is_newer(file1, file2):
os.path.getmtime(file1) > os.path.getmtime(file2)


@cli.command(help='Compiles LilyPond sources to MIDI & PDF.')
def lilypond():
files = filter(lambda f: f.endswith('.ly'), os.listdir(SRC_DIR))
for ly in files:
Expand All @@ -54,6 +64,7 @@ def lilypond():
run('cd %s && lilypond %s' % (OUT_DIR, os.path.abspath(ly)))


@cli.command(help='Compiles MuseScore sources to MIDI & PDF.')
def mscore():
files = filter(lambda f: f.endswith('.mscz') or f.endswith('.mscx'),
os.listdir(SRC_DIR))
Expand All @@ -68,8 +79,14 @@ def mscore():
run('mscore %s -o %s' % (ms, pdf))


def python():
files = filter(lambda f: f.endswith('.py'), os.listdir(SRC_PY_DIR))
@cli.command(help='Compiles Python sources to MIDI.')
@click.argument('filename', required=False)
def python(filename=None):
if filename:
files = [filename]
else:
files = filter(lambda f: f.endswith('.py'), os.listdir(SRC_PY_DIR))

for py in files:
info_file(py)
midi = out_file(py, 'midi')
Expand All @@ -88,6 +105,8 @@ def python():
song_module.song.save(midi)


@cli.command(help='Renders MIDI files to OGG/FLAC.')
# TODO option for OGG/FLAC
def timidity():
files = filter(lambda f: f.endswith('.midi') or f.endswith('.mid'),
os.listdir(OUT_DIR))
Expand All @@ -100,6 +119,7 @@ def timidity():
run('timidity %s -OF -o %s' % (midi, flac))


@cli.command(help='Creates PNG thumbnails for PDFs.')
def imagemagick():
files = filter(lambda f: f.endswith('.pdf'), os.listdir(OUT_DIR))
for pdf in files:
Expand All @@ -111,13 +131,6 @@ def imagemagick():
'%s[0] %s' % (pdf, png))


def main():
lilypond()
mscore()
timidity()
imagemagick()


if __name__ == '__main__':
os.makedirs(OUT_DIR, exist_ok=True)
main()
cli()
39 changes: 30 additions & 9 deletions midi_diff.py
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,35 @@
import mido


TIME_EPSILON = 5
TIME_EPSILON = .5
DURATION_EPSILON = .2
VOLUME_EPSILON = 2


def extract_notes(filename):
mid = mido.MidiFile(filename)
notes = []
time = 0
for track in mid.tracks:
time = 0
for msg in track:
try:
time += msg.time / mid.ticks_per_beat
if msg.is_meta:
continue
if msg.type == 'note_on' and msg.velocity:
notes.append({'time': time, 'note': msg.note, 'channel': msg.channel})
notes.append({
'time': time,
'note': msg.note,
'channel': msg.channel,
'velocity': msg.velocity,
})
elif ((msg.type == 'note_on' and not msg.velocity)
or msg.type == 'note_off'):
note_on = next(filter(
lambda n: n['channel'] == msg.channel and n['note'] == msg.note,
notes[::-1]
))
note_on['duration'] = time - note_on['time']
except Exception as e:
print(e)
return notes
Expand All @@ -40,30 +54,35 @@ def find(cmp_func, notes):
note['time'] += shift2to1


def notes_diff(notes1, notes2):
def notes_diff(notes1, notes2, ignore_channels=False):
notes1_extra = []
for n1 in notes1:
for n2 in notes2:
if (n1['note'] == n2['note']
and abs(n1['velocity'] - n2['velocity']) < VOLUME_EPSILON
and (n1['channel'] == n2['channel']
or ignore_channels
or (n1['channel'] != 9 and n2['channel'] != 9))
# non-percussion channels compared loosely
and abs(n1['time'] - n2['time']) < TIME_EPSILON):
and abs(n1['time'] - n2['time']) <= TIME_EPSILON
and (abs(n1.get('duration', 0) - n2.get('duration', 0)) <= DURATION_EPSILON
or n1['channel'] == n2['channel'] == 9)
):
break
else:
notes1_extra.append(n1)
return notes1_extra


def diff(filename1, filename2, normalize_times=True):
def diff(filename1, filename2, normalize_times=True, ignore_channels=False):
notes1 = extract_notes(filename1)
notes2 = extract_notes(filename2)

if normalize_times:
_normalize_times(notes1, notes2)

notes1_extra = notes_diff(notes1, notes2)
notes2_extra = notes_diff(notes2, notes1)
notes1_extra = notes_diff(notes1, notes2, ignore_channels)
notes2_extra = notes_diff(notes2, notes1, ignore_channels)

return {'notes1_extra': notes1_extra, 'notes2_extra': notes2_extra}

Expand All @@ -79,7 +98,9 @@ def main():
continue

print('Comparing %s to %s...' % (filename, filename_orig))
d = diff(filename_orig, filename)
ignore_channels = 'prochazka' in filename # MuseScore exported it as a 1 channel song
d = diff(filename_orig, filename, ignore_channels)
pprint({'len1': len(d['notes1_extra']), 'len2': len(d['notes2_extra'])})
pprint(d)

if any(d.values()):
Expand Down
96 changes: 86 additions & 10 deletions midi_lib.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from contextlib import contextmanager
import itertools

from mido import Message, MetaMessage, MidiTrack, MidiFile, bpm2tempo


GRACE_DURATION = 1.0 / 8


class Scale:

midi_root_tones = ['c', 'cis', 'd', 'dis', 'e', 'f', 'fis', 'g', 'gis', 'a', 'ais', 'b']
Expand Down Expand Up @@ -57,6 +61,8 @@ def __init__(self, midi_file=None, channel=0):
self.grace_portion = 8 # TODO change to grace_beats
self.default_beats = 1
self.arpeggio_delay_beats = 1/8
self.volume = 100
self.shorten_tones = False

def _note(self, tone):
if self.channel == 9: # percussion
Expand All @@ -71,19 +77,36 @@ def _note(self, tone):
def _time(self, beats):
return int(beats * self.parent.ticks_per_beat)

def _note_on(self, tone, beats=0):
self.append(Message('note_on', note=self._note(tone), velocity=100, time=self._time(beats),
channel=self.channel))
def _apply_volume(self, volume):
if not volume:
return self.volume
if isinstance(volume, int) or isinstance(volume, float):
return self.volume + volume
raise ValueError(f'Unknown volume/velocity: {volume}')

def _note_on(self, tone, beats=0, volume=None):
self.append(Message(
'note_on',
note=self._note(tone),
velocity=int(self._apply_volume(volume)),
time=self._time(beats),
channel=self.channel,
))

def _note_off(self, tone, beats=0):
self.append(Message('note_off', note=self._note(tone), time=self._time(beats),
channel=self.channel))

def play(self, tones, beats=None, grace=False, staccato=False, arpeggio=False):
def play(self, tones, beats=None, grace=False, staccato=False, arpeggio=False,
volume=None):
if isinstance(tones, Scale):
self.scale = tones
return

if tones == 'volume': # TODO make nicer
self.volume = volume
return

if beats == 'grace':
return self.grace(tones)
elif not beats:
Expand All @@ -95,14 +118,16 @@ def play(self, tones, beats=None, grace=False, staccato=False, arpeggio=False):
if not isinstance(tones, list):
tones = [tones]

self._note_on(tones[0], self._beats_to_rest)
self._note_on(tones[0], self._beats_to_rest, volume=volume)
self._beats_to_rest = 0
for tone in tones[1:]:
if arpeggio:
self._note_on(tone, self.arpeggio_delay_beats)
self._note_on(tone, self.arpeggio_delay_beats, volume=volume)
beats -= self.arpeggio_delay_beats
else:
self._note_on(tone)
self._note_on(tone, volume=volume)

beats_to_rest = 0

if grace:
self._note_off(tones[0], beats)
Expand All @@ -111,13 +136,17 @@ def play(self, tones, beats=None, grace=False, staccato=False, arpeggio=False):
beats -= self._beats_stolen
self._beats_stolen = 0
if staccato:
beats /= 2
beats_to_rest = beats - beats / int(staccato) / 2
beats /= int(staccato) * 2
elif self.shorten_tones:
beats_to_rest = beats * .125
beats *= .875
self._note_off(tones[0], beats)
for tone in tones[1:]:
self._note_off(tone)

if staccato:
self.rest(beats)
if beats_to_rest:
self.rest(beats_to_rest)

@contextmanager
def shadow_play(self, tones):
Expand All @@ -135,6 +164,8 @@ def shadow_play(self, tones):
self._note_off(tone)

def sequence(self, sequence):
sequence = ungrace(sequence, self.default_beats)

for play_args in sequence:
# it should be a tuple/dict to fully use `play`
if isinstance(play_args, int): # single tone
Expand Down Expand Up @@ -219,6 +250,8 @@ def __init__(self, *args, **kwargs):
self.time_signature = None
self.bpm = None
self.default_beats = None
self.volume = 100
self.shorten_tones = False
self._new_channel = 0

def new_track(self, channel=None):
Expand All @@ -236,6 +269,9 @@ def new_track(self, channel=None):
track.bpm = self.bpm
if self.default_beats:
track.default_beats = self.default_beats
if self.volume:
track.volume = self.volume
track.shorten_tones = self.shorten_tones

return track

Expand All @@ -246,6 +282,7 @@ def new_drumming_track(self):
instruments = {
'bright acoustic piano': 2,
'harpsichord': 7,
'celesta': 9,
'church organ': 20,
'electric guitar (clean)': 28,
'acoustic bass': {
Expand All @@ -254,6 +291,11 @@ def new_drumming_track(self):
},
'violin': 41,
'cello': 43,
'contrabass': {
'midi_number': 44,
'octave_shift': -2,
},
'trumpet': 57,
'baritone sax': {
'midi_number': 68,
'octave_shift': -1,
Expand All @@ -272,3 +314,37 @@ def new_drumming_track(self):
mar = 70
hh = 42
ridecymbal = 51


# helpers:

def line(*tones, beats):
if isinstance(beats, list):
return list(zip(tones, itertools.cycle(beats)))
return list(map(lambda t: (t, beats), tones))


def ungrace(line, default_beats=1):
# grace notes here are meant to be played before,
# trimming previous tones/chords
# TODO do not assume everything in line is a tuple of max. size 2

line = map(lambda x: {'tones': x} if isinstance(x, int) or isinstance(x, str) or isinstance(x, list)
else {'tones': x[0], 'beats': x[1]} if isinstance(x, tuple) and len(x) > 1
else x,
line)
line = list(line)
for x in line:
if isinstance(x, dict):
x['beats'] = x.get('beats', default_beats)

for ix, obj in filter(lambda x: isinstance(x[1], dict)
and (x[1].get('beats') == 'grace' or x[1].get('grace')),
reversed(list(enumerate(line)))):
pix, prev = next(filter(lambda x: isinstance(x[1], dict)
and not(x[1].get('beats') == 'grace' or x[1].get('grace')),
reversed(list(enumerate(line[:ix])))))
line[pix]['beats'] -= GRACE_DURATION
line[ix]['beats'] = GRACE_DURATION

return line
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
click
colorama
mido
Loading