import tests.production_fixtures 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.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 setUp(self): tests.production_fixtures.install(self) 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_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_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].end, fractions.Fraction(43, 2)) 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_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.composer.compose', 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_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())