412 lines
29 KiB
Python
412 lines
29 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.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'<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_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='<dot/>' if dots else '') if sounding else f'<note><rest/><duration>{duration}</duration><type>{kind}</type>{"<dot/>" if dots else ""}</note>'
|
|
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 = '<note><rest/><duration>4</duration><type>whole</type></note>'
|
|
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='<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, (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_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_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='<dot/>')
|
|
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='<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 DetachedAccompanimentTests(unittest.TestCase):
|
|
def source(self):
|
|
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>')
|
|
second = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
|
|
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)
|