import copy import dataclasses import fractions import pathlib import tempfile import unittest import xml.etree.ElementTree import generator.accompaniment import generator.model import generator.parts import generator.playback import tests.arrangement_fixtures 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_third_series_supports_long_notes_and_rests_in_every_meter(self): for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)): for sounding in (False, True): with self.subTest(beats=beats, sounding=sounding): content = tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='' if dots else '') if sounding else f'{duration}{kind}{"" if dots else ""}' if beats == 5: content += tests.test_musicxml.xml_note(duration=1, kind='quarter') snippet = self.load({f'{beats}_4/3/0001.xml': xml_bars([content], beats=beats)}).for_meter(f'{beats}/4')[0] self.assertEqual(snippet.bars[0].events[0], generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), sounding, kind, dots)) self.assertEqual(snippet.bars[0].length, beats) def test_all_three_series_participate_in_deterministic_long_note_selection(self): silence = '4whole' halves = tests.test_musicxml.xml_note(duration=2, kind='half') * 2 library = self.load({'4_4/1/0001.xml': xml_bars([silence] * 2), '4_4/2/0001.xml': xml_bars([silence]), '4_4/3/0001.xml': xml_bars([halves])}) self.assertEqual(len(library.for_meter('4/4')), 3) measures = melody_measures(8) plan = generator.accompaniment.plan_section(measures, library) expected = library.for_meter('4/4')[-1].bars[0] for lane in (plan.acoustic_bass, plan.acoustic_upper, plan.electric): self.assertEqual(lane, (expected,) * 8) reordered = generator.rhythm.Library(tuple(reversed(library.for_meter('4/4')))) self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan) 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/4/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, (sounding,) * 4) def test_electric_density_targets_two_stabs_per_four_quarter_beats(self): library = tests.rhythm_fixtures.library() for beats in (2, 3, 4, 5): with self.subTest(beats=beats): plan = generator.accompaniment.plan_section(melody_measures(4, beats=beats), library) for bar in plan.electric[:3]: self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2}) self.assertLessEqual(len(plan.electric[3].attacks()), (beats + 1) // 2 + 1) def test_upper_density_targets_three_attacks_per_four_quarter_beats(self): library = tests.rhythm_fixtures.library() for beats, counts in ((2, {1, 2}), (3, {2}), (4, {3}), (5, {4})): with self.subTest(beats=beats): plan = generator.accompaniment.plan_section(melody_measures(8, beats=beats, dense=True), library) for bar in plan.acoustic_upper[:3]: self.assertIn(len(bar.attacks()), counts) for bar in plan.acoustic_bass[:3]: self.assertIn(len(bar.attacks()), {beats // 2, (beats + 1) // 2}) self.assertEqual(plan.acoustic_upper[:4], plan.acoustic_upper[4:]) def test_upper_density_scales_with_pickup_and_active_harmony_duration(self): for beats in (1, 2, 3): for pickup in (False, True): with self.subTest(beats=beats, pickup=pickup): length = beats if pickup else 4 content = tests.test_parts.xml_harmony(offset=0 if pickup else 4 - beats) + tests.test_musicxml.xml_note(duration=1, kind='quarter') * length measure = tests.test_musicxml.parse(xml_bars([content], implicit=pickup))['staves'][0]['measures'][0] item = generator.accompaniment.context(measure, None) bar = quarter_bar(4, {0, 1, 2, 3}) self.assertEqual(item.offset, 4 - beats if pickup else 0) self.assertEqual(len(generator.accompaniment.sounding_attacks(bar, item)), beats) self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], 'upper')[2], fractions.Fraction(beats, 4)) def test_harmonic_coverage_outweighs_upper_density_target(self): content = ''.join(tests.test_parts.xml_harmony(root, offset=i) for i, root in enumerate(('C', 'F', 'G', 'C'))) + tests.test_musicxml.xml_note() measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0] item = generator.accompaniment.context(measure, None) sparse, covered = quarter_bar(4, {0, 1, 2}), quarter_bar(4, {0, 1, 2, 3}) sparse_rank = generator.accompaniment.rank(sparse, [(item, ())], 'upper') covered_rank = generator.accompaniment.rank(covered, [(item, ())], 'upper') self.assertEqual((sparse_rank[2], covered_rank[2]), (0, 1)) self.assertLess(covered_rank, sparse_rank) library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('sparse', sparse), ('covered', covered)))) plan = generator.accompaniment.plan_section([measure] * 3, library) self.assertEqual(plan.acoustic_upper, (covered,) * 3) def test_upper_density_is_not_an_attack_cap(self): bar = quarter_bar(4, {0, 1, 2, 3}) library = generator.rhythm.Library((generator.rhythm.Snippet('dense', '4/4', (bar,)),)) plan = generator.accompaniment.plan_section(melody_measures(4, dense=True), library) self.assertEqual(plan.acoustic_upper, (bar,) * 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_target_density_has_gaps(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note() measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures'] bass = quarter_bar(4, {0, 2}) electric = quarter_bar(4, {1, 3}) upper = generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i, 2), fractions.Fraction(1, 2), i in (1, 3, 5), 'eighth') for i in range(8)), fractions.Fraction(4)) library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(name, '4/4', (bar,)) for name, bar in (('bass', bass), ('electric', electric), ('upper', upper)))) plan = generator.accompaniment.plan_section(measures, library) self.assertEqual(plan.acoustic_bass, (bass,) * 4) self.assertEqual(plan.acoustic_upper, (upper,) * 4) self.assertEqual(plan.electric, (electric,) * 4) def test_equal_density_candidates_favor_gaps_around_melody_and_companions(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note() measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0] item = generator.accompaniment.context(measure, None) for role, companions, collision, gap in (('upper', (), {0, 1, 2}, {1, 2, 3}), ('electric', (2,), {0, 2}, {1, 3})): with self.subTest(role=role): ranks = [generator.accompaniment.rank(quarter_bar(4, attacks), [(item, companions)], role) for attacks in (gap, collision)] self.assertLess(ranks[0], ranks[1]) def test_equal_density_candidates_consider_sustained_melody(self): content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=2, kind='half') + '2half' measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0] item = generator.accompaniment.context(measure, None) occupied, gap = quarter_bar(4, {1, 3}), quarter_bar(4, {2, 3}) for role in ('upper', 'electric'): with self.subTest(role=role): self.assertLess(generator.accompaniment.rank(gap, [(item, ())], role), generator.accompaniment.rank(occupied, [(item, ())], role)) 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_electric_preserves_half_dotted_half_and_whole_durations(self): for beats, duration, kind, dots in ((2, 2, 'half', 0), (3, 3, 'half', 1), (4, 4, 'whole', 0), (5, 4, 'whole', 0)): with self.subTest(beats=beats): measure = melody_measures(1, beats=beats)[0] events = [generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(duration), True, kind, dots)] if beats == 5: events.append(generator.rhythm.Event(fractions.Fraction(4), fractions.Fraction(1), False, 'quarter')) notes = generator.accompaniment.realize(measure, generator.rhythm.Bar(tuple(events), fractions.Fraction(beats)), None, lane='electric') self.assertEqual(notes[0]['written'], {'type': kind, 'dots': dots}) self.assertEqual(generator.model.fraction(notes[0]['duration']), duration) self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), beats) self.assertEqual([generator.model.fraction(n['onset']) for n in notes if n['kind'] == 'note'], [0]) def test_clipped_long_notes_use_single_note_spelling_then_rests(self): bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4)) for kind in ('major', 'none'): for offset, divisions, expected in ((5, 2, fractions.Fraction(2)), (7, 2, fractions.Fraction(7, 2)), (4, 3, fractions.Fraction(4, 3))): with self.subTest(kind=kind, offset=offset, divisions=divisions): content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=offset) + tests.test_musicxml.xml_note(duration=4 * divisions) measure = tests.test_musicxml.parse(xml_bars([content], divisions=divisions))['staves'][0]['measures'][0] notes = generator.accompaniment.realize(measure, bar, None, lane='electric') self.assertEqual(generator.model.fraction(notes[0]['duration']), expected) self.assertEqual([n['kind'] for n in notes], ['note'] + ['rest'] * (len(notes) - 1)) self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes), 4) if divisions == 2: self.assertTrue(all('ratio' not in n['written'] for n in notes)) for left, right in zip(notes, notes[1:]): self.assertEqual(generator.model.fraction(left['onset']) + generator.model.fraction(left['duration']), generator.model.fraction(right['onset'])) def test_long_note_pickup_is_clipped_without_extending_the_measure(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_musicxml.xml_note(duration=3, kind='half', extra='') measure = tests.test_musicxml.parse(xml_bars([content], implicit=True))['staves'][0]['measures'][0] notes = generator.accompaniment.realize(measure, bar, None, lane='electric') self.assertEqual(timing(notes), [(0, 3, 'note')]) self.assertEqual(notes[0]['written'], {'type': 'half', 'dots': 1}) 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), 2, 'rest'), (fractions.Fraction(7, 2), fractions.Fraction(1, 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] for lane in ('bass', 'upper', 'electric'): events = generator.accompaniment.realize(measure, bar, None, lane=lane) 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 DetachedAccompanimentTests(unittest.TestCase): def source(self): first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='') second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='') return tests.test_musicxml.parse(xml_bars([first, second])) def test_rhythm_continuations_are_silent_in_all_accompaniment_lanes(self): source = self.source() first, second = source['staves'][0]['measures'] bars = tied_library(2).for_meter('4/4')[0].bars for lane in ('bass', 'upper', 'electric'): events = generator.accompaniment.realize(second, bars[1], first['harmonies'][0], lane=lane) self.assertTrue(all(n['kind'] == 'rest' and '_rhythm_continuation' not in n for n in events)) def test_continuation_only_bar_does_not_count_as_audible_accompaniment(self): measures = self.source()['staves'][0]['measures'] with self.assertRaisesRegex(ValueError, 'no rhythm motif'): generator.accompaniment.plan_section(measures, tied_library(4)) def test_planner_chooses_attacks_instead_of_continuation_only_bars(self): source = self.source() library = generator.rhythm.Library(tied_library(4).for_meter('4/4') + tests.rhythm_fixtures.library().for_meter('4/4')) plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library) for bar in (*plan.acoustic_bass, *plan.acoustic_upper): self.assertTrue(bar.attacks()) def test_acoustic_stops_at_barline_without_inventing_continuation_attacks(self): source = self.source() before = copy.deepcopy(source) bars = tied_library(2).for_meter('4/4')[0].bars plan = generator.accompaniment.Plan(bars, bars, ()) prepared = generator.parts.prepare_document(source) result = tests.arrangement_fixtures.section_document(prepared, 'acoustic', rhythms=tests.rhythm_fixtures.library(), plan=plan) generator.model.validate_document(result) self.assertEqual(timing(tests.test_musicxml.notes(result)), [(0, 4, 'note')]) self.assertEqual(timing(tests.test_musicxml.notes(result, 1)), [(0, 4, 'rest')]) timeline = generator.playback.compile_timeline(result) self.assertTrue(timeline['events']) self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 for e in timeline['events'])) for measure in result['staves'][0]['measures']: for voice in measure['voices']: for note in voice['notes']: for pitch in note['pitches']: self.assertNotIn('tie_from', pitch) self.assertNotIn('tie_to', pitch) self.assertEqual(source, before)