PiCoGen / sheetsage /theory /lead_sheet.py
Vansh Chugh
vendor jukebox+sheetsage source and recovered weights, use_jukebox=True
ff7b988
Raw
History Blame Contribute Delete
16.4 kB
import tempfile
from collections import defaultdict
import numpy as np
import pretty_midi
from ..align import create_beat_to_time_fn
from .internal import (
Harmony,
KeyChanges,
Melody,
MeterChanges,
Note,
TempoChanges,
_ImmutableIterable,
)
from .theorytab import (
TheorytabChord,
TheorytabKey,
TheorytabMeter,
TheorytabNote,
TheorytabTempo,
TheorytabValueError,
)
from .utils import theorytab_find_applicable
_LILY_HEADER_TEMPLATE = r"""
\header {{
{title_line}
{composer_line}
}}
""".strip()
_LILY_TEMPLATE = r"""
#(set-default-paper-size "letter")
{header}
<<
\new ChordNames {{
\set majorSevenSymbol = \markup {{ maj7 }}
\set additionalPitchPrefix = #"add"
\chordmode {{
{harmony}
}}
}}
\new Staff {{
{{
\clef {clef}
\key {key}
\time {meter}
\tempo {tempo}
{melody}
}}
}}
>>
\version "2.18.2"
""".strip()
_NUM_SIXTEENTHS_TO_LILY_NAME = {
1: "16",
3: "8.",
2: "8",
4: "4",
6: "4.",
8: "2",
12: "2.",
16: "1",
}
class LeadSheet(_ImmutableIterable):
def __new__(
cls,
meter_changes,
tempo_changes,
key_changes,
harmony,
melody,
total_num_tertiary=None,
):
# Run value checks
meter_changes = MeterChanges(*meter_changes)
tempo_changes = TempoChanges(*tempo_changes)
key_changes = KeyChanges(*key_changes)
harmony = Harmony(*harmony)
melody = Melody(*melody)
# Compute tertiary per group (measure)
if len(meter_changes) != 1:
raise NotImplementedError()
meter = meter_changes[0][1]
assert meter in [(3, 2, 2), (4, 2, 2)]
# TODO: Ensure all changes start on downbeats
# Compute and/or validate total_num_tertiary
if total_num_tertiary is None:
total_num_tertiary = 1
user_defined = False
else:
if not isinstance(total_num_tertiary, int):
raise TypeError()
if total_num_tertiary <= 0:
raise ValueError()
user_defined = True
for l in [meter_changes, tempo_changes, key_changes, harmony, melody]:
if len(l) == 0:
continue
if len(l[-1]) == 2:
o, _ = l[-1]
d = 1
else:
o, d, _ = l[-1]
if (o + d) > total_num_tertiary:
if user_defined:
raise ValueError()
else:
total_num_tertiary = o + d
# Round up to nearest measure
# NOTE: We always need at least one measure because key/meter are defined at the beginning
tertiary_per_group = int(np.prod(meter))
while total_num_tertiary % tertiary_per_group != 0:
total_num_tertiary += 1
assert isinstance(total_num_tertiary, int)
assert total_num_tertiary % tertiary_per_group == 0
assert total_num_tertiary // tertiary_per_group > 0
return super().__new__(
cls,
(
meter_changes,
tempo_changes,
key_changes,
harmony,
melody,
total_num_tertiary,
),
)
def as_lily(
self,
clef="treble",
adjust_melody_octave=True,
skip_unknown_chords=False,
artist=None,
title=None,
):
if clef not in ["treble", "bass"]:
raise ValueError()
(
meter_changes,
tempo_changes,
key_changes,
harmony,
melody,
total_num_tertiary,
) = self
# Format meter
assert len(meter_changes) == 1
meter = meter_changes[0][1]
assert meter in [(3, 2, 2), (4, 2, 2)]
tertiary_per_group = int(np.prod(meter))
meter_lily = meter.as_lily()
meter_lily = f"{meter_lily[0]}/{meter_lily[1]}"
# Format tempo
if len(tempo_changes) != 1:
raise NotImplementedError()
assert len(tempo_changes) == 1
tempo = tempo_changes[0][1]
tempo_lily = tempo.as_lily(meter)
tempo_lily = f"{tempo_lily[0]} = {tempo_lily[1]}"
# Format key
if len(key_changes) != 1:
raise NotImplementedError()
key = key_changes[0][1]
key_lily = key.as_lily()
key_lily = f"{key_lily[0]} \\{key_lily[1]}"
# Add in rests in between chords
chords_and_rests = []
if len(harmony) == 0:
chords_and_rests.append((total_num_tertiary, None))
else:
for i, (t, c) in enumerate(harmony):
if i == 0 and t > 0:
chords_and_rests.append((t, None))
if i + 1 < len(harmony):
d = harmony[i + 1][0] - t
else:
d = total_num_tertiary - t
chords_and_rests.append((d, c))
assert all(d > 0 for d, _ in chords_and_rests)
assert sum(d for d, _ in chords_and_rests) == total_num_tertiary
# Format chords
harmony = []
for d, c in chords_and_rests:
# NOTE: c is None means no chord *change*, cs is (None, None) means *change* to N.C.
if c is None:
lpc = ("s", "")
else:
lpc = c.as_lily(key)
if not isinstance(lpc[1], str):
assert isinstance(lpc[1], tuple)
if not skip_unknown_chords:
raise ValueError("Unknown chord")
lpc = ("s", "")
lp = f"{lpc[0]}16*{d}"
if len(lpc[1]) > 0:
lp += ":" + lpc[1]
harmony.append(lp)
harmony_lily = " ".join(harmony)
# Adjust melody to be centered in clef
# NOTE: Finds the octave where the most melody are on the staff lines
if adjust_melody_octave and len(melody) > 0:
if clef == "treble":
midi_pitch_range = (63, 78) # Eb at bottom of treble clef, F# at top
elif clef == "bass":
midi_pitch_range = (42, 58) # Gb at bottom of bass clef, A# at top
else:
assert False
midi_pitches = np.array([ns.as_midi_pitch() for _, _, ns in melody])
candidate_octaves = np.arange(-1000, 1000)
midi_pitches_adjusted = (candidate_octaves * 12)[
:, np.newaxis
] + midi_pitches[np.newaxis, :]
midi_pitches_onstaff = np.logical_and(
midi_pitches_adjusted >= midi_pitch_range[0],
midi_pitches_adjusted <= midi_pitch_range[1],
)
best_octave = int(
candidate_octaves[
np.argmax(midi_pitches_onstaff.astype(np.int64).sum(axis=1))
]
)
melody = [(s, d, Note(ns[0], ns[1] + best_octave)) for s, d, ns in melody]
# Add in rests in between melody
last_offset = 0
notes_and_rests = []
for t, d, ns in melody:
assert t >= last_offset
if t != last_offset:
notes_and_rests.append((t - last_offset, None))
notes_and_rests.append((d, ns))
last_offset = t + d
t = total_num_tertiary
assert t >= last_offset
if t != last_offset:
notes_and_rests.append((t - last_offset, None))
assert all(d > 0 for d, _ in notes_and_rests)
assert sum(d for d, _ in notes_and_rests) == total_num_tertiary
# Beaming logic
bar_to_notes = defaultdict(list)
t = 0
for d, ns in notes_and_rests:
tied = False
while d > 0:
bar = t // tertiary_per_group
bar_remaining = ((bar + 1) * tertiary_per_group) - t
consumed = min(d, bar_remaining)
if consumed not in _NUM_SIXTEENTHS_TO_LILY_NAME:
while _NUM_SIXTEENTHS_TO_LILY_NAME.get(consumed, ".").endswith("."):
consumed -= 1
d -= consumed
t += consumed
if ns is None:
lp = "r"
else:
lp = "".join(ns.as_lily(key))
lp += _NUM_SIXTEENTHS_TO_LILY_NAME[consumed]
lp += "~" if d > 0 else ""
bar_to_notes[bar].append(lp)
tied = True
# Format notes
melody_lily = " | ".join([" ".join(notes) for _, notes in bar_to_notes.items()])
header = ""
if title is not None or artist is not None:
header = _LILY_HEADER_TEMPLATE.format(
title_line="" if title is None else f'title = "{title}"',
composer_line="" if artist is None else f'composer = "{artist}"',
)
return _LILY_TEMPLATE.format(
header=header,
clef=clef,
key=key_lily,
meter=meter_lily,
tempo=tempo_lily,
harmony=harmony_lily,
melody=melody_lily,
)
def as_midi(self, pulse_to_time_fn=None, adjust_melody_octave=True):
(
meter_changes,
tempo_changes,
_,
harmony,
melody,
total_num_tertiary,
) = self
# Assumptions
assert len(meter_changes) == 1
meter = meter_changes[0][1]
assert meter in [(3, 2, 2), (4, 2, 2)]
tertiary_per_group = int(np.prod(meter))
tertiary_per_pulse = int(np.prod(meter[1:]))
# Create tertiary_to_time_fn
if pulse_to_time_fn is None:
if len(tempo_changes) != 1:
raise NotImplementedError()
tempo = tempo_changes[0][1]
pps = tempo[0] / 60
tertiaries = [0, tertiary_per_pulse]
times = [0, 1 / pps]
tertiary_to_time_fn = create_beat_to_time_fn(tertiaries, times)
else:
tertiary_to_time_fn = lambda t: pulse_to_time_fn(t / tertiary_per_pulse)
# Adjust melody to be mostly in midi octave 5
if adjust_melody_octave and len(melody) > 0:
midi_pitch_range = (60, 71)
midi_pitches = np.array([ns.as_midi_pitch() for _, _, ns in melody])
candidate_octaves = np.arange(-1000, 1000)
midi_pitches_adjusted = (candidate_octaves * 12)[
:, np.newaxis
] + midi_pitches[np.newaxis, :]
midi_pitches_onstaff = np.logical_and(
midi_pitches_adjusted >= midi_pitch_range[0],
midi_pitches_adjusted <= midi_pitch_range[1],
)
best_octave = int(
candidate_octaves[
np.argmax(midi_pitches_onstaff.astype(np.int64).sum(axis=1))
]
)
melody = [(s, d, Note(ns[0], ns[1] + best_octave)) for s, d, ns in melody]
# Create click
click = pretty_midi.Instrument(program=0, is_drum=True)
for t in range(0, total_num_tertiary, tertiary_per_pulse):
velocity = 75
pitch = 31
# Downbeat
if t % tertiary_per_group == 0:
velocity = 100
pitch = 37
click.notes.append(
pretty_midi.Note(
velocity,
pitch,
tertiary_to_time_fn(t),
tertiary_to_time_fn(t + tertiary_per_pulse),
)
)
# Create harmony
harmony_ins = pretty_midi.Instrument(program=24) # Acoustic Guitar (nylon)
for i, (t, c) in enumerate(harmony):
if i + 1 < len(harmony):
d = harmony[i + 1][0] - t
else:
d = total_num_tertiary - t
for p in c.as_midi_pitches():
harmony_ins.notes.append(
pretty_midi.Note(
67,
p,
tertiary_to_time_fn(t),
tertiary_to_time_fn(t + d),
)
)
# Create melody
melody_ins = pretty_midi.Instrument(program=0)
for t, d, ns in melody:
melody_ins.notes.append(
pretty_midi.Note(
100,
ns.as_midi_pitch(),
tertiary_to_time_fn(t),
tertiary_to_time_fn(t + d),
)
)
# Create MIDI
midi = pretty_midi.PrettyMIDI()
midi.instruments.extend([click, harmony_ins, melody_ins])
with tempfile.NamedTemporaryFile() as f:
midi.write(f.name)
with open(f.name, "rb") as f:
return f.read()
@classmethod
def from_theorytab(
cls,
analysis,
ignore_inversion=True,
skip_bad_notes_and_chords=False,
):
# For theorytab we always subdivide into 1/4 beat
beat_to_tertiary = lambda b: round(b * 4)
end_beat = analysis["endBeat"] - 1
# Meters
meter_changes = []
last_meter = None
for ttm in analysis["meters"]:
ttm = TheorytabMeter(ttm)
meter = ttm.as_meter()
if meter != last_meter:
meter_changes.append((beat_to_tertiary(ttm["beat"] - 1), meter))
last_meter = meter
# Tempos
tempo_changes = []
last_tempo = None
for ttt in analysis["tempos"]:
ttt = TheorytabTempo(ttt)
tempo = ttt.as_tempo()
if tempo != last_tempo:
tempo_changes.append((beat_to_tertiary(ttt["beat"] - 1), tempo))
last_tempo = tempo
# Keys
ttk_keys = []
key_changes = []
last_key = None
for ttk in analysis["keys"]:
ttk = TheorytabKey(ttk)
ttk_keys.append(ttk)
key = ttk.as_key()
if key != last_key:
key_changes.append((beat_to_tertiary(ttk["beat"] - 1), key))
last_key = key
# Chords
harmony = []
last_chord = None
for ttc in analysis["chords"]:
try:
ttc = TheorytabChord(ttc)
except TheorytabValueError as e:
if not skip_bad_notes_and_chords:
raise e
continue
if ttc.will_sound():
chord = ttc.as_chord(
theorytab_find_applicable(ttk_keys, ttc),
root_position=ignore_inversion,
)
if chord != last_chord:
ob = ttc["beat"] - 1
db = ttc["duration"]
if ob + db > (end_beat + 1e-6):
raise ValueError()
harmony.append((beat_to_tertiary(ob), chord))
last_chord = chord
# Notes
melody = []
for ttn in analysis["notes"]:
try:
ttn = TheorytabNote(ttn)
except TheorytabValueError as e:
if not skip_bad_notes_and_chords:
raise e
continue
if ttn.will_sound():
ob = ttn["beat"] - 1
db = ttn["duration"]
if ob + db > (end_beat + 1e-6):
raise ValueError()
melody.append(
(
beat_to_tertiary(ob),
beat_to_tertiary(db),
ttn.as_note(theorytab_find_applicable(ttk_keys, ttn)),
)
)
# Trim extra changes
total_num_tertiary = beat_to_tertiary(end_beat)
meter_changes = [m for m in meter_changes if m[0] < total_num_tertiary]
tempo_changes = [t for t in tempo_changes if t[0] < total_num_tertiary]
key_changes = [k for k in key_changes if k[0] < total_num_tertiary]
return cls(
meter_changes,
tempo_changes,
key_changes,
harmony,
melody,
total_num_tertiary=total_num_tertiary,
)