Files
product-vamp/tests/test_comping_rhythm.py
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

287 lines
20 KiB
Python

"""Independent rhythm contracts and their complete sounding durations."""
import generator.composition_types
import generator.arrange.assembly
import generator.composition
import generator.validation
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.score
import generator.source
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_attacks_cannot_overlap_and_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')
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
for events in ((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), (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(1, 3)),)):
with self.assertRaises(ValueError):
generator.comping_rhythm.validate_events(events, 'acoustic')
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']
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 for e in events], [fractions.Fraction(r['onset']) for r in pattern['events']])
for i, (e, r) in enumerate(zip(events, pattern['events'])):
intended = e.start + fractions.Fraction(r['duration'])
self.assertEqual(e.end, min(intended, events[i + 1].start) if i + 1 < len(events) else intended)
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, '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], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('combined', 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([(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))
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'}]}
# A long combined event remains available alongside short attacks.
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_completed_score_rejects_bass_under_upper_before_and_after_merge(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
source['staves'][0]['arrangement_part_type'] = 'acoustic'
source['staves'][0]['measures'][0]['voices'] = [
{'id': 'bass', 'notes': [generator.model.make_event('bass', fractions.Fraction(), fractions.Fraction(1), 'note', [
{'letter': 'c', 'alter': 0, 'octave': 3, 'tablature': {'string': 5, 'fret': 3}}])]},
{'id': 'upper', 'notes': [generator.model.make_event('upper', fractions.Fraction(1, 2), fractions.Fraction(1), 'note', [
{'letter': 'e', 'alter': 0, 'octave': 3, 'tablature': {'string': 4, 'fret': 2}}])]}]
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
generator.acoustic.validate_comping(source)
generator.acoustic.combine(source)
# The generic merger preserves imported overlaps faithfully. Generated
# accompaniment must fail even when the overlap is encoded using ties.
with self.assertRaisesRegex(ValueError, 'release before the next 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, ('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))
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.source.SourceAnalysis.read, (self.source, changed))
self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
for role in ('acoustic_accompaniment', 'electric_accompaniment'):
first = generator.composition.generate_role(a, role, pathlib.Path('data'))
second = generator.composition.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_releases_and_canonical_reuse(self):
source = generator.source.SourceAnalysis.read(self.source)
results = {role: generator.composition.generate_role(source, role, pathlib.Path('data')) for role in generator.composition_types.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.arrange.assembly.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.validation.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.source.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()