Soloing
ci / checks (push) Successful in 50m39s

This commit is contained in:
khodowany
2026-10-01 06:31:14 -05:00
parent ce695a626a
commit eae432aa83
29 changed files with 1402 additions and 158 deletions
+8
View File
@@ -0,0 +1,8 @@
import functools
import itertools
import generator.solo.sequences
@functools.cache
def library():
return generator.solo.sequences.Library(tuple(generator.solo.sequences.Sequence(f'{count}_4/{index:04}.xml', steps) for count in (2, 3, 4) for index, steps in enumerate(itertools.product((1, -1, 2, -2), repeat=count - 1))))
+3 -1
View File
@@ -5,6 +5,8 @@ import unittest
import generator.arrange.generate
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class CatalogTests(unittest.TestCase):
@@ -13,7 +15,7 @@ class CatalogTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir():
metadata = json.loads(path.read_text())['metadata']
+3 -1
View File
@@ -25,12 +25,13 @@ import generator.pico_artwork
import generator.score
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml')", workflow)
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'generator/palette.json', 'data/songs/*.xml', 'data/rhythms/**/*.xml', 'data/scales/**/*.xml', 'data/sequences/**/*.xml')", workflow)
self.assertIn('python3 modulus.py python -m generator --renderer-fingerprint', workflow)
self.assertIn('restore-keys: product_vamp_pages_${{ steps.renderer.outputs.fingerprint }}_', workflow)
self.assertIn('path: build/build_common/engraving_cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
@@ -347,6 +348,7 @@ class EngravingCacheTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'must not overlap'):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=cache)
def test_parallel_generation_preserves_assets_on_worker_failure(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
data, output = self.root / 'data', self.root / 'output'
+4 -2
View File
@@ -20,6 +20,7 @@ import generator.playback
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class GuitarTests(unittest.TestCase):
@@ -248,11 +249,12 @@ class GuitarTests(unittest.TestCase):
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library()), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library())
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
def test_unplayable_generation_preserves_previous_assets(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
+2
View File
@@ -17,10 +17,12 @@ import generator.parts
import generator.pitch
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
class MusicModelTests(unittest.TestCase):
def setUp(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
engraving.start()
+3 -1
View File
@@ -19,6 +19,7 @@ import generator.playback
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.scale_fixtures
import tests.solo_fixtures
import tests.test_musicxml
def xml_harmony(root='C', kind='major', offset=0):
@@ -166,11 +167,12 @@ class PartGenerationTests(unittest.TestCase):
for name in names:
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library())
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_fixtures.library(), scales=tests.scale_fixtures.library(), sequences=tests.solo_fixtures.library())
for name in names:
self.assertIn(str(source / (name + '.xml')), str(raised.exception))
self.assertEqual([p.name for p in output.iterdir()], ['sentinel'])
def test_pipeline_parses_and_normalizes_each_source_once(self):
self.enterContext(unittest.mock.patch('generator.solo.sequences.load_library', return_value=tests.solo_fixtures.library()))
self.enterContext(unittest.mock.patch('generator.scales.library.load_library', return_value=tests.scale_fixtures.library()))
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
+9 -3
View File
@@ -42,6 +42,7 @@ class ScoreBundleTests(unittest.TestCase):
data, output = root / 'data', root / 'output'
(data / 'songs').mkdir(parents=True)
shutil.copytree(pathlib.Path('data/scales'), data / 'scales')
shutil.copytree(pathlib.Path('data/sequences/2_4'), data / 'sequences/2_4')
source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
@@ -49,12 +50,17 @@ class ScoreBundleTests(unittest.TestCase):
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
(data / 'rhythms' / 'drafts').mkdir(parents=True)
(data / 'sequences' / '3_4').mkdir(parents=True)
(data / 'sequences/3_4/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml', 'sequences/0001.xml', 'sequences/3_4/0002.xml'):
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
(data / path).write_text('<unfinished')
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
sequence = data / 'sequences/2_4/0001.xml'
saved = sequence.read_bytes()
sequence.write_text('<unfinished')
with self.assertRaisesRegex(ValueError, '0001.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
sequence.write_bytes(saved)
scale = data / 'scales/major_scale_modes/0002.xml'
previous = scale.read_bytes()
scale.write_text('<unfinished')
+262
View File
@@ -0,0 +1,262 @@
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)
first = result.phrases[0]
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])
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(d.treatment == 'fragment' for p in result.phrases for d in p.decisions))
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())