+61
-46
@@ -1,10 +1,13 @@
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import fractions
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree
|
||||
|
||||
from generator.musicxml import MusicXMLError, load_musicxml
|
||||
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
|
||||
def xml_score(content, attributes='', *, divisions=1, implicit=False):
|
||||
return ('<?xml version="1.0"?>'
|
||||
@@ -22,39 +25,54 @@ def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, o
|
||||
|
||||
def parse(text):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'fixture.xml'
|
||||
path = pathlib.Path(directory) / 'fixture.xml'
|
||||
path.write_text(text, encoding='utf-8')
|
||||
return load_musicxml(path)
|
||||
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'<identification><miscellaneous><miscellaneous-field name="vamp:style">{value}</miscellaneous-field></miscellaneous></identification>'
|
||||
parse(xml_score(xml_note()).replace('<part-list>', field + '<part-list>'))
|
||||
def test_style_free_roundtrip_keeps_barline_notation_and_fonts(self):
|
||||
content = '<barline location="left"><bar-style>heavy-light</bar-style></barline>' + xml_note() + '<barline location="right"><bar-style>light-heavy</bar-style></barline>'
|
||||
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 = '<sound tempo="120"/>'
|
||||
direct = parse(xml_score(sound + xml_note()))
|
||||
direction = parse(xml_score('<direction>' + sound + '</direction>' + xml_note()))
|
||||
self.assertEqual(direct, direction)
|
||||
with self.assertRaises(MusicXMLError):
|
||||
with self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse(xml_score('<sound tempo="0"/>' + xml_note()))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
with self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse(xml_score('<sound dacapo="yes"/>' + xml_note()))
|
||||
def test_regression_fixture(self):
|
||||
score = load_musicxml(Path('tests/fixtures/notation.xml'))
|
||||
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(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml'))
|
||||
sources = sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml'))
|
||||
self.assertTrue(sources)
|
||||
for path in sources:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(set(load_musicxml(path)), {'metadata', 'staves'})
|
||||
self.assertEqual(set(generator.musicxml.load_musicxml(path)), {'metadata', 'staves'})
|
||||
def test_all_written_values_and_dots(self):
|
||||
for kind in NOTE_TYPES:
|
||||
for kind in generator.model.NOTE_TYPES:
|
||||
for dots in (0, 1, 2, 3):
|
||||
value = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2**dots))
|
||||
value = generator.model.TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2**dots))
|
||||
score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='<dot/>' * dots), divisions=value.denominator, implicit=True))
|
||||
self.assertEqual(fraction(notes(score)[0]['duration']), value)
|
||||
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)))
|
||||
@@ -67,59 +85,57 @@ class MusicXMLTests(unittest.TestCase):
|
||||
content = '<forward><duration>1</duration><voice>1</voice></forward>' + xml_note(duration=3, kind='half', extra='<dot/>')
|
||||
events = notes(parse(xml_score(content)))
|
||||
self.assertEqual([(n['id'], n['kind']) for n in events], [('spacer-1', 'spacer'), ('note-2', 'note')])
|
||||
self.assertEqual([fraction(n['duration']) for n in events], [1, 3])
|
||||
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('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
|
||||
self.assertEqual(parse(source), parse(xml_score(xml_note())))
|
||||
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
|
||||
with self.assertRaises(MusicXMLError):
|
||||
with self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse(malicious)
|
||||
with self.assertRaises(MusicXMLError):
|
||||
with self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse('<score-partwise')
|
||||
def test_harmony_inside_sustained_note_remains_independent(self):
|
||||
h = '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
|
||||
score = parse(xml_score(xml_note() + h))
|
||||
self.assertEqual(len(notes(score)), 1)
|
||||
self.assertEqual(fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
|
||||
self.assertEqual(generator.model.fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
|
||||
def test_partial_chord_ties_and_unequal_tones(self):
|
||||
source = xml_note(duration=2, kind='half', extra='<tie type="start"/>')
|
||||
source += xml_note('E', 1, 'quarter', prefix='<chord/>')
|
||||
source += xml_note(duration=2, kind='half', extra='<tie type="stop"/>')
|
||||
source += xml_note('G', 2, 'half', prefix='<chord/>')
|
||||
result = notes(parse(xml_score(source)))
|
||||
self.assertEqual(fraction(result[0]['pitches'][1]['duration']), 1)
|
||||
self.assertEqual(generator.model.fraction(result[0]['pitches'][1]['duration']), 1)
|
||||
self.assertEqual(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id'])
|
||||
self.assertNotIn('tie_to', result[0]['pitches'][1])
|
||||
def test_standard_minor_harmonies_normalize_display_and_preserve_pitches(self):
|
||||
from generator.harmony import pitch_classes
|
||||
for text, kind, label, expected in (('-7', 'minor-seventh', 'm7', (0, 3, 7, 10)), ('-7b5', 'half-diminished', 'm7b5', (0, 3, 6, 10)), ('-maj7', 'major-minor', 'mM7', (0, 3, 7, 11)), ('-6', 'minor-sixth', 'm6', (0, 3, 7, 9))):
|
||||
with self.subTest(text=text):
|
||||
content = f'<harmony><root><root-step>C</root-step></root><kind text="{text}">{kind}</kind></harmony>'
|
||||
score = parse(xml_score(content + xml_note()))
|
||||
chord = score['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertEqual(chord['symbol'], 'C' + label)
|
||||
self.assertEqual(pitch_classes(chord), expected)
|
||||
self.assertEqual(generator.harmony.pitch_classes(chord), expected)
|
||||
def test_other_harmony_requires_degrees_or_recognized_text(self):
|
||||
for kind in ('<kind>other</kind>', '<kind text="">other</kind>', '<kind text="unknown">other</kind>'):
|
||||
with self.subTest(kind=kind), self.assertRaisesRegex(MusicXMLError, r'fixture.xml: part P1, measure 1:'):
|
||||
with self.subTest(kind=kind), self.assertRaisesRegex(generator.musicxml.MusicXMLError, r'fixture.xml: part P1, measure 1:'):
|
||||
parse(xml_score('<harmony><root><root-step>C</root-step></root>' + kind + '</harmony>' + xml_note()))
|
||||
def test_other_harmony_with_explicit_degrees_remains_supported(self):
|
||||
from generator.harmony import pitch_classes
|
||||
degrees = ''.join(f'<degree><degree-value>{value}</degree-value><degree-alter>{alter}</degree-alter><degree-type>add</degree-type></degree>' for value, alter in ((1, 0), (3, -1), (5, 0), (7, 0)))
|
||||
content = '<harmony><root><root-step>C</root-step></root><kind text="-7">other</kind>' + degrees + '</harmony>'
|
||||
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(pitch_classes(chord), (0, 3, 7, 10))
|
||||
with self.assertRaisesRegex(MusicXMLError, 'contradictory harmony text'):
|
||||
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 = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
|
||||
regular = xml_note(extra='<notations><ornaments><tremolo type="single">3</tremolo></ornaments></notations>', prefix='<cue/>')
|
||||
result = notes(parse(xml_score(grace + regular)))
|
||||
self.assertEqual(fraction(result[0]['duration']), 0)
|
||||
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)
|
||||
@@ -131,14 +147,14 @@ class MusicXMLTests(unittest.TestCase):
|
||||
extra += '<notations>' + tuplet + '</notations>'
|
||||
content += xml_note(duration=4, kind='quarter', extra=extra)
|
||||
result = parse(xml_score(content, divisions=5))
|
||||
self.assertEqual(sum(fraction(n['duration']) for n in notes(result)), 4)
|
||||
self.assertTrue(all(fraction(n['duration']) == Fraction(4, 5) for n in notes(result)))
|
||||
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 = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'):
|
||||
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
|
||||
parse(xml_score(xml_note(extra=modulation)))
|
||||
with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'):
|
||||
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
|
||||
parse(xml_score(xml_note(kind='half')))
|
||||
def test_paired_tremolo_timing(self):
|
||||
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||
@@ -146,12 +162,12 @@ class MusicXMLTests(unittest.TestCase):
|
||||
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(fraction(result[0]['duration']), 2)
|
||||
self.assertEqual(generator.model.fraction(result[0]['duration']), 2)
|
||||
def test_mid_measure_clef_and_key_changes(self):
|
||||
change = '<attributes><key><fifths>-3</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>'
|
||||
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(fraction(attr['onset']), 2)
|
||||
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):
|
||||
@@ -160,7 +176,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
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(MusicXMLError, 'measure 1'):
|
||||
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'measure 1'):
|
||||
parse(xml_score(content))
|
||||
def test_repeat_navigation_and_pass_specific_ties_are_rejected(self):
|
||||
rejected = [
|
||||
@@ -173,38 +189,37 @@ class MusicXMLTests(unittest.TestCase):
|
||||
'<sound dacapo="yes"/>', '<sound dalsegno="segno"/>', '<sound tocoda="coda"/>',
|
||||
]
|
||||
for content in rejected:
|
||||
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'measure 1'):
|
||||
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(MusicXMLError, 'pass-specific ties'):
|
||||
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'pass-specific ties'):
|
||||
parse(xml_score(xml_note(extra=f'<tie type="start" time-only="{passes}"/>')))
|
||||
def test_rejects_musical_content_outside_contract(self):
|
||||
extras = ['<notations><slur type="start"/></notations>', '<notations><articulations><accent/></articulations></notations>',
|
||||
'<voice>2</voice><staff>2</staff>', '<pitch><unpitched/></pitch>',
|
||||
'<notations><ornaments><trill-mark/></ornaments></notations>']
|
||||
for extra in extras:
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse(xml_score(xml_note(extra=extra)))
|
||||
for extra in ('<barline location="middle"><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
|
||||
parse(xml_score(xml_note() + extra))
|
||||
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
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='<tie type="stop"/>')))
|
||||
with self.assertRaises(MusicXMLError):
|
||||
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(pitch_number(b), 72)
|
||||
self.assertEqual(pitch_number(c), 59)
|
||||
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 = '<harmony><root><root-step>C</root-step></root><kind>minor</kind><degree><degree-value>3</degree-value><degree-alter>1</degree-alter><degree-type>alter</degree-type></degree></harmony>'
|
||||
from generator.harmony import degrees
|
||||
score = parse(xml_score(h + xml_note()))
|
||||
self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0)
|
||||
self.assertEqual(generator.harmony.degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0)
|
||||
h = h.replace('<kind>minor</kind>', '<kind text="maj7">minor</kind>')
|
||||
with self.assertRaisesRegex(MusicXMLError, 'contradictory'):
|
||||
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory'):
|
||||
parse(xml_score(h + xml_note()))
|
||||
def test_nested_and_cross_bar_tuplets(self):
|
||||
modulation = '<time-modification><actual-notes>9</actual-notes><normal-notes>4</normal-notes></time-modification>'
|
||||
@@ -218,5 +233,5 @@ class MusicXMLTests(unittest.TestCase):
|
||||
result = parse(text)
|
||||
spans = result['staves'][0]['tuplets']
|
||||
self.assertEqual([t['depth'] for t in spans], [0, 1])
|
||||
self.assertEqual(fraction(notes(result, 1)[0]['duration']), Fraction(4, 9))
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user