Skip to content

Commit e3bd068

Browse files
committed
musicxml: fix chord names combined with other features
- a note can carry several chord names (a chord change during a long note was dropped), a note split over a barline does not repeat them, and chord name offsets count for the divisions - \transpose also transposes the root and bass of chord names Add tests that combine features, and remove unused xfail markers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CMyxTwm2w6VtbxBdt5dL1T
1 parent eaf59ed commit e3bd068

7 files changed

Lines changed: 145 additions & 27 deletions

‎ly/musicxml/ly2xml_mediator.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -794,15 +794,17 @@ def new_harmony(self, note, chord):
794794
"""A note in chord mode, written as a chord name (harmony) on an
795795
invisible rest."""
796796
kind, degrees = chord.kind()
797+
root = self.transpose(chord.root)
797798
bass = chord.bass_pitch()
798799
if bass:
800+
bass = self.transpose(bass)
799801
bass = (getNoteName(bass.note), get_xml_alter(bass.alter))
800802
self.current_is_rest = True
801803
self.clear_chord()
802804
self.current_note = xml_objs.BarRest(note.duration, self.voice, invisible=True)
803-
self.current_note.harmony = xml_objs.Harmony(
804-
getNoteName(chord.root.note), get_xml_alter(chord.root.alter),
805-
kind, bass, degrees)
805+
self.current_note.harmonies = [xml_objs.Harmony(
806+
getNoteName(root.note), get_xml_alter(root.alter),
807+
kind, bass, degrees)]
806808
self.check_current_note(rest=True)
807809

808810
def copy_prev_chord(self, duration):

‎ly/musicxml/xml_objs.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,7 @@ def new_xml_bar_attr(self, obj):
152152

153153
def before_note(self, obj):
154154
"""Xml-nodes before note."""
155-
if obj.harmony:
156-
h = obj.harmony
155+
for h in obj.harmonies:
157156
offset = self.count_duration(h.offset)
158157
self.musxml.add_harmony(h.root, h.root_alter, h.kind, h.bass,
159158
h.degrees, offset)
@@ -264,7 +263,8 @@ def merge_globally(self, section, override=False):
264263

265264
def divisions(self):
266265
"""Return the smallest number of divisions per quarter note
267-
that gives every note, rest and backup an integer duration.
266+
that gives every note, rest and backup an integer duration, and
267+
every chord name offset an integer offset.
268268
269269
Grace notes have no duration and are not counted.
270270
"""
@@ -273,13 +273,15 @@ def divisions(self):
273273
for bar in part.barlist:
274274
for obj in bar.obj_list:
275275
if isinstance(obj, BarBackup):
276-
length = obj.length
276+
lengths = [obj.length]
277277
elif isinstance(obj, BarMus) and not obj.grace[0]:
278-
length = obj.length()
278+
lengths = [obj.length()]
279+
lengths += [h.offset for h in obj.harmonies]
279280
else:
280281
continue
281-
den = (length * 4).denominator
282-
divs = divs * den // gcd(divs, den)
282+
for length in lengths:
283+
den = (length * 4).denominator
284+
divs = divs * den // gcd(divs, den)
283285
return divs
284286

285287
def merge_chord_names(self):
@@ -481,8 +483,8 @@ def merge_harmonies(self, chord_names):
481483
sounding at the same moment."""
482484
for bar, chord_bar in zip(self.barlist, chord_names.barlist):
483485
for pos, obj in chord_bar.positions():
484-
if obj.harmony:
485-
bar.attach_harmony(obj.harmony, pos)
486+
for harmony in obj.harmonies:
487+
bar.attach_harmony(harmony, pos)
486488

487489
def extract_global_to_section(self, name):
488490
"""Extract only elements that is relevant for the score globally into a given section."""
@@ -556,12 +558,12 @@ def attach_harmony(self, harmony, pos):
556558
to the note sounding at pos."""
557559
for start, obj in self.positions():
558560
if start == pos:
559-
obj.harmony = harmony
561+
obj.harmonies.append(harmony)
560562
return
561563
for start, obj in self.positions():
562564
if start < pos < start + obj.length():
563565
harmony.offset = pos - start
564-
obj.harmony = harmony
566+
obj.harmonies.append(harmony)
565567
return
566568
print("Warning: no note to attach chord name to!")
567569

@@ -658,7 +660,7 @@ def __init__(self, duration, voice=1):
658660
self.other_notation = None
659661
self.dynamic = []
660662
self.oct_shift = None
661-
self.harmony = None
663+
self.harmonies = []
662664

663665
def __repr__(self):
664666
return '<{0} {1}>'.format(self.__class__.__name__, self.duration)
@@ -685,6 +687,7 @@ def continuation(self):
685687
for t in self.tuplet]
686688
cont.other_notation = None
687689
cont.dynamic = []
690+
cont.harmonies = []
688691
cont.oct_shift = None
689692
return cont
690693

‎tests/test_musicxml_chords.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
from .musicxml_helpers import convert, measure_lengths, measures, parts, validate
55

6-
xfail = pytest.mark.xfail(strict=True, reason="chord mode is not implemented")
7-
86

97
def harmony(el):
108
"""Return (root, alter, kind, bass) of a harmony element."""

‎tests/test_musicxml_divisions.py‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
"""The <divisions> value in the MusicXML export."""
2-
import pytest
3-
4-
52
from .musicxml_helpers import convert, divisions, notes, validate
63

7-
xfail = pytest.mark.xfail(strict=True, reason="divisions are not minimal")
8-
94

105
def durations(root):
116
return [int(n.findtext('duration')) for n in notes(root) if n.find('grace') is None]

