Files
product-vamp/tests/test_solo.py
T
khodowany 5414caf30a
ci / checks (push) Successful in 1h53m40s
Soloing
2026-10-01 11:23:47 -05:00

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())