Rhythm Comping Pairs
ci / checks (push) Successful in 56m36s

This commit is contained in:
khodowany
2026-10-07 20:57:23 -05:00
parent a0e13434e4
commit 3a01b4b6bd
10 changed files with 849 additions and 555 deletions
+157
View File
@@ -0,0 +1,157 @@
"""Acoustic attack assignment across musical, rather than storage, boundaries."""
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)), ('upper', F(3, 2))])
# Bass keeps ringing, without another bass attack under the upper chord.
self.assertGreater(events[0].end, events[1].start)
def test_same_strings_release_but_different_strings_can_ring(self):
events = self.assign(('0', '1/2', '1'), ('2', '2', '1'))
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1)), ('upper', F(1)), ('combined', F(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) + F(1, 2))
def test_anticipated_pair_plays_same_future_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], ['bass', 'upper'])
self.assertEqual(events[0].harmonic_time, 4)
self.assertGreater(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)
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.model.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()