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 ('' '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 = 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=''*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(']>') source += xml_note('E',1,'quarter',prefix='') source += xml_note(duration=2,kind='half',extra='') source += xml_note('G',2,'half',prefix='') 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 = 'D416th' regular = xml_note(extra='3', prefix='') 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 = '' 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(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 = '32' 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 = '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(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(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(MusicXMLError,'measure 1'): parse(xml_score(content)) def test_rejects_musical_content_outside_contract(self): extras = ['', '', '22', '', ''] for extra in extras: with self.subTest(extra=extra), self.assertRaises(MusicXMLError): parse(xml_score(xml_note(extra=extra))) for extra in ('', '4', ''): 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=''))) 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 = 'Cminor31alter' 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('minor','minor') with self.assertRaisesRegex(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(fraction(notes(result,1)[0]['duration']),Fraction(4,9)) self.assertTrue(all(t['end']==notes(result,1)[0]['id'] for t in spans))