import argparse import copy import dataclasses import fractions import pathlib import tempfile import unittest import unittest.mock import generator.__main__ import generator.arrange.generate import generator.fretboard import generator.harmony import generator.model import generator.parts import generator.playback import generator.scales.library import generator.scales.selection import generator.scales.timeline import generator.score import generator.solo.analysis import generator.solo.arrangement import generator.solo.composer import generator.solo.notation import generator.solo.placement import generator.solo.planning import generator.solo.realization import generator.solo.report import generator.solo.sequences import generator.solo.types import tests.rhythm_fixtures import tests.scale_fixtures import tests.solo_fixtures import tests.test_musicxml def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')): document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G')))) first = document['staves'][0]['measures'][0] measures = [] for index in range(count): measure = copy.deepcopy(first) measure.update(id=f'measure_{index + 1}', number=str(index + 1), duration=generator.model.rational(meter), meter={'groups': [{'beats': [meter], 'unit': 4}]}, time_signature=f'{meter}/4') measure['voices'][0]['notes'] = [generator.model.make_event(f'source_{index}_{beat}', fractions.Fraction(beat), fractions.Fraction(1), 'note', [{'letter': 'cdefg'[beat % 5], 'octave': 4, 'alter': 0}]) for beat in range(meter)] measure['harmonies'] = [{**generator.harmony.parse_symbol(chords[index % len(chords)]), 'id': f'h_{index}', 'onset': generator.model.rational(0)}] if chords else [] for harmony in measure['harmonies']: harmony['symbol'] = generator.harmony.symbol(harmony) measures.append(measure) document['staves'][0]['measures'] = measures return generator.parts.prepare_document(document) def compose(document, part='acoustic', cutoff=None): analysis = generator.solo.analysis.analyze(document) measures = document['staves'][0]['measures'] end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff return generator.solo.composer.compose(analysis, measures, part, end, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library()) class SoloTests(unittest.TestCase): def test_catalog_intervals_and_negative_scale_indices(self): library = generator.solo.sequences.load_library(pathlib.Path('data/sequences')) self.assertEqual({k: len(v) for k, v in library.paths.items()}, {2: 14, 3: 196, 4: 2744}) self.assertEqual(len({s.steps for s in library.paths[4]}), 2744) self.assertTrue(all(s.offsets()[-1] == 0 for s in library.connecting(3, 0))) scale = tests.scale_fixtures.library().scale('major', 6, 'D') pitches = [generator.solo.types.scale_pitch(scale, i) for i in (2, 3, 5, 4)] self.assertEqual([p.label() for p in pitches], ['F4', 'G4', 'Bb4', 'A4']) self.assertEqual(generator.solo.types.scale_pitch(scale, -1).label(), 'C4') self.assertEqual(generator.solo.types.scale_pitch(scale, -8).label(), 'C3') self.assertEqual(generator.solo.types.scale_pitch(scale, 7).label(), 'D5') diminished = tests.scale_fixtures.library().scale('harmonic_minor', 7, 'C') self.assertEqual(generator.solo.types.scale_pitch(diminished, 6).label(), 'Bbb4') def test_invalid_sequences_fail_with_source(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) with self.assertRaisesRegex(ValueError, 'empty or missing'): generator.solo.sequences.load_library(root) path = root / '2_4/0001.xml' path.parent.mkdir() path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) with self.assertRaisesRegex(ValueError, '0001.xml.*2/4'): generator.solo.sequences.load_library(root) for steps in ((0,), (8,), (), (1, 1, 1, 1)): with self.assertRaisesRegex(ValueError, 'invalid'): generator.solo.sequences.Library((generator.solo.sequences.Sequence('bad', steps),)) def test_ties_are_one_attack_but_repeated_notes_are_two(self): content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='') content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='') content += tests.test_musicxml.xml_note(duration=2, kind='half') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) attacks = generator.solo.analysis.attacks(document) self.assertEqual([(n.start, n.end) for n in attacks], [(0, 2), (2, 4)]) self.assertEqual(len(attacks[0].sources), 2) self.assertEqual(attacks[0].pitch, attacks[1].pitch) def test_held_pitch_checked_across_every_collection(self): document = song(count=2, chords=('CM7', 'DmM7')) choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library()) self.assertIn(0, generator.solo.types.permitted(choices, 0, 4)) self.assertNotIn(0, generator.solo.types.permitted(choices, 3, 5)) self.assertIn(2, generator.solo.types.permitted(choices, 3, 5)) self.assertFalse(generator.solo.types.permitted(choices, 7, 9)) def test_targets_preserve_suspensions_alterations_and_extensions(self): for symbol, present, absent in (('Csus', 5, 4), ('Cm7b5', 6, 7), ('CmM7', 11, 10), ('C7b9', 1, 2)): pitches = generator.solo.planning.target_pitches(generator.harmony.parse_symbol(symbol), False) self.assertIn(present, pitches) self.assertNotIn(absent, pitches) def test_compose_all_meters_without_stretching(self): for meter in (2, 3, 4, 5): with self.subTest(meter=meter): source = song(meter, 3) result = compose(source) generator.solo.composer.validate(result) self.assertEqual(result.end, meter * 3) self.assertTrue(result.notes()) self.assertTrue(all(1 <= n.position.fret <= 20 for n in result.notes())) self.assertEqual(result, compose(source)) def test_motif_recurrence_and_whole_fragment_octave_quote(self): source = song(count=6, chords=('CM7',)) result = compose(source) self.assertLessEqual(len({p.plan.motif.id for p in result.phrases}), 3) self.assertEqual(result.phrases[0].plan.motif.id, result.phrases[1].plan.motif.id) self.assertFalse(any(d.treatment.startswith('quote') for p in result.phrases for d in p.decisions)) original = result.phrases[0].plan explicit = dataclasses.replace(original, treatment='exact_quote', target=None) first = generator.solo.realization.compose(explicit, source['staves'][0]['measures'], result.choices, tests.solo_fixtures.library(), tests.rhythm_fixtures.library(), result.tempo) quote = next(d for d in first.decisions if d.treatment.startswith('quote')) self.assertEqual(len(quote.notes), len(first.plan.motif.notes)) shifts = {a.pitch.number() - b.pitch.number() for a, b in zip(quote.notes, first.plan.motif.notes)} self.assertEqual(len(shifts), 1) self.assertEqual(next(iter(shifts)) % 12, 0) self.assertEqual([(n.start, n.end) for n in quote.notes], [(first.plan.start + n.start, first.plan.start + n.end) for n in first.plan.motif.notes]) result = dataclasses.replace(result, phrases=(first, *result.phrases[1:])) report = generator.solo.report.describe(result, tests.scale_fixtures.library()) self.assertIn('quote octave', report) self.assertIn('source events:', report) def test_no_harmony_is_silence_and_conflict_is_an_error(self): result = compose(song(chords=())) self.assertFalse(result.notes()) with self.assertRaisesRegex(ValueError, 'source measure 1.*No exact match'): compose(song(chords=('A13b9',))) def test_no_harmony_inside_a_motif_is_an_explicit_fragment(self): source = song(count=3, chords=('CM7',)) measure = source['staves'][0]['measures'][0] measure['harmonies'].append({'id': 'nc', 'onset': generator.model.rational(1), 'kind': 'none', 'degrees': [], 'symbol': 'N.C.'}) measure['harmonies'].append({**copy.deepcopy(measure['harmonies'][0]), 'id': 'return', 'onset': generator.model.rational(2)}) result = compose(source) self.assertTrue(result.notes()) self.assertFalse(any(n.start < 2 and n.end > 1 for n in result.notes())) self.assertTrue(any(c.scale is None and c.span.start == 1 and c.span.end == 2 for c in result.choices)) def test_microtones_are_not_rounded_into_the_catalog(self): with self.assertRaisesRegex(ValueError, 'semitones'): generator.solo.types.Pitch('c', fractions.Fraction(1, 2), 4) def test_pickup_cutoff_is_reserved_before_composition(self): result = compose(song(count=6), 'electric', fractions.Fraction(43, 2)) self.assertTrue(all(n.end <= fractions.Fraction(43, 2) for n in result.notes())) self.assertEqual(result.phrases[-1].plan.end, fractions.Fraction(43, 2)) def test_search_exhaustion_does_not_claim_impossibility(self): source = song() with unittest.mock.patch('generator.solo.realization.candidates', return_value=[]), self.assertRaisesRegex(generator.solo.realization.SearchExhausted, 'not proof'): compose(source) def test_time_aware_placement_does_not_penalize_absolute_frets(self): p = generator.solo.types.Pitch('c', fractions.Fraction(), 4) before = generator.solo.types.Note(0, 1, p, position=generator.fretboard.TabPosition(2, 1, 60)) after = generator.solo.types.Note(1, 2, p, position=generator.fretboard.TabPosition(2, 6, 65)) self.assertGreater(generator.solo.placement.travel(before, after, fractions.Fraction(180)), generator.solo.placement.travel(before, after, fractions.Fraction(90))) shifted = dataclasses.replace(before, position=generator.fretboard.TabPosition(2, 8, 67)) current = dataclasses.replace(after, position=generator.fretboard.TabPosition(2, 13, 72)) self.assertEqual(generator.solo.placement.travel(before, after, fractions.Fraction(120)), generator.solo.placement.travel(shifted, current, fractions.Fraction(120))) def test_notation_ties_cross_bars_and_keep_string_without_new_attack(self): document = song(count=2, chords=('CM7',)) note = generator.solo.types.Note(fractions.Fraction(3), fractions.Fraction(6), generator.solo.types.Pitch('e', fractions.Fraction(), 4), position=generator.fretboard.TabPosition(2, 5, 64)) document['staves'][0]['measures'] = generator.solo.notation.measures(document['staves'][0]['measures'], (note,), 'test') document['staves'][0]['tablature'] = True generator.model.validate_document(document) events = generator.playback.compile_timeline(document)['events'] self.assertEqual(len(events), 1) self.assertEqual(generator.model.fraction(events[0]['duration']), 3) self.assertEqual(generator.model.fraction(events[0]['pitch']), 64) def test_rational_motif_rhythm_has_visible_tuplets_and_exact_playback(self): document = song(count=1, chords=('CM7',)) pitch = generator.solo.types.Pitch('e', fractions.Fraction(), 4) notes = tuple(generator.solo.types.Note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), pitch, position=generator.fretboard.TabPosition(2, 5, 64)) for i in range(3)) replacement = generator.solo.notation.measures(document['staves'][0]['measures'], notes, 'triplet') generator.solo.notation.replace_region(document, 0, replacement) generator.model.validate_document(document) self.assertEqual(document['staves'][0]['tuplets'][0]['note_count'], 3) self.assertEqual(document['staves'][0]['tuplets'][0]['space_count'], 2) timeline = generator.playback.compile_timeline(document) self.assertEqual([generator.model.fraction(e['duration']) for e in timeline['events']], [fractions.Fraction(1, 3)] * 3) def test_source_tempo_and_fallback_are_quarter_note_rates(self): source = song() self.assertEqual(generator.solo.analysis.analyze(source).tempo, 120) source['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'written': {'type': 'half', 'dots': 0}, 'bpm': generator.model.rational(75)}] self.assertEqual(generator.solo.analysis.analyze(source).tempo, 150) def test_harmony_carries_into_the_solo_until_an_explicit_no_chord(self): source = song(count=2, chords=()) source['staves'][0]['measures'][1]['harmonies'] = [{'id': 'nc', 'onset': generator.model.rational(0), 'kind': 'none', 'symbol': 'N.C.'}] result = generator.solo.composer.compose(generator.solo.analysis.analyze(source), source['staves'][0]['measures'], 'acoustic', 8, initial=generator.harmony.parse_symbol('CM7'), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library(), rhythms=tests.rhythm_fixtures.library()) self.assertTrue(result.notes()) self.assertTrue(all(n.end <= 4 for n in result.notes())) def test_arrangement_preserves_head_and_vocal_and_backs_both_solos(self): source = song(count=3, chords=('CM7',)) rhythms = tests.rhythm_fixtures.library() plans = generator.arrange.generate.plan_arrangement(source, rhythms) parts = {p: generator.arrange.generate._arrange_document(source, p, plans) for p in generator.parts.PART_TYPES} before = copy.deepcopy(parts) generator.solo.arrangement.apply(source, parts, rhythms=rhythms, scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()) self.assertEqual(before['vocal'], parts['vocal']) regions = generator.solo.arrangement.regions(source) low, high = regions[3][0], regions[4][0] + regions[4][1] for part in ('acoustic', 'electric'): old, new = before[part]['staves'][0]['measures'], parts[part]['staves'][0]['measures'] self.assertEqual(old[:low], new[:low]) self.assertEqual(old[high:], new[high:]) for section in (3, 4): first, count, _, _ = regions[section] self.assertTrue(any(n['pitches'] for m in new[first:first + count] for n in generator.score.measure_notes(m))) generator.model.validate_document(parts[part]) def test_conflict_preserves_published_assets(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) (root / 'data/songs').mkdir(parents=True) path = root / 'data/songs/conflict.xml' path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) output = root / 'assets' output.mkdir() (output / 'accepted').write_text('previous build') args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()), unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()), unittest.mock.patch('generator.solo.arrangement.apply', side_effect=ValueError('No exact match within the permitted catalog')): with self.assertRaisesRegex(ValueError, 'conflict.xml.*No exact match'): generator.__main__.generate_assets(args) self.assertEqual([p.name for p in output.iterdir()], ['accepted']) self.assertEqual((output / 'accepted').read_text(), 'previous build') def test_unrelated_song_does_not_change_an_accepted_solo(self): with tempfile.TemporaryDirectory() as directory: root = pathlib.Path(directory) source, output = root / 'songs', root / 'assets/songs' source.mkdir() xml = tests.test_musicxml.xml_score('Cmajor-seventh' + ''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))) (source / 'accepted.xml').write_text(xml) resources = {'rhythms': tests.rhythm_fixtures.library(), 'scales': tests.scale_fixtures.library(), 'sequences': tests.solo_fixtures.library()} generator.arrange.generate.generate_performance_scores(source, output, **resources) before = {p: p.read_bytes() for p in root.joinpath('assets').rglob('*') if p.is_file()} (source / 'another.xml').write_text(xml.replace('Fixture', 'Another')) generator.arrange.generate.generate_performance_scores(source, output, **resources) self.assertEqual(before, {p: p.read_bytes() for p in before}) def test_quote_can_reselect_a_collection_without_changing_harmony(self): document = song(count=2, chords=('DmM7',)) timeline = generator.scales.timeline.read(document['staves'][0]) region = generator.scales.timeline.Solo(0, 8, 'solo') retained = ((0, 2, {11}),) result = generator.scales.selection.select(timeline, region, tests.scale_fixtures.library(), retained=retained) self.assertTrue(all(11 in c.scale.pitches for c in result)) self.assertTrue(all(set(generator.harmony.pitch_classes(c.span.chord)) <= c.scale.pitches for c in result)) self.assertEqual([c.span.chord for c in result], [s.chord for s in timeline]) def test_pitch_adaptation_crosses_collection_without_root_restart(self): document = song(count=2, chords=('CM7', 'DmM7')) choices = generator.scales.selection.select(generator.scales.timeline.read(document['staves'][0]), generator.scales.timeline.Solo(0, 8, 'solo'), tests.scale_fixtures.library()) pitches = (generator.solo.types.Pitch('b', fractions.Fraction(), 3), generator.solo.types.Pitch('c', fractions.Fraction(), 4)) paths = generator.solo.realization.adapt_path(pitches, ((3, 4), (4, 5)), choices, (55, 74)) self.assertTrue(paths) for path in paths: self.assertTrue(generator.solo.realization.valid_notes(path, choices)) self.assertLessEqual(abs(path[1].pitch.number() - path[0].pitch.number()), 3) def test_sequence_sources_are_packaged_and_tracked(self): self.assertIn('"data/sequences/**/*.xml"', pathlib.Path('conanfile.py').read_text()) self.assertIn("'data/sequences/**/*.xml'", pathlib.Path('.gitea/workflows/ci.yml').read_text()) self.assertIn('data/*.xml', pathlib.Path('cmake/zero.cmake').read_text())