Skip to content

Commit

Permalink
Merge pull request CPJKU#320 from CPJKU/develop
Browse files Browse the repository at this point in the history
PR for Release 1.4.0
  • Loading branch information
huispaty authored Sep 26, 2023
2 parents 81d1884 + fe4dbea commit ef67b95
Show file tree
Hide file tree
Showing 39 changed files with 14,248 additions and 433 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/partitura_unittests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
pip install -r requirements.txt
pip install .
- name: Install Optional dependencies
run: |
run: |
pip install music21==8.3.0 Pillow==9.5.0 musescore==0.0.1
pip install miditok==2.0.6 tokenizers==0.13.3
- name: Run Tests
Expand Down
27 changes: 27 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,33 @@
Release Notes
=============

Version 1.4.0 (Released on 2023-09-22)
--------------------------------------

New Features
------------
* new class for performed notes
* minimal unfolding for part
* updated Musescore parser for version 4
* `load_score` auto-selects parser based on file type
* new attributes for `Score` object for capturing meta information
* new score note attributes in matchfile export (`grace`, `voice_overlap`)
* new `tempo_indication` score property line in matchfile export

Bug Fixes
------------
* Fixed bug: #297
* Fixed bug: #304
* Fixed bug: #306
* Fixed bug: #308
* Fixed bug: #310
* Fixed bug: #315

Other Changes
------------
* new unit test for cross-staff beaming for musicxml


Version 1.3.1 (Released on 2023-07-06)
--------------------------------------

Expand Down
4 changes: 2 additions & 2 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@
# built documents.
#
# The short X.Y version.
version = "1.3.1" # pkg_resources.get_distribution("partitura").version
version = "1.4.0" # pkg_resources.get_distribution("partitura").version
# The full version, including alpha/beta/rc tags.
release = "1.3.1"
release = "1.4.0"

# # The full version, including alpha/beta/rc tags
# release = pkg_resources.get_distribution("partitura").version
Expand Down
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
76 changes: 41 additions & 35 deletions partitura/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
This module contains methods for importing and exporting symbolic music formats.
"""
from typing import Union
import os

from .importmusicxml import load_musicxml
from .importmidi import load_score_midi, load_performance_midi
Expand Down Expand Up @@ -35,7 +36,7 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
"""
Load a score format supported by partitura. Currently the accepted formats
are MusicXML, MIDI, Kern and MEI, plus all formats for which
MuseScore has support import-support (requires MuseScore 3).
MuseScore has support import-support (requires MuseScore 4 or 3).
Parameters
----------
Expand All @@ -54,20 +55,16 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
scr: :class:`partitura.score.Score`
A score instance.
"""
part = None

# Catch exceptions
exception_dictionary = dict()
# Load MusicXML
try:
extension = os.path.splitext(filename)[-1].lower()
if extension in (".mxl", ".xml", ".musicxml"):
# Load MusicXML
return load_musicxml(
filename=filename,
force_note_ids=force_note_ids,
)
except Exception as e:
exception_dictionary["MusicXML"] = e
# Load MIDI
try:
elif extension in [".midi", ".mid"]:
# Load MIDI
if (force_note_ids is None) or (not force_note_ids):
assign_note_ids = False
else:
Expand All @@ -76,44 +73,53 @@ def load_score(filename: PathLike, force_note_ids="keep") -> Score:
filename=filename,
assign_note_ids=assign_note_ids,
)
except Exception as e:
exception_dictionary["MIDI"] = e
# Load MEI
try:
elif extension in [".mei"]:
# Load MEI
return load_mei(filename=filename)
except Exception as e:
exception_dictionary["MEI"] = e
# Load Kern
try:
elif extension in [".kern", ".krn"]:
return load_kern(
filename=filename,
force_note_ids=force_note_ids,
)
except Exception as e:
exception_dictionary["Kern"] = e
# Load MuseScore
try:
elif extension in [
".mscz",
".mscx",
".musescore",
".mscore",
".ms",
".kar",
".md",
".cap",
".capx",
".bww",
".mgu",
".sgu",
".ove",
".scw",
".ptb",
".gtp",
".gp3",
".gp4",
".gp5",
".gpx",
".gp",
]:
# Load MuseScore
return load_via_musescore(
filename=filename,
force_note_ids=force_note_ids,
)
except Exception as e:
exception_dictionary["MuseScore"] = e
try:
elif extension in [".match"]:
# Load the score information from a Matchfile
_, _, part = load_match(
_, _, score = load_match(
filename=filename,
create_score=True,
)

except Exception as e:
exception_dictionary["matchfile"] = e
if part is None:
for score_format, exception in exception_dictionary.items():
print(f"Error loading score as {score_format}:")
print(exception)

raise NotSupportedFormatError
return score
else:
raise NotSupportedFormatError(
f"{extension} file extension is not supported. If this should be supported, consider editing partitura/io/__init__.py file"
)


def load_score_as_part(filename: PathLike) -> Part:
Expand Down
54 changes: 51 additions & 3 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 @@ -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 @@ -324,6 +329,15 @@ 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,
anchor=str(snote.id),
Expand All @@ -346,6 +360,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 +402,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 +429,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 Down Expand Up @@ -441,6 +488,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 +585,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
10 changes: 8 additions & 2 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 @@ -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
Loading

0 comments on commit ef67b95

Please sign in to comment.