Rhythm Patterns

This commit is contained in:
khodowany
2026-10-06 23:55:58 -05:00
parent d2f493c17b
commit 23b606e47f
11 changed files with 673 additions and 109 deletions
+15 -14
View File
@@ -53,15 +53,17 @@ class LayeredComping(unittest.TestCase):
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
def test_separate_lanes_can_overlap_and_short_combined_attacks_are_valid(self):
F = fractions.Fraction
attack = generator.comping_rhythm.Attack
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
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)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
@@ -71,10 +73,11 @@ class LayeredComping(unittest.TestCase):
continue
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
for start in (0, 4):
local = [e for e in events if start <= e.start < start + 4]
if local:
self.assertIn(local[0].lane, ('bass', 'combined'))
if events:
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(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
@@ -86,24 +89,22 @@ 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], [('bass', fractions.Fraction(3, 2), 2, None), ('upper', 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), ('bass', 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(limited, (events[0],))
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)])
def test_combined_first_attack_and_carried_hold_do_not_require_reattacks(self):
attack, F = generator.comping_rhythm.Attack, fractions.Fraction
valid = (attack('combined', F(3), F(5)), attack('bass', F(6), F(7)))
generator.comping_rhythm.validate_events(valid, 'acoustic', (4, 4))
generator.comping_rhythm.validate_events(valid[:1], 'acoustic', (4, 4))
invalid = (valid[0], attack('upper', F(6), F(7)))
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
generator.comping_rhythm.validate_events(invalid, 'acoustic', (4, 4))
generator.comping_rhythm.validate_events((valid[0], attack('upper', F(6), F(7))), 'acoustic', (4, 4))
def test_completed_score_rejects_upper_first_even_after_a_rest(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
# The final combined event must be longer than one beat.
# A long combined event remains available alongside short overlaps.
pattern['events'][-1]['duration'] = '2'
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
@@ -135,7 +136,7 @@ class LayeredComping(unittest.TestCase):
for c in anticipated:
for e in c.events:
if e.harmonic_time is not None:
self.assertIn(e.lane, ('upper', 'electric'))
self.assertIn(e.lane, ('bass', 'combined', 'upper', 'electric'))
self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2))
self.assertLess(e.harmonic_time, e.end)
self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates))