Improve Catalog
ci / checks (push) Successful in 46m42s

This commit is contained in:
khodowany
2026-09-27 11:03:43 -05:00
parent a416a80dd9
commit 142d843fac
418 changed files with 440 additions and 324488 deletions
+33 -32
View File
@@ -16,7 +16,7 @@ import generator.musicxml
import generator.parts
import generator.pitch
import generator.playback
import generator.practice.generate
import tests.arrangement_fixtures
import tests.rhythm_fixtures
import tests.test_musicxml
@@ -33,7 +33,7 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(cursor, generator.model.fraction(measure['duration']))
def test_dense_melody_keeps_rhythmic_accompaniment_without_sustaining_across_changes(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()), generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())):
for result in (tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library()), generator.arrange.generate.arrange_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())):
generator.model.validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
@@ -52,7 +52,7 @@ class PartGenerationTests(unittest.TestCase):
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
result = generator.practice.generate.practice_document(tests.test_musicxml.parse(source), 'acoustic', rhythms=tests.rhythm_fixtures.library())
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(source), 'acoustic', rhythms=tests.rhythm_fixtures.library())
for measure in result['staves'][0]['measures']:
self.assert_complete_measure(measure)
sounding = [n for n in tests.test_musicxml.notes(result) if n['pitches']]
@@ -80,9 +80,11 @@ class PartGenerationTests(unittest.TestCase):
self.assertTrue(all(n['_harmony']['root']['letter'] == 'f' for n in sounding))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')))
with self.assertRaisesRegex(ValueError, 'mid-measure meter'):
generator.practice.generate.practice_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
def test_exercise_006_has_accompaniment_in_every_measure(self):
result = generator.practice.generate.practice_document(generator.musicxml.load_musicxml(pathlib.Path('data/practice/0006.xml')), 'acoustic', rhythms=tests.rhythm_fixtures.library())
tests.arrangement_fixtures.section_document(source, 'acoustic', rhythms=tests.rhythm_fixtures.library())
def test_dense_melody_has_accompaniment_in_every_measure(self):
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(len(result['staves'][0]['measures']), 12)
for measure in result['staves'][0]['measures']:
self.assertTrue(any(n['pitches'] for v in measure['voices'] for n in v['notes']), measure['id'])
@@ -97,22 +99,21 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': -1}]
original = copy.deepcopy(source)
for builder, wrapper, practice in ((generator.practice.generate._practice_document, generator.practice.generate.practice_document, True), (generator.arrange.generate._arrange_document, generator.arrange.generate.arrange_document, False)):
prepared = generator.parts.prepare_document(source, practice=practice)
before = copy.deepcopy(prepared)
plan = generator.accompaniment.plan_section(prepared['staves'][0]['measures'], tests.rhythm_fixtures.library(), electric=False) if practice else generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
plan_before = copy.deepcopy(plan)
parts = [builder(prepared, part, plan) for part in generator.parts.PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(plan, plan_before)
self.assertEqual(parts, [wrapper(source, part, rhythms=tests.rhythm_fixtures.library()) for part in generator.parts.PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
tests.test_musicxml.notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
prepared = generator.parts.prepare_document(source)
before = copy.deepcopy(prepared)
plan = generator.arrange.generate.plan_arrangement(prepared, tests.rhythm_fixtures.library())
plan_before = copy.deepcopy(plan)
parts = [generator.arrange.generate._arrange_document(prepared, part, plan) for part in generator.parts.PART_TYPES]
self.assertEqual(prepared, before)
self.assertEqual(plan, plan_before)
self.assertEqual(parts, [generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library()) for part in generator.parts.PART_TYPES])
vocal = copy.deepcopy(parts[2])
parts[1]['metadata']['title'] = 'changed'
tests.test_musicxml.notes(parts[1])[0]['pitches'].clear()
if parts[1]['staves'][0]['octave_shifts']:
parts[1]['staves'][0]['octave_shifts'][0]['octaves'] = 9
self.assertEqual(parts[2], vocal)
self.assertEqual(prepared, before)
self.assertEqual(source, original)
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
content = ''
@@ -126,13 +127,13 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
before = copy.deepcopy(source)
expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']]
for builder in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document):
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document):
for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
starts = [0] if builder is generator.practice.generate.practice_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
starts = [0] if builder is tests.arrangement_fixtures.section_document else [i for i, m in enumerate(measures) if m.get('section_label') == ('Guitar Melody' if part == 'electric' else 'Vocal Melody')]
self.assertTrue(starts)
for start in starts:
notes = [n for m in measures[start:start + 2] for n in m['voices'][0]['notes']]
@@ -142,16 +143,16 @@ class PartGenerationTests(unittest.TestCase):
self.assertEqual(generated[key], original[key])
self.assertEqual(len(generated['pitches']), len(original['pitches']))
for pitch, tone in zip(original['pitches'], generated['pitches']):
octave = 12 if part == 'electric' and builder is generator.arrange.generate.arrange_document else 0
octave = 12 if part == 'electric' else 0
self.assertEqual(generator.model.pitch_number(tone), generator.model.pitch_number(pitch) + octave)
self.assertEqual(tone['written'], pitch['written'])
for key in ('tie_from', 'tie_to'):
self.assertEqual(key in tone, key in pitch)
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id'])
if builder is generator.practice.generate.practice_document:
if builder is tests.arrangement_fixtures.section_document:
events = generator.playback.compile_timeline(result)['events']
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 51)])
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)])
self.assertEqual(source, before)
def test_song_slug_collisions_fail_before_writes(self):
for names in (('one-two', 'one_two'), ('Song', 'song'), ('!!!', '???')):
@@ -171,8 +172,8 @@ class PartGenerationTests(unittest.TestCase):
def test_pipeline_parses_and_normalizes_each_source_once(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
for category, name in (('songs', 'song'), ('practice', '0001')):
(root / 'data' / category).mkdir(parents=True)
for category, name in (('songs', 'song'), ('songs', 'other')):
(root / 'data' / category).mkdir(parents=True, exist_ok=True)
(root / 'data' / category / (name + '.xml')).write_text(tests.test_musicxml.xml_score(xml_harmony() + tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=pathlib.Path('package/assets'), check=False)
with (
@@ -186,11 +187,11 @@ class PartGenerationTests(unittest.TestCase):
):
generator.__main__.generate_assets(args)
load_library.assert_called_once_with(args.data_dir.resolve() / 'rhythms')
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml'), unittest.mock.call(args.data_dir.resolve() / 'practice/0001.xml')])
self.assertEqual(load.call_args_list, [unittest.mock.call(args.data_dir.resolve() / 'songs/other.xml'), unittest.mock.call(args.data_dir.resolve() / 'songs/song.xml')])
self.assertEqual(normalize.call_count, 2)
original = {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()}
(root / 'data/practice/0002.xml').write_text('<broken')
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0002.xml'):
(root / 'data/songs/broken.xml').write_text('<broken')
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, 'broken.xml'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(args.output_dir): p.read_bytes() for p in args.output_dir.rglob('*') if p.is_file()})
self.assertEqual(len(list(args.output_dir.rglob('*.json'))), 6)