import copy from dataclasses import replace from fractions import Fraction from pathlib import Path import unittest from generator.engraving import EngravingError, Fonts, Style, layout_score, rasterize_page from generator.engraving.audit import audit_layout from generator.engraving.notation import ScoreIndex from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational from generator.musicxml import load_musicxml from tests.test_musicxml import notes, parse, xml_note, xml_score class EngravingTests(unittest.TestCase): @classmethod def setUpClass(cls): cls.fonts = Fonts(Path('package/assets/fonts/petaluma')) def layout(self, source, style=None): before = copy.deepcopy(source) layout = layout_score(source, style or Style(), self.fonts) self.assertEqual(source, before) return layout def primitives(self, layout, role): return [p for page in layout.pages for p in page.primitives if p.role == role] def test_notation_fixture_visual_golden(self): from PIL import Image, ImageChops layout = self.layout(load_musicxml(Path('tests/fixtures/notation.xml'))) self.assertEqual(len(layout.pages), 1) actual = rasterize_page(layout.pages[0], self.fonts) with Image.open('tests/fixtures/notation-python.png') as expected: self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox()) def test_every_written_note_and_rest_value(self): for kind in NOTE_TYPES: for dots in (0, 1, 3): duration = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots)) for rest in (False, True): with self.subTest(kind=kind, dots=dots, rest=rest): content = xml_note(duration=duration.numerator, kind=kind, extra='' * dots) if rest: content = f'{duration.numerator}{kind}' + '' * dots + '' source = parse(xml_score(content, divisions=duration.denominator, implicit=True)) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'dot')), dots) self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1) def test_sixteen_dots_are_not_truncated(self): duration = TYPE_LENGTHS['quarter'] * (2 - Fraction(1, 2 ** 16)) source = parse(xml_score(xml_note(duration=duration.numerator, kind='quarter', extra='' * 16), divisions=duration.denominator, implicit=True)) self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16) def test_arbitrary_voice_ids_and_more_than_four_voices(self): content = '' for i in range(6): if i: content += '4' content += xml_note(extra=f'part-{i}') source = parse(xml_score(content)) self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6) def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self): source = parse(xml_score(xml_note(alter='0.1'))) self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)') notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'} with self.assertRaisesRegex(EngravingError, 'measure 1, voice 1.*missingVampGlyph'): self.layout(source) def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self): content = xml_note(duration=1, kind='quarter', alter=1, extra='a') content += xml_note(duration=1, kind='quarter', alter=1, extra='a') content += xml_note(duration=2, kind='half', extra='a') source = parse(xml_score(content)) self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural']) conflict = parse(xml_score(xml_note(alter=1, extra='a') + '4' + xml_note(extra='b'))) self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2) def test_dense_measure_splits_exactly_without_modifying_music(self): content = ''.join(xml_note('G', 1, '64th', extra='fast') for _ in range(64)) content += '64' + xml_note('C', 64, 'whole', extra='held') source = parse(xml_score(content, divisions=16)) layout = self.layout(source) systems = [s for p in layout.pages for s in p.systems] self.assertGreater(len(systems), 1) held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id'] pieces = [f for s in systems for f in s.fragments if f.source == held] self.assertEqual(sum(f.length() for f in pieces), 4) self.assertGreater(len(pieces), 1) self.assertTrue(self.primitives(layout, 'tie')) for page in layout.pages: self.assertEqual(rasterize_page(page, self.fonts).size, (976, 484)) def test_hidden_pitch_does_not_suppress_a_visible_accidental(self): source = parse(xml_score(xml_note(duration=2, kind='half', alter=1) + xml_note(duration=2, kind='half', alter=1))) notes(source)[0]['hidden'] = True self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp']) def test_octave_shift_changes_display_not_pitch(self): source = parse(xml_score(xml_note(octave=5))) original = ScoreIndex(source).pitch_position(notes(source)[0]['pitches'][0], Fraction()) source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}] index = ScoreIndex(source) self.assertEqual(index.pitch_position(notes(source)[0]['pitches'][0], Fraction()), original - 7) self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift'))) def test_wide_pitch_range_can_break_inside_a_measure_vertically(self): source = parse(xml_score(xml_note(duration=2, kind='half', octave=10) + xml_note(duration=2, kind='half', octave=-1))) layout = self.layout(source) self.assertEqual(len(layout.pages), 2) self.assertEqual(len(self.primitives(layout, 'head')), 2) def test_cue_grace_and_tremolo_are_drawn(self): grace = 'D416th' source = parse(xml_score(grace + xml_note(duration=4, kind='whole', prefix=''))) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'head')), 2) self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1) source = parse(xml_score(xml_note(duration=2, kind='half', extra='8'), implicit=True)) self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8) def test_percussion_and_tab_use_explicit_display_positions(self): percussion = 'C54wholex' source = parse(xml_score(percussion, attributes='percussion')) self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole') tab = parse(xml_score(xml_note(extra='21'), attributes='TAB56')) self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1') def test_lyrics_hyphens_and_extenders(self): begin = 'beginhel' end = 'endlo' source = parse(xml_score(xml_note(duration=1, kind='quarter', extra=begin) + xml_note(duration=1, kind='quarter', extra=end) + xml_note(duration=2, kind='half'))) layout = self.layout(source) self.assertTrue(self.primitives(layout, 'lyric-hyphen')) self.assertTrue(self.primitives(layout, 'lyric-extension')) def test_nested_and_cross_bar_tuplets_keep_span_ownership(self): from xml.etree import ElementTree modulation = '32' content = ''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)) tree = ElementTree.fromstring(xml_score(content, divisions=3)) second = ElementTree.SubElement(tree.find('part'), 'measure', number='2') for element in ElementTree.fromstring(xml_score(content, divisions=3)).findall('part/measure/note'): second.append(element) source = parse(ElementTree.tostring(tree, encoding='unicode')) events = notes(source) + notes(source, 1) spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}] source['staves'][0]['tuplets'] = spans for i, event in enumerate(events): event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else []) layout = self.layout(source, Style(width=600)) self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1) self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'}) def test_paired_tremolo_and_double_stems(self): modulation = '21' content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'3') for step, action in [('C', 'start'), ('G', 'stop')]) self.assertEqual(len(self.primitives(self.layout(parse(xml_score(content))), 'tremolo')), 3) source = parse(xml_score(xml_note(duration=1, kind='eighth', extra='double'), divisions=2, implicit=True)) layout = self.layout(source) self.assertEqual(len(self.primitives(layout, 'stem')), 2) self.assertEqual(len(self.primitives(layout, 'flag')), 2) def test_meter_symbols_and_timed_attributes(self): for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'): source = parse(xml_score(xml_note()).replace('