Skip to content

Commit

Permalink
Merge pull request #303 from CPJKU/match_export
Browse files Browse the repository at this point in the history
new score attributes in match export, new score properties, misc fixes
  • Loading branch information
manoskary authored Sep 22, 2023
2 parents 4027a9a + c143358 commit 4838419
Show file tree
Hide file tree
Showing 15 changed files with 423 additions and 249 deletions.
3 changes: 3 additions & 0 deletions partitura/directions.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ def unabbreviate(s):
"adagio",
"agitato",
"andante",
"andante cantabile",
"andante amoroso",
"andantino",
"animato",
"appassionato",
Expand Down Expand Up @@ -193,6 +195,7 @@ def unabbreviate(s):
"tranquilamente",
"tranquilo",
"recitativo",
"allegro moderato",
r"/(vivo|vivacissimamente|vivace)/",
r"/(allegro|allegretto)/",
r"/(espressivo|espress\.?)/",
Expand Down
55 changes: 50 additions & 5 deletions partitura/io/exportmatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
FractionalSymbolicDuration,
MatchKeySignature,
MatchTimeSignature,
MatchTempoIndication,
Version,
)

Expand Down Expand Up @@ -71,6 +72,8 @@ def matchfile_from_alignment(
score_filename: Optional[PathLike] = None,
performance_filename: Optional[PathLike] = None,
assume_part_unfolded: bool = False,
tempo_indication: Optional[str] = None,
diff_score_version_notes: Optional[list] = None,
version: Version = LATEST_VERSION,
debug: bool = False,
) -> MatchFile:
Expand Down Expand Up @@ -106,6 +109,10 @@ def matchfile_from_alignment(
repetitions in the alignment. If False, the part will be automatically
unfolded to have maximal coverage of the notes in the alignment.
See `partitura.score.unfold_part_alignment`.
tempo_indication : str or None
The tempo direction indicated in the beginning of the score
diff_score_version_notes : list or None
A list of score notes that reflect a special score version (e.g., original edition/Erstdruck, Editors note etc.)
version: Version
Version of the match file. For now only 1.0.0 is supported.
Returns
Expand Down Expand Up @@ -180,7 +187,7 @@ def matchfile_from_alignment(
alignment=alignment,
remove_ornaments=True,
)

measures = np.array(list(spart.iter_all(score.Measure)))
measure_starts_divs = np.array([m.start.t for m in measures])
measure_starts_beats = beat_map(measure_starts_divs)
Expand All @@ -199,7 +206,6 @@ def matchfile_from_alignment(

# Score prop header lines
scoreprop_lines = defaultdict(list)

# For score notes
score_info = dict()
# Info for sorting lines
Expand Down Expand Up @@ -276,7 +282,6 @@ def matchfile_from_alignment(
# Get all notes in the measure
snotes = spart.iter_all(score.Note, m.start, m.end, include_subclasses=True)
# Beginning of each measure

for snote in snotes:
onset_divs, offset_divs = snote.start.t, snote.start.t + snote.duration_tied
duration_divs = offset_divs - onset_divs
Expand Down Expand Up @@ -323,6 +328,12 @@ def matchfile_from_alignment(

if fermata is not None:
score_attributes_list.append("fermata")

if isinstance(snote, score.GraceNote):
score_attributes_list.append("grace")

if diff_score_version_notes is not None and snote.id in diff_score_version_notes:
score_attributes_list.append("diff_score_version")

score_info[snote.id] = MatchSnote(
version=version,
Expand All @@ -346,6 +357,22 @@ def matchfile_from_alignment(
)
snote_sort_info[snote.id] = (onset_beats, snote.doc_order)

# # NOTE time position is hardcoded, not pretty... Assumes there is only one tempo indication at the beginning of the score
if tempo_indication is not None:
score_tempo_direction_header = make_scoreprop(
version=version,
attribute="tempoIndication",
value=MatchTempoIndication(
tempo_indication,
is_list=False,
),
measure=measure_starts[0][0],
beat=1,
offset=0,
time_in_beats=measure_starts[0][2],
)
scoreprop_lines["tempo_indication"].append(score_tempo_direction_header)

perf_info = dict()
pnote_sort_info = dict()
for pnote in ppart.notes:
Expand All @@ -372,6 +399,21 @@ def matchfile_from_alignment(

sort_stime = []
note_lines = []

# Get ids of notes which voice overlap
sna = spart.note_array()
onset_pitch_slice = sna[["onset_div", "pitch"]]
uniques, counts = np.unique(onset_pitch_slice, return_counts=True)
duplicate_values = uniques[counts > 1]
duplicates = dict()
for v in duplicate_values:
idx = np.where(onset_pitch_slice == v)[0]
duplicates[tuple(v)] = idx
voice_overlap_note_ids = []
if len(duplicates) > 0:
duplicate_idx = np.concatenate(np.array(list(duplicates.values()))).flatten()
voice_overlap_note_ids = list(sna[duplicate_idx]['id'])

for al_note in alignment:
label = al_note["label"]

Expand All @@ -384,6 +426,8 @@ def matchfile_from_alignment(

elif label == "deletion":
snote = score_info[al_note["score_id"]]
if al_note["score_id"] in voice_overlap_note_ids:
snote.ScoreAttributesList.append('voice_overlap')
deletion_line = MatchSnoteDeletion(version=version, snote=snote)
note_lines.append(deletion_line)
sort_stime.append(snote_sort_info[al_note["score_id"]])
Expand All @@ -407,7 +451,7 @@ def matchfile_from_alignment(

note_lines.append(ornament_line)
sort_stime.append(pnote_sort_info[al_note["performance_id"]])

# sort notes by score onset (performed insertions are sorted
# according to the interpolation map
sort_stime = np.array(sort_stime)
Expand Down Expand Up @@ -441,6 +485,7 @@ def matchfile_from_alignment(
"clock_rate",
"key_signatures",
"time_signatures",
"tempo_indication",
]
all_match_lines = []
for h in header_order:
Expand Down Expand Up @@ -537,7 +582,7 @@ def save_match(
else:
raise ValueError(
"`performance_data` should be a `Performance`, a `PerformedPart`, or a "
f"list of `PerformedPart` objects, but is {type(score_data)}"
f"list of `PerformedPart` objects, but is {type(performance_data)}"
)

# Get matchfile
Expand Down
12 changes: 9 additions & 3 deletions partitura/io/exportmidi.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections import defaultdict, OrderedDict
from typing import Optional, Iterable

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

import partitura.score as score
from partitura.score import Score, Part, PartGroup, ScoreLike
Expand Down Expand Up @@ -87,6 +87,7 @@ def save_performance_midi(
mpq: int = 500000,
ppq: int = 480,
default_velocity: int = 64,
merge_tracks_save: Optional[bool] = False,
) -> Optional[MidiFile]:
"""Save a :class:`~partitura.performance.PerformedPart` or
a :class:`~partitura.performance.Performance` as a MIDI file
Expand All @@ -107,6 +108,8 @@ def save_performance_midi(
default_velocity : int, optional
A default velocity value (between 0 and 127) to be used for
notes without a specified velocity. Defaults to 64.
merge_tracks_save : bool, optional
Determines whether midi tracks are merged when exporting to a midi file. Defaults to False.
Returns
-------
Expand Down Expand Up @@ -134,7 +137,6 @@ def save_performance_midi(
)

track_events = defaultdict(lambda: defaultdict(list))

for performed_part in performed_parts:
for c in performed_part.controls:
track = c.get("track", 0)
Expand Down Expand Up @@ -200,7 +202,7 @@ def save_performance_midi(
track_events[tr][min(timepoints)].append(
Message("program_change", program=0, channel=ch)
)

midi_type = 0 if len(track_events) == 1 else 1

mf = MidiFile(type=midi_type, ticks_per_beat=ppq)
Expand All @@ -217,6 +219,10 @@ def save_performance_midi(
track.append(msg.copy(time=t_delta))
t_delta = 0
t = t_msg

if merge_tracks_save and len(mf.tracks) > 1:
mf.tracks = [merge_tracks(mf.tracks)]

if out is not None:
if hasattr(out, "write"):
mf.save(file=out)
Expand Down
5 changes: 2 additions & 3 deletions partitura/io/importmidi.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,10 @@ def load_performance_midi(
# end note if it's a 'note off' event or 'note on' with velocity 0
elif note_off or (note_on and msg.velocity == 0):
if note not in sounding_notes:
warnings.warn("ignoring MIDI message %s" % msg)
warnings.warn(f"ignoring MIDI message {msg}")
continue

# append the note to the list associated with the channel

notes.append(
dict(
# id=f"n{len(notes)}",
Expand Down Expand Up @@ -218,7 +217,7 @@ def load_performance_midi(

# add note id to every note
for k, note in enumerate(notes):
note["id"] = f"n{k}"
note["id"] = f"n{k+1}"

if len(notes) > 0 or len(controls) > 0 or len(programs) > 0:
pp = performance.PerformedPart(
Expand Down
45 changes: 40 additions & 5 deletions partitura/io/importmusicxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@
"sustain_pedal": score.SustainPedalDirection,
}

TEMPO_DIRECTIONS = {
"Adagio": score.ConstantTempoDirection,
"Andante": score.ConstantTempoDirection,
"Andante amoroso": score.ConstantTempoDirection,
"Andante cantabile": score.ConstantTempoDirection,
"Andante grazioso": score.ConstantTempoDirection,
"Menuetto": score.ConstantTempoDirection,
"Allegretto grazioso": score.ConstantTempoDirection,
"Allegro moderato": score.ConstantTempoDirection,
"Allegro assai": score.ConstantTempoDirection,
"Allegro": score.ConstantTempoDirection,
"Allegretto": score.ConstantTempoDirection,
"Molto allegro": score.ConstantTempoDirection,
"Presto": score.ConstantTempoDirection,
}

OCTAVE_SHIFTS = {8: 1, 15: 2, 22: 3}


Expand Down Expand Up @@ -114,7 +130,6 @@ def _parse_partlist(partlist):
structure = []
current_group = None
part_dict = {}

for e in partlist:
if e.tag == "part-group":
if e.get("type") == "start":
Expand Down Expand Up @@ -146,7 +161,6 @@ def _parse_partlist(partlist):
part.part_abbreviation = next(
iter(e.xpath("part-abbreviation/text()")), None
)

part_dict[part_id] = part

if current_group is None:
Expand Down Expand Up @@ -239,11 +253,15 @@ def load_musicxml(

composer = None
scid = None
work_title = None
work_number = None
movement_title = None
movement_number = None
title = None
subtitle = None
lyricist = None
copyright = None

# The work tag is preferred for the title of the score, otherwise
# this method will search in the credit tags
work_info_el = document.find("work")
Expand All @@ -254,8 +272,20 @@ def load_musicxml(
tag="work-title",
as_type=str,
)
scidn = get_value_from_tag(
e=work_info_el,
tag="work-number",
as_type=str,
)
work_title = scid
work_number = scidn

title = scid
movement_title_el = document.find('.//movement-title')
movement_number_el = document.find('.//movement-number')
if movement_title_el is not None:
movement_title = movement_title_el.text
if movement_number_el is not None:
movement_number = movement_number_el.text

score_identification_el = document.find("identification")

Expand Down Expand Up @@ -289,6 +319,10 @@ def load_musicxml(
scr = score.Score(
id=scid,
partlist=partlist,
work_number=work_number,
work_title=work_title,
movement_number=movement_number,
movement_title=movement_title,
title=title,
subtitle=subtitle,
composer=composer,
Expand Down Expand Up @@ -880,6 +914,7 @@ def _handle_direction(e, position, part, ongoing):
warnings.warn("Did not find a wedge start element for wedge stop!")

elif dt.tag == "dashes":

# start/stop/continue
dashes_type = get_value_from_attribute(dt, "type", str)
number = get_value_from_attribute(dt, "number", int) or 1
Expand Down Expand Up @@ -1138,7 +1173,7 @@ def _handle_sound(e, position, part):
if "tempo" in e.attrib:
tempo = score.Tempo(int(e.attrib["tempo"]), "q")
# part.add_starting_object(position, tempo)
_add_tempo_if_unique(position, part, tempo)
(position, part, tempo)


def _handle_note(e, position, part, ongoing, prev_note, doc_order):
Expand Down
26 changes: 26 additions & 0 deletions partitura/io/matchfile_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,32 @@ def format_time_signature_list(value: MatchTimeSignature) -> str:
return str(value)


class MatchTempoIndication(MatchParameter):
def __init__(
self,
value: str,
is_list: bool = False,
):
super().__init__()
self.value = self.from_string(value)[0]
self.is_list = is_list

def __str__(self):
return self.value

@classmethod
def from_string(cls, string: str) -> MatchTempoIndication:
content = interpret_as_list(string)
return content

def interpret_as_tempo_indication(value: str) -> MatchTempoIndication:
tempo_indication = MatchTempoIndication.from_string(value)
return tempo_indication

def format_tempo_indication(value: MatchTempoIndication) -> str:
value.is_list = False
return str(value)

## Miscellaneous utils


Expand Down
Loading

0 comments on commit 4838419

Please sign in to comment.