import copy import dataclasses import fractions import pathlib import tempfile import unittest import xml.etree.ElementTree import generator.accompaniment import generator.model import generator.playback import generator.practice.generate import generator.rhythm import tests.rhythm_fixtures import tests.test_musicxml import tests.test_parts def xml_bars(contents, *, beats=4, divisions=1, implicit=False): root = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(contents[0], f'', divisions=divisions, implicit=implicit)) for index, content in enumerate(contents[1:], 2): root.find('part').append(xml.etree.ElementTree.fromstring(f'{content}')) return xml.etree.ElementTree.tostring(root, encoding='unicode') def melody_measures(count, *, beats=4, dense=False): notes = ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth' if dense else 'quarter') for _ in range(beats * (2 if dense else 1))) content = tests.test_parts.xml_harmony() + notes return tests.test_musicxml.parse(xml_bars([content] * count, beats=beats, divisions=2 if dense else 1))['staves'][0]['measures'] def quarter_bar(beats, attacks): return generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i), fractions.Fraction(1), i in attacks, 'quarter') for i in range(beats)), fractions.Fraction(beats)) def timing(events): return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), n['kind']) for n in events] def tied_library(continuation_duration): first = tests.test_musicxml.xml_note(extra='') second = tests.test_musicxml.xml_note(duration=continuation_duration, kind='whole' if continuation_duration == 4 else 'half', extra='') if continuation_duration == 2: second += '2half' with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) path = root / '4-4/2/tied.xml' path.parent.mkdir(parents=True) path.write_text(xml_bars([first, second]), encoding='utf-8') return generator.rhythm.load_library(root) def held_half_bar(sounding=True): return generator.rhythm.Bar(( generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(2), sounding, 'half'), generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(1), False, 'quarter'), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter'), ), fractions.Fraction(4)) class RhythmLibraryTests(unittest.TestCase): def test_unbracketed_time_modifications_are_rejected(self): extra = '32' source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3) with self.assertRaisesRegex(ValueError, 'time modifications'): self.load({'4-4/1/ratio.xml': source}) def load(self, sources): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) for relative, content in sources.items(): path = root / relative path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding='utf-8') return generator.rhythm.load_library(root) def test_snippet_retains_exact_timing_rests_dots_and_beams(self): content = tests.test_musicxml.xml_note(duration=3, kind='eighth', extra='begin') content += tests.test_musicxml.xml_note(duration=1, kind='16th', extra='endbackward hook') content += '4quarter' content += tests.test_musicxml.xml_note(duration=8, kind='half') library = self.load({'4-4/1/dotted.xml': xml_bars([content], divisions=4)}) snippet = library.for_meter('4/4')[0] self.assertEqual((snippet.id, snippet.meter), ('4-4/1/dotted.xml', '4/4')) self.assertEqual(snippet.bars, (generator.rhythm.Bar(( generator.rhythm.Event(fractions.Fraction(0), fractions.Fraction(3, 4), True, 'eighth', 1, ((1, 'begin'),)), generator.rhythm.Event(fractions.Fraction(3, 4), fractions.Fraction(1, 4), True, '16th', 0, ((1, 'end'), (2, 'backward hook'))), generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(1), False, 'quarter'), generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(2), True, 'half'), ), fractions.Fraction(4)),)) self.assertEqual(snippet.bars[0].attacks(), (0, fractions.Fraction(3, 4), 2)) self.assertTrue(all(type(e.onset) is fractions.Fraction and type(e.duration) is fractions.Fraction for e in snippet.bars[0].events)) def test_two_bar_ties_are_continuations_without_new_attacks(self): content = xml_bars([tests.test_musicxml.xml_note(extra=''), tests.test_musicxml.xml_note(extra='')]) snippet = self.load({'4-4/2/tied.xml': content}).for_meter('4/4')[0] self.assertEqual(len(snippet.bars), 2) self.assertEqual([bar.attacks() for bar in snippet.bars], [(0,), ()]) self.assertEqual([bar.events[0].continuation for bar in snippet.bars], [False, True]) self.assertEqual([bar.length for bar in snippet.bars], [4, 4]) def test_beat_comparison_counts_held_half_notes_and_quarter_reattacks(self): half = held_half_bar() silent_half = held_half_bar(False) quarters = quarter_bar(4, {0, 1, 3}) silent_quarter = quarter_bar(4, {1, 3}) for left, right, changed in ((half, silent_half, [0, 1]), (half, quarters, [1]), (quarters, silent_quarter, [0])): with self.subTest(left=left, right=right): self.assertEqual([beat for beat in range(4) if left.beat(beat) != right.beat(beat)], changed) def test_snippets_and_nested_events_are_immutable(self): library = tests.rhythm_fixtures.library() snippet = library.for_meter('4/4')[0] for value, name, replacement in ((snippet, 'meter', '3/4'), (snippet, 'bars', ()), (snippet.bars[0], 'events', ()), (snippet.bars[0].events[0], 'duration', fractions.Fraction(9))): with self.subTest(field=name), self.assertRaises(dataclasses.FrozenInstanceError): setattr(value, name, replacement) with self.assertRaises(TypeError): library.snippets['4/4'] = () with self.assertRaises(TypeError): snippet.bars[0].events[0] = snippet.bars[0].events[0] def test_supported_meters_and_source_order(self): sources = {} for beats in (2, 3, 4, 5): content = xml_bars([''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(beats))], beats=beats) for name in ('z', 'a'): sources[f'{beats}-4/1/{name}.xml'] = content library = self.load(sources) for beats in (2, 3, 4, 5): self.assertEqual([s.id for s in library.for_meter(f'{beats}/4')], [f'{beats}-4/1/a.xml', f'{beats}-4/1/z.xml']) self.assertTrue(all(s.bars[0].length == beats for s in library.for_meter(f'{beats}/4'))) def test_missing_empty_unsupported_and_misfiled_libraries_fail(self): with tempfile.TemporaryDirectory() as temporary, self.assertRaisesRegex(ValueError, 'not found'): generator.rhythm.load_library(pathlib.Path(temporary) / 'missing') with self.assertRaisesRegex(ValueError, 'empty'): self.load({}) source = xml_bars([tests.test_musicxml.xml_note()]) for path in ('snippet.xml', '4-4/snippet.xml', '4-4/3/snippet.xml', '4-4/1/nested/snippet.xml', '6-8/1/snippet.xml'): with self.subTest(path=path), self.assertRaisesRegex(ValueError, 'meter/series'): self.load({path: source}) with self.assertRaisesRegex(ValueError, 'meter disagrees'): self.load({'3-4/1/snippet.xml': source}) library = self.load({'4-4/1/snippet.xml': source}) for meter in ('2/4', '6/8', '7/4', '4/8', 'invalid'): with self.subTest(meter=meter), self.assertRaisesRegex(ValueError, 'rhythm snippets'): library.for_meter(meter) with self.assertRaisesRegex(ValueError, 'duplicate rhythm identity'): generator.rhythm.Library(library.for_meter('4/4') * 2) def test_invalid_source_formats_fail(self): quarter = tests.test_musicxml.xml_note(duration=1, kind='quarter') whole = tests.test_musicxml.xml_note() triplet = ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra='32eighth' if i in (0, 2) else '32eighth') for i in range(3)) cases = { 'three bars': xml_bars([whole] * 3), 'pickup': xml_bars([quarter], implicit=True), 'multiple voices': xml_bars([whole + '4' + tests.test_musicxml.xml_note('E', extra='2')]), 'meter change': xml_bars([whole, '' + quarter * 3]), 'mid-measure meter': xml_bars([quarter + '' + quarter * 3]), 'grace': xml_bars(['D4eighth' + whole]), 'spacer': xml_bars(['11' + quarter * 3]), 'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='1')]), 'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='')], divisions=3), 'malformed XML': '', } for name, content in cases.items(): with self.subTest(source=name), self.assertRaises(ValueError): self.load({'4-4/1/invalid.xml': content}) class AccompanimentPlanningTests(unittest.TestCase): def test_pickup_coverage_excludes_notes_that_ended_before_the_excerpt(self): measure = melody_measures(1)[0] measure.update(implicit=True, duration=generator.model.rational(1)) item = generator.accompaniment.context(measure, None) bar = quarter_bar(4, {0, 1}) self.assertEqual(generator.accompaniment.sounding_spans(bar, item), ()) self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[0], 1) def test_melody_context_uses_audible_hidden_notes_but_not_cues(self): measure = melody_measures(1)[0] notes = measure['voices'][0]['notes'] notes[0]['hidden'] = True notes[1]['cue'] = True notes[2]['pitches'][0]['cue'] = True item = generator.accompaniment.context(measure, None) self.assertEqual(item.attacks, frozenset((0, 3))) self.assertEqual(item.occupied, ((0, 1), (3, 4))) def test_fourth_bar_cannot_replace_a_held_half_note_as_one_beat_variation(self): sounding, silent = held_half_bar(), held_half_bar(False) library = generator.rhythm.Library((generator.rhythm.Snippet('sounding-half', '4/4', (sounding,)), generator.rhythm.Snippet('silent-half', '4/4', (silent,)))) content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note() measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] plan = generator.accompaniment.plan_section(measures, library) self.assertEqual(plan.acoustic_upper, (sounding,) * 4) self.assertEqual(plan.electric, (silent,) * 4) def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self): for beats in (2, 3, 4, 5): measures = melody_measures(12, beats=beats, dense=True) before = copy.deepcopy(measures) library = tests.rhythm_fixtures.library() plan = generator.accompaniment.plan_section(measures, library) for part in (plan.acoustic_bass, plan.acoustic_upper, plan.electric): with self.subTest(beats=beats, part=part): self.assertEqual(len(part), 12) self.assertEqual(part[:4], part[4:8]) self.assertEqual(part[:4], part[8:]) self.assertEqual(part[0], part[2]) self.assertLessEqual(sum(part[1].beat(i) != part[3].beat(i) for i in range(beats)), 1) self.assertTrue(all(bar in [b for s in library.for_meter(f'{beats}/4') for b in s.bars] for bar in part)) reordered = generator.rhythm.Library(tuple(reversed([s for group in library.snippets.values() for s in group]))) self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan) self.assertEqual(measures, before) def test_acoustic_and_electric_coordinate_when_space_is_available(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note() measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library()) for index in range(3): acoustic, electric = set(plan.acoustic_upper[index].attacks()), set(plan.electric[index].attacks()) self.assertTrue(acoustic) self.assertTrue(electric) self.assertFalse(acoustic & electric) self.assertNotIn(0, acoustic | electric) self.assertGreater(len(acoustic), len(electric)) def test_dense_melody_allows_collisions_and_keeps_accompaniment_audible(self): measures = melody_measures(8, dense=True) plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library()) for index, measure in enumerate(measures): melody = {generator.model.fraction(n['onset']) for n in measure['voices'][0]['notes']} for bars in (plan.acoustic_bass, plan.acoustic_upper, plan.electric): events = generator.accompaniment.realize(measure, bars[index], None) attacks = {generator.model.fraction(n['onset']) for n in events if n['kind'] == 'note'} if bars is not plan.electric: self.assertTrue(attacks, (index, bars)) self.assertTrue(attacks <= melody) self.assertTrue(any(bar.attacks() for bar in plan.electric)) def test_section_and_local_meter_changes_restart_the_motif(self): library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(f'{beats}-4/pair', f'{beats}/4', (quarter_bar(beats, {0}), quarter_bar(beats, {1}))) for beats in (3, 4))) segments = [melody_measures(3), melody_measures(2, beats=3), melody_measures(3), melody_measures(4)] segments[-1][0]['section_label'] = 'Bridge' measures = [m for segment in segments for m in segment] plan = generator.accompaniment.plan_section(measures, library) start = 0 for segment in segments: expected = generator.accompaniment.plan_section(segment, library) self.assertEqual(plan.acoustic_upper[start:start + len(segment)], expected.acoustic_upper) self.assertEqual(plan.acoustic_bass[start:start + len(segment)], expected.acoustic_bass) self.assertEqual(plan.electric[start:start + len(segment)], expected.electric) self.assertEqual(plan.acoustic_upper[start].attacks(), (0,)) start += len(segment) def test_mid_measure_meter_changes_and_unsupported_meters_fail(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='') measures = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'] with self.assertRaisesRegex(ValueError, 'measure 1.*mid-measure meter'): generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library()) content = '' source = tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra=''), content) with self.assertRaisesRegex(ValueError, '6/8'): generator.accompaniment.plan_section(tests.test_musicxml.parse(source)['staves'][0]['measures'], tests.rhythm_fixtures.library()) def test_carried_harmony_and_no_chord_control_active_intervals(self): content = tests.test_musicxml.xml_note() measures = tests.test_musicxml.parse(xml_bars([content, tests.test_parts.xml_harmony(kind='none', offset=2) + content, content]))['staves'][0]['measures'] initial = melody_measures(1)[0]['harmonies'][0] previous = generator.accompaniment.carried_chords(measures, initial) self.assertEqual(previous, [initial, initial, measures[1]['harmonies'][0]]) contexts = [generator.accompaniment.context(m, chord) for m, chord in zip(measures, previous)] self.assertEqual([item.intervals for item in contexts], [((0, 4),), ((0, 2),), ()]) plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library(), initial=initial) self.assertTrue(generator.accompaniment.sounding_attacks(plan.acoustic_upper[0], contexts[0])) self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic_upper[2], contexts[2]), ()) class AccompanimentRealizationTests(unittest.TestCase): def test_harmonic_gating_removes_dangling_beams(self): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=1) + tests.test_musicxml.xml_note(duration=8) measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0] bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'begin'),)), generator.rhythm.Event(fractions.Fraction(1, 2), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'end'),)), generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(3), False, 'half', 1)), fractions.Fraction(4)) events = generator.accompaniment.realize(measure, bar, None) self.assertEqual([n['kind'] for n in events], ['note', 'rest', 'rest']) self.assertTrue(all('beams' not in n for n in events)) def test_harmony_and_no_chord_cut_sustain_without_injecting_attacks(self): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4)) for kind in ('major', 'none'): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=3) + tests.test_parts.xml_harmony('G', offset=5) + tests.test_musicxml.xml_note(duration=8) measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0] before = copy.deepcopy(measure) events = generator.accompaniment.realize(measure, bar, None) self.assertEqual(timing(events), [(0, fractions.Fraction(3, 2), 'note'), (fractions.Fraction(3, 2), fractions.Fraction(5, 2), 'rest')]) self.assertEqual(events[0]['_harmony']['root']['letter'], 'c') self.assertEqual(measure, before) def test_chord_changes_preserve_authored_rhythm_rests_until_next_attack(self): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4)) content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=1) + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_parts.xml_harmony('G', offset=3) + tests.test_musicxml.xml_note() measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0] events = generator.accompaniment.realize(measure, bar, None) self.assertEqual(timing(events), [(0, 3, 'rest'), (3, 1, 'note')]) self.assertEqual(events[1]['_harmony']['root']['letter'], 'g') self.assertEqual(events[0]['written'], {'type': 'half', 'dots': 1}) def test_identical_and_coincident_chords_do_not_retrigger_sustains(self): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4)) content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F') + tests.test_parts.xml_harmony('F', offset=2) + tests.test_musicxml.xml_note() measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0] events = generator.accompaniment.realize(measure, bar, None) self.assertEqual(timing(events), [(0, 4, 'note')]) self.assertEqual(events[0]['_harmony']['root']['letter'], 'f') def test_pickup_uses_right_edge_of_nominal_bar_with_exact_clipping(self): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4)) content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='quarter', extra='') measure = tests.test_musicxml.parse(xml_bars([content], divisions=2, implicit=True))['staves'][0]['measures'][0] self.assertEqual(generator.accompaniment.context(measure, None).offset, fractions.Fraction(5, 2)) events = generator.accompaniment.realize(measure, bar, None) self.assertEqual(timing(events), [(0, fractions.Fraction(1, 2), 'rest'), (fractions.Fraction(1, 2), 1, 'note')]) self.assertEqual(events[1]['written'], {'type': 'quarter', 'dots': 0}) library = generator.rhythm.Library((generator.rhythm.Snippet('pickup', '4/4', (bar,)),)) plan = generator.accompaniment.plan_section([measure], library) self.assertEqual(plan.acoustic_upper, (bar,)) self.assertEqual(plan.acoustic_bass, (bar,)) self.assertEqual(plan.electric, (bar,)) class TiedAccompanimentTests(unittest.TestCase): def source(self, root='C', kind='major', offset=0): first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='') second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='') return tests.test_musicxml.parse(xml_bars([first, second])) def assert_tied_chord(self, first, continuation): self.assertTrue(first['pitches']) self.assertEqual(len(first['pitches']), len(continuation['pitches'])) for previous, current in zip(first['pitches'], continuation['pitches']): self.assertEqual(current['tie_from'], previous['id']) self.assertEqual(previous['tie_to'], current['id']) self.assertEqual(generator.model.pitch_number(current), generator.model.pitch_number(previous)) self.assertEqual(current['tablature'], previous['tablature']) for note in (first, continuation): self.assertNotIn('_rhythm_continuation', note) for pitch in note['pitches']: self.assertEqual(pitch['duration'], note['duration']) self.assertEqual(pitch['written'], note['written']) def test_whole_note_tie_keeps_second_bar_audible_without_a_new_attack(self): self.check_tied_rhythm(4) def test_half_note_continuation_preserves_tone_duration_strings_and_following_rest(self): self.check_tied_rhythm(2) def check_tied_rhythm(self, continuation_duration): source = self.source() before = copy.deepcopy(source) library = tied_library(continuation_duration) bars = library.for_meter('4/4')[0].bars self.assertEqual(bars[1].attacks(), ()) plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) self.assertEqual(plan.acoustic_upper, bars) self.assertEqual(plan.acoustic_bass, bars) self.assertEqual(plan.electric, bars) result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=library) first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) self.assertEqual(timing(first), [(0, 4, 'note')]) expected = [(0, continuation_duration, 'note')] if continuation_duration == 2: expected.append((2, 2, 'rest')) self.assertEqual(second[1]['pitches'], []) self.assertEqual(timing(second), expected) self.assert_tied_chord(first[0], second[0]) first_tones = [p for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']] self.assertEqual({generator.model.pitch_number(p) % 12 for p in first_tones}, {0, 4, 7}) self.assert_tied_chord(result['staves'][0]['measures'][0]['voices'][1]['notes'][0], result['staves'][0]['measures'][1]['voices'][1]['notes'][0]) generator.model.validate_document(result) timeline = generator.playback.compile_timeline(result) self.assertEqual(generator.model.fraction(timeline['duration']), 8) self.assertEqual(len(timeline['events']), len(first_tones)) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events'])) self.assertEqual(source, before) self.assertEqual(library.for_meter('4/4')[0].bars, bars) def test_changed_harmony_breaks_rhythm_ties_without_inventing_attacks(self): for root, kind in (('F', 'major'), ('C', 'minor'), ('C', 'none')): with self.subTest(root=root, kind=kind): source = self.source(root, kind) before = copy.deepcopy(source) result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2)) first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) self.assertEqual(timing(first), [(0, 4, 'note')]) self.assertEqual(timing(second), [(0, 2, 'rest'), (2, 2, 'rest')]) self.assertEqual({generator.model.pitch_number(p) % 12 for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'] for p in n['pitches']}, {0, 4, 7}) self.assertEqual(second[0]['pitches'], []) for note in first + second: for pitch in note['pitches']: self.assertNotIn('tie_from', pitch) self.assertNotIn('tie_to', pitch) self.assertEqual(pitch['duration'], note['duration']) generator.model.validate_document(result) events = generator.playback.compile_timeline(result)['events'] self.assertEqual({(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in events}, {(0, 4)}) self.assertEqual(source, before) def test_harmony_change_inside_continuation_cuts_tied_sound_and_preserves_rest(self): source = self.source('F', offset=1) result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2)) first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1) self.assertEqual(timing(first), [(0, 4, 'note')]) self.assertEqual(timing(second), [(0, 1, 'note'), (1, 1, 'rest'), (2, 2, 'rest')]) self.assert_tied_chord(first[0], second[0]) generator.model.validate_document(result) events = generator.playback.compile_timeline(result)['events'] self.assertEqual(len(events), sum(len(n['pitches']) for v in result['staves'][0]['measures'][0]['voices'] for n in v['notes'])) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 5 for e in events))