Files
product-vamp/tests/test_comping_rhythm.py
T
khodowany c7f6a30a13 Comping
2026-10-06 21:28:42 -05:00

211 lines
14 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)
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(any(c.events[0].lane == 'upper' for c in choices['acoustic']))
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_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()