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

new score attributes in match export, new score properties, misc fixes #303

Merged
merged 10 commits into from
Sep 22, 2023
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
53 changes: 48 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,19 @@ def matchfile_from_alignment(

sort_stime = []
note_lines = []

# Get ids of notes which voice overlap
spart_array = np.array([list(row) for row in spart.note_array()])
onset_pitch_slice = [tuple(i) for i in list(spart_array[:, [0, 6]])]
huispaty marked this conversation as resolved.
Show resolved Hide resolved

onset_pitch_dict = defaultdict(list)
for index, tup in enumerate(onset_pitch_slice):
onset_pitch_dict[tup].append(index)
duplicates = {key: indices for key, indices in onset_pitch_dict.items() if len(indices) > 1}

voice_overlap_note_ids = [spart_array[idx][:,-1] for k, idx in duplicates.items()]
voice_overlap_note_ids = [i for sub in voice_overlap_note_ids for i in sub]

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

Expand All @@ -384,6 +424,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 +449,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 +483,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 +580,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 @@ -879,6 +913,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 @@ -1137,7 +1172,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
41 changes: 41 additions & 0 deletions partitura/io/matchfile_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,47 @@ 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:

# Note particularities of the BpM dataset....
if string is not None:
if 'Rond' in string:
content = string.split(' ')
content = [content[-1]]
elif 'Alla' in string: # for kv331_3
CarlosCancino-Chacon marked this conversation as resolved.
Show resolved Hide resolved
content = ['Allegretto']
elif 'Menuetto' in string:
content = ['Menuetto']
else:
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