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()
+2 -2
View File
@@ -176,10 +176,10 @@ class RecurringGrooves(unittest.TestCase):
{'onset': '1/2', 'duration': '1', 'layer': 'upper'}, {'onset': '5/2', 'duration': '1/2', 'layer': 'upper'}]}
events, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic')
self.assertEqual([(e.start, e.lane) for e in events], [(fractions.Fraction(1, 2), 'combined'), (fractions.Fraction(5, 2), 'combined')])
# A computational boundary inside continuing harmony must not add bass.
# Unpaired attacks include bass even when the harmony is unchanged.
inputs = ((source['staves'][0]['measures'][1], source['staves'][0]['measures'][1]['harmonies'][0], 1),)
r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source))
self.assertTrue(all(e.lane == 'upper' for e in r.events))
self.assertTrue(all(e.lane == 'combined' for e in r.events))
def test_clean_cached_parallel_and_other_role_settings_keep_groove(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7')))
root = pathlib.Path('data').resolve()
+3 -3
View File
@@ -60,7 +60,7 @@ class LayeredComping(unittest.TestCase):
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic')
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
for events in ((attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
@@ -77,7 +77,7 @@ class LayeredComping(unittest.TestCase):
self.assertIn(events[0].lane, ('bass', 'combined'))
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual(events[-1].lane, 'upper')
self.assertEqual(events[-1].lane, 'combined')
self.assertEqual(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
@@ -89,7 +89,7 @@ class LayeredComping(unittest.TestCase):
{'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True},
{'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]}
events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True)
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('bass', fractions.Fraction(7, 2), 4, None)])
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('combined', fractions.Fraction(7, 2), 4, None)])
self.assertIn('layer_assignment', {kind for kind, _ in changes})
limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2))
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)])