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.xml' 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_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(MusicXMLError): parse(xml_score('' + xml_note())) with self.assertRaises(MusicXMLError): parse(xml_score('' + xml_note())) def test_regression_fixture(self): score = load_musicxml(Path('tests/fixtures/notation.xml')) 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('*.xml')) self.assertTrue(sources) for path in sources: with self.subTest(path=path): self.assertEqual(load_musicxml(path)['schema_version'], 3) 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(']>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 ('', '5', ''): with self.subTest(extra=extra), self.assertRaises(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): 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))