Files
product-vamp/tests/test_musicxml.py
T
khodowany f84717990b
ci / checks (push) Successful in 42m24s
Music XML
2026-09-10 22:01:18 -05:00

189 lines
11 KiB
Python

from fractions import Fraction
from pathlib import Path
import tempfile
import unittest
from generator.musicxml import MusicXMLError, load_musicxml
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number
def xml_score(content, attributes='', *, divisions=1, implicit=False):
return ('<?xml version="1.0"?>'
'<score-partwise version="4.0"><work><work-title>Fixture</work-title></work>'
'<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>'
f'<part id="P1"><measure number="1" implicit="{"yes" if implicit else "no"}">'
f'<attributes><divisions>{divisions}</divisions><key><fifths>0</fifths></key>'
'<time><beats>4</beats><beat-type>4</beat-type></time>'
f'<clef><sign>G</sign><line>2</line></clef>{attributes}</attributes>'
f'{content}</measure></part></score-partwise>')
def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, octave=4):
return (f'<note>{prefix}<pitch><step>{step}</step><alter>{alter}</alter><octave>{octave}</octave></pitch>'
f'<duration>{duration}</duration><type>{kind}</type>{extra}</note>')
def parse(text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'fixture.musicxml'
path.write_text(text, encoding='utf-8')
return load_musicxml(path)
def notes(document, measure=0):
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
class MusicXMLTests(unittest.TestCase):
def test_regression_fixture(self):
score = load_musicxml(Path('tests/fixtures/notation.musicxml'))
self.assertEqual(len(score['staves'][0]['measures']),4)
self.assertEqual(len(score['staves'][0]['octave_shifts']),1)
def test_catalogue_imports(self):
sources = sorted(Path('data').rglob('*.musicxml'))
self.assertEqual(len(sources), 6)
for path in sources:
with self.subTest(path=path):
self.assertEqual(load_musicxml(path)['schema_version'], 2)
def test_all_written_values_and_dots(self):
for kind in NOTE_TYPES:
for dots in (0, 1, 2, 3):
value = TYPE_LENGTHS[kind] * (2-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(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_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)['schema_version'], 2)
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise',1)
with self.assertRaises(MusicXMLError):
parse(malicious)
with self.assertRaises(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)
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(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id'])
self.assertNotIn('tie_to', result[0]['pitches'][1])
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.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 = '<tuplet type="start"/>' if i==0 else '<tuplet type="stop"/>' if i==4 else ''
extra = '<time-modification><actual-notes>5</actual-notes><normal-notes>4</normal-notes></time-modification>'
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(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'):
parse(xml_score(xml_note(extra=modulation)))
with self.assertRaisesRegex(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>'
content = ''.join(xml_note(step,2,'whole',extra=modulation+f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>')
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)
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(attr['key']['mode'],'minor')
self.assertEqual(attr['clef']['sign'],'C')
def test_optional_marker_contract(self):
marker = '<direction><direction-type><words>vamp:optional-chord</words></direction-type></direction>'
h = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
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'):
parse(xml_score(content))
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):
parse(xml_score(xml_note(extra=extra)))
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>4</duration></backup>',
'<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
parse(xml_score(xml_note()+extra))
with self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(alter='0.5')))
with self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
with self.assertRaises(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)
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)
h = h.replace('<kind>minor</kind>','<kind text="maj7">minor</kind>')
with self.assertRaisesRegex(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>'
def tuplet(number,action):
details = '<tuplet-actual><tuplet-number>3</tuplet-number></tuplet-actual><tuplet-normal><tuplet-number>2</tuplet-number></tuplet-normal>' if action=='start' else ''
return f'<tuplet number="{number}" type="{action}">{details}</tuplet>'
first = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(1,'start')+tuplet(2,'start')+'</notations>')
last = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(2,'stop')+tuplet(1,'stop')+'</notations>')
text = xml_score(first,divisions=9,implicit=True)
text = text.replace('</measure></part>', '</measure><measure number="2" implicit="yes">'+last+'</measure></part>')
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.assertTrue(all(t['end']==notes(result,1)[0]['id'] for t in spans))