import fractions
import pathlib
import tempfile
import unittest
import xml.etree.ElementTree
import generator.harmony
import generator.model
import generator.musicxml
import generator.musicxml_writer
def xml_score(content, attributes='', *, divisions=1, implicit=False):
return (''
'Fixture'
'Lead'
f''
f'{divisions}0'
''
f'G2{attributes}'
f'{content}')
def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, octave=4):
return (f'{prefix}{step}{alter}{octave}'
f'{duration}{kind}{extra}')
def parse(text):
with tempfile.TemporaryDirectory() as directory:
path = pathlib.Path(directory) / 'fixture.xml'
path.write_text(text, encoding='utf-8')
return generator.musicxml.load_musicxml(path)
def notes(document, measure=0):
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
class MusicXMLTests(unittest.TestCase):
def test_obsolete_style_hints_are_rejected(self):
for value in ('Medium Swing', 'Med.', 'Bossa Nova', 'Unrecognized style', ''):
with self.subTest(value=value), self.assertRaisesRegex(generator.musicxml.MusicXMLError, "fixture.xml: unsupported Vamp property 'vamp:style'"):
field = f'{value}'
parse(xml_score(xml_note()).replace('', field + ''))
def test_style_free_roundtrip_keeps_barline_notation_and_fonts(self):
content = 'heavy-light' + xml_note() + 'light-heavy'
source = parse(xml_score(content))
self.assertNotIn('style', source['metadata'])
root = generator.musicxml_writer.musicxml_tree(source).getroot()
self.assertEqual(root.findall('identification/miscellaneous/miscellaneous-field'), [])
self.assertEqual(root.find('defaults/music-font').get('font-family'), 'Petaluma')
restored = parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
self.assertNotIn('style', restored['metadata'])
self.assertEqual(restored['staves'][0]['measures'][0]['barlines'], {'left': 'heavy-light', 'right': 'light-heavy'})
def test_measure_sound_tempo_matches_direction_sound(self):
sound = ''
direct = parse(xml_score(sound + xml_note()))
direction = parse(xml_score('' + sound + '' + xml_note()))
self.assertEqual(direct, direction)
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score('' + xml_note()))
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score('' + xml_note()))
def test_regression_fixture(self):
score = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml'))
self.assertEqual(len(score['staves'][0]['measures']), 4)
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
def test_catalog_imports(self):
sources = sorted(pathlib.Path('data/songs').glob('*.xml'))
self.assertTrue(sources)
for path in sources:
with self.subTest(path=path):
self.assertEqual(set(generator.musicxml.load_musicxml(path)), {'metadata', 'staves'})
def test_all_written_values_and_dots(self):
for kind in generator.model.NOTE_TYPES:
for dots in (0, 1, 2, 3):
value = generator.model.TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2**dots))
score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='' * dots), divisions=value.denominator, implicit=True))
self.assertEqual(generator.model.fraction(notes(score)[0]['duration']), value)
self.assertEqual(notes(score)[0]['written'], {'type': kind, 'dots': dots})
def test_divisions_do_not_change_music(self):
a = parse(xml_score(xml_note(duration=4)))
b = parse(xml_score(xml_note(duration=1920), divisions=480))
self.assertEqual(a, b)
def test_cursor_only_forward_does_not_change_event_ids(self):
movement = '11'
self.assertEqual(parse(xml_score(movement + xml_note())), parse(xml_score(xml_note())))
def test_voiced_forward_has_its_own_event_id(self):
content = '11' + xml_note(duration=3, kind='half', extra='')
events = notes(parse(xml_score(content)))
self.assertEqual([(n['id'], n['kind']) for n in events], [('spacer_1', 'spacer'), ('note_2', 'note')])
self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, 3])
def test_doctype_allowed_but_entities_rejected(self):
source = xml_score(xml_note())
source = source.replace(']>C{kind}'
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['symbol'], 'C' + label)
self.assertEqual(generator.harmony.pitch_classes(chord), expected)
def test_other_harmony_requires_degrees_or_recognized_text(self):
for kind in ('other', 'other', 'other'):
with self.subTest(kind=kind), self.assertRaisesRegex(generator.musicxml.MusicXMLError, r'fixture.xml: part P1, measure 1:'):
parse(xml_score('C' + kind + '' + xml_note()))
def test_other_harmony_with_explicit_degrees_remains_supported(self):
degrees = ''.join(f'{value}{alter}add' for value, alter in ((1, 0), (3, -1), (5, 0), (7, 0)))
content = 'Cother' + degrees + ''
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['kind'], 'other')
self.assertEqual(chord['symbol'], 'Cm7')
self.assertEqual(generator.harmony.pitch_classes(chord), (0, 3, 7, 10))
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory harmony text'):
parse(xml_score(content.replace('text="-7"', 'text="maj7"') + xml_note()))
def test_grace_cue_and_measured_tremolo(self):
grace = 'D416th'
regular = xml_note(extra='3', prefix='')
result = notes(parse(xml_score(grace + regular)))
self.assertEqual(generator.model.fraction(result[0]['duration']), 0)
self.assertTrue(result[0]['grace']['slash'])
self.assertTrue(result[1]['cue'])
self.assertEqual(result[1]['tremolo']['marks'], 3)
def test_exact_quintuplets(self):
content = ''
for i in range(5):
tuplet = '' if i == 0 else '' if i == 4 else ''
extra = '54'
extra += '' + tuplet + ''
content += xml_note(duration=4, kind='quarter', extra=extra)
result = parse(xml_score(content, divisions=5))
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes(result)), 4)
self.assertTrue(all(generator.model.fraction(n['duration']) == fractions.Fraction(4, 5) for n in notes(result)))
self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'], 5)
def test_inconsistent_written_timing_is_rejected(self):
modulation = '32'
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
parse(xml_score(xml_note(extra=modulation)))
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
parse(xml_score(xml_note(kind='half')))
def test_paired_tremolo_timing(self):
modulation = '21'
content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'3')
for step, action in [('C', 'start'), ('G', 'stop')])
result = notes(parse(xml_score(content)))
self.assertEqual(result[0]['tremolo']['partner'], result[1]['id'])
self.assertEqual(generator.model.fraction(result[0]['duration']), 2)
def test_mid_measure_clef_and_key_changes(self):
change = '-3minorC3'
result = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
attr = result['staves'][0]['measures'][0]['attributes'][0]
self.assertEqual(generator.model.fraction(attr['onset']), 2)
self.assertEqual(attr['key']['mode'], 'minor')
self.assertEqual(attr['clef']['sign'], 'C')
def test_optional_marker_contract(self):
marker = 'vamp:optional-chord'
h = 'Cmajor'
score = parse(xml_score(marker + h + xml_note()))
self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional'])
for content in (marker + xml_note(), marker + marker + h + xml_note(), marker.replace('optional-chord', 'unknown') + xml_note()):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'measure 1'):
parse(xml_score(content))
def test_repeat_navigation_and_pass_specific_ties_are_rejected(self):
rejected = [
'',
'',
'',
'',
'',
'',
'', '', '',
]
for content in rejected:
with self.subTest(content=content), self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'measure 1'):
parse(xml_score(content + xml_note()))
for passes in ('1', ''):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'pass-specific ties'):
parse(xml_score(xml_note(extra=f'')))
def test_rejects_musical_content_outside_contract(self):
extras = ['', '',
'22', '',
'']
for extra in extras:
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(extra=extra)))
for extra in ('', '5', ''):
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note() + extra))
self.assertEqual(generator.model.fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), fractions.Fraction(1, 2))
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(extra='')))
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(duration=5)))
def test_b_sharp_c_flat_preserve_octave(self):
b = notes(parse(xml_score(xml_note('B', alter=1))))[0]['pitches'][0]
c = notes(parse(xml_score(xml_note('C', alter=-1))))[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(b), 72)
self.assertEqual(generator.model.pitch_number(c), 59)
def test_musicxml_degree_alterations_are_relative(self):
h = 'Cminor31alter'
score = parse(xml_score(h + xml_note()))
self.assertEqual(generator.harmony.degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0)
h = h.replace('minor', 'minor')
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory'):
parse(xml_score(h + xml_note()))
def test_nested_and_cross_bar_tuplets(self):
modulation = '94'
def tuplet(number, action):
details = '32' if action == 'start' else ''
return f'{details}'
first = xml_note(duration=4, kind='quarter', extra=modulation + '' + tuplet(1, 'start') + tuplet(2, 'start') + '')
last = xml_note(duration=4, kind='quarter', extra=modulation + '' + tuplet(2, 'stop') + tuplet(1, 'stop') + '')
text = xml_score(first, divisions=9, implicit=True)
text = text.replace('', '' + last + '')
result = parse(text)
spans = result['staves'][0]['tuplets']
self.assertEqual([t['depth'] for t in spans], [0, 1])
self.assertEqual(generator.model.fraction(notes(result, 1)[0]['duration']), fractions.Fraction(4, 9))
self.assertTrue(all(t['end'] == notes(result, 1)[0]['id'] for t in spans))