"""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_lanes_can_overlap_and_short_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') generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), 'acoustic') for events in ((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, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']]) 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 overlaps. 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, ('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.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()