189 lines
12 KiB
Python
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()
|