"""Meter-specific vocabulary and source-derived quintuple grouping.""" import concurrent.futures import copy import fractions import json import multiprocessing import pathlib import tempfile import unittest import unittest.mock import generator.accompaniment import generator.comping import generator.comping_rhythm import generator.model import generator.production import generator.vocabulary import tests.solo_fixtures import tests.test_comping_cycles F = fractions.Fraction class CompingMeters(unittest.TestCase): def source(self, beats=5, count=10, explicit=()): source = tests.solo_fixtures.song(meter=beats, count=count, chords=('C7', 'FM7')) if explicit: for m in source['staves'][0]['measures']: m['meter']['groups'][0]['beats'] = list(explicit) m['time_signature'] = generator.model.meter_label(m['meter']) source['metadata']['time_signature'] = source['staves'][0]['measures'][0]['time_signature'] return source def test_balanced_pools_and_all_families_reach_joint_evaluation(self): for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))): source = self.source(beats, 2, grouping) inputs = tests.test_comping_cycles.groups(source)[0] flat = generator.comping_rhythm.flatten(inputs) for role in ('acoustic', 'electric'): with self.subTest(beats=beats, grouping=grouping, role=role): policy = generator.vocabulary.accompaniment(role) pool = generator.comping_rhythm.patterns_for(policy, flat, (F(beats),) * 2, grouping) self.assertEqual(len(pool), 24) self.assertEqual(len({generator.comping_rhythm.identity(p) for p in pool}), 24) self.assertEqual(len({p['family'] for p in pool}), 11 if beats == 4 else 12) candidates = generator.comping_rhythm.candidates(flat, None, role, (), 'coverage', lengths=(F(beats),) * 2, grouping=grouping) self.assertEqual({p['family'] for p in pool}, {dict(c.families)['pattern'] for c in candidates}) for p in pool: fitted = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source)) self.assertIsNotNone(fitted, p['id']) generator.comping_rhythm.validate_events(fitted.events, role) for start in (0, beats): self.assertLessEqual(len({e.start for e in fitted.events if start <= e.start < start + beats}), 3 if role == 'acoustic' else 2) if role == 'electric': self.assertTrue(any(F(e['onset']) < beats < F(e['onset']) + F(e['duration']) for p in pool for e in p['events'])) def test_explicit_grouping_overrides_harmony_and_melody(self): source = self.source(explicit=(2, 3)) for m in source['staves'][0]['measures']: m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(3)}) grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source)) self.assertEqual(grouping, (2, 3)) self.assertEqual(evidence['reason'], 'written') def test_harmonic_boundary_then_melodic_entrances_then_attacks(self): for boundary in (2, 3): source = self.source() for m in source['staves'][0]['measures']: m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(boundary)}) grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source)) self.assertEqual(grouping, (boundary, 5 - boundary)) source = self.source() # Competing secondary pulses have equal attack counts. Only one follows # an actual rest; an uninterrupted reattack is weaker grouping evidence. other = 5 - boundary contexts = tuple(((F(other) - F(1, 2), F(other)), (F(other), F(other) + F(1, 2)), (F(boundary), F(boundary) + F(1, 2))) for _ in range(10)) grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts) self.assertEqual(grouping, (boundary, 5 - boundary)) contexts = tuple(((F(boundary), F(boundary + 1)),) for _ in range(10)) grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts) self.assertEqual(grouping, (boundary, 5 - boundary)) def test_same_chord_marking_is_not_a_harmonic_change(self): source = self.source() for m in source['staves'][0]['measures']: m['harmonies'].append({**m['harmonies'][0], 'onset': generator.model.rational(2)}) grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source)) self.assertEqual(grouping, (3, 2)) self.assertEqual(evidence['reason'], 'default_3+2') def test_not_just_equal_measure_length(self): source = self.source(3) m = source['staves'][0]['measures'][0] m.update(meter={'groups': [{'beats': [6], 'unit': 8}]}, time_signature='6/8') with self.assertRaisesRegex(ValueError, 'no comping vocabulary for 6/8'): generator.comping.Canonical(source, 'electric', 'compound') def test_schema_rejects_wrong_meter_lengths_and_missing_grouping(self): root = pathlib.Path('data/composition') contents = {p.stem: p.read_text() for p in root.glob('*.json')} for change in ({'meter': '3/4'}, {'grouping': []}, {'grouping': [4, 1]}): value = json.loads(contents['accompaniment']) pattern = next(p for p in value['instruments']['electric']['patterns'] if p['meter'] == '5/4') pattern.update(change) with self.assertRaises(ValueError): generator.vocabulary._load(tuple((name, json.dumps(value) if name == 'accompaniment' else text) for name, text in contents.items())) def test_full_cycles_odd_forms_and_grouping_consistency(self): for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))): source = self.source(beats, 9, grouping) reports = [] for role, width in (('acoustic', 2), ('electric', 4)): canonical = generator.comping.Canonical(source, role, 'cycles') report = canonical.groove_report[0] reports.append(report) self.assertEqual(report['cycle_patterns'], width) self.assertEqual(report['eligible_patterns'], 24) self.assertIsNone(report['reduction']) for location in canonical.locations.values(): slot = location['cycle_slot'] self.assertEqual(location['base_pattern'], report['patterns'][slot]) last = canonical.block_rhythms[8] self.assertTrue(all(0 <= e.start < e.end <= beats for e in last.events)) self.assertEqual(reports[0]['grouping'], reports[1]['grouping']) def test_grouping_changes_restart_and_fills_cannot_cross_groups(self): source = self.source(count=18, explicit=(3, 2)) for m in source['staves'][0]['measures'][9:]: m['meter']['groups'][0]['beats'] = [2, 3] m['time_signature'] = '2+3/4' for role in ('acoustic', 'electric'): canonical = generator.comping.Canonical(source, role, 'change') self.assertEqual([r['grouping'] for r in canonical.groove_report], [[3, 2], [2, 3]]) self.assertEqual(canonical.locations[9]['cycle_slot'], 0) for pattern, _ in canonical.patterns.values(): suffix = '.' + '+'.join(map(str, pattern['grouping'])) + '.' if '.fill.' in pattern['id']: self.assertTrue(all(suffix in p for p in pattern['id'].split('.fill.'))) def test_every_fill_candidate_uses_its_base_grouping(self): source = self.source(count=16, explicit=(2, 3)) checked = [] realize = generator.comping_rhythm.realize def inspect(pattern, *args, **kwargs): if '.fill.' in pattern['id']: checked.append(pattern['id']) self.assertTrue(all('.2+3.' in p for p in pattern['id'].split('.fill.'))) return realize(pattern, *args, **kwargs) with unittest.mock.patch.object(generator.comping_rhythm, 'realize', side_effect=inspect): generator.comping.Canonical(source, 'electric', 'fill-group') self.assertTrue(checked) def test_partial_pickup_and_mid_pattern_entry_keep_meter_and_phase(self): for beats in (3, 5): source = self.source(beats) m = copy.deepcopy(source['staves'][0]['measures'][0]) m['duration'] = generator.model.rational(F(3, 2)) candidates = generator.comping_rhythm.candidates(m, None, 'electric', (), 'pickup') self.assertTrue(candidates) self.assertTrue(all(0 <= e.start < e.end <= F(3, 2) for c in candidates for e in c.events)) for role in ('acoustic', 'electric'): canonical = generator.comping.Canonical(source, role, 'entry') measures = source['staves'][0]['measures'] carried = generator.accompaniment.carried_chords(measures) full = canonical(measures[3], carried[3], 3) partial = canonical(measures[3], carried[3], 3, bounds=(3, 10)) # Rest engraving can split differently at a sliced boundary. Sounded # onsets, releases, voicings and tie relationships must remain equal. self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]]) def test_unused_meter_and_other_role_data_do_not_reseed(self): resources = generator.vocabulary.load() measures = self.source(4)['staves'][0]['measures'] baseline = generator.production.dependencies('acoustic_accompaniment', resources, measures) changed = copy.deepcopy(resources) for role, policy in changed['accompaniment']['instruments'].items(): for p in policy['patterns']: if role == 'electric' or p['meter'] in ('3/4', '5/4'): p['events'] = [] self.assertEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures)) changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'] = [] self.assertNotEqual(baseline, generator.production.dependencies('acoustic_accompaniment', changed, measures)) def test_chord_changes_anticipations_and_cross_bar_ties(self): for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))): source = self.source(beats, 2, grouping) for m in source['staves'][0]['measures']: m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(beats - 1)}) inputs = tests.test_comping_cycles.groups(source)[0] for role in ('acoustic', 'electric'): pool = generator.comping_rhythm.patterns_for(generator.vocabulary.accompaniment(role), inputs[0][0], (F(beats),) * 2, grouping) anticipated = False for p in pool: result = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source), anticipate=True) self.assertIsNotNone(result, p['id']) generator.comping_rhythm.validate_events(result.events, role) for e in result.events: if e.harmonic_time is not None: anticipated = True self.assertEqual(e.harmonic_time - e.start, F(1, 2)) self.assertTrue(anticipated) def test_roles_are_deterministic_alone_cached_and_parallel(self): for meter in (3, 5): source = generator.production.SourceAnalysis.read(self.source(meter, 9)) roles = ('acoustic_accompaniment', 'electric_accompaniment') with tempfile.TemporaryDirectory() as cache: clean = {r: generator.production.generate_role(source, r, pathlib.Path('data')) for r in roles} for role in reversed(roles): cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache) warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=cache) self.assertEqual(clean[role]['document'], cold['document']) self.assertEqual(cold['document'], warm['document']) self.assertTrue(warm['cache_hit']) with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool: jobs = {r: pool.submit(generator.production._job, (source, r, pathlib.Path('data'), {}, None)) for r in reversed(roles)} for role, job in jobs.items(): self.assertEqual(clean[role]['document'], job.result()['document']) if __name__ == '__main__': unittest.main()