Skip to content

Commit

Permalink
Merge pull request #397 from CPJKU/cross_staff_voices
Browse files Browse the repository at this point in the history
Cross staff voices and MEI import update
  • Loading branch information
manoskary authored Nov 5, 2024
2 parents 37811e0 + 471e65f commit 94b0566
Show file tree
Hide file tree
Showing 10 changed files with 1,457 additions and 101 deletions.
81 changes: 47 additions & 34 deletions partitura/assets/score_example.mei
Original file line number Diff line number Diff line change
@@ -1,52 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<?xml-model href="https://music-encoding.org/schema/4.0.0/mei-all.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="https://music-encoding.org/schema/4.0.0/mei-all.rng" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="4.0.0">
<meiHead>
<fileDesc>
<titleStmt>
<?xml version="1.0" encoding="UTF-8"?>
<?xml-model href="https://music-encoding.org/schema/5.0/mei-all.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="https://music-encoding.org/schema/5.0/mei-all.rng" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">
<meiHead xml:id="m21agkk">
<fileDesc xml:id="f1x9ud6p">
<titleStmt xml:id="t1pe4slb">
<title />
<respStmt />
</titleStmt>
<pubStmt></pubStmt>
<pubStmt xml:id="p1kxcrok">
<date isodate="2024-10-30" type="encoding-date">2024-10-30</date>
</pubStmt>
</fileDesc>
<encodingDesc xml:id="encodingdesc-2o9bqo">
<appInfo xml:id="appinfo-j7rtco">
<application xml:id="application-hc9py4" isodate="2021-12-09T11:27:15" version="3.8.0-dev-45c3f2c">
<name xml:id="name-symba1">Verovio</name>
<p xml:id="p-roup2t">Transcoded from MusicXML</p>
<encodingDesc xml:id="e1xr2zpp">
<appInfo xml:id="a1wyxc5n">
<application xml:id="a1t43ge2" isodate="2024-10-30T10:56:32" version="4.3.1-3b8cc17">
<name xml:id="n1cp60l4">Verovio</name>
<p xml:id="p13nndma">Transcoded from MusicXML</p>
</application>
</appInfo>
</encodingDesc>
</meiHead>
<music>
<body>
<mdiv xml:id="mhblkrl">
<score xml:id="ssc72wy">
<scoreDef xml:id="s3uaoz5">
<staffGrp xml:id="sjczhy0">
<staffDef xml:id="P1" n="1" lines="5" ppq="12">
<label xml:id="lezfcog">Piano</label>
<meterSig xml:id="mhw0sp2" count="4" unit="4" />
</staffDef>
<mdiv xml:id="muo97v6">
<score xml:id="suneqlv">
<scoreDef xml:id="sz00r05">
<staffGrp xml:id="s1h35kps">
<staffGrp xml:id="P1" bar.thru="true">
<grpSym xml:id="g1eji31e" symbol="brace" />
<label xml:id="l8lirjj">Piano</label>
<instrDef xml:id="iggd40h" midi.channel="0" midi.instrnum="0" midi.volume="78.00%" />
<staffDef xml:id="sbpks8p" n="1" lines="5" ppq="1">
<clef xml:id="c1p7nrnw" shape="G" line="2" />
<keySig xml:id="ki5anqv" sig="0" />
<meterSig xml:id="m1vpw2w2" count="4" unit="4" />
</staffDef>
<staffDef xml:id="s1g416nz" n="2" lines="5" ppq="1">
<clef xml:id="cezkswz" shape="G" line="2" />
<keySig xml:id="kk41xfz" sig="0" />
<meterSig xml:id="m7alo7s" count="4" unit="4" />
</staffDef>
</staffGrp>
</staffGrp>
</scoreDef>
<sb xml:id="st1gphw" />
<section xml:id="swgpvx8">
<pb xml:id="paw6v6b" />
<measure xml:id="mz87quy" n="1">
<staff xml:id="sxxu2aq" n="1">
<layer xml:id="llktcv2" n="1">
<note xml:id="n01" dur.ppq="48" dur="1" staff="2" oct="4" pname="a" />
</layer>
<layer xml:id="lgap59p" n="2">
<rest xml:id="r01" dur.ppq="24" dur="2" />
<chord xml:id="carc8ao" dur.ppq="24" dur="2">
<note xml:id="n02" oct="5" pname="c" />
<note xml:id="n03" oct="5" pname="e" />
<section xml:id="suu4o7p">
<measure xml:id="m1e358qx" n="1">
<staff xml:id="s1ufvigy" n="1">
<layer xml:id="l1r7cvga" n="1">
<rest xml:id="ro1o7cb" dur.ppq="2" dur="2" />
<chord xml:id="c1c1r6b7" dur.ppq="2" dur="2" stem.dir="down">
<note xml:id="n6dpu2p" oct="5" pname="c" />
<note xml:id="njfgcwp" oct="5" pname="e" />
</chord>
</layer>
</staff>
<staff xml:id="s710zw2" n="2">
<layer xml:id="leffrs2" n="5">
<note xml:id="n1txt37q" dur.ppq="4" dur="1" oct="4" pname="a" />
</layer>
</staff>
</measure>
</section>
</score>
Expand Down
52 changes: 40 additions & 12 deletions partitura/io/importmei.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
This module contains methods for importing MEI files.
"""
import os
from collections import OrderedDict
from lxml import etree
from fractions import Fraction
Expand Down Expand Up @@ -64,6 +65,9 @@ def load_mei(filename: PathLike) -> score.Score:

class MeiParser(object):
def __init__(self, mei_path: PathLike) -> None:
# check if the file exists. Verovio won't complain if it doesn't
if not os.path.exists(mei_path):
raise FileNotFoundError(f"File {mei_path} not found.")
document, ns = self._parse_mei(mei_path, use_verovio=VEROVIO_AVAILABLE)
self.document = document
self.ns = ns # the namespace in the MEI file
Expand Down Expand Up @@ -322,11 +326,9 @@ def _handle_clef(self, element, position, part):
# find the staff number
parent = element.getparent()
if parent.tag == self._ns_name("staffDef"):
# number = parent.attrib["n"]
number = 1
number = parent.attrib.get("n", 1)
else: # go back another level to staff element
# number = parent.getparent().attrib["n"]
number = 1
number = parent.getparent().attrib.get("n", 1)
sign = element.attrib["shape"]
line = element.attrib["line"]
octave = self._compute_clef_octave(
Expand Down Expand Up @@ -641,6 +643,10 @@ def _handle_note(self, note_el, position, voice, staff, part) -> int:
note_id, duration, symbolic_duration = self._duration_info(note_el, part)
# find if it's grace
grace_attr = note_el.get("grace")
# find if it has a different staff specification (for staff crossings)
different_staff = note_el.get("staff")
if different_staff is not None:
staff = int(different_staff)
if grace_attr is None:
# create normal note
note = score.Note(
Expand All @@ -649,7 +655,7 @@ def _handle_note(self, note_el, position, voice, staff, part) -> int:
alter=alter,
id=note_id,
voice=voice,
staff=1,
staff=staff,
symbolic_duration=symbolic_duration,
articulations=None, # TODO : add articulation
)
Expand All @@ -668,7 +674,7 @@ def _handle_note(self, note_el, position, voice, staff, part) -> int:
alter=alter,
id=note_id,
voice=voice,
staff=1,
staff=staff,
symbolic_duration=symbolic_duration,
articulations=None, # TODO : add articulation
)
Expand Down Expand Up @@ -702,11 +708,15 @@ def _handle_rest(self, rest_el, position, voice, staff, part):
"""
# find duration info
rest_id, duration, symbolic_duration = self._duration_info(rest_el, part)
# find if it has a different staff specification (for staff crossings)
different_staff = rest_el.get("staff")
if different_staff is not None:
staff = int(different_staff)
# create rest
rest = score.Rest(
id=rest_id,
voice=voice,
staff=1,
staff=staff,
symbolic_duration=symbolic_duration,
articulations=None,
)
Expand Down Expand Up @@ -744,12 +754,16 @@ def _handle_mrest(self, mrest_el, position, voice, staff, part):
# find divs per measure
ppq = part.quarter_duration_map(position)
parts_per_measure = int(ppq * 4 * last_ts.beats / last_ts.beat_type)
# find if it has a different staff specification (for staff crossings)
different_staff = mrest_el.get("staff")
if different_staff is not None:
staff = int(different_staff)

# create dummy rest to insert in the timeline
rest = score.Rest(
id=mrest_id,
voice=voice,
staff=1,
staff=staff,
symbolic_duration=estimate_symbolic_duration(parts_per_measure, ppq),
articulations=None,
)
Expand Down Expand Up @@ -793,12 +807,16 @@ def _handle_multirest(self, multirest_el, position, voice, staff, part):
# find divs per measure
ppq = part.quarter_duration_map(position)
parts_per_measure = int(ppq * 4 * last_ts.beats / last_ts.beat_type)
# find if it has a different staff specification (for staff crossings)
different_staff = multirest_el.get("staff")
if different_staff is not None:
staff = int(different_staff)

# create dummy rest to insert in the timeline
rest = score.Rest(
id=multirest_id,
voice=voice,
staff=1,
staff=staff,
symbolic_duration=estimate_symbolic_duration(parts_per_measure, ppq),
articulations=None,
)
Expand Down Expand Up @@ -832,20 +850,30 @@ def _handle_chord(self, chord_el, position, voice, staff, part):
"""
# find duration info
chord_id, duration, symbolic_duration = self._duration_info(chord_el, part)
# find if the entire chord has a different staff specification (for staff crossings)
different_staff = chord_el.get("staff")
if different_staff is not None:
staff = int(different_staff)
# find notes info
notes_el = chord_el.findall(self._ns_name("note"))
for note_el in notes_el:
note_id = note_el.attrib[self._ns_name("id", XML_NAMESPACE)]
# find pitch info
step, octave, alter = self._pitch_info(note_el)
# find if single notes have a different staff specification
different_staff = note_el.get("staff")
if different_staff is not None:
note_staff = int(different_staff)
else:
note_staff = staff
# create note
note = score.Note(
step=step,
octave=octave,
alter=alter,
id=note_id,
voice=voice,
staff=1,
staff=note_staff,
symbolic_duration=symbolic_duration,
articulations=None, # TODO : add articulation
)
Expand Down Expand Up @@ -982,7 +1010,7 @@ def _handle_staff_in_measure(
for i_layer, layer_el in enumerate(layers_el):
end_positions.append(
self._handle_layer_in_staff_in_measure(
layer_el, i_layer + 1, staff_ind, position, part
layer_el, int(layer_el.attrib.get("n", i_layer+1)), staff_ind, position, part
)
)
# check if layers have equal duration (bad encoding, but it often happens)
Expand Down Expand Up @@ -1063,7 +1091,7 @@ def _handle_section(self, section_el, parts, position: int, measure_number: int)
for i_s, (part, staff_el) in enumerate(zip(parts, staves_el)):
end_positions.append(
self._handle_staff_in_measure(
staff_el, i_s + 1, position, part, measure_number
staff_el, int(staff_el.attrib.get("n", i_s + 1)), position, part, measure_number
)
)
# handle directives (dir elements)
Expand Down
63 changes: 38 additions & 25 deletions partitura/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -5436,6 +5436,10 @@ def merge_parts(parts, reassign="voice"):
If "voice", the new part have only one staff, and as manually
voices as the sum of the voices in parts; the voice number
get reassigned.
If "both", we reassign all the staves and voices to have unique staff
and voice numbers. According to musicxml standards, we consider 4 voices
per staff. So for example staff 1 will have voices 1,2,3,4, staff 2 will
have voices 5,6,7,8, and so on.
Returns
-------
Expand All @@ -5444,7 +5448,7 @@ def merge_parts(parts, reassign="voice"):
"""
# check if reassign has valid values
if reassign not in ["staff", "voice"]:
if reassign not in ["staff", "voice","auto"]:
raise ValueError(
"Only 'staff' and 'voice' are supported ressign values. Found", reassign
)
Expand Down Expand Up @@ -5482,26 +5486,20 @@ def merge_parts(parts, reassign="voice"):
new_part._quarter_durations = [lcm]

note_arrays = [part.note_array(include_staff=True) for part in parts]
# find the maximum number of voices for each part (voice number start from 1)
maximum_voices = [
(
max(note_array["voice"], default=0)
if max(note_array["voice"], default=0) != 0
else 1
)
for note_array in note_arrays
# find the unique number of voices for each part (voice numbers start from 1)
unique_voices = [
np.unique(note_array["voice"]) for note_array in note_arrays
]
# find the maximum number of staves for each part (staff number start from 0 but we force them to 1)
maximum_staves = [
(
max(note_array["staff"], default=0)
if max(note_array["staff"], default=0) != 0
else 1
)
for note_array in note_arrays
# find the unique number of staves for each part
unique_staves = [
np.unique(note_array["staff"]) for note_array in note_arrays
]
# find the maximum number of voices for each part (voice numbers start from 1)
maximum_voices = [max(unique_voice, default=1) for unique_voice in unique_voices]
# find the maximum number of staves for each part
maximum_staves = [max(unique_staff, default=1) for unique_staff in unique_staves]

if reassign == "staff":
if reassign in ["staff","auto"]:
el_to_discard = (
Barline,
Page,
Expand Down Expand Up @@ -5532,6 +5530,20 @@ def merge_parts(parts, reassign="voice"):
)

for p_ind, p in enumerate(parts):
if reassign == "auto":
# find how many staves this part has
n_staves = len(unique_staves[p_ind])
# find total number of staves in previous parts
if p_ind == 0:
n_previous_staves = 0
else:
n_previous_staves = sum([len(unique_staff) for unique_staff in unique_staves[:p_ind]])
# build a mapping between the old staff numbers and the new staff numbers
staff_mapping = dict(zip(unique_staves[p_ind], n_previous_staves+ np.arange(1, n_staves + 1)))
# find how many voices this part has
n_voices = len(unique_voices[p_ind])
# build a mapping between the old and new voices
voice_mapping = dict(zip(unique_voices[p_ind], n_previous_staves*4 + np.arange(1, n_voices + 1)))
for e in p.iter_all():
# full copy the first part and partially copy the others
# we don't copy elements like duplicate barlines, clefs or
Expand All @@ -5551,16 +5563,17 @@ def merge_parts(parts, reassign="voice"):
if isinstance(e, GenericNote):
e.voice = e.voice + sum(maximum_voices[:p_ind])
elif reassign == "staff":
if isinstance(e, (GenericNote, Words, Direction)):
e.staff = (e.staff if e.staff is not None else 1) + sum(
maximum_staves[:p_ind]
)
elif isinstance(
e, Clef
): # TODO: to update if "number" get changed in "staff"
if isinstance(e, (GenericNote, Words, Direction, Clef)):
e.staff = (e.staff if e.staff is not None else 1) + sum(
maximum_staves[:p_ind]
)
elif reassign == "auto":
# assign based on the voice and staff mappings
if isinstance(e, GenericNote):
# new voice is computed as the sum of voices in staves in previous parts, plus the current
e.voice = voice_mapping[e.voice]
if isinstance(e, (GenericNote, Words, Direction,Clef)):
e.staff = staff_mapping[e.staff]
new_part.add(e, start=new_start, end=new_end)

# new_part.add(copy.deepcopy(e), start=new_start, end=new_end)
Expand Down
13 changes: 11 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,11 @@
"test_single_part_change_divs.xml",
"test_merge_voices1.xml",
"test_merge_voices2.xml",
]
]
]] + [
os.path.join(MEI_PATH, fn)
for fn in [
"test_merge_voices2.mei",
]]

PIANOROLL_TESTFILES = [
os.path.join(MUSICXML_PATH, fn)
Expand Down Expand Up @@ -256,3 +259,9 @@
CLEF_TESTFILES = [
os.path.join(DATA_PATH, "musicxml", "test_clef.musicxml")
]

CROSS_STAFF_TESTFILES = [
os.path.join(DATA_PATH, "musicxml", "test_cross_staff_beaming.musicxml"),
os.path.join(DATA_PATH, MUSICXML_PATH, "test_cross_staff_voices.musicxml"),
os.path.join(DATA_PATH, MEI_PATH, "test_cross_staff_voices.mei"),
]
Loading

0 comments on commit 94b0566

Please sign in to comment.