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
+221
View File
@@ -0,0 +1,221 @@
"""Recurring source-position grooves, fills and chord-first bass behavior."""
import concurrent.futures
import copy
import dataclasses
import fractions
import multiprocessing
import pathlib
import tempfile
import unittest
import unittest.mock
import generator.accompaniment
import generator.acoustic
import generator.arrange.accompaniment
import generator.arrange.form
import generator.comping
import generator.comping_rhythm
import generator.fretboard
import generator.harmony
import generator.model
import generator.production
import generator.score
import generator.vocabulary
import tests.solo_fixtures
def groups(source):
measures = source['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
return [tuple((m, carried[i], i) for i, m in enumerate(measures[a:a + 2], a)) for a in range(0, len(measures), 2)]
def signature(measure):
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
[(generator.model.pitch_number(p), p.get('tablature'), bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']])
for n in generator.score.measure_notes(measure)]
class RecurringGrooves(unittest.TestCase):
def test_cycles_repeat_distinct_patterns_for_arbitrary_lengths(self):
for count in (9, 18, 32):
source = tests.solo_fixtures.song(count=count, chords=('C7',))
for part, width in (('acoustic', 2), ('electric', 4)):
canonical = generator.comping.Canonical(source, part, 'cycle')
self.assertEqual(canonical.groove_report[0]['cycle_patterns'], width)
patterns = [p for p, _ in canonical.patterns.values()]
identities = [generator.comping_rhythm.identity(p) for p in patterns[:width]]
self.assertEqual(len(set(identities)), width)
for i, p in enumerate(patterns):
self.assertEqual(generator.comping_rhythm.identity(p), identities[i % width])
# The odd final measure is the first half of its cycle's figure.
if count % 2:
self.assertEqual(canonical.block_rhythms[count - 1].events,
tuple(e for e in canonical.block_rhythms[(len(patterns) - 1) % width * 2].events if e.start < 4))
def test_whole_form_evaluation_can_reject_locally_best_pattern(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
resources = copy.deepcopy(generator.vocabulary.load())
policy = resources['accompaniment']['instruments']['electric']
policy['groove']['cycle_patterns'] = 1
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.quarters.statement')]
realize = generator.comping_rhythm.realize
def evaluated(pattern, inputs, *args, **kwargs):
candidate = realize(pattern, inputs, *args, **kwargs)
# Charleston wins the first two bars, but loses the full eight-bar span.
cost = (-1 if inputs[0][2] == 0 else 2) if 'charleston' in pattern['id'] else 0
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage')
self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected))
self.assertEqual(report[0]['cost'], 0)
def test_melody_and_progression_influence_selection_with_fixed_seed(self):
source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7'))
sparse = copy.deepcopy(source)
for m in sparse['staves'][0]['measures']:
m['voices'][0]['notes'] = [generator.model.make_event(m['id'] + '_held', fractions.Fraction(), fractions.Fraction(1, 2), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
choices = [generator.comping_rhythm.choose_cycles(groups(s), 'electric', generator.comping_rhythm.melody_contexts(s), 'fixed')[2] for s in (source, sparse)]
self.assertNotEqual(choices[0][0]['patterns'], choices[1][0]['patterns'])
changed = copy.deepcopy(source)
for m in changed['staves'][0]['measures']:
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(3, 2))})
other = generator.comping_rhythm.choose_cycles(groups(changed), 'electric', generator.comping_rhythm.melody_contexts(changed), 'fixed')[2]
self.assertNotEqual(choices[0][0]['cost'], other[0]['cost'])
def test_sparse_fill_replaces_only_second_bar_and_does_not_change_phase(self):
source = tests.solo_fixtures.song(count=32, chords=('C7',))
resources = copy.deepcopy(generator.vocabulary.load())
policy = resources['accompaniment']['instruments']['electric']
policy['groove']['cycle_patterns'] = 1
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.charleston.answer')]
# Section-ending evidence is source data. Deliberately put it at nearby
# positions to exercise spacing, including the cyclic end/start join.
for i in (2, 4, 12, 20, 30):
source['staves'][0]['measures'][i]['section_label'] = 'Source section'
realize = generator.comping_rhythm.realize
def evaluated(pattern, inputs, *args, **kwargs):
candidate = realize(pattern, inputs, *args, **kwargs)
cost = -1 if '.fill.' in pattern['id'] and pattern['id'].endswith('.answer') else 0 if pattern['id'].endswith('.statement') else 1
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
selected, locations, _ = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'fills')
fills = [v['fill']['source_index'] for v in locations if v['fill']]
self.assertGreater(len(fills), 0)
self.assertLessEqual(len(fills), 4)
for i, a in enumerate(fills):
for b in fills[i + 1:]:
self.assertGreaterEqual(min(abs(a - b), 32 - abs(a - b)), 8)
original = policy['patterns'][0]
for pattern, _, _ in selected:
self.assertEqual([r for r in pattern['events'] if fractions.Fraction(r['onset']) < 4], [r for r in original['events'] if fractions.Fraction(r['onset']) < 4])
self.assertTrue(all(v['cycle_slot'] == 0 for v in locations))
def test_real_source_fit_can_select_a_fill_without_mocked_scores(self):
source = tests.solo_fixtures.song(count=16, chords=('C7',))
resources = copy.deepcopy(generator.vocabulary.load())
policy = resources['accompaniment']['instruments']['electric']
policy['groove']['cycle_patterns'] = 1
policy['patterns'] = [{'id': 'test.' + name, 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
{'onset': '1/2', 'duration': '1/2', 'layer': 'electric'}, {'onset': onset, 'duration': '1/2', 'layer': 'electric'}]}
for name, onset in (('base', '11/2'), ('answer', '5'))]
# Most of the form favors the offbeat answer. At the final boundary the
# source enters on beat two after a breath, so the related on-beat answer
# supports that entrance. All contributions come from the real evaluator.
source['staves'][0]['measures'][-1]['voices'][0]['notes'].pop(0)
with generator.vocabulary.using(resources, 'electric_accompaniment'):
_, locations, reports = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'real-fill')
self.assertEqual(reports[0]['patterns'], ['test.base'])
self.assertEqual([r['fill']['source_index'] for r in locations if r['fill']], [15])
def test_meter_change_restarts_compatible_cycle_and_reports_reduction(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
shorter = tests.solo_fixtures.song(meter=3, count=4, chords=('C7',))
source['staves'][0]['measures'][4:] = shorter['staves'][0]['measures']
canonical = generator.comping.Canonical(source, 'electric', 'meter')
self.assertEqual([r['meter'] for r in canonical.groove_report], ['4/4', '3/4'])
self.assertTrue(all(r['reduction'] for r in canonical.groove_report))
self.assertEqual(canonical.locations[4]['cycle_slot'], 0)
def test_partial_entry_uses_second_half_instead_of_restarting(self):
source = tests.solo_fixtures.song(count=10, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'entry')
measures = source['staves'][0]['measures']
full = canonical(measures[3], measures[2]['harmonies'][0], 3)
sliced = canonical(measures[3], measures[2]['harmonies'][0], 3, bounds=(3, 10))
# The entry may attack a formerly carried chord; actual relative times
# and fret positions remain from the same second half of the figure.
def music(m):
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for n in generator.score.measure_notes(m)]
self.assertEqual(music(full), music(sliced))
def test_final_harmony_change_keeps_unaffected_rhythm_and_grips(self):
source = tests.solo_fixtures.song(count=8, chords=('C7',))
canonical = generator.comping.Canonical(source, 'acoustic', 'final')
original = source['staves'][0]['measures']
changed = copy.deepcopy(original)
changed[-1]['harmonies'] = [{**generator.harmony.parse_symbol('FM7'), 'id': 'final', 'onset': generator.model.rational(0)}]
before = canonical(original[6], original[5]['harmonies'][0], 6)
after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed)
self.assertEqual(signature(before), signature(after))
self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern'])
def test_overlap_survives_single_voice_merge_and_tablature(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
resources = copy.deepcopy(generator.vocabulary.load())
policy = resources['accompaniment']['instruments']['acoustic']
policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping']
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'overlap')
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(source['staves'][0]['measures'])]
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
generator.acoustic.validate_comping(source)
generator.acoustic.combine(source)
generator.model.validate_document(source)
generator.fretboard.validate_document(source)
generator.acoustic.validate_comping(source)
first = source['staves'][0]['measures'][0]
self.assertEqual(len(first['voices']), 1)
overlap = next(n for n in generator.score.measure_notes(first) if generator.model.fraction(n['onset']) == fractions.Fraction(1, 2))
self.assertEqual(len(overlap['pitches']), 4)
self.assertTrue(any(p.get('tie_from') and p['tablature']['string'] >= 5 for p in overlap['pitches']))
def test_mid_measure_chord_change_bass_without_forced_barline_attack(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
m = source['staves'][0]['measures'][0]
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'middle', 'onset': generator.model.rational(2)})
pattern = {'id': 'test', 'family': 'quarters', 'lengths': ['4'], 'events': [
{'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.
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))
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()
roles = ('acoustic_accompaniment', 'electric_accompaniment')
clean = {r: generator.production.generate_role(source, r, root) for r in roles}
with tempfile.TemporaryDirectory() as directory:
for r in roles:
cold = generator.production.generate_role(source, r, root, cache_dir=directory)
warm = generator.production.generate_role(source, r, root, cache_dir=directory)
self.assertTrue(warm['cache_hit'])
self.assertEqual(clean[r]['document'], cold['document'])
self.assertEqual(clean[r]['document'], warm['document'])
generator.production.generate_role(source, roles[1], root, {'seed': 'different'}, directory)
self.assertEqual(clean[roles[0]]['document'], generator.production.generate_role(source, roles[0], root, cache_dir=directory)['document'])
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
futures = {r: pool.submit(generator.production._job, (source, r, root, {}, None)) for r in reversed(roles)}
for r, future in futures.items():
self.assertEqual(clean[r]['document'], future.result()['document'])
def test_reuse_audit_checks_rests_and_all_six_roles(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
for role in generator.production.ROLES:
part = role.split('_')[0]
wanted = 'slash' if role.endswith('_solo') else 'vocal' if part == 'vocal' else 'melody' if role.endswith('_melody') else 'accompaniment'
section = generator.arrange.form.ArrangementSection('Repeated role', 0, 2, 1, wanted)
doc = copy.deepcopy(source)
doc['staves'][0]['measures'] *= 2
doc = copy.deepcopy(doc)
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section, section]):
result = generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
self.assertEqual(result[role]['reused_bars'], 2)
# Remove music only in the second occurrence; reuse must fail.
doc['staves'][0]['measures'][2] = copy.deepcopy(doc['staves'][0]['measures'][2])
n = doc['staves'][0]['measures'][2]['voices'][0]['notes'][0]
n.update(kind='rest', pitches=[])
with self.assertRaisesRegex(ValueError, 'canonical'):
generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
if __name__ == '__main__':
unittest.main()
+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))