267 lines
18 KiB
Python
267 lines
18 KiB
Python
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='<tie type="start"/>')
|
|
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
|
|
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, anchor_options=(), gestures=(), passage_id='', passage_start=None, passage_end=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('<harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + ''.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())
|