Files
product-vamp/tests/test_accompaniment.py
T
2026-09-26 10:12:51 -05:00

386 lines
27 KiB
Python

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'<time><beats>{beats}</beats><beat-type>4</beat-type></time>', divisions=divisions, implicit=implicit))
for index, content in enumerate(contents[1:], 2):
root.find('part').append(xml.etree.ElementTree.fromstring(f'<measure number="{index}">{content}</measure>'))
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='<tie type="start"/>')
second = tests.test_musicxml.xml_note(duration=continuation_duration, kind='whole' if continuation_duration == 4 else 'half', extra='<tie type="stop"/>')
if continuation_duration == 2:
second += '<note><rest/><duration>2</duration><type>half</type></note>'
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 = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
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='<dot/><beam number="1">begin</beam>')
content += tests.test_musicxml.xml_note(duration=1, kind='16th', extra='<beam number="1">end</beam><beam number="2">backward hook</beam>')
content += '<note><rest/><duration>4</duration><type>quarter</type></note>'
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='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')])
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='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification><notations><tuplet type="' + ('start' if i == 0 else 'stop') + '"/></notations>' if i in (0, 2) else '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>') for i in range(3))
cases = {
'three bars': xml_bars([whole] * 3),
'pickup': xml_bars([quarter], implicit=True),
'multiple voices': xml_bars([whole + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>')]),
'meter change': xml_bars([whole, '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'mid-measure meter': xml_bars([quarter + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'grace': xml_bars(['<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>' + whole]),
'spacer': xml_bars(['<forward><duration>1</duration><voice>1</voice></forward>' + quarter * 3]),
'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>')]),
'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='<dot/>')], divisions=3),
'malformed XML': '<broken',
'wrong root': '<score-timewise version="4.0"/>',
}
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') + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
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 = '<time><beats>6</beats><beat-type>8</beat-type></time>'
source = tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'), 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='<dot/>')
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='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
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))