Files
product-vamp/tests/test_comping_rhythm.py
T
2026-10-06 02:57:37 -05:00

189 lines
12 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.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 test_candidates_preserve_all_counts_and_articulations(self):
m = self.source['staves'][0]['measures'][0]
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources)
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
self.assertEqual({len(c.events) for c in candidates}, expected)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True)
if lane == 'electric':
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
else:
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)})
if lane == 'bass':
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
def test_ranges_downbeats_and_overlap(self):
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light')
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures'])
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)):
result = canonical(m, prior, i)
voices = {v['id']: v['notes'] for v in result['voices']}
self.assertEqual(set(voices), {'bass', 'upper'})
self.assertTrue(voices['bass'][0]['pitches'])
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0)
for lane, high in (('bass', 2), ('upper', 3)):
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertTrue(1 <= len(attacks) <= high)
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report))
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report))
def test_combined_crowding_favors_shared_attacks(self):
m = self.source['staves'][0]['measures'][0]
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint')
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates))
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates))
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1)))
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2)))
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated))
def test_harmonic_changes_share_one_measure_budget(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
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'):
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes')
for c in choices:
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
count = sum(e.lane == lane for e in c.events)
self.assertLessEqual(count, high + 1)
if count > high:
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
for e in c.events:
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4)
if part == 'electric':
self.assertTrue(all(c.exceptions for c in choices))
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_preserve_full_bar_bounds(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')
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):
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2))
m = self.source['staves'][0]['measures'][0]
harmony = generator.comping_rhythm.spans(m, None)
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4))
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4)))
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
self.assertGreater(first['melody_support'], second['melody_support'])
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
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_sustained_join_is_one_attack_and_keeps_position(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'tie')
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))]
chord = inputs[0][0]['harmonies'][0]
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0]
for m, p, i in inputs:
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip))
note['voice'] = 'electric'
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}]
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0)
canonical.connect_holds(inputs)
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs]
for p, q in zip(a['pitches'], b['pitches']):
self.assertEqual(p['tie_to'], q['id'])
self.assertEqual(q['tie_from'], p['id'])
self.assertEqual(p['tablature'], q['tablature'])
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0)
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()