Skip to content

Commit ef67b95

Browse files
authored
Merge pull request #320 from CPJKU/develop
PR for Release 1.4.0
2 parents 81d1884 + fe4dbea commit ef67b95

39 files changed

+14248
-433
lines changed

.github/workflows/partitura_unittests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
pip install -r requirements.txt
2727
pip install .
2828
- name: Install Optional dependencies
29-
run: |
29+
run: |
3030
pip install music21==8.3.0 Pillow==9.5.0 musescore==0.0.1
3131
pip install miditok==2.0.6 tokenizers==0.13.3
3232
- name: Run Tests

CHANGES.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
Release Notes
22
=============
33

4+
Version 1.4.0 (Released on 2023-09-22)
5+
--------------------------------------
6+
7+
New Features
8+
------------
9+
* new class for performed notes
10+
* minimal unfolding for part
11+
* updated Musescore parser for version 4
12+
* `load_score` auto-selects parser based on file type
13+
* new attributes for `Score` object for capturing meta information
14+
* new score note attributes in matchfile export (`grace`, `voice_overlap`)
15+
* new `tempo_indication` score property line in matchfile export
16+
17+
Bug Fixes
18+
------------
19+
* Fixed bug: #297
20+
* Fixed bug: #304
21+
* Fixed bug: #306
22+
* Fixed bug: #308
23+
* Fixed bug: #310
24+
* Fixed bug: #315
25+
26+
Other Changes
27+
------------
28+
* new unit test for cross-staff beaming for musicxml
29+
30+
431
Version 1.3.1 (Released on 2023-07-06)
532
--------------------------------------
633

docs/source/conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@
2929
# built documents.
3030
#
3131
# The short X.Y version.
32-
version = "1.3.1" # pkg_resources.get_distribution("partitura").version
32+
version = "1.4.0" # pkg_resources.get_distribution("partitura").version
3333
# The full version, including alpha/beta/rc tags.
34-
release = "1.3.1"
34+
release = "1.4.0"
3535

3636
# # The full version, including alpha/beta/rc tags
3737
# release = pkg_resources.get_distribution("partitura").version

partitura/directions.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ def unabbreviate(s):
151151
"adagio",
152152
"agitato",
153153
"andante",
154+
"andante cantabile",
155+
"andante amoroso",
154156
"andantino",
155157
"animato",
156158
"appassionato",
@@ -193,6 +195,7 @@ def unabbreviate(s):
193195
"tranquilamente",
194196
"tranquilo",
195197
"recitativo",
198+
"allegro moderato",
196199
r"/(vivo|vivacissimamente|vivace)/",
197200
r"/(allegro|allegretto)/",
198201
r"/(espressivo|espress\.?)/",

partitura/io/__init__.py

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
This module contains methods for importing and exporting symbolic music formats.
55
"""
66
from typing import Union
7+
import os
78

89
from .importmusicxml import load_musicxml
910
from .importmidi import load_score_midi, load_performance_midi
@@ -35,7 +36,7 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
3536
"""
3637
Load a score format supported by partitura. Currently the accepted formats
3738
are MusicXML, MIDI, Kern and MEI, plus all formats for which
38-
MuseScore has support import-support (requires MuseScore 3).
39+
MuseScore has support import-support (requires MuseScore 4 or 3).
3940
4041
Parameters
4142
----------
@@ -54,20 +55,16 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
5455
scr: :class:`partitura.score.Score`
5556
A score instance.
5657
"""
57-
part = None
5858

59-
# Catch exceptions
60-
exception_dictionary = dict()
61-
# Load MusicXML
62-
try:
59+
extension = os.path.splitext(filename)[-1].lower()
60+
if extension in (".mxl", ".xml", ".musicxml"):
61+
# Load MusicXML
6362
return load_musicxml(
6463
filename=filename,
6564
force_note_ids=force_note_ids,
6665
)
67-
except Exception as e:
68-
exception_dictionary["MusicXML"] = e
69-
# Load MIDI
70-
try:
66+
elif extension in [".midi", ".mid"]:
67+
# Load MIDI
7168
if (force_note_ids is None) or (not force_note_ids):
7269
assign_note_ids = False
7370
else:
@@ -76,44 +73,53 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
7673
filename=filename,
7774
assign_note_ids=assign_note_ids,
7875
)
79-
except Exception as e:
80-
exception_dictionary["MIDI"] = e
81-
# Load MEI
82-
try:
76+
elif extension in [".mei"]:
77+
# Load MEI
8378
return load_mei(filename=filename)
84-
except Exception as e:
85-
exception_dictionary["MEI"] = e
86-
# Load Kern
87-
try:
79+
elif extension in [".kern", ".krn"]:
8880
return load_kern(
8981
filename=filename,
9082
force_note_ids=force_note_ids,
9183
)
92-
except Exception as e:
93-
exception_dictionary["Kern"] = e
94-
# Load MuseScore
95-
try:
84+
elif extension in [
85+
".mscz",
86+
".mscx",
87+
".musescore",
88+
".mscore",
89+
".ms",
90+
".kar",
91+
".md",
92+
".cap",
93+
".capx",
94+
".bww",
95+
".mgu",
96+
".sgu",
97+
".ove",
98+
".scw",
99+
".ptb",
100+
".gtp",
101+
".gp3",
102+
".gp4",
103+
".gp5",
104+
".gpx",
105+
".gp",
106+
]:
107+
# Load MuseScore
96108
return load_via_musescore(
97109
filename=filename,
98110
force_note_ids=force_note_ids,
99111
)
100-
except Exception as e:
101-
exception_dictionary["MuseScore"] = e
102-
try:
112+
elif extension in [".match"]:
103113
# Load the score information from a Matchfile
104-
_, _, part = load_match(
114+
_, _, score = load_match(
105115
filename=filename,
106116
create_score=True,
107117
)
108-
109-
except Exception as e:
110-
exception_dictionary["matchfile"] = e
111-
if part is None:
112-
for score_format, exception in exception_dictionary.items():
113-
print(f"Error loading score as {score_format}:")
114-
print(exception)
115-
116-
raise NotSupportedFormatError
118+
return score
119+
else:
120+
raise NotSupportedFormatError(
121+
f"{extension} file extension is not supported. If this should be supported, consider editing partitura/io/__init__.py file"
122+
)
117123

118124

119125
def load_score_as_part(filename: PathLike) -> Part:

partitura/io/exportmatch.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
FractionalSymbolicDuration,
3838
MatchKeySignature,
3939
MatchTimeSignature,
40+
MatchTempoIndication,
4041
Version,
4142
)
4243

@@ -71,6 +72,8 @@ def matchfile_from_alignment(
7172
score_filename: Optional[PathLike] = None,
7273
performance_filename: Optional[PathLike] = None,
7374
assume_part_unfolded: bool = False,
75+
tempo_indication: Optional[str] = None,
76+
diff_score_version_notes: Optional[list] = None,
7477
version: Version = LATEST_VERSION,
7578
debug: bool = False,
7679
) -> MatchFile:
@@ -106,6 +109,10 @@ def matchfile_from_alignment(
106109
repetitions in the alignment. If False, the part will be automatically
107110
unfolded to have maximal coverage of the notes in the alignment.
108111
See `partitura.score.unfold_part_alignment`.
112+
tempo_indication : str or None
113+
The tempo direction indicated in the beginning of the score
114+
diff_score_version_notes : list or None
115+
A list of score notes that reflect a special score version (e.g., original edition/Erstdruck, Editors note etc.)
109116
version: Version
110117
Version of the match file. For now only 1.0.0 is supported.
111118
Returns
@@ -199,7 +206,6 @@ def matchfile_from_alignment(
199206

200207
# Score prop header lines
201208
scoreprop_lines = defaultdict(list)
202-
203209
# For score notes
204210
score_info = dict()
205211
# Info for sorting lines
@@ -276,7 +282,6 @@ def matchfile_from_alignment(
276282
# Get all notes in the measure
277283
snotes = spart.iter_all(score.Note, m.start, m.end, include_subclasses=True)
278284
# Beginning of each measure
279-
280285
for snote in snotes:
281286
onset_divs, offset_divs = snote.start.t, snote.start.t + snote.duration_tied
282287
duration_divs = offset_divs - onset_divs
@@ -324,6 +329,15 @@ def matchfile_from_alignment(
324329
if fermata is not None:
325330
score_attributes_list.append("fermata")
326331

332+
if isinstance(snote, score.GraceNote):
333+
score_attributes_list.append("grace")
334+
335+
if (
336+
diff_score_version_notes is not None
337+
and snote.id in diff_score_version_notes
338+
):
339+
score_attributes_list.append("diff_score_version")
340+
327341
score_info[snote.id] = MatchSnote(
328342
version=version,
329343
anchor=str(snote.id),
@@ -346,6 +360,22 @@ def matchfile_from_alignment(
346360
)
347361
snote_sort_info[snote.id] = (onset_beats, snote.doc_order)
348362

363+
# # NOTE time position is hardcoded, not pretty... Assumes there is only one tempo indication at the beginning of the score
364+
if tempo_indication is not None:
365+
score_tempo_direction_header = make_scoreprop(
366+
version=version,
367+
attribute="tempoIndication",
368+
value=MatchTempoIndication(
369+
tempo_indication,
370+
is_list=False,
371+
),
372+
measure=measure_starts[0][0],
373+
beat=1,
374+
offset=0,
375+
time_in_beats=measure_starts[0][2],
376+
)
377+
scoreprop_lines["tempo_indication"].append(score_tempo_direction_header)
378+
349379
perf_info = dict()
350380
pnote_sort_info = dict()
351381
for pnote in ppart.notes:
@@ -372,6 +402,21 @@ def matchfile_from_alignment(
372402

373403
sort_stime = []
374404
note_lines = []
405+
406+
# Get ids of notes which voice overlap
407+
sna = spart.note_array()
408+
onset_pitch_slice = sna[["onset_div", "pitch"]]
409+
uniques, counts = np.unique(onset_pitch_slice, return_counts=True)
410+
duplicate_values = uniques[counts > 1]
411+
duplicates = dict()
412+
for v in duplicate_values:
413+
idx = np.where(onset_pitch_slice == v)[0]
414+
duplicates[tuple(v)] = idx
415+
voice_overlap_note_ids = []
416+
if len(duplicates) > 0:
417+
duplicate_idx = np.concatenate(np.array(list(duplicates.values()))).flatten()
418+
voice_overlap_note_ids = list(sna[duplicate_idx]["id"])
419+
375420
for al_note in alignment:
376421
label = al_note["label"]
377422

@@ -384,6 +429,8 @@ def matchfile_from_alignment(
384429

385430
elif label == "deletion":
386431
snote = score_info[al_note["score_id"]]
432+
if al_note["score_id"] in voice_overlap_note_ids:
433+
snote.ScoreAttributesList.append("voice_overlap")
387434
deletion_line = MatchSnoteDeletion(version=version, snote=snote)
388435
note_lines.append(deletion_line)
389436
sort_stime.append(snote_sort_info[al_note["score_id"]])
@@ -441,6 +488,7 @@ def matchfile_from_alignment(
441488
"clock_rate",
442489
"key_signatures",
443490
"time_signatures",
491+
"tempo_indication",
444492
]
445493
all_match_lines = []
446494
for h in header_order:
@@ -537,7 +585,7 @@ def save_match(
537585
else:
538586
raise ValueError(
539587
"`performance_data` should be a `Performance`, a `PerformedPart`, or a "
540-
f"list of `PerformedPart` objects, but is {type(score_data)}"
588+
f"list of `PerformedPart` objects, but is {type(performance_data)}"
541589
)
542590

543591
# Get matchfile

partitura/io/exportmidi.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections import defaultdict, OrderedDict
99
from typing import Optional, Iterable
1010

11-
from mido import MidiFile, MidiTrack, Message, MetaMessage
11+
from mido import MidiFile, MidiTrack, Message, MetaMessage, merge_tracks
1212

1313
import partitura.score as score
1414
from partitura.score import Score, Part, PartGroup, ScoreLike
@@ -87,6 +87,7 @@ def save_performance_midi(
8787
mpq: int = 500000,
8888
ppq: int = 480,
8989
default_velocity: int = 64,
90+
merge_tracks_save: Optional[bool] = False,
9091
) -> Optional[MidiFile]:
9192
"""Save a :class:`~partitura.performance.PerformedPart` or
9293
a :class:`~partitura.performance.Performance` as a MIDI file
@@ -107,6 +108,8 @@ def save_performance_midi(
107108
default_velocity : int, optional
108109
A default velocity value (between 0 and 127) to be used for
109110
notes without a specified velocity. Defaults to 64.
111+
merge_tracks_save : bool, optional
112+
Determines whether midi tracks are merged when exporting to a midi file. Defaults to False.
110113
111114
Returns
112115
-------
@@ -134,7 +137,6 @@ def save_performance_midi(
134137
)
135138

136139
track_events = defaultdict(lambda: defaultdict(list))
137-
138140
for performed_part in performed_parts:
139141
for c in performed_part.controls:
140142
track = c.get("track", 0)
@@ -217,6 +219,10 @@ def save_performance_midi(
217219
track.append(msg.copy(time=t_delta))
218220
t_delta = 0
219221
t = t_msg
222+
223+
if merge_tracks_save and len(mf.tracks) > 1:
224+
mf.tracks = [merge_tracks(mf.tracks)]
225+
220226
if out is not None:
221227
if hasattr(out, "write"):
222228
mf.save(file=out)

0 commit comments

Comments
 (0)