"""Acoustic attack assignment across musical, rather than storage, boundaries.""" import generator.validation import copy import dataclasses import fractions import unittest import generator.acoustic import generator.comping import generator.comping_rhythm import generator.fretboard import generator.harmony import generator.model import generator.score import generator.vocabulary import tests.solo_fixtures F = fractions.Fraction class AcousticPairs(unittest.TestCase): def assign(self, onsets, durations=None, harmony=None, breaks=()): events = tuple(generator.comping_rhythm.Attack('acoustic', F(a), F(a) + F(d)) for a, d in zip(onsets, durations or ['1/2'] * len(onsets))) harmony = harmony or [(F(0), F(40), generator.harmony.parse_symbol('C7'))] return generator.comping_rhythm.assign_acoustic(events, harmony, breaks) def test_unpaired_and_nonoverlapping_pairs(self): for onsets, lanes in ( (('0', '1', '2'), ('combined',) * 3), (('0', '1/2'), ('bass', 'upper')), (('0', '1/2', '1'), ('bass', 'upper', 'combined')), (('0', '1/2', '1', '3/2'), ('bass', 'upper', 'bass', 'upper')), (('0', '1/2', '1', '3/2', '2'), ('bass', 'upper', 'bass', 'upper', 'combined')), (('0', '1', '3/2', '3'), ('combined', 'bass', 'upper', 'combined'))): with self.subTest(onsets=onsets): events = self.assign(onsets) self.assertEqual(tuple(e.lane for e in events), lanes) self.assertEqual(tuple(e.start for e in events), tuple(map(F, onsets))) def test_pairing_uses_attack_spacing_not_written_duration(self): events = self.assign(('0', '1/2'), ('1', '1')) self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(3, 2))]) self.assertEqual(events[0].end, events[1].start) def test_each_attack_releases_previous_sound_in_both_directions(self): events = self.assign(('0', '1/2', '1'), ('2', '2', '1')) self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('combined', F(2))]) events = self.assign(('0', '1/2', '1', '3/2'), ('2', '2', '2', '2')) self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('bass', F(3, 2)), ('upper', F(7, 2))]) events = self.assign(('0', '1/2'), ('1', '3')) self.assertEqual(events[-1].end, F(7, 2)) def test_chord_change_breaks_pair_but_repeated_label_does_not(self): source = tests.solo_fixtures.song(count=2, chords=('C7', 'C7')) flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures']))) pattern = {'lengths': ['4', '4'], 'events': [{'onset': a, 'duration': '1/2', 'layer': 'acoustic'} for a in ('7/2', '4')]} events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic') self.assertEqual([e.lane for e in events], ['bass', 'upper']) flat['harmonies'][-1].update(generator.harmony.parse_symbol('FM7')) events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic') self.assertEqual([e.lane for e in events], ['combined', 'combined']) def test_pairing_at_barline_in_each_supported_meter(self): for beats, grouping in ((3, ()), (4, ()), (5, (3, 2)), (5, (2, 3))): with self.subTest(beats=beats, grouping=grouping): source = tests.solo_fixtures.song(count=2, meter=beats, chords=('C7',)) measures = source['staves'][0]['measures'] if grouping: for m in measures: m['meter']['groups'][0]['beats'] = list(grouping) m['time_signature'] = generator.model.meter_label(m['meter']) flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(measures))) pattern = {'lengths': [str(beats)] * 2, 'events': [ {'onset': str(t), 'duration': '1', 'layer': 'acoustic'} for t in (F(beats) - F(1, 2), F(beats))]} events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic') self.assertEqual([e.lane for e in events], ['bass', 'upper']) self.assertEqual(events[0].end, F(beats)) def test_release_rebinds_expired_anticipation_to_direct_harmony(self): source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7')) flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures']))) pattern = {'lengths': ['4', '4'], 'events': [ {'onset': '7/2', 'duration': '1', 'layer': 'acoustic', 'anticipate': True}, {'onset': '4', 'duration': '1', 'layer': 'acoustic'}]} events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True) self.assertEqual([e.lane for e in events], ['combined', 'combined']) self.assertIsNone(events[0].harmonic_time) self.assertEqual(events[0].end, 4) # A participation break prevents pairing; an expired anticipation must # revert to the current harmony rather than keep an unresolved label. raw, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True, assign=False) broken = generator.comping_rhythm.assign_acoustic(raw, generator.comping_rhythm.spans(flat, None), (F(4),)) self.assertEqual([e.lane for e in broken], ['combined', 'combined']) self.assertIsNone(broken[0].harmonic_time) self.assertEqual(broken[0].end, 4) # With no intervening attack the bounded anticipated hold stays intact. pattern['events'].pop() held, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True) self.assertEqual(held[0].harmonic_time, 4) self.assertEqual(held[0].end, F(9, 2)) def resources(self): resources = copy.deepcopy(generator.vocabulary.load()) policy = resources['accompaniment']['instruments']['acoustic'] policy['groove']['cycle_patterns'] = 1 policy['patterns'] = [{'id': 'test.cross_block', 'family': 'quarters', 'lengths': ['4', '4'], 'events': [{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '1/2', '2', '15/2')]}] return resources def test_pattern_boundary_carries_pair_and_odd_leftover(self): source = tests.solo_fixtures.song(count=8, chords=('C7',)) with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'): canonical = generator.comping.Canonical(source, 'acoustic', 'cross-block') self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'bass') self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['upper', 'combined']) measures = source['staves'][0]['measures'] rendered = [canonical(m, None, i) for i, m in enumerate(measures)] source['staves'][0]['measures'] = rendered source['staves'][0].update(arrangement_part_type='acoustic', tablature=True) generator.acoustic.combine(source) generator.validation.validate_document(source) generator.fretboard.validate_document(source) generator.acoustic.validate_comping(source) attacks = [n for n in generator.score.measure_notes(rendered[2]) if n['pitches']] strings = [{p['tablature']['string'] for p in n['pitches']} for n in attacks[:2]] self.assertEqual(strings[0], {2, 3, 4}) self.assertIn(strings[1], ({2, 3, 4, 5}, {2, 3, 4, 6})) def test_entry_and_handoff_break_pairs_without_restarting_pattern(self): source = tests.solo_fixtures.song(count=8, chords=('C7',)) with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'): canonical = generator.comping.Canonical(source, 'acoustic', 'entry') measures = source['staves'][0]['measures'] entry = canonical.acoustic_realizations(measures, (2, 8)) self.assertEqual(entry[0].events[-1].lane, 'combined') self.assertEqual([e.lane for e in entry[2].events[:2]], ['bass', 'upper']) stop = canonical.acoustic_realizations(measures, stop=(0, F(1, 2))) self.assertEqual([(e.start, e.lane) for e in stop[0].events], [(F(0), 'combined')]) repeated = canonical.acoustic_realizations(measures) self.assertEqual(repeated[2].events, canonical.block_rhythms[2].events) def test_source_defined_partial_entry_is_consistent_in_every_iteration(self): source = tests.solo_fixtures.song(count=10, chords=('C7',)) with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'): canonical = generator.comping.Canonical(source, 'acoustic', 'partial') # The source-defined eight-bar introduction starts at measure index two. self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'combined') self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['bass', 'upper']) measures = source['staves'][0]['measures'] self.assertEqual(canonical(measures[2], None, 2), canonical(measures[2], None, 2, bounds=(2, 10))) def test_neighbor_harmony_change_invalidates_cached_pair(self): source = tests.solo_fixtures.song(count=8, chords=('C7',)) with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'): canonical = generator.comping.Canonical(source, 'acoustic', 'neighbor') measures = source['staves'][0]['measures'] before = canonical(measures[1], None, 1) changed = copy.deepcopy(measures) changed[2]['harmonies'][0].update(generator.harmony.parse_symbol('FM7')) after = canonical(changed[1], None, 1, sources=changed) def last_lanes(m): return {v['id'] for v in m['voices'] for n in v['notes'] if n['pitches'] and generator.model.fraction(n['onset']) == F(7, 2)} self.assertEqual(last_lanes(before), {'bass'}) self.assertEqual(last_lanes(after), {'bass', 'upper'}) def test_assignment_is_independent_of_computational_partition(self): onsets = tuple(map(F, ('0', '1/2', '7/2', '4', '9/2', '15/2', '8'))) full = self.assign(onsets) events = [generator.comping_rhythm.Attack('acoustic', a, a + F(1, 2)) for a in onsets] harmony = ((F(0), F(20), generator.harmony.parse_symbol('C7')),) for cut in (F(4), F(8)): parts = [[e for e in events if e.start < cut], [dataclasses.replace(e, start=e.start - cut, end=e.end - cut) for e in events if e.start >= cut]] restored = (*parts[0], *(dataclasses.replace(e, start=e.start + cut, end=e.end + cut) for e in parts[1])) self.assertEqual(full, generator.comping_rhythm.assign_acoustic(restored, harmony)) if __name__ == '__main__': unittest.main()