Files
product-vamp/tests/test_comping_rhythm.py
T
2026-10-06 22:13:50 -05:00

266 lines
18 KiB
Python

"""Independent rhythm contracts, including real overlapping guitar voices."""
import copy
import dataclasses
import fractions
import pathlib
import unittest
import generator.accompaniment
import generator.acoustic
import generator.arrange.accompaniment
import generator.comping
import generator.comping_rhythm
import generator.fretboard
import generator.harmonic_time
import generator.harmony
import generator.model
import generator.production
import generator.score
import generator.solo.analysis
import generator.solo.decoder
import generator.solo.harmonic
import generator.solo.learned
import generator.vocabulary
import tests.solo_fixtures
def sounding(document):
return [(m['number'], v['id'], n['onset'], n['duration'], [(p['letter'], p['alter'], p['octave'], p['duration'], p['tablature'], bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']]) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
class LayeredComping(unittest.TestCase):
def setUp(self):
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
self.resources = generator.vocabulary.load()
def block(self, part, source=None, melody=()):
source = source or self.source
measures = source['staves'][0]['measures'][:2]
inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures))))
flat = generator.comping_rhythm.flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m in measures)
return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths)
def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self):
expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'}
choices = {part: self.block(part) for part in ('acoustic', 'electric')}
for part, candidates in choices.items():
self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
for c in candidates:
generator.comping_rhythm.validate_events(c.events, part, (4, 4))
for start in (0, 4):
self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
a = {tuple(e.start for e in c.events) for c in choices['acoustic']}
e = {tuple(e.start for e in c.events) for c in choices['electric']}
self.assertFalse(a & e)
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):
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)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(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']
before = copy.deepcopy(patterns)
for pattern in patterns:
if pattern['lengths'] != ['4', '4']:
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'))
self.assertEqual(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{'id': 'absent', 'kind': 'none', 'onset': generator.model.rational(0)},
{**generator.harmony.parse_symbol('C7'), 'id': 'current', 'onset': generator.model.rational(1)},
{**generator.harmony.parse_symbol('FM7'), 'id': 'next', 'onset': generator.model.rational(2)}]
pattern = {'lengths': ['4'], 'events': [
{'onset': '0', 'duration': '1', 'layer': 'bass'},
{'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.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],))
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))
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.
pattern['events'][-1]['duration'] = '2'
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'bass-first')
measures = source['staves'][0]['measures']
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(measures)]
generator.acoustic.validate_comping(source)
first = source['staves'][0]['measures'][0]
first['voices'] = [v for v in first['voices'] if v['id'] != 'bass']
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
generator.acoustic.validate_comping(source)
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
source = copy.deepcopy(self.source)
for m in source['staves'][0]['measures'][:2]:
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
for part in ('acoustic', 'electric'):
for c in self.block(part, source):
self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4)
for e in c.events:
if e.harmonic_time is None:
self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6)))
def test_anticipation_binds_exact_upcoming_chord_and_keeps_direct_alternative(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
for part in ('acoustic', 'electric'):
candidates = self.block(part, source)
anticipated = [c for c in candidates if any(e.harmonic_time is not None for e in c.events)]
self.assertTrue(anticipated)
for c in anticipated:
for e in c.events:
if e.harmonic_time is not None:
self.assertIn(e.lane, ('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))
flat = generator.comping_rhythm.flatten(tuple((m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))))
pattern = next(p for p in self.resources['accompaniment']['instruments']['electric']['patterns'] if p['id'] == 'electric.forward_motion.statement')
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'electric', anticipate=True, limit=fractions.Fraction(4))
self.assertTrue(all(e.harmonic_time is None for e in events))
def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'][0]['kind'] = 'none'
self.assertTrue(all(not c.events for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'silence')))
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
def test_two_other_meters_use_authored_patterns(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'meter')
self.assertTrue(all(str(meter) + '_beat' in r.pattern_id for r in canonical.block_rhythms.values()))
for rhythm in canonical.rhythms.values():
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
def test_melody_changes_the_objective_without_changing_seed(self):
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8))
space = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)}
second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)}
common = first.keys() & second.keys()
self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common))
def test_tempo_changes_metadata_but_not_accompaniment_music(self):
changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240
changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}]
a, b = map(generator.production.SourceAnalysis.read, (self.source, changed))
self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
for role in ('acoustic_accompaniment', 'electric_accompaniment'):
first = generator.production.generate_role(a, role, pathlib.Path('data'))
second = generator.production.generate_role(b, role, pathlib.Path('data'))
self.assertEqual(sounding(first['document']), sounding(second['document']))
self.assertEqual(first['details'], second['details'])
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
source = generator.production.SourceAnalysis.read(self.source)
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
# Silence in source-defined solo slots is sufficient to test union;
# no generated solo participates in these accompaniment decisions.
for part in ('acoustic', 'electric'):
empty = copy.deepcopy(results[part + '_accompaniment'])
for m in empty['document']['staves'][0]['measures']:
for v in m['voices']:
for n in v['notes']:
n.update(kind='rest', pitches=[])
results[part + '_solo'] = empty
assembled = generator.production.assemble(source, results)
self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures']))
audit = generator.arrange.accompaniment.audit(self.source, assembled)
self.assertGreater(audit['acoustic']['reused_bars'], 0)
for doc in assembled.values():
generator.model.validate_document(doc)
generator.fretboard.validate_document(doc)
def test_combined_voices_reject_conflicting_strings_and_fret_spans(self):
for string, fret in ((5, 3), (2, 10)):
doc = tests.solo_fixtures.song(count=1, chords=('C',))
staff = doc['staves'][0]
staff['tablature'] = True
m = staff['measures'][0]
pitches = [{'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': 5, 'fret': 3}}, {'letter': 'c', 'alter': 0, 'octave': 4, 'tablature': {'string': string, 'fret': fret}}]
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
generator.fretboard.validate_document(doc)
def test_explicit_cross_bar_hold_survives_projection_without_reattack(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')):
resources = copy.deepcopy(self.resources)
patterns = resources['accompaniment']['instruments'][part]['patterns']
resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id]
with generator.vocabulary.using(resources, part + '_accompaniment'):
canonical = generator.comping.Canonical(source, part, 'held')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')]
b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']}
cross = [p for p in a if p['tie_to'] in b]
self.assertTrue(cross)
for p in cross:
self.assertEqual(p['tablature'], b[p['tie_to']]['tablature'])
self.assertEqual(p['id'], b[p['tie_to']]['tie_from'])
if part == 'acoustic':
document = copy.deepcopy(source)
document['staves'][0]['measures'] = rendered
generator.acoustic.validate_comping(document)
def test_separate_repeated_attacks_are_never_joined(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['electric']['patterns'] = [{'id': 'test.repeated', 'family': 'quarters', 'lengths': ['4', '4'],
'events': [{'onset': str(t), 'duration': '4', 'layer': 'electric'} for t in (0, 4)]}]
with generator.vocabulary.using(resources, 'electric_accompaniment'):
canonical = generator.comping.Canonical(source, 'electric', 'repeat')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
notes = [n for m in rendered for n in generator.score.measure_notes(m) if n['pitches']]
self.assertEqual(len(notes), 2)
self.assertTrue(all(not p.get('tie_from') and not p.get('tie_to') for n in notes for p in n['pitches']))
class SoloRhythmConditioning(unittest.TestCase):
def test_melody_rhythm_and_progression_change_learned_probabilities(self):
examples = []
for held, future, duration in ((False, 'FM7', 1), (True, 'FM7', 2), (False, 'Dm7', 3)):
source = tests.solo_fixtures.song(count=2, chords=('C7', future))
if held:
m = source['staves'][0]['measures'][0]
m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
analysis = generator.solo.analysis.analyze(source)
harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]})
model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'}))
probabilities = []
for example in examples:
decoder = generator.solo.decoder.Decoder(example['analysis'], example['harmony'], model, 'acoustic', fractions.Fraction(), fractions.Fraction(4), seed='fixed')
harmonic, root, phase, source, _ = decoder.context(fractions.Fraction())
context = generator.solo.learned.contexts(harmonic, phase, source, ())
probabilities.append(model.probability(generator.solo.learned.token(fractions.Fraction(1), 64, None, root), context))
self.assertGreater(probabilities[0], probabilities[1])
self.assertGreater(probabilities[0], probabilities[2])
if __name__ == '__main__':
unittest.main()