‎tests/test_musicxml_integration.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Combinations of features of the MusicXML export."""
2+
from fractions import Fraction
3+
4+
from .musicxml_helpers import (
5+
convert, divisions, measure_lengths, measures, notes, pitch, pitches,
6+
quarters, validate)
7+
8+
9+
def test_after_grace_with_fraction_and_articulation():
10+
root = convert(r"{ \afterGrace 7/8 c'2-> { d'16 } f'2 }")
11+
n = notes(root)
12+
assert pitches(root) == ['C4', 'D4', 'F4']
13+
assert n[0].find('notations/articulations/accent') is not None
14+
assert n[1].find('grace').get('steal-time-previous') == '12.5'
15+
assert measure_lengths(root) == [4]
16+
validate(root)
17+
18+
19+
def test_transposed_chord_names():
20+
root = convert(r"\transpose c d \chords { c1 f1/a bes1:7 }")
21+
h = root.findall('.//harmony')
22+
assert [(x.findtext('root/root-step'), x.findtext('bass/bass-step')) for x in h] == [
23+
('D', None), ('G', 'B'), ('C', None)]
24+
validate(root)
25+
26+
27+
def test_transposed_chordmode_notes():
28+
root = convert(r"\transpose c d \new Staff \chordmode { c1:7 }")
29+
assert pitches(root) == ['D3', 'F#3', 'A3', 'C4']
30+
31+
32+
def test_lyrics_on_split_notes():
33+
root = convert(r"""\score { <<
34+
\new Voice = "m" { \time 3/4 c'2 d'2 e'2 }
35+
\new Lyrics \lyricsto "m" { a b c }
36+
>> }""")
37+
assert [(pitch(n), [l.findtext('text') for l in n.findall('lyric')]) for n in notes(root)] == [
38+
('C4', ['a']), ('D4', ['b']), ('D4', []), ('E4', ['c'])]
39+
validate(root)
40+
41+
42+
def test_chord_name_offset_needs_finer_divisions():
43+
root = convert(r"""\score { <<
44+
\chords { c8 g2..:7 }
45+
\new Staff { c'1 }
46+
>> }""")
47+
h = root.findall('part/measure/harmony')
48+
assert len(h) == 2
49+
assert divisions(root) == 2
50+
assert h[1].findtext('offset') == '1'
51+
validate(root)
52+
53+
54+
def test_drum_chord_at_end_of_measure():
55+
root = convert(r"\new DrumStaff \drummode { <bd hh>2 <sn hh>2 <bd hh>1 }")
56+
assert measure_lengths(root) == [4, 4]
57+
assert [len(m.findall('note')) for m in measures(root)] == [4, 2]
58+
validate(root)
59+
60+
61+
def test_tuplet_with_rest_at_barline():
62+
root = convert(r"{ \time 2/4 \tuplet 3/2 { c'4 r c' } c'2 }")
63+
assert measure_lengths(root) == [2, 2]
64+
assert divisions(root) == 3
65+
validate(root)
66+
67+
68+
def test_dynamic_on_empty_chord_in_measures():
69+
root = convert(r"{ c'1 <>\p d'1 }")
70+
m = measures(root)
71+
assert measure_lengths(root) == [4, 4]
72+
assert m[0].find('direction') is None
73+
assert m[1].find('direction/direction-type/dynamics/p') is not None
74+
validate(root)
75+
76+
77+
def test_part_combine_across_barlines():
78+
root = convert(r"\new Staff \partCombine { \time 3/4 c''2. c''2. } { a'2 a'1 }")
79+
assert measure_lengths(root) == [3, 3]
80+
q = quarters(root)
81+
assert [q(n.findtext('duration')) for n in notes(root) if n.findtext('voice') == '2'] == [2, 1, 3]
82+
validate(root)
83+
84+
85+
def test_repeat_with_chords_and_lyrics():
86+
root = convert(r"""\score { <<
87+
\chords { \repeat volta 2 { c1 } g1:7 }
88+
\new Voice = "m" { \repeat volta 2 { e'1 } d'1 }
89+
\new Lyrics \lyricsto "m" { la lo }
90+
>> }""")
91+
m = measures(root)
92+
assert len(m) == 2
93+
assert m[0].find("barline[@location='left']/repeat").get('direction') == 'forward'
94+
assert m[0].find("barline[@location='right']/repeat").get('direction') == 'backward'
95+
assert [len(x.findall('harmony')) for x in m] == [1, 1]
96+
assert [[l.findtext('text') for l in n.findall('lyric')] for n in notes(root)] == [['la'], ['lo']]
97+
validate(root)
98+
99+
100+
def test_after_grace_in_transposed_variable():
101+
root = convert(r"""
102+
orn = \afterGrace c'2 { d'16 }
103+
\score { \transpose c d { \orn e'2 } }
104+
""")
105+
assert pitches(root) == ['D4', 'E4', 'F#4']
106+
assert measure_lengths(root) == [4]
107+
108+
109+
def test_addlyrics_for_voices_in_staff_group():
110+
root = convert(r"""\score {
111+
\new StaffGroup <<
112+
\new Voice = "S" { \set Staff.instrumentName = #"Cantus" c''2 d'' }
113+
\addlyrics { Du tout }
114+
\new Voice = "B" { \set Staff.instrumentName = #"Bassus" c2 g, }
115+
\addlyrics { plon giet }
116+
>>
117+
}""")
118+
assert [p.findtext('part-name') for p in root.findall('part-list/score-part')] == [
119+
'Cantus', 'Bassus']
120+
assert [[(pitch(n), [l.findtext('text') for l in n.findall('lyric')]) for n in notes(root, i)]
121+
for i in range(2)] == [
122+
[('C5', ['Du']), ('D5', ['tout'])],
123+
[('C3', ['plon']), ('G2', ['giet'])],
124+
]
125+
validate(root)

‎tests/test_musicxml_lyrics.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
"""Lyrics in the MusicXML export."""
2-
import pytest
32

43
from .musicxml_helpers import convert, notes, pitch, validate
54

6-
xfail = pytest.mark.xfail(strict=True, reason="lyrics support is incomplete")
7-
85

96
def lyrics(root):
107
"""Return (pitch, [(number, syllabic, text, extend), ...]) for every note."""

‎tests/test_musicxml_partcombine.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
from .musicxml_helpers import convert, measure_lengths, measures, notes, parts, pitch, pitches, validate
55

6-
xfail = pytest.mark.xfail(strict=True, reason=r"\partCombine is not implemented")
7-
86

97
@pytest.mark.parametrize('command', [r'\partCombine', r'\partcombine'])
108
def test_part_combine_two_voices(command):

0 commit comments

Comments
 (0)