Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+1
View File
@@ -6,6 +6,7 @@ add_executable(product-vamp-test
test_catalog_runtime.cpp
test_health.cpp
test_pico.cpp
test_portal_artwork.cpp
test_playback.cpp
test_score_loader.cpp
test_touch_input.cpp
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+7 -7
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
from pathlib import Path
import pathlib
from generator import harmony
from generator.musicxml import load_musicxml
import generator.harmony
import generator.musicxml
def same(left, right, location: str) -> None:
if left != right:
@@ -16,9 +16,9 @@ def tuplet_members(staff: dict, identifier: str) -> list:
positions = event_positions(staff)
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
def audit_roundtrip(source: Path, output: Path) -> None:
before = load_musicxml(source)['staves'][0]
after = load_musicxml(output)['staves'][0]
def audit_roundtrip(source: pathlib.Path, output: pathlib.Path) -> None:
before = generator.musicxml.load_musicxml(source)['staves'][0]
after = generator.musicxml.load_musicxml(output)['staves'][0]
before_positions, after_positions = event_positions(before), event_positions(after)
before_instruments = {i['id']: i for i in before.get('instruments', [])}
after_instruments = {i['id']: i for i in after.get('instruments', [])}
@@ -36,7 +36,7 @@ def audit_roundtrip(source: Path, output: Path) -> None:
for a, b in zip(original['harmonies'], rendered['harmonies']):
for field in ('root', 'bass', 'onset'):
same(a.get(field), b.get(field), f'{location} harmony {field}')
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
same(generator.harmony.degrees(a), generator.harmony.degrees(b), f'{location} harmony degrees')
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
for first, second in zip(original['voices'], rendered['voices']):
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
+20
View File
@@ -0,0 +1,20 @@
import fractions
import functools
import generator.rhythm
@functools.cache
def library() -> generator.rhythm.Library:
snippets = []
for beats in (2, 3, 4, 5):
bars = []
for mask in range(1 << beats):
events = tuple(generator.rhythm.Event(fractions.Fraction(i), fractions.Fraction(1), bool(mask & (1 << i)), 'quarter') for i in range(beats))
bar = generator.rhythm.Bar(events, fractions.Fraction(beats))
bars.append(bar)
snippets.append(generator.rhythm.Snippet(f'{beats}-4/quarters/{mask}', f'{beats}/4', (bar,)))
for phase in (0, 1):
events = tuple(generator.rhythm.Event(fractions.Fraction(i, 2), fractions.Fraction(1, 2), i % 2 == phase, 'eighth') for i in range(beats * 2))
snippets.append(generator.rhythm.Snippet(f'{beats}-4/eighths/{phase}', f'{beats}/4', (generator.rhythm.Bar(events, fractions.Fraction(beats)),)))
snippets.append(generator.rhythm.Snippet(f'{beats}-4/pair', f'{beats}/4', (bars[1], bars[-1])))
return generator.rhythm.Library(tuple(snippets))
+1 -1
View File
@@ -97,7 +97,7 @@ auto score_asset_json(std::string title, std::string key = "C", std::string part
auto value = boost::json::parse(R"json({
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"id":"staff-lead","name":"Lead","measures":[{"id":"measure-1","voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[{"path":"fixture.pages/0001.png",
"presentation":{"title_image":"0000000000000000000000000000000000000000000000000000000000000000","width":976,"height":484,"pages":[{"path":"fixture.pages/0001.png",
"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]}
})json");
auto &metadata = value.as_object()["music"].as_object()["metadata"].as_object();
+378
View File
@@ -0,0 +1,378 @@
import copy
import dataclasses
import fractions
import pathlib
import tempfile
import unittest
import xml.etree.ElementTree
import generator.accompaniment
import generator.model
import generator.playback
import generator.practice.generate
import generator.rhythm
import tests.rhythm_fixtures
import tests.test_musicxml
import tests.test_parts
def xml_bars(contents, *, beats=4, divisions=1, implicit=False):
root = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(contents[0], f'<time><beats>{beats}</beats><beat-type>4</beat-type></time>', divisions=divisions, implicit=implicit))
for index, content in enumerate(contents[1:], 2):
root.find('part').append(xml.etree.ElementTree.fromstring(f'<measure number="{index}">{content}</measure>'))
return xml.etree.ElementTree.tostring(root, encoding='unicode')
def melody_measures(count, *, beats=4, dense=False):
notes = ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth' if dense else 'quarter') for _ in range(beats * (2 if dense else 1)))
content = tests.test_parts.xml_harmony() + notes
return tests.test_musicxml.parse(xml_bars([content] * count, beats=beats, divisions=2 if dense else 1))['staves'][0]['measures']
def quarter_bar(beats, attacks):
return generator.rhythm.Bar(tuple(generator.rhythm.Event(fractions.Fraction(i), fractions.Fraction(1), i in attacks, 'quarter') for i in range(beats)), fractions.Fraction(beats))
def timing(events):
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), n['kind']) for n in events]
def tied_library(continuation_duration):
first = tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_musicxml.xml_note(duration=continuation_duration, kind='whole' if continuation_duration == 4 else 'half', extra='<tie type="stop"/>')
if continuation_duration == 2:
second += '<note><rest/><duration>2</duration><type>half</type></note>'
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
path = root / '4-4/2/tied.xml'
path.parent.mkdir(parents=True)
path.write_text(xml_bars([first, second]), encoding='utf-8')
return generator.rhythm.load_library(root)
def held_half_bar(sounding=True):
return generator.rhythm.Bar((
generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(2), sounding, 'half'),
generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(1), False, 'quarter'),
generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter'),
), fractions.Fraction(4))
class RhythmLibraryTests(unittest.TestCase):
def test_unbracketed_time_modifications_are_rejected(self):
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
source = xml_bars([tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=extra) * 6], divisions=3)
with self.assertRaisesRegex(ValueError, 'time modifications'):
self.load({'4-4/1/ratio.xml': source})
def load(self, sources):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
for relative, content in sources.items():
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding='utf-8')
return generator.rhythm.load_library(root)
def test_snippet_retains_exact_timing_rests_dots_and_beams(self):
content = tests.test_musicxml.xml_note(duration=3, kind='eighth', extra='<dot/><beam number="1">begin</beam>')
content += tests.test_musicxml.xml_note(duration=1, kind='16th', extra='<beam number="1">end</beam><beam number="2">backward hook</beam>')
content += '<note><rest/><duration>4</duration><type>quarter</type></note>'
content += tests.test_musicxml.xml_note(duration=8, kind='half')
library = self.load({'4-4/1/dotted.xml': xml_bars([content], divisions=4)})
snippet = library.for_meter('4/4')[0]
self.assertEqual((snippet.id, snippet.meter), ('4-4/1/dotted.xml', '4/4'))
self.assertEqual(snippet.bars, (generator.rhythm.Bar((
generator.rhythm.Event(fractions.Fraction(0), fractions.Fraction(3, 4), True, 'eighth', 1, ((1, 'begin'),)),
generator.rhythm.Event(fractions.Fraction(3, 4), fractions.Fraction(1, 4), True, '16th', 0, ((1, 'end'), (2, 'backward hook'))),
generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(1), False, 'quarter'),
generator.rhythm.Event(fractions.Fraction(2), fractions.Fraction(2), True, 'half'),
), fractions.Fraction(4)),))
self.assertEqual(snippet.bars[0].attacks(), (0, fractions.Fraction(3, 4), 2))
self.assertTrue(all(type(e.onset) is fractions.Fraction and type(e.duration) is fractions.Fraction for e in snippet.bars[0].events))
def test_two_bar_ties_are_continuations_without_new_attacks(self):
content = xml_bars([tests.test_musicxml.xml_note(extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')])
snippet = self.load({'4-4/2/tied.xml': content}).for_meter('4/4')[0]
self.assertEqual(len(snippet.bars), 2)
self.assertEqual([bar.attacks() for bar in snippet.bars], [(0,), ()])
self.assertEqual([bar.events[0].continuation for bar in snippet.bars], [False, True])
self.assertEqual([bar.length for bar in snippet.bars], [4, 4])
def test_beat_comparison_counts_held_half_notes_and_quarter_reattacks(self):
half = held_half_bar()
silent_half = held_half_bar(False)
quarters = quarter_bar(4, {0, 1, 3})
silent_quarter = quarter_bar(4, {1, 3})
for left, right, changed in ((half, silent_half, [0, 1]), (half, quarters, [1]), (quarters, silent_quarter, [0])):
with self.subTest(left=left, right=right):
self.assertEqual([beat for beat in range(4) if left.beat(beat) != right.beat(beat)], changed)
def test_snippets_and_nested_events_are_immutable(self):
library = tests.rhythm_fixtures.library()
snippet = library.for_meter('4/4')[0]
for value, name, replacement in ((snippet, 'meter', '3/4'), (snippet, 'bars', ()), (snippet.bars[0], 'events', ()), (snippet.bars[0].events[0], 'duration', fractions.Fraction(9))):
with self.subTest(field=name), self.assertRaises(dataclasses.FrozenInstanceError):
setattr(value, name, replacement)
with self.assertRaises(TypeError):
library.snippets['4/4'] = ()
with self.assertRaises(TypeError):
snippet.bars[0].events[0] = snippet.bars[0].events[0]
def test_supported_meters_and_source_order(self):
sources = {}
for beats in (2, 3, 4, 5):
content = xml_bars([''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(beats))], beats=beats)
for name in ('z', 'a'):
sources[f'{beats}-4/1/{name}.xml'] = content
library = self.load(sources)
for beats in (2, 3, 4, 5):
self.assertEqual([s.id for s in library.for_meter(f'{beats}/4')], [f'{beats}-4/1/a.xml', f'{beats}-4/1/z.xml'])
self.assertTrue(all(s.bars[0].length == beats for s in library.for_meter(f'{beats}/4')))
def test_missing_empty_unsupported_and_misfiled_libraries_fail(self):
with tempfile.TemporaryDirectory() as temporary, self.assertRaisesRegex(ValueError, 'not found'):
generator.rhythm.load_library(pathlib.Path(temporary) / 'missing')
with self.assertRaisesRegex(ValueError, 'empty'):
self.load({})
source = xml_bars([tests.test_musicxml.xml_note()])
for path in ('snippet.xml', '4-4/snippet.xml', '4-4/3/snippet.xml', '4-4/1/nested/snippet.xml', '6-8/1/snippet.xml'):
with self.subTest(path=path), self.assertRaisesRegex(ValueError, 'meter/series'):
self.load({path: source})
with self.assertRaisesRegex(ValueError, 'meter disagrees'):
self.load({'3-4/1/snippet.xml': source})
library = self.load({'4-4/1/snippet.xml': source})
for meter in ('2/4', '6/8', '7/4', '4/8', 'invalid'):
with self.subTest(meter=meter), self.assertRaisesRegex(ValueError, 'rhythm snippets'):
library.for_meter(meter)
with self.assertRaisesRegex(ValueError, 'duplicate rhythm identity'):
generator.rhythm.Library(library.for_meter('4/4') * 2)
def test_invalid_source_formats_fail(self):
quarter = tests.test_musicxml.xml_note(duration=1, kind='quarter')
whole = tests.test_musicxml.xml_note()
triplet = ''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra='<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification><notations><tuplet type="' + ('start' if i == 0 else 'stop') + '"/></notations>' if i in (0, 2) else '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes><normal-type>eighth</normal-type></time-modification>') for i in range(3))
cases = {
'three bars': xml_bars([whole] * 3),
'pickup': xml_bars([quarter], implicit=True),
'multiple voices': xml_bars([whole + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>')]),
'meter change': xml_bars([whole, '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'mid-measure meter': xml_bars([quarter + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + quarter * 3]),
'grace': xml_bars(['<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>' + whole]),
'spacer': xml_bars(['<forward><duration>1</duration><voice>1</voice></forward>' + quarter * 3]),
'tremolo': xml_bars([tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo type="single">1</tremolo></ornaments></notations>')]),
'tuplet': xml_bars([triplet + tests.test_musicxml.xml_note(duration=9, kind='half', extra='<dot/>')], divisions=3),
'malformed XML': '<broken',
'wrong root': '<score-timewise version="4.0"/>',
}
for name, content in cases.items():
with self.subTest(source=name), self.assertRaises(ValueError):
self.load({'4-4/1/invalid.xml': content})
class AccompanimentPlanningTests(unittest.TestCase):
def test_pickup_coverage_excludes_notes_that_ended_before_the_excerpt(self):
measure = melody_measures(1)[0]
measure.update(implicit=True, duration=generator.model.rational(1))
item = generator.accompaniment.context(measure, None)
bar = quarter_bar(4, {0, 1})
self.assertEqual(generator.accompaniment.sounding_spans(bar, item), ())
self.assertEqual(generator.accompaniment.rank(bar, [(item, ())], False)[0], 1)
def test_melody_context_uses_audible_hidden_notes_but_not_cues(self):
measure = melody_measures(1)[0]
notes = measure['voices'][0]['notes']
notes[0]['hidden'] = True
notes[1]['cue'] = True
notes[2]['pitches'][0]['cue'] = True
item = generator.accompaniment.context(measure, None)
self.assertEqual(item.attacks, frozenset((0, 3)))
self.assertEqual(item.occupied, ((0, 1), (3, 4)))
def test_fourth_bar_cannot_replace_a_held_half_note_as_one_beat_variation(self):
sounding, silent = held_half_bar(), held_half_bar(False)
library = generator.rhythm.Library((generator.rhythm.Snippet('sounding-half', '4/4', (sounding,)), generator.rhythm.Snippet('silent-half', '4/4', (silent,))))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, library)
self.assertEqual(plan.acoustic, (sounding,) * 4)
self.assertEqual(plan.electric, (silent,) * 4)
def test_section_repeats_four_bar_motif_with_at_most_one_beat_variation(self):
for beats in (2, 3, 4, 5):
measures = melody_measures(12, beats=beats, dense=True)
before = copy.deepcopy(measures)
library = tests.rhythm_fixtures.library()
plan = generator.accompaniment.plan_section(measures, library)
for part in (plan.acoustic, plan.electric):
with self.subTest(beats=beats, part=part):
self.assertEqual(len(part), 12)
self.assertEqual(part[:4], part[4:8])
self.assertEqual(part[:4], part[8:])
self.assertEqual(part[0], part[2])
self.assertLessEqual(sum(part[1].beat(i) != part[3].beat(i) for i in range(beats)), 1)
self.assertTrue(all(bar in [b for s in library.for_meter(f'{beats}/4') for b in s.bars] for bar in part))
reordered = generator.rhythm.Library(tuple(reversed([s for group in library.snippets.values() for s in group])))
self.assertEqual(generator.accompaniment.plan_section(measures, reordered), plan)
self.assertEqual(measures, before)
def test_acoustic_and_electric_coordinate_when_space_is_available(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content] * 4))['staves'][0]['measures']
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
for index in range(3):
acoustic, electric = set(plan.acoustic[index].attacks()), set(plan.electric[index].attacks())
self.assertTrue(acoustic)
self.assertTrue(electric)
self.assertFalse(acoustic & electric)
self.assertNotIn(0, acoustic | electric)
self.assertGreater(len(acoustic), len(electric))
def test_dense_melody_allows_collisions_and_keeps_accompaniment_audible(self):
measures = melody_measures(8, dense=True)
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
for index, measure in enumerate(measures):
melody = {generator.model.fraction(n['onset']) for n in measure['voices'][0]['notes']}
for bars in (plan.acoustic, plan.electric):
events = generator.accompaniment.realize(measure, bars[index], None)
attacks = {generator.model.fraction(n['onset']) for n in events if n['kind'] == 'note'}
self.assertTrue(attacks, (index, bars))
self.assertTrue(attacks & melody)
def test_section_and_local_meter_changes_restart_the_motif(self):
library = generator.rhythm.Library(tuple(generator.rhythm.Snippet(f'{beats}-4/pair', f'{beats}/4', (quarter_bar(beats, {0}), quarter_bar(beats, {1}))) for beats in (3, 4)))
segments = [melody_measures(3), melody_measures(2, beats=3), melody_measures(3), melody_measures(4)]
segments[-1][0]['section_label'] = 'Bridge'
measures = [m for segment in segments for m in segment]
plan = generator.accompaniment.plan_section(measures, library)
start = 0
for segment in segments:
expected = generator.accompaniment.plan_section(segment, library)
self.assertEqual(plan.acoustic[start:start + len(segment)], expected.acoustic)
self.assertEqual(plan.electric[start:start + len(segment)], expected.electric)
self.assertEqual(plan.acoustic[start].attacks(), (0,))
start += len(segment)
def test_mid_measure_meter_changes_and_unsupported_meters_fail(self):
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=1, kind='quarter') + '<attributes><time><beats>3</beats><beat-type>4</beat-type></time></attributes>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
measures = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures']
with self.assertRaisesRegex(ValueError, 'measure 1.*mid-measure meter'):
generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library())
content = '<time><beats>6</beats><beat-type>8</beat-type></time>'
source = tests.test_musicxml.xml_score(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>'), content)
with self.assertRaisesRegex(ValueError, '6/8'):
generator.accompaniment.plan_section(tests.test_musicxml.parse(source)['staves'][0]['measures'], tests.rhythm_fixtures.library())
def test_carried_harmony_and_no_chord_control_active_intervals(self):
content = tests.test_musicxml.xml_note()
measures = tests.test_musicxml.parse(xml_bars([content, tests.test_parts.xml_harmony(kind='none', offset=2) + content, content]))['staves'][0]['measures']
initial = melody_measures(1)[0]['harmonies'][0]
previous = generator.accompaniment.carried_chords(measures, initial)
self.assertEqual(previous, [initial, initial, measures[1]['harmonies'][0]])
contexts = [generator.accompaniment.context(m, chord) for m, chord in zip(measures, previous)]
self.assertEqual([item.intervals for item in contexts], [((0, 4),), ((0, 2),), ()])
plan = generator.accompaniment.plan_section(measures, tests.rhythm_fixtures.library(), initial=initial)
self.assertTrue(generator.accompaniment.sounding_attacks(plan.acoustic[0], contexts[0]))
self.assertEqual(generator.accompaniment.sounding_attacks(plan.acoustic[2], contexts[2]), ())
class AccompanimentRealizationTests(unittest.TestCase):
def test_harmonic_gating_removes_dangling_beams(self):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony(kind='none', offset=1) + tests.test_musicxml.xml_note(duration=8)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'begin'),)), generator.rhythm.Event(fractions.Fraction(1, 2), fractions.Fraction(1, 2), True, 'eighth', beams=((1, 'end'),)), generator.rhythm.Event(fractions.Fraction(1), fractions.Fraction(3), False, 'half', 1)), fractions.Fraction(4))
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual([n['kind'] for n in events], ['note', 'rest', 'rest'])
self.assertTrue(all('beams' not in n for n in events))
def test_harmony_and_no_chord_cut_sustain_without_injecting_attacks(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
for kind in ('major', 'none'):
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', kind, offset=3) + tests.test_parts.xml_harmony('G', offset=5) + tests.test_musicxml.xml_note(duration=8)
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2))['staves'][0]['measures'][0]
before = copy.deepcopy(measure)
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, fractions.Fraction(3, 2), 'note'), (fractions.Fraction(3, 2), fractions.Fraction(5, 2), 'rest')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'c')
self.assertEqual(measure, before)
def test_chord_changes_preserve_authored_rhythm_rests_until_next_attack(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=1) + tests.test_parts.xml_harmony(kind='none', offset=2) + tests.test_parts.xml_harmony('G', offset=3) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, 3, 'rest'), (3, 1, 'note')])
self.assertEqual(events[1]['_harmony']['root']['letter'], 'g')
self.assertEqual(events[0]['written'], {'type': 'half', 'dots': 1})
def test_identical_and_coincident_chords_do_not_retrigger_sustains(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(4), True, 'whole'),), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F') + tests.test_parts.xml_harmony('F', offset=2) + tests.test_musicxml.xml_note()
measure = tests.test_musicxml.parse(xml_bars([content]))['staves'][0]['measures'][0]
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, 4, 'note')])
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
def test_pickup_uses_right_edge_of_nominal_bar_with_exact_clipping(self):
bar = generator.rhythm.Bar((generator.rhythm.Event(fractions.Fraction(), fractions.Fraction(3), False, 'half', 1), generator.rhythm.Event(fractions.Fraction(3), fractions.Fraction(1), True, 'quarter')), fractions.Fraction(4))
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(duration=3, kind='quarter', extra='<dot/>')
measure = tests.test_musicxml.parse(xml_bars([content], divisions=2, implicit=True))['staves'][0]['measures'][0]
self.assertEqual(generator.accompaniment.context(measure, None).offset, fractions.Fraction(5, 2))
events = generator.accompaniment.realize(measure, bar, None)
self.assertEqual(timing(events), [(0, fractions.Fraction(1, 2), 'rest'), (fractions.Fraction(1, 2), 1, 'note')])
self.assertEqual(events[1]['written'], {'type': 'quarter', 'dots': 0})
library = generator.rhythm.Library((generator.rhythm.Snippet('pickup', '4/4', (bar,)),))
plan = generator.accompaniment.plan_section([measure], library)
self.assertEqual(plan.acoustic, (bar,))
self.assertEqual(plan.electric, (bar,))
class TiedAccompanimentTests(unittest.TestCase):
def source(self, root='C', kind='major', offset=0):
first = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(extra='<tie type="start"/>')
second = tests.test_parts.xml_harmony(root, kind, offset) + tests.test_musicxml.xml_note(extra='<tie type="stop"/>')
return tests.test_musicxml.parse(xml_bars([first, second]))
def assert_tied_chord(self, first, continuation):
self.assertTrue(first['pitches'])
self.assertEqual(len(first['pitches']), len(continuation['pitches']))
for previous, current in zip(first['pitches'], continuation['pitches']):
self.assertEqual(current['tie_from'], previous['id'])
self.assertEqual(previous['tie_to'], current['id'])
self.assertEqual(generator.model.pitch_number(current), generator.model.pitch_number(previous))
self.assertEqual(current['tablature'], previous['tablature'])
for note in (first, continuation):
self.assertNotIn('_rhythm_continuation', note)
for pitch in note['pitches']:
self.assertEqual(pitch['duration'], note['duration'])
self.assertEqual(pitch['written'], note['written'])
def test_whole_note_tie_keeps_second_bar_audible_without_a_new_attack(self):
self.check_tied_rhythm(4)
def test_half_note_continuation_preserves_tone_duration_strings_and_following_rest(self):
self.check_tied_rhythm(2)
def check_tied_rhythm(self, continuation_duration):
source = self.source()
before = copy.deepcopy(source)
library = tied_library(continuation_duration)
bars = library.for_meter('4/4')[0].bars
self.assertEqual(bars[1].attacks(), ())
plan = generator.accompaniment.plan_section(source['staves'][0]['measures'], library)
self.assertEqual(plan.acoustic, bars)
self.assertEqual(plan.electric, bars)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=library)
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
expected = [(0, continuation_duration, 'note')]
if continuation_duration == 2:
expected.append((2, 2, 'rest'))
self.assertEqual(second[1]['pitches'], [])
self.assertEqual(timing(second), expected)
self.assert_tied_chord(first[0], second[0])
self.assertEqual({generator.model.pitch_number(p) % 12 for p in first[0]['pitches']}, {0, 4, 7})
generator.model.validate_document(result)
timeline = generator.playback.compile_timeline(result)
self.assertEqual(generator.model.fraction(timeline['duration']), 8)
self.assertEqual(len(timeline['events']), len(first[0]['pitches']))
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 4 + continuation_duration for e in timeline['events']))
self.assertEqual(source, before)
self.assertEqual(library.for_meter('4/4')[0].bars, bars)
def test_changed_harmony_breaks_rhythm_ties_without_inventing_attacks(self):
for root, kind in (('F', 'major'), ('C', 'minor'), ('C', 'none')):
with self.subTest(root=root, kind=kind):
source = self.source(root, kind)
before = copy.deepcopy(source)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 2, 'rest'), (2, 2, 'rest')])
self.assertEqual({generator.model.pitch_number(p) % 12 for p in first[0]['pitches']}, {0, 4, 7})
self.assertEqual(second[0]['pitches'], [])
for note in first + second:
for pitch in note['pitches']:
self.assertNotIn('tie_from', pitch)
self.assertNotIn('tie_to', pitch)
self.assertEqual(pitch['duration'], note['duration'])
generator.model.validate_document(result)
events = generator.playback.compile_timeline(result)['events']
self.assertEqual({(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in events}, {(0, 4)})
self.assertEqual(source, before)
def test_harmony_change_inside_continuation_cuts_tied_sound_and_preserves_rest(self):
source = self.source('F', offset=1)
result = generator.practice.generate.practice_document(source, 'acoustic', rhythms=tied_library(2))
first, second = tests.test_musicxml.notes(result), tests.test_musicxml.notes(result, 1)
self.assertEqual(timing(first), [(0, 4, 'note')])
self.assertEqual(timing(second), [(0, 1, 'note'), (1, 1, 'rest'), (2, 2, 'rest')])
self.assert_tied_chord(first[0], second[0])
generator.model.validate_document(result)
events = generator.playback.compile_timeline(result)['events']
self.assertEqual(len(events), len(first[0]['pitches']))
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 and generator.model.fraction(e['duration']) == 5 for e in events))
+13 -13
View File
@@ -1,18 +1,18 @@
import json
import importlib.util
import json
import os
from pathlib import Path
import pathlib
import shutil
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
import unittest.mock
class BuildBootstrapTests(unittest.TestCase):
def test_arm_profile_selects_versioned_compilers_available_in_ci_and_docker(self):
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
profile = (root / 'conan/profiles/arm_64_gcc12_release').read_text()
self.assertIn('tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}', profile)
for path in ('.gitea/workflows/ci.yml', 'package/Dockerfile.build'):
@@ -22,12 +22,12 @@ class BuildBootstrapTests(unittest.TestCase):
self.assertNotIn(f'{compiler}-aarch64-linux-gnu', source)
@unittest.skipUnless(shutil.which('conan'), 'Conan is required for package identity checks')
def test_arm_package_ids_exclude_unverified_internal_binaries_only(self):
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
if not (root / 'build/build_tools/conan/profiles/arm_64_gcc12_release').is_file():
self.skipTest('Configure first to export the shared Conan profiles')
profile = root / 'conan/profiles/arm_64_gcc12_release'
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
directory = pathlib.Path(temporary)
environment = dict(os.environ, CONAN_HOME=str(directory / 'conan'), CONAN_NON_INTERACTIVE='1')
command = ['conan', 'graph', 'info', str(directory), f'--profile:host={profile}', f'--profile:build={profile}', '--no-remote', '--format=json']
def package_id(*extra):
@@ -46,7 +46,7 @@ class BuildBootstrapTests(unittest.TestCase):
self.assertNotEqual(current, legacy)
self.assertNotEqual(current, different_compiler)
def test_build_recipe_is_declared_and_editor_commands_do_not_bootstrap(self):
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
bootstrap = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bootstrap)
@@ -57,14 +57,14 @@ class BuildBootstrapTests(unittest.TestCase):
package = types.ModuleType('modulus')
cli = types.ModuleType('modulus.cli')
package.cli = cli
docker_run = Mock()
docker_run = unittest.mock.Mock()
cli.docker_run = docker_run
cli.main = Mock(return_value=0)
cli.main = unittest.mock.Mock(return_value=0)
for command in ('clangd', 'format'):
with patch.dict(sys.modules, {'modulus': package, 'modulus.cli': cli}), \
patch.object(bootstrap, 'ensure_runtime') as prepare, \
patch.object(bootstrap, 'runtime_ready', return_value=True), \
patch.object(sys, 'path', list(sys.path)):
with unittest.mock.patch.dict(sys.modules, {'modulus': package, 'modulus.cli': cli}), \
unittest.mock.patch.object(bootstrap, 'ensure_runtime') as prepare, \
unittest.mock.patch.object(bootstrap, 'runtime_ready', return_value=True), \
unittest.mock.patch.object(sys, 'path', list(sys.path)):
self.assertEqual(bootstrap.delegate(command, []), 0)
prepare.assert_not_called()
docker_run.assert_not_called()
+28 -27
View File
@@ -1,67 +1,68 @@
import json
from pathlib import Path
import pathlib
import tempfile
import unittest
import unittest.mock
from generator.practice.generate import generate_practice_scores
from generator.arrange.generate import generate_performance_scores
from generator.score import validate_generated_tree
from generator.summary import validate_practice_parts
from generator.parts import PART_TYPES
from unittest.mock import patch
from tests.test_musicxml import xml_note, xml_score
import generator.arrange.generate
import generator.parts
import generator.practice.generate
import generator.score
import generator.summary
import tests.rhythm_fixtures
import tests.test_musicxml
class CatalogTests(unittest.TestCase):
def test_every_numbered_source_is_wired_to_three_part_paths(self):
source = Path('data/practice')
source = pathlib.Path('data/practice')
sources = sorted(source.glob('*.xml'))
self.assertEqual([p.name for p in sources], [f'{i:04}.xml' for i in range(1, 367)])
expected = {Path(path.stem) / (part + '.json') for path in sources for part in PART_TYPES}
expected = {pathlib.Path(path.stem) / (part + '.json') for path in sources for part in generator.parts.PART_TYPES}
self.assertEqual(len(expected), 1098)
with tempfile.TemporaryDirectory() as temporary, patch('generator.practice.generate._practice_document', side_effect=lambda document, part: document):
output = Path(temporary)
self.assertEqual(generate_practice_scores(source, output), len(expected))
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch('generator.practice.generate._practice_document', side_effect=lambda document, part, plan: document):
output = pathlib.Path(temporary)
self.assertEqual(generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), len(expected))
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
validate_practice_parts(output)
validate_generated_tree(output)
generator.summary.validate_practice_parts(output)
generator.score.validate_generated_tree(output)
def test_numbers_and_duplicate_titles_remain_distinct_and_songs_are_excluded(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir(parents=True)
for name in ('1000.xml', '0000.xml', '0001.xml', '0999.xml', '0010.xml', '0366.xml'):
(source / name).write_text(xml_score(xml_note()), encoding='utf-8')
self.assertEqual(generate_practice_scores(source, output), 18)
(source / name).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
self.assertEqual(generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 18)
self.assertEqual(sorted(p.name for p in output.iterdir()), ['0000', '0001', '0010', '0366', '0999', '1000'])
for directory in output.iterdir():
self.assertEqual({p.name for p in directory.glob('*.json')}, {(part + '.json') for part in PART_TYPES})
self.assertEqual({p.name for p in directory.glob('*.json')}, {(part + '.json') for part in generator.parts.PART_TYPES})
self.assertEqual(json.loads((directory / 'vocal.json').read_text())['metadata']['title'], 'Fixture')
def test_invalid_names_and_legacy_nesting_preserve_output(self):
for name in ('1.xml', '001.xml', '00001.xml', '\u0660\u0660\u0660\u0661.xml', '0001-copy.xml', '0001_copy.xml', 'chord/0001.xml', 'practice/melody/lesson/0001.xml'):
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
for relative in ('0002.xml', name):
path = source / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(xml_score(xml_note()), encoding='utf-8')
path.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
output.mkdir()
sentinel = output / 'existing'
sentinel.write_text('unchanged')
with self.assertRaisesRegex(ValueError, 'practice sources'):
generate_practice_scores(source, output)
generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(list(output.iterdir()), [sentinel])
self.assertEqual(sentinel.read_text(), 'unchanged')
def test_empty_part_directory_fails(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
(root / '0001').mkdir()
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
validate_practice_parts(root)
generator.summary.validate_practice_parts(root)
def test_songs_generate_only_authored_key_parts(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'songs', Path(temporary) / 'output'
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / 'song.xml').write_text(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generate_performance_scores(source, output), 3)
(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({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']
+52 -30
View File
@@ -1,17 +1,18 @@
from contextlib import closing
import contextlib
import json
import pathlib
import sqlite3
import subprocess
import tempfile
import unittest
from pathlib import Path
from generator.catalog import APPLICATION_ID, build_catalog
import generator.catalog
def bundle(title='Fixture', key='C', part='Lead'):
return {
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4'},
'staves': [{'name': part, 'arrangement_part_type': part.lower(), 'measures': [{'voices': []}]}]},
'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
'presentation': {'width': 976, 'height': 484, 'title_image': '0' * 64, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
}
def write_score(root, relative, document=None):
@@ -24,97 +25,118 @@ class CatalogTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.root = pathlib.Path(self.temporary.name)
(self.root / 'songs').mkdir()
def rows(self, table):
with closing(sqlite3.connect(self.root / 'catalog.db')) as connection:
with contextlib.closing(sqlite3.connect(self.root / 'catalog.db')) as connection:
connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')]
def test_metadata_relative_paths_and_complete_documents(self):
document = bundle('Song', 'F')
document['presentation']['title_image'] = '0123456789abcdef' * 4
write_score(self.root, 'songs/tune.json', document)
for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle('Exercise', 'Eb', part))
database = build_catalog(self.root)
database = generator.catalog.build_catalog(self.root)
self.assertEqual(database, self.root / 'catalog.db')
song = self.rows('songs')[0]
self.assertEqual(json.loads(song['source_json']), document)
self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json'))
self.assertEqual((song['measure_count'], song['total_pages']), (1, 1))
self.assertEqual(song['title_image'], document['presentation']['title_image'])
practice = self.rows('practice_exercises')
self.assertEqual([row['id'] for row in practice], ['0001-acoustic', '0001-electric', '0001-vocal'])
self.assertEqual(practice[0]['exercise_id'], '0001')
self.assertNotIn('practice_type_id', practice[0])
self.assertNotIn('lesson_group_id', practice[0])
self.assertEqual(practice[0]['key_signature'], 'Eb')
self.assertTrue(all(row['title_image'] == '0' * 64 for row in practice))
self.assertTrue(all(json.loads(row['source_json'])['presentation']['title_image'] == row['title_image'] for row in practice))
self.assertNotIn('authored_key_signature', practice[0])
with closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID)
with contextlib.closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID)
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('practice_exercises',), ('songs',)])
self.assertFalse(Path(str(database) + '-wal').exists())
self.assertFalse(Path(str(database) + '-journal').exists())
for table in ('songs', 'practice_exercises'):
columns = {row[1]: row for row in connection.execute(f'PRAGMA table_info({table})')}
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
self.assertFalse(pathlib.Path(str(database) + '-wal').exists())
self.assertFalse(pathlib.Path(str(database) + '-journal').exists())
def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self):
score = write_score(self.root, 'songs/tune.json')
database = build_catalog(self.root)
database = generator.catalog.build_catalog(self.root)
previous = database.read_bytes()
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous)
with tempfile.TemporaryDirectory() as other:
root = Path(other)
root = pathlib.Path(other)
write_score(root, 'songs/tune.json')
self.assertEqual(build_catalog(root).read_bytes(), previous)
self.assertEqual(generator.catalog.build_catalog(root).read_bytes(), previous)
score.rename(score.with_name('renamed.json'))
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed'])
score.with_name('renamed.json').unlink()
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
self.assertEqual(self.rows('songs'), [])
def test_invalid_input_preserves_existing_database(self):
score = write_score(self.root, 'songs/tune.json')
database = build_catalog(self.root)
database = generator.catalog.build_catalog(self.root)
previous = database.read_bytes()
for document in ({}, bundle(title=''), bundle(key=None)):
score.write_text(json.dumps(document))
with self.assertRaises(ValueError):
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous)
self.assertFalse(list(self.root.glob('.vamp-catalog-*')))
def test_missing_parts_and_metadata_disagreement_fail(self):
for part in ('Acoustic', 'Electric'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle(part=part))
with self.assertRaisesRegex(ValueError, 'all three parts'):
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
write_score(self.root, 'practice/0001/vocal.json', bundle(key='F', part='Vocal'))
with self.assertRaisesRegex(ValueError, 'disagree'):
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
def test_missing_or_invalid_title_image_preserves_existing_database(self):
score = write_score(self.root, 'songs/tune.json')
database = generator.catalog.build_catalog(self.root)
previous = database.read_bytes()
invalid = [bundle() for _ in range(9)]
invalid[0]['presentation'].pop('title_image')
for document, value in zip(invalid[1:], (None, 64, '', '0' * 63, '0' * 65, 'A' * 64, 'g' * 64, '0' * 63 + '\n')):
document['presentation']['title_image'] = value
for document in invalid:
with self.subTest(presentation=document['presentation']):
score.write_text(json.dumps(document), encoding='utf-8')
with self.assertRaisesRegex(ValueError, 'title image'):
generator.catalog.build_catalog(self.root)
self.assertEqual(database.read_bytes(), previous)
self.assertFalse(list(self.root.glob('.vamp-catalog-*')))
def test_keyless_parts_preserve_their_original_key(self):
for part in ('Acoustic', 'Electric', 'Vocal'):
write_score(self.root, f'practice/0001/{part.lower()}.json', bundle(key='No key', part=part))
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
self.assertTrue(all(row['key_signature'] == 'No key' for row in self.rows('practice_exercises')))
def test_invalid_layout_and_escaping_source_fail(self):
with self.assertRaisesRegex(ValueError, 'directory not found'):
build_catalog(self.root / 'missing')
generator.catalog.build_catalog(self.root / 'missing')
self.assertFalse((self.root / 'missing').exists())
score = write_score(self.root, 'songs/nested/tune.json')
with self.assertRaisesRegex(ValueError, 'unexpected songs'):
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
score.unlink()
(self.root / 'songs/escape.json').symlink_to('/etc/passwd')
with self.assertRaises(ValueError):
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
def test_runtime_asset_packaging_excludes_score_json(self):
import subprocess
write_score(self.root, 'songs/tune.json')
build_catalog(self.root)
generator.catalog.build_catalog(self.root)
(self.root / 'songs/tune.png').write_bytes(b'page')
with tempfile.TemporaryDirectory() as temporary:
destination = Path(temporary)
destination = pathlib.Path(temporary)
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
self.assertTrue((destination / 'catalog.db').exists())
for name in ('vamp.example.json', 'vamp.development.json'):
config = json.loads((Path('package/config') / name).read_text())
self.assertEqual(Path(config['database']['path']).name, 'catalog.db')
config = json.loads((pathlib.Path('package/config') / name).read_text())
self.assertEqual(pathlib.Path(config['database']['path']).name, 'catalog.db')
self.assertTrue((destination / 'songs/tune.png').exists())
self.assertFalse(list(destination.rglob('*.json')))
+5 -5
View File
@@ -83,7 +83,7 @@ auto catalog_bundle(std::string_view title, std::string_view key = "C", std::str
auto value = boost::json::parse(R"json({
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"name":"Lead","measures":[{"voices":[]},{"voices":[]},{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[
"presentation":{"width":976,"height":484,"title_image":"0000000000000000000000000000000000000000000000000000000000000000","pages":[
{"path":"fixture.pages/1.png","sha256":"0000000000000000000000000000000000000000000000000000000000000000"},
{"path":"fixture.pages/2.png","sha256":"1111111111111111111111111111111111111111111111111111111111111111"}]}
})json");
@@ -341,12 +341,12 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
BOOST_REQUIRE(db.build());
// Complete cached metadata must not require parsing or retaining the score body.
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
"key_signature, measure_count, total_pages) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2)");
"key_signature, measure_count, total_pages, title_image) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
for (auto part_name : vamp_zero::practice_part_names()) {
db.query("INSERT INTO practice_exercises(id, exercise_id, "
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, part) "
"VALUES(?1, '0001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2)",
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, part, title_image) "
"VALUES(?1, '0001', 'Metadata Exercise', 'Composer', 'Eb', '{}', ?3, 3, 2, ?2, '0000000000000000000000000000000000000000000000000000000000000000')",
{noto::sqlite_value{std::string{"practice_part-"} + std::string{part_name}}, noto::sqlite_value{std::string{part_name}},
noto::sqlite_value{std::string{"practice/0001/"} + std::string{part_name} + ".json"}});
}
+161 -94
View File
@@ -1,35 +1,106 @@
import collections
import copy
from dataclasses import replace
from fractions import Fraction
from pathlib import Path
import dataclasses
import fractions
import pathlib
import unittest
import unittest.mock
import xml.etree.ElementTree
from generator.engraving import EngravingError, Fonts, Style, layout_score, rasterize_page
from generator.engraving.audit import audit_layout
from generator.engraving.notation import ScoreIndex
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational
from generator.musicxml import load_musicxml
from generator.practice.generate import practice_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
import PIL.Image
import PIL.ImageChops
import generator.engraving.audit
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.raster
import generator.engraving.types
import generator.guitar
import generator.model
import generator.musicxml
import generator.playback
import generator.practice.generate
import tests.rhythm_fixtures
import tests.test_musicxml
class EngravingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fonts = Fonts(Path('package/assets/fonts/petaluma'))
cls.fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
def layout(self, source, style=None):
before = copy.deepcopy(source)
layout = layout_score(source, style or Style(), self.fonts)
layout = generator.engraving.layout.layout_score(source, style or generator.engraving.types.Style(), self.fonts)
self.assertEqual(source, before)
return layout
def primitives(self, layout, role):
return [p for page in layout.pages for p in page.primitives if p.role == role]
def test_notation_fixture_visual_golden(self):
from PIL import Image, ImageChops
layout = self.layout(load_musicxml(Path('tests/fixtures/notation.xml')))
layout = self.layout(generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')))
self.assertEqual(len(layout.pages), 1)
actual = rasterize_page(layout.pages[0], self.fonts)
with Image.open('tests/fixtures/notation-python.png') as expected:
self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox())
actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts)
with PIL.Image.open('tests/fixtures/notation-python.png') as expected:
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
dense = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(dense, divisions=16))
documents = [generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml')),
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tied)),
generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())]
checked = 0
for sampling in (1, 2, 3, 4):
for document in documents:
style = generator.engraving.types.Style(supersampling=sampling, margin_y=18.25)
for page in self.layout(document, style).pages:
with self.subTest(sampling=sampling, title=document['metadata']['title'], page=page.number):
bands = generator.engraving.raster.page_bands(page)
self.assertIsNotNone(bands)
with generator.engraving.raster.rasterize_band(page, bands[1].primitives, bands[1].top, bands[1].bottom, self.fonts) as body:
with generator.engraving.raster.compose_page(page, self.fonts, bands, body) as actual, generator.engraving.raster.rasterize_page(page, self.fonts) as reference:
self.assertIsNone(PIL.ImageChops.difference(actual, reference).getbbox())
checked += 1
self.assertGreater(checked, 12)
def test_band_key_tracks_positions_dimensions_and_complete_context(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
systems = generator.engraving.layout.layout_systems(source, generator.engraving.types.Style(), self.fonts)
page = generator.engraving.layout.paginate(systems, source, self.fonts, generator.engraving.types.Style())[0]
key = generator.engraving.cache.band_fingerprint('body', page, generator.engraving.raster.page_bands(page)[1])
source['metadata']['title'] = 'g' * 100
changed = generator.engraving.layout.paginate(systems, source, self.fonts, generator.engraving.types.Style())[0]
self.assertNotEqual(page.systems[0].box, changed.systems[0].box)
self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('body', changed, generator.engraving.raster.page_bands(changed)[1]))
self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('another body', page, generator.engraving.raster.page_bands(page)[1]))
self.assertNotEqual(key, generator.engraving.cache.band_fingerprint('body', dataclasses.replace(page, supersampling=2), generator.engraving.raster.page_bands(page)[1]))
def test_nonseparable_page_falls_back_to_full_page(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
page = self.layout(source).pages[0]
decoration = self.fonts.text('Overlay', 50, page.systems[0].box.top + 4, 12, 'subtitle', ())
primitive = self.fonts.text('Extra', 50, 400, 12, 'other', ())
for altered in (dataclasses.replace(page, primitives=page.primitives + (primitive,)),
dataclasses.replace(page, primitives=page.primitives[:1] + (decoration,) + page.primitives[1:])):
self.assertIsNone(generator.engraving.raster.page_bands(altered))
def test_musical_style_does_not_become_a_subtitle(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
before = self.layout(source)
source['metadata']['style'] = 'Unrelated musical metadata'
self.assertEqual(before, self.layout(source))
self.assertNotEqual(before, self.layout(source, generator.engraving.types.Style(column_gap=16)))
def test_candidate_columns_are_reused_without_changing_clipped_sustains(self):
content = tests.test_musicxml.xml_note(extra='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
index = generator.engraving.notation.ScoreIndex(source)
columns = collections.OrderedDict()
style = generator.engraving.types.Style()
with unittest.mock.patch('generator.engraving.layout.column', wraps=generator.engraving.layout.column) as draw:
for end in (2, 3, 4, 4):
actual = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(end), self.fonts, style, columns=columns)
with unittest.mock.patch('generator.engraving.layout.column', wraps=draw._mock_wraps):
expected = generator.engraving.layout.make_system(index, fractions.Fraction(), fractions.Fraction(end), self.fonts, style)
self.assertEqual(actual, expected)
self.assertLess(draw.call_count, 2 + 3 + 4 + 4)
def test_accidental_work_is_bounded_by_local_event_count(self):
class Unscannable(list):
def __iter__(self):
@@ -41,10 +112,10 @@ class EngravingTests(unittest.TestCase):
Counted.visited += 1
yield value
for count in (100, 200, 400):
content = ''.join(xml_note(duration=1, kind='quarter', alter=i % 2) for i in range(4))
content = ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=i % 2) for i in range(4))
extra = ''.join(f'<measure number="{i + 1}">{content}</measure>' for i in range(1, count))
document = parse(xml_score(content).replace('</part></score-partwise>', extra + '</part></score-partwise>'))
index = ScoreIndex(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content).replace('</part></score-partwise>', extra + '</part></score-partwise>'))
index = generator.engraving.notation.ScoreIndex(document)
expected = index.accidentals
index.events = Unscannable(index.events)
index.events_by_onset = {time: Counted(events) for time, events in index.events_by_onset.items()}
@@ -52,66 +123,65 @@ class EngravingTests(unittest.TestCase):
self.assertEqual(index.accidental_state(), expected)
self.assertEqual(Counted.visited, count * 4)
def test_indexed_columns_preserve_sustains_and_timed_annotations(self):
from generator.engraving.layout import column
class Unscannable(list):
def __iter__(self):
raise AssertionError('whole-score scan from column layout')
content = xml_note(extra='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
content += ''.join(xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
content = tests.test_musicxml.xml_note(extra='<voice>held</voice>') + '<backup><duration>4</duration></backup>'
content += ''.join(tests.test_musicxml.xml_note('G', 1, 'quarter', extra='<voice>moving</voice>') for _ in range(4))
content += '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
content += '<direction><direction-type><metronome><beat-unit>quarter</beat-unit><per-minute>90</per-minute></metronome></direction-type><offset>-2</offset></direction>'
index = ScoreIndex(parse(xml_score(content)))
expected = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
index = generator.engraving.notation.ScoreIndex(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)))
expected = generator.engraving.layout.column(index, fractions.Fraction(2), fractions.Fraction(2), fractions.Fraction(4), self.fonts, generator.engraving.types.Style())
index.events = Unscannable(index.events)
index.measures = Unscannable(index.measures)
actual = column(index, Fraction(2), Fraction(2), Fraction(4), self.fonts, Style())
actual = generator.engraving.layout.column(index, fractions.Fraction(2), fractions.Fraction(2), fractions.Fraction(4), self.fonts, generator.engraving.types.Style())
self.assertEqual(actual, expected)
self.assertEqual(len(index.events_at(Fraction(2), include_sustained=True)), 2)
self.assertEqual(len(index.events_at(fractions.Fraction(2), include_sustained=True)), 2)
self.assertTrue(any(p.role == 'harmony' for p in actual[0]))
self.assertEqual(len(index.events_at(Fraction(4), include_sustained=True)), 0)
self.assertEqual(len(index.events_at(fractions.Fraction(4), include_sustained=True)), 0)
def test_every_written_note_and_rest_value(self):
for kind in NOTE_TYPES:
for kind in generator.model.NOTE_TYPES:
for dots in (0, 1, 3):
duration = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
duration = generator.model.TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots))
for rest in (False, True):
with self.subTest(kind=kind, dots=dots, rest=rest):
content = xml_note(duration=duration.numerator, kind=kind, extra='<dot/>' * dots)
content = tests.test_musicxml.xml_note(duration=duration.numerator, kind=kind, extra='<dot/>' * dots)
if rest:
content = f'<note><rest/><duration>{duration.numerator}</duration><type>{kind}</type>' + '<dot/>' * dots + '</note>'
source = parse(xml_score(content, divisions=duration.denominator, implicit=True))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=duration.denominator, implicit=True))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'dot')), dots)
self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1)
def test_sixteen_dots_are_not_truncated(self):
duration = TYPE_LENGTHS['quarter'] * (2 - Fraction(1, 2 ** 16))
source = parse(xml_score(xml_note(duration=duration.numerator, kind='quarter', extra='<dot/>' * 16), divisions=duration.denominator, implicit=True))
duration = generator.model.TYPE_LENGTHS['quarter'] * (2 - fractions.Fraction(1, 2 ** 16))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=duration.numerator, kind='quarter', extra='<dot/>' * 16), divisions=duration.denominator, implicit=True))
self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16)
def test_arbitrary_voice_ids_and_more_than_four_voices(self):
content = ''
for i in range(6):
if i:
content += '<backup><duration>4</duration></backup>'
content += xml_note(extra=f'<voice>part-{i}</voice>')
source = parse(xml_score(content))
content += tests.test_musicxml.xml_note(extra=f'<voice>part-{i}</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6)
def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self):
source = parse(xml_score(xml_note(alter='0.1')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.1')))
self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)')
notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'}
with self.assertRaisesRegex(EngravingError, 'measure 1, voice 1.*missingVampGlyph'):
tests.test_musicxml.notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'}
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'measure 1, voice 1.*missingVampGlyph'):
self.layout(source)
def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self):
content = xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += xml_note(duration=2, kind='half', extra='<voice>a</voice>')
source = parse(xml_score(content))
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += tests.test_musicxml.xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += tests.test_musicxml.xml_note(duration=2, kind='half', extra='<voice>a</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
conflict = parse(xml_score(xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + xml_note(extra='<voice>b</voice>')))
conflict = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note(extra='<voice>b</voice>')))
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
def test_dense_measure_splits_exactly_without_modifying_music(self):
content = ''.join(xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
content += '<backup><duration>64</duration></backup>' + xml_note('C', 64, 'whole', extra='<voice>held</voice>')
source = parse(xml_score(content, divisions=16))
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
content += '<backup><duration>64</duration></backup>' + tests.test_musicxml.xml_note('C', 64, 'whole', extra='<voice>held</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
@@ -121,40 +191,40 @@ class EngravingTests(unittest.TestCase):
self.assertGreater(len(pieces), 1)
self.assertTrue(self.primitives(layout, 'tie'))
for page in layout.pages:
self.assertEqual(rasterize_page(page, self.fonts).size, (976, 484))
self.assertEqual(generator.engraving.raster.rasterize_page(page, self.fonts).size, (976, 484))
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
source = parse(xml_score(xml_note(duration=2, kind='half', alter=1) + xml_note(duration=2, kind='half', alter=1)))
notes(source)[0]['hidden'] = True
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', alter=1) + tests.test_musicxml.xml_note(duration=2, kind='half', alter=1)))
tests.test_musicxml.notes(source)[0]['hidden'] = True
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp'])
def test_octave_shift_changes_display_not_pitch(self):
source = parse(xml_score(xml_note(octave=5)))
original = ScoreIndex(source).pitch_position(notes(source)[0]['pitches'][0], Fraction())
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
index = ScoreIndex(source)
self.assertEqual(index.pitch_position(notes(source)[0]['pitches'][0], Fraction()), original - 7)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=5)))
original = generator.engraving.notation.ScoreIndex(source).pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction())
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': -1}]
index = generator.engraving.notation.ScoreIndex(source)
self.assertEqual(index.pitch_position(tests.test_musicxml.notes(source)[0]['pitches'][0], fractions.Fraction()), original - 7)
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
source = parse(xml_score(xml_note(duration=2, kind='half', octave=10) + xml_note(duration=2, kind='half', octave=-1)))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', octave=10) + tests.test_musicxml.xml_note(duration=2, kind='half', octave=-1)))
layout = self.layout(source)
self.assertEqual(len(layout.pages), 2)
self.assertEqual(len(self.primitives(layout, 'head')), 2)
def test_cue_grace_and_tremolo_are_drawn(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
source = parse(xml_score(grace + xml_note(duration=4, kind='whole', prefix='<cue/>')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note(duration=4, kind='whole', prefix='<cue/>')))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'head')), 2)
self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1)
source = parse(xml_score(xml_note(duration=2, kind='half', extra='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8)
def test_percussion_and_tab_use_explicit_display_positions(self):
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
tab = practice_document(parse(xml_score(xml_note('C', octave=7))), 'electric')
tab = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=7))), 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '20')
def test_generated_tab_shares_columns_and_systems_without_duplicating_music(self):
content = ''.join(xml_note('G', 1, '64th') for _ in range(64))
source = practice_document(parse(xml_score(content, divisions=16)), 'electric')
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th') for _ in range(64))
source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16)), 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(source)
systems = [s for page in layout.pages for s in page.systems]
self.assertGreater(len(systems), 1)
@@ -167,11 +237,10 @@ class EngravingTests(unittest.TestCase):
self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab')))
self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs})
self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs))
from generator.playback import compile_timeline
self.assertEqual(len(compile_timeline(source)['events']), 64)
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64)
def test_generated_tab_ties_and_barline_styles(self):
content = xml_note('E', 2, 'half', extra='<tie type="start"/>') + xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = practice_document(parse(xml_score(content)), 'electric')
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
source = generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric', rhythms=tests.rhythm_fixtures.library())
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'tab-tie')), 1)
self.assertEqual(len(self.primitives(layout, 'barline')), len(self.primitives(layout, 'tab-barline')))
@@ -184,10 +253,9 @@ class EngravingTests(unittest.TestCase):
self.assertAlmostEqual(x1, x2)
self.assertAlmostEqual((y1 - baselines['staff']) * 5 / 4, y2 - baselines['tab-staff'])
def test_adjacent_string_tab_numerals_do_not_overlap(self):
from generator.guitar import place_guitar
content = ''.join(xml_note(step, octave=octave, prefix='<chord/>' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5))))
source = parse(xml_score(content))
place_guitar(source)
content = ''.join(tests.test_musicxml.xml_note(step, octave=octave, prefix='<chord/>' if index else '') for index, (step, octave) in enumerate((('G', 3), ('C', 4), ('E', 4), ('G', 4), ('C', 5), ('E', 5))))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
generator.guitar.place_guitar(source)
layout = self.layout(source)
tabs = self.primitives(layout, 'tab')
self.assertEqual(len(tabs), 6)
@@ -197,63 +265,62 @@ class EngravingTests(unittest.TestCase):
def test_lyrics_hyphens_and_extenders(self):
begin = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
source = parse(xml_score(xml_note(duration=1, kind='quarter', extra=begin) + xml_note(duration=1, kind='quarter', extra=end) + xml_note(duration=2, kind='half')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=begin) + tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=end) + tests.test_musicxml.xml_note(duration=2, kind='half')))
layout = self.layout(source)
self.assertTrue(self.primitives(layout, 'lyric-hyphen'))
self.assertTrue(self.primitives(layout, 'lyric-extension'))
def test_nested_and_cross_bar_tuplets_keep_span_ownership(self):
from xml.etree import ElementTree
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
content = ''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6))
tree = ElementTree.fromstring(xml_score(content, divisions=3))
second = ElementTree.SubElement(tree.find('part'), 'measure', number='2')
for element in ElementTree.fromstring(xml_score(content, divisions=3)).findall('part/measure/note'):
content = ''.join(tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6))
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=3))
second = xml.etree.ElementTree.SubElement(tree.find('part'), 'measure', number='2')
for element in xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=3)).findall('part/measure/note'):
second.append(element)
source = parse(ElementTree.tostring(tree, encoding='unicode'))
events = notes(source) + notes(source, 1)
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
events = tests.test_musicxml.notes(source) + tests.test_musicxml.notes(source, 1)
spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}]
source['staves'][0]['tuplets'] = spans
for i, event in enumerate(events):
event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else [])
layout = self.layout(source, Style(width=600))
layout = self.layout(source, generator.engraving.types.Style(width=600))
self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1)
self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'})
def test_paired_tremolo_and_double_stems(self):
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')])
self.assertEqual(len(self.primitives(self.layout(parse(xml_score(content))), 'tremolo')), 3)
source = parse(xml_score(xml_note(duration=1, kind='eighth', extra='<stem>double</stem>'), divisions=2, implicit=True))
content = ''.join(tests.test_musicxml.xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')])
self.assertEqual(len(self.primitives(self.layout(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))), 'tremolo')), 3)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra='<stem>double</stem>'), divisions=2, implicit=True))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'stem')), 2)
self.assertEqual(len(self.primitives(layout, 'flag')), 2)
def test_meter_symbols_and_timed_attributes(self):
for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'):
source = parse(xml_score(xml_note()).replace('<time>', f'<time symbol="{symbol}">'))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()).replace('<time>', f'<time symbol="{symbol}">'))
with self.subTest(symbol=symbol):
self.assertTrue(self.primitives(self.layout(source), 'meter'))
change = '<attributes><key><fifths>-3</fifths></key><clef><sign>C</sign><line>3</line></clef></attributes>'
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half')))
layout = self.layout(source)
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
def test_lyric_extender_continues_on_systems_without_syllables(self):
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
content = ''.join(xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
source = parse(xml_score(content, divisions=16))
content = ''.join(tests.test_musicxml.xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, divisions=16))
layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
self.assertTrue(all(any(p.role == 'lyric-extension' for p in s.primitives) for s in systems))
def test_audit_catches_removed_pitch_and_accidental_primitives(self):
source = parse(xml_score(xml_note(alter=1)))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter=1)))
layout = self.layout(source)
for role in ('head', 'accidental'):
pages = tuple(replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
with self.assertRaisesRegex(EngravingError, 'no drawing primitive|accidental was omitted'):
audit_layout(source, replace(layout, pages=pages), self.fonts)
pages = tuple(dataclasses.replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'no drawing primitive|accidental was omitted'):
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=pages), self.fonts)
def test_fingerprint_includes_style_and_fixed_overflow_errors(self):
source = parse(xml_score(xml_note()))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
a = self.layout(source)
b = self.layout(source, Style(column_gap=14))
b = self.layout(source, generator.engraving.types.Style(column_gap=14))
self.assertNotEqual(a.fingerprint, b.fingerprint)
with self.assertRaisesRegex(EngravingError, 'page width'):
self.layout(source, Style(width=100))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'page width'):
self.layout(source, generator.engraving.types.Style(width=100))
+231 -72
View File
@@ -4,21 +4,32 @@ import copy
import hashlib
import io
import json
from pathlib import Path
import pathlib
import shutil
import tempfile
import unittest
from unittest.mock import patch
import unittest.mock
from generator.__main__ import generate_assets
from generator.engraving import EngravingError, Fonts, Style, engrave_tree, layout_score
from generator.engraving.cache import renderer_fingerprint, renderer_namespace, restore_pages, score_fingerprint
from generator.score import validate_generated_tree
from tests.test_musicxml import parse, xml_note, xml_score
import PIL.Image
import PIL.ImageChops
import generator.__main__
import generator.engraving.cache
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.pipeline
import generator.engraving.raster
import generator.engraving.types
import generator.engraving.validation
import generator.pico_artwork
import generator.score
import tests.rhythm_fixtures
import tests.test_musicxml
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml')", workflow)
workflow = pathlib.Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml', 'data/rhythms/**/*.xml')", workflow)
self.assertIn('python3 -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)
@@ -27,10 +38,10 @@ class EngravingCacheTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.root = pathlib.Path(self.temporary.name)
self.cache = self.root / 'cache'
self.fonts = Path('package/assets/fonts/petaluma')
self.document = parse(xml_score(xml_note()))
self.fonts = pathlib.Path('package/assets/fonts/petaluma')
self.document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
def write_scores(self, root, documents):
root.mkdir(parents=True, exist_ok=True)
for index, document in enumerate(documents):
@@ -40,16 +51,150 @@ class EngravingCacheTests(unittest.TestCase):
def seed_cache(self):
root = self.root / 'seed'
self.write_scores(root, [self.document])
engrave_tree(root, self.fonts, cache_dir=self.cache)
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
return next(self.cache.rglob('pages.json')).parent
def titled(self, title):
document = copy.deepcopy(self.document)
document['metadata']['title'] = title
return document
def body_calls(self, render):
return [call for call in render.call_args_list if any(p.role == 'staff' for p in call.args[1])]
def test_cold_titles_group_before_dispatch_and_render_body_once(self):
root = self.root / 'cold'
self.write_scores(root, [self.titled(f'Fixture {index}') for index in range(8)])
progress = io.StringIO()
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as layout, unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render, unittest.mock.patch('generator.engraving.pipeline.concurrent.futures.ProcessPoolExecutor', side_effect=AssertionError('one body must have one owner')), contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=4, progress=True)
layout.assert_called_once()
self.assertEqual(len(self.body_calls(render)), 1)
self.assertIn('fullscore 0, bodyhits 7, bodies 1, renderedpages 8', progress.getvalue())
self.assertIn('Engraving timings:', progress.getvalue())
for phase in ('prepare', 'layout', 'pagination', 'raster', 'cache', 'validation', 'wall'):
self.assertIn(phase + ' ', progress.getvalue())
def test_title_change_reuses_persistent_notation_and_matches_full_page(self):
self.seed_cache()
document = self.titled('Fixture A')
root = self.root / 'title'
self.write_scores(root, [document])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertFalse(self.body_calls(render))
self.assertEqual(len(list(self.cache.rglob('band.json'))), 1)
family = generator.engraving.fonts.Fonts(self.fonts)
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), family)
with PIL.Image.open(root / '000.pages/0001.png') as actual:
reference = generator.engraving.raster.rasterize_page(layout.pages[0], family)
self.assertIsNone(PIL.ImageChops.difference(actual, reference).getbbox())
def test_duplicate_fullscores_share_rendering_without_persistent_cache(self):
root = self.root / 'duplicate'
self.write_scores(root, [self.document] * 8)
with unittest.mock.patch('generator.engraving.raster.compose_page', wraps=generator.engraving.raster.compose_page) as render, unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
trusted = generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=4)
render.assert_called_once()
decode.assert_called_once()
self.assertEqual(len(trusted), 1)
def test_full_page_validation_is_once_per_content_but_every_file_is_read(self):
self.seed_cache()
root = self.root / 'validation'
self.write_scores(root, [self.document] * 4)
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
trusted = generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
decode.assert_called_once()
read_bytes = pathlib.Path.read_bytes
reads = []
def read(path):
reads.append(path)
return read_bytes(path)
pages = sorted(root.glob('*.pages/*.png'))
digest = next(iter(trusted))
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', read):
for path in pages:
generator.engraving.validation.check_page(path, trusted, expected_sha256=digest)
self.assertCountEqual(reads, pages)
decode.assert_called_once()
pages[-1].write_bytes(pages[-1].read_bytes() + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'):
generator.engraving.validation.check_page(pages[-1], trusted, expected_sha256=digest)
pages[-1].unlink()
pages[-1].symlink_to(pages[0])
with self.assertRaisesRegex(ValueError, 'regular PNG'):
generator.engraving.validation.check_page(pages[-1], trusted, expected_sha256=digest)
def test_font_content_change_creates_new_shared_notation_namespace(self):
self.seed_cache()
fonts = self.root / 'fonts'
shutil.copytree(self.fonts, fonts)
metadata = fonts / 'petaluma_metadata.json'
metadata.write_bytes(metadata.read_bytes() + b'\n')
root = self.root / 'font-change'
self.write_scores(root, [self.document])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, fonts, cache_dir=self.cache)
self.assertEqual(len(self.body_calls(render)), 1)
self.assertEqual(len(list(self.cache.iterdir())), 2)
self.assertEqual(len(list(self.cache.rglob('band.json'))), 2)
def test_corrupt_shared_bands_are_rejected_and_repaired(self):
self.seed_cache()
manifest = next(self.cache.rglob('band.json'))
asset = manifest.with_name('band.png')
original, metadata = asset.read_bytes(), manifest.read_bytes()
for index, corruption in enumerate(('checksum', 'undecodable', 'blank', 'wrong_size', 'manifest', 'context', 'symlink', 'directory')):
with self.subTest(corruption=corruption):
if corruption in ('checksum', 'undecodable'):
asset.write_bytes(original + b'changed' if corruption == 'checksum' else original[:24])
elif corruption in ('blank', 'wrong_size'):
dimensions = json.loads(metadata)
size = (dimensions['width'], dimensions['height']) if corruption == 'blank' else (10, 10)
PIL.Image.new('RGB', size, 'white').save(asset)
elif corruption == 'manifest':
manifest.write_text('invalid json')
elif corruption == 'context':
manifest.write_text(json.dumps({**json.loads(metadata), 'context': 'different'}))
elif corruption == 'symlink':
external = self.root / 'external.png'
external.write_bytes(original)
asset.unlink()
asset.symlink_to(external)
else:
asset.unlink()
asset.mkdir()
if corruption in ('undecodable', 'blank', 'wrong_size'):
manifest.write_text(json.dumps({**json.loads(metadata), 'sha256': hashlib.sha256(asset.read_bytes()).hexdigest()}))
root = self.root / f'corrupt-band-{index}'
self.write_scores(root, [self.titled(f'Fixture A {index}')])
with unittest.mock.patch('generator.engraving.raster.rasterize_band', wraps=generator.engraving.raster.rasterize_band) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(len(self.body_calls(render)), 1)
self.assertEqual(asset.read_bytes(), original)
self.assertEqual(json.loads(manifest.read_bytes()), json.loads(metadata))
def test_band_eviction_reloads_final_pixels_without_rerendering(self):
family = generator.engraving.fonts.Fonts(self.fonts)
style = generator.engraving.types.Style()
documents = [self.document, tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))]
pages = [generator.engraving.layout.layout_score(document, style, family).pages[0] for document in documents]
result = generator.engraving.pipeline.GroupResult()
bands = generator.engraving.pipeline.BodyBands(self.root / 'spool', None, result, limit=1)
self.addCleanup(bands.close)
original = None
for index in (0, 1, 0):
page = pages[index]
notation = generator.engraving.cache.notation_fingerprint(documents[index], 'renderer')
image = bands.get(notation, page, generator.engraving.raster.page_bands(page)[1], family)
if index == 0:
if original is None:
original = image.tobytes()
else:
self.assertEqual(image.tobytes(), original)
self.assertEqual(len(bands.images), 1)
self.assertEqual(result.bodies, 2)
self.assertEqual(result.bodyhits, 1)
def test_cache_hit_preserves_bundle_bytes_across_output_directories(self):
cold, warm = self.root / 'cold', self.root / 'warm'
self.write_scores(cold, [self.document])
engrave_tree(cold, self.fonts, cache_dir=self.cache)
generator.engraving.pipeline.engrave_tree(cold, self.fonts, cache_dir=self.cache)
self.write_scores(warm, [self.document])
with patch('generator.engraving.layout_score', side_effect=AssertionError('cache miss')):
with patch('generator.engraving.rasterize_page', side_effect=AssertionError('cache miss')):
engrave_tree(warm, self.fonts, cache_dir=self.cache)
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache)
self.assertEqual(self.files(cold), self.files(warm))
def test_playback_is_not_restored_from_page_cache(self):
self.seed_cache()
@@ -57,34 +202,34 @@ class EngravingCacheTests(unittest.TestCase):
changed['playback'] = {'new-playback': 'independent of page rendering'}
root = self.root / 'warm'
self.write_scores(root, [changed])
with patch('generator.engraving.layout_score', side_effect=AssertionError('cache miss')):
engrave_tree(root, self.fonts, cache_dir=self.cache)
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
self.assertEqual(json.loads((root / '000.json').read_text())['playback'], changed['playback'])
def test_changed_music_and_renderer_invalidate_pages(self):
self.seed_cache()
changed = parse(xml_score(xml_note('D')).replace('Fixture', 'Changed'))
self.assertNotEqual(score_fingerprint(self.document, 'renderer'), score_fingerprint(changed, 'renderer'))
self.assertNotEqual(score_fingerprint(self.document, 'renderer'), score_fingerprint(self.document, 'new-renderer'))
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed'))
self.assertNotEqual(generator.engraving.cache.score_fingerprint(self.document, 'renderer'), generator.engraving.cache.score_fingerprint(changed, 'renderer'))
self.assertNotEqual(generator.engraving.cache.score_fingerprint(self.document, 'renderer'), generator.engraving.cache.score_fingerprint(self.document, 'new-renderer'))
root = self.root / 'changed'
self.write_scores(root, [changed])
with patch('generator.engraving.layout_score', wraps=layout_score) as render:
engrave_tree(root, self.fonts, cache_dir=self.cache)
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
self.assertEqual(len(list(self.cache.rglob('pages.json'))), 2)
def test_renderer_fingerprint_tracks_fonts_style_code_and_dependencies(self):
fonts, style = Fonts(self.fonts), Style()
expected = renderer_fingerprint(fonts, style)
self.assertEqual(expected, renderer_fingerprint(fonts, style))
self.assertNotEqual(expected, renderer_fingerprint(fonts, Style(margin_x=29)))
with patch.object(fonts, 'fingerprint', return_value='changed-fonts'):
self.assertNotEqual(expected, renderer_fingerprint(fonts, style))
with patch('generator.engraving.cache.importlib.metadata.version', return_value='new-version'):
self.assertNotEqual(expected, renderer_fingerprint(fonts, style))
with patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'):
self.assertNotEqual(expected, renderer_fingerprint(fonts, style))
read_bytes = Path.read_bytes
with patch.object(Path, 'read_bytes', lambda path: read_bytes(path) + (b'\n' if path.name == 'raster.py' else b'')):
self.assertNotEqual(expected, renderer_fingerprint(fonts, style))
fonts, style = generator.engraving.fonts.Fonts(self.fonts), generator.engraving.types.Style()
expected = generator.engraving.cache.renderer_fingerprint(fonts, style)
self.assertEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, generator.engraving.types.Style(margin_x=29)))
with unittest.mock.patch.object(fonts, 'fingerprint', return_value='changed-fonts'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
with unittest.mock.patch('generator.engraving.cache.importlib.metadata.version', return_value='new-version'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
read_bytes = pathlib.Path.read_bytes
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', lambda path: read_bytes(path) + (b'\n' if path.name == 'raster.py' else b'')):
self.assertNotEqual(expected, generator.engraving.cache.renderer_fingerprint(fonts, style))
def test_corrupt_or_missing_pages_are_regenerated_and_repaired(self):
entry = self.seed_cache()
page = entry / '0001.png'
@@ -106,98 +251,112 @@ class EngravingCacheTests(unittest.TestCase):
target.mkdir()
root = self.root / corruption
self.write_scores(root, [self.document])
with patch('generator.engraving.layout_score', wraps=layout_score) as render:
engrave_tree(root, self.fonts, cache_dir=self.cache)
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
self.assertEqual(page.read_bytes(), original)
self.assertIsNotNone(restore_pages(entry, self.root / 'restored'))
self.assertIsNotNone(generator.engraving.cache.restore_pages(entry, self.root / 'restored'))
def test_cache_manifest_cannot_escape_entry_or_inject_bundle_fields(self):
entry = self.seed_cache()
original = json.loads((entry / 'pages.json').read_text())
for invalid in ([], {}, [{'path': '../outside.png', 'sha256': original[0]['sha256']}], [{**original[0], 'extra': True}]):
for invalid in ([], {}, [{'path': '../outside.png', 'sha256': original[0]['sha256']}], [{**original[0], 'extra': True}], [{**original[0], 'sha256': None}], [{**original[0], 'sha256': 1}]):
with self.subTest(manifest=invalid):
(entry / 'pages.json').write_text(json.dumps(invalid))
self.assertIsNone(restore_pages(entry, self.root / 'output'))
self.assertIsNone(generator.engraving.cache.restore_pages(entry, self.root / 'output'))
(entry / 'pages.json').write_text(json.dumps(original))
page = entry / '0001.png'
external = self.root / 'outside.png'
page.rename(external)
page.symlink_to(external)
self.assertIsNone(restore_pages(entry, self.root / 'output'))
self.assertIsNone(generator.engraving.cache.restore_pages(entry, self.root / 'output'))
alias = self.root / 'alias'
alias.symlink_to(entry, target_is_directory=True)
self.assertIsNone(restore_pages(alias, self.root / 'output'))
self.assertIsNone(generator.engraving.cache.restore_pages(alias, self.root / 'output'))
def test_parallel_output_and_shared_cache_are_deterministic(self):
documents = [self.document, parse(xml_score(xml_note('D')))] * 4
documents = []
for index in range(4):
for letter in ('C', 'D'):
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(letter)))
document['metadata']['title'] = f'Fixture {index}'
documents.append(document)
serial, parallel, warm = (self.root / name for name in ('serial', 'parallel', 'warm'))
self.write_scores(serial, documents)
engrave_tree(serial, self.fonts)
generator.engraving.pipeline.engrave_tree(serial, self.fonts)
for root in (parallel, warm):
self.write_scores(root, documents)
engrave_tree(root, self.fonts, cache_dir=self.cache, jobs=2)
progress = io.StringIO()
with contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, jobs=2, progress=True)
self.assertEqual(self.files(serial), self.files(root))
self.assertEqual(len(list(self.cache.rglob('pages.json'))), 2)
if root == parallel:
self.assertIn('fullscore 0, bodyhits 6, bodies 2, renderedpages 8', progress.getvalue())
else:
self.assertIn('fullscore 8, bodyhits 0, bodies 0, renderedpages 0', progress.getvalue())
self.assertEqual(len(list(self.cache.rglob('pages.json'))), 8)
self.assertEqual(len(list(self.cache.rglob('band.json'))), 2)
self.assertFalse(list(self.cache.rglob('.engraving-*')))
def test_environment_change_gets_new_namespace_and_clear_progress(self):
old = self.seed_cache()
root = self.root / 'new-environment'
self.write_scores(root, [self.document])
progress = io.StringIO()
with patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress):
engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
self.assertIn('cold namespace', progress.getvalue())
self.assertIn('0 reused, 1 missing, 0 rejected', progress.getvalue())
self.assertTrue((old / 'pages.json').is_file())
self.assertEqual(len(list(self.cache.iterdir())), 2)
self.write_scores(root, [self.document])
progress = io.StringIO()
with patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress), patch('generator.engraving.layout_score', side_effect=AssertionError('cache miss')):
engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
with unittest.mock.patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress), unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache, progress=True)
self.assertIn('1 reused, 0 missing, 0 rejected', progress.getvalue())
def test_namespace_symlink_is_rejected(self):
self.cache.mkdir()
(self.cache / 'renderer').symlink_to(self.root, target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'must not be symlinks'):
renderer_namespace(self.cache, 'renderer')
generator.engraving.cache.renderer_namespace(self.cache, 'renderer')
def test_only_changed_score_is_rendered_and_validation_reads_json_once(self):
root = self.root / 'scores'
self.write_scores(root, [self.document, self.document])
engrave_tree(root, self.fonts, cache_dir=self.cache)
changed = parse(xml_score(xml_note('D')))
self.write_scores(root, [self.document, changed])
with patch('generator.engraving.layout_score', wraps=layout_score) as render:
engrave_tree(root, self.fonts, cache_dir=self.cache)
self.write_scores(root / 'practice', [self.document, self.document])
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
self.write_scores(root / 'practice', [self.document, changed])
with unittest.mock.patch('generator.engraving.layout.layout_systems', wraps=generator.engraving.layout.layout_systems) as render:
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
render.assert_called_once()
read_text = Path.read_text
generator.pico_artwork.generate_titles(root, self.fonts)
read_text = pathlib.Path.read_text
reads = []
def read(path, *args, **kwargs):
reads.append(path)
return read_text(path, *args, **kwargs)
with patch.object(Path, 'read_text', read):
validate_generated_tree(root)
self.assertCountEqual(reads, sorted(root.glob('*.json')))
with unittest.mock.patch.object(pathlib.Path, 'read_text', read):
generator.score.validate_generated_tree(root)
self.assertCountEqual(reads, sorted(root.rglob('*.json')))
def test_invalid_worker_count_and_overlapping_cache_are_rejected(self):
root = self.root / 'assets'
self.write_scores(root, [self.document])
for jobs in (0, -1, 17, True):
with self.assertRaisesRegex(ValueError, 'between 1 and 16'):
engrave_tree(root, self.fonts, jobs=jobs)
generator.engraving.pipeline.engrave_tree(root, self.fonts, jobs=jobs)
for cache in (root, root / 'cache', self.root):
with self.assertRaisesRegex(ValueError, 'must not overlap'):
engrave_tree(root, self.fonts, cache_dir=cache)
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.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'practice/0001.xml'
source.write_text(xml_score(xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generate_assets(args)
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, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args)
original = self.files(output)
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output))
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
generate_assets(args)
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output))
self.assertFalse(list(output.parent.glob('.vamp-assets-*')))
+100 -98
View File
@@ -1,185 +1,187 @@
import argparse
import copy
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
import random
import tempfile
import unittest
import unittest.mock
from generator import harmony
from generator.__main__ import generate_assets
from generator.arrange.generate import arrange_document
from generator.guitar import Placement, TabPosition, chord_positions, chord_requirements, nearest_positions, place_guitar, position_center, positions_for_pitch, within_fret_span
from generator.model import fraction, pitch_number, rational, validate_document
from generator.parts import PART_TYPES
from generator.pitch import concert_document
from generator.playback import compile_timeline
from generator.practice.generate import generate_practice_scores, practice_document
from generator.summary import validate_practice_parts
from tests.test_musicxml import notes, parse, xml_note, xml_score
import generator.__main__
import generator.arrange.generate
import generator.guitar
import generator.harmony
import generator.model
import generator.parts
import generator.pitch
import generator.playback
import generator.practice.generate
import generator.summary
import tests.rhythm_fixtures
import tests.test_musicxml
class GuitarTests(unittest.TestCase):
def test_fret_boundaries_and_span(self):
self.assertIn(TabPosition(6, 0, 40), positions_for_pitch(Fraction(40)))
self.assertIn(TabPosition(1, 20, 84), positions_for_pitch(Fraction(84)))
self.assertFalse(positions_for_pitch(Fraction(85)))
self.assertFalse(positions_for_pitch(Fraction(129, 2)))
self.assertTrue(within_fret_span((TabPosition(4, 11, 61), TabPosition(3, 12, 67), TabPosition(2, 14, 73))))
self.assertTrue(within_fret_span((TabPosition(6, 0, 40), TabPosition(1, 20, 84))))
self.assertFalse(within_fret_span((TabPosition(4, 10, 60), TabPosition(1, 20, 84))))
self.assertIn(generator.guitar.TabPosition(6, 0, 40), generator.guitar.positions_for_pitch(fractions.Fraction(40)))
self.assertIn(generator.guitar.TabPosition(1, 20, 84), generator.guitar.positions_for_pitch(fractions.Fraction(84)))
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(85)))
self.assertFalse(generator.guitar.positions_for_pitch(fractions.Fraction(129, 2)))
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 11, 61), generator.guitar.TabPosition(3, 12, 67), generator.guitar.TabPosition(2, 14, 73))))
self.assertTrue(generator.guitar.within_fret_span((generator.guitar.TabPosition(6, 0, 40), generator.guitar.TabPosition(1, 20, 84))))
self.assertFalse(generator.guitar.within_fret_span((generator.guitar.TabPosition(4, 10, 60), generator.guitar.TabPosition(1, 20, 84))))
def test_theory_controls_omissions_and_bass(self):
for symbol in ('CM', 'G7b9', 'FM7#11/A', 'CmM7', 'Cdim7', 'C13', 'Csus', 'Cmadd9'):
with self.subTest(symbol=symbol):
required, allowed, bass = chord_requirements(harmony.parse_symbol(symbol))
candidates = chord_positions(required, allowed, bass)
required, allowed, bass = generator.guitar.chord_requirements(generator.harmony.parse_symbol(symbol))
candidates = generator.guitar.chord_positions(required, allowed, bass)
self.assertTrue(candidates)
for candidate in candidates:
covered = {p.midi % 12 for p in candidate}
self.assertTrue(set(required) <= covered <= set(allowed))
self.assertEqual(len(candidate), len({p.string_number for p in candidate}))
self.assertTrue(within_fret_span(candidate))
self.assertTrue(generator.guitar.within_fret_span(candidate))
if bass is not None:
self.assertEqual(min(p.midi for p in candidate) % 12, bass)
self.assertIn(2, chord_requirements(harmony.parse_symbol('Cmadd9'))[0])
self.assertNotIn(7, chord_requirements(harmony.parse_symbol('C7'))[0])
self.assertIn(7, chord_requirements(harmony.parse_symbol('CM'))[0])
self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 0})[2], 0)
self.assertEqual(chord_requirements({**harmony.parse_symbol('CM'), 'inversion': 1})[2], 4)
self.assertTrue(any(p.fret > 12 for c in chord_positions(*chord_requirements(harmony.parse_symbol('CM'))) for p in c))
self.assertIn(2, generator.guitar.chord_requirements(generator.harmony.parse_symbol('Cmadd9'))[0])
self.assertNotIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('C7'))[0])
self.assertIn(7, generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))[0])
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 0})[2], 0)
self.assertEqual(generator.guitar.chord_requirements({**generator.harmony.parse_symbol('CM'), 'inversion': 1})[2], 4)
self.assertTrue(any(p.fret > 12 for c in generator.guitar.chord_positions(*generator.guitar.chord_requirements(generator.harmony.parse_symbol('CM'))) for p in c))
def test_melody_placement_preserves_notes_timing_and_sound(self):
source = parse(xml_score(xml_note('E', 2, 'half', octave=4) + xml_note('F', 2, 'half', octave=4)))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('F', 2, 'half', octave=4)))
before = copy.deepcopy(source)
electric = practice_document(source, 'electric')
vocal = practice_document(source, 'vocal')
validate_document(electric)
electric = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
vocal = generator.practice.generate.practice_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(electric)
self.assertEqual(source, before)
self.assertEqual(compile_timeline(electric)['events'], compile_timeline(vocal)['events'])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in compile_timeline(electric)['events']], [(rational(fraction(e['pitch']) - 12), e['onset'], e['duration']) for e in compile_timeline(source)['events']])
for original, generated in zip(notes(source), notes(electric)):
self.assertEqual(pitch_number(generated['pitches'][0]), pitch_number(original['pitches'][0]))
self.assertEqual(generator.playback.compile_timeline(electric)['events'], generator.playback.compile_timeline(vocal)['events'])
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(generator.model.rational(generator.model.fraction(e['pitch']) - 12), e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']])
for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)):
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]))
self.assertIn('tablature', generated['pitches'][0])
def test_instrument_transposition_and_octave_clef_applied_once(self):
for attributes, expected in (('', 48), ('<transpose><chromatic>-12</chromatic><diatonic>-7</diatonic></transpose>', 48), ('<transpose><chromatic>0</chromatic></transpose>', 60), ('<clef><sign>G</sign><line>2</line><clef-octave-change>-1</clef-octave-change></clef>', 60), ('<clef><sign>F</sign><line>4</line></clef>', 48)):
source = parse(xml_score(xml_note(), attributes))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
for part in ('electric', 'vocal'):
document = practice_document(source, part)
self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected)
document = generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected)
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9}), (7, 0, 84, {'string': 1, 'fret': 20})):
source = parse(xml_score(xml_note(octave=octave, alter=alter)))
document = practice_document(source, 'electric')
tone = notes(document)[0]['pitches'][0]
self.assertEqual(pitch_number(tone), 12 * (octave + 1) + alter)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave, alter=alter)))
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
self.assertEqual(tone['tablature'], tab)
self.assertEqual(fraction(compile_timeline(document)['events'][0]['pitch']), expected)
validate_document(document)
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected)
generator.model.validate_document(document)
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half')))
before = copy.deepcopy(source)
concert = concert_document(source, octave_transposing=True)
self.assertEqual(concert_document(concert, octave_transposing=True), concert)
concert = generator.pitch.concert_document(source, octave_transposing=True)
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
self.assertEqual(source, before)
document = practice_document(source, 'electric')
self.assertEqual([fraction(e['pitch']) for e in compile_timeline(document)['events']], [48, 60])
document = generator.practice.generate.practice_document(source, 'electric', rhythms=tests.rhythm_fixtures.library())
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [48, 60])
def test_all_parts_share_written_octave_convention_in_songs_and_practice(self):
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
source = parse(xml_score(chord + xml_note()))
for generate in (practice_document, arrange_document):
for part in PART_TYPES:
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note()))
for generate in (generator.practice.generate.practice_document, generator.arrange.generate.arrange_document):
for part in generator.parts.PART_TYPES:
with self.subTest(generator=generate.__name__, part=part):
document = generate(source, part)
validate_document(document)
written = {p['id']: pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = compile_timeline(document)['events']
document = generate(source, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(document)
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
events = generator.playback.compile_timeline(document)['events']
self.assertTrue(events)
self.assertTrue(all(fraction(e['pitch']) == written[e['id']] - 12 for e in events))
self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events))
if part == 'vocal':
self.assertEqual(set(written.values()), {60})
self.assertTrue(all(m['transpose']['octave_change'] == -1 for m in document['staves'][0]['measures']))
def test_ties_and_overlapping_voices_keep_strings(self):
content = xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
document = parse(xml_score(content))
place_guitar(document)
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
generator.guitar.place_guitar(document)
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
self.assertEqual(first, continuation)
self.assertNotEqual(continuation['string'], other['string'])
self.assertEqual(len(compile_timeline(document)['events']), 2)
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2)
def test_proximity_chooses_a_path_without_relocating_tied_notes(self):
source = parse(xml_score(xml_note('B', 2, 'half', octave=5) + xml_note('C', 2, 'half', octave=6)))
place_guitar(source)
tabs = [n['pitches'][0]['tablature'] for n in notes(source)]
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('B', 2, 'half', octave=5) + tests.test_musicxml.xml_note('C', 2, 'half', octave=6)))
generator.guitar.place_guitar(source)
tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)]
self.assertEqual(tabs, [{'string': 1, 'fret': 19}, {'string': 1, 'fret': 20}])
def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self):
randomizer = random.Random(17)
states = {}
for index in range(40):
positions = tuple(TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
states[index] = Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
grid = nearest_positions(states)
positions = tuple(generator.guitar.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
states[index] = generator.guitar.Placement(tuple(randomizer.randrange(8) for _ in range(3)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
grid = generator.guitar.nearest_positions(states)
for x, row in enumerate(grid):
for y, actual in enumerate(row):
candidates = []
for index, state in enumerate(states.values()):
a, b = position_center(tuple(p for _, p in state.active))
a, b = generator.guitar.position_center(tuple(p for _, p in state.active))
candidates.append(((state.cost[0] + abs(x - a) + abs(y - b), *state.cost[1:]), index))
self.assertEqual(actual[:2], min(candidates))
def test_generated_chord_retains_strings_when_a_melody_enters(self):
content = xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
document = parse(xml_score(content))
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('C', 2, 'half', octave=7, extra='<voice>2</voice>')
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
measure = document['staves'][0]['measures'][0]
measure['transpose']['octave_change'] = -1
chord, melody = [n for v in measure['voices'] for n in v['notes']]
chord['pitches'] = []
chord['_harmony'] = harmony.parse_symbol('CM')
place_guitar(document)
validate_document(document)
chord['_harmony'] = generator.harmony.parse_symbol('CM')
generator.guitar.place_guitar(document)
generator.model.validate_document(document)
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 20})
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
self.assertEqual(len(compile_timeline(document)['events']), 4)
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 4)
def test_more_than_six_simultaneous_tones_fail_without_omission(self):
content = '<backup><duration>4</duration></backup>'.join(xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
document = parse(xml_score(content))
content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
before = copy.deepcopy(document)
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
place_guitar(document)
generator.guitar.place_guitar(document)
self.assertEqual(before, document)
def test_source_contract_and_unplayable_music_fail(self):
cases = [(xml_note(octave=7, alter=1), '', '0-20'), (xml_note(alter='0.5'), '', '0-20'),
(xml_note() + xml_note('E', prefix='<chord/>'), '', 'monophonic'),
(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E', extra='<voice>2</voice>'), '', 'one monophonic voice'),
(xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
cases = [(tests.test_musicxml.xml_note(octave=7, alter=1), '', '0-20'), (tests.test_musicxml.xml_note(alter='0.5'), '', '0-20'),
(tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', prefix='<chord/>'), '', 'monophonic'),
(tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E', extra='<voice>2</voice>'), '', 'one monophonic voice'),
(tests.test_musicxml.xml_note(), '<staff-details><capo>1</capo></staff-details>', 'capo')]
for content, attributes, error in cases:
with self.subTest(error=error), self.assertRaisesRegex(ValueError, error):
practice_document(parse(xml_score(content, attributes)), 'electric')
for content, attributes in [(xml_note(), '<clef><sign>TAB</sign></clef>'), (xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
generator.practice.generate.practice_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes)), 'electric', rhythms=tests.rhythm_fixtures.library())
for content, attributes in [(tests.test_musicxml.xml_note(), '<clef><sign>TAB</sign></clef>'), (tests.test_musicxml.xml_note(extra='<notations><technical><fingering>1</fingering></technical></notations>'), '')]:
with self.assertRaisesRegex(ValueError, 'authored TAB'):
parse(xml_score(content, attributes))
tests.test_musicxml.parse(tests.test_musicxml.xml_score(content, attributes))
def test_three_parts_are_deterministic_without_transposition(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir()
(source / '0000.xml').write_text(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generate_practice_scores(source, output), 3)
validate_practice_parts(output)
(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.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library()), 3)
generator.summary.validate_practice_parts(output)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generate_practice_scores(source, output)
generator.practice.generate.generate_practice_scores(source, output, rhythms=tests.rhythm_fixtures.library())
self.assertEqual(first, {p.name: p.read_bytes() for p in output.rglob('*.json')})
(output / '0000/vocal.json').unlink()
with self.assertRaisesRegex(ValueError, 'practice generator drift'):
validate_practice_parts(output)
generator.summary.validate_practice_parts(output)
def test_unplayable_generation_preserves_previous_assets(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True)
(root / 'data/practice').mkdir()
(root / 'data/practice/0001.xml').write_text(xml_score(xml_note(octave=7, alter=1)))
(root / 'data/practice/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1)))
output = root / 'output'
output.mkdir()
(output / 'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=root / 'data', output_dir=output, static_assets_dir=Path('package/assets'), check=False)
with self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*0-20'):
generate_assets(args)
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()), self.assertRaisesRegex(ValueError, '0001.xml, part electric:.*0-20'):
generator.__main__.generate_assets(args)
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
self.assertEqual((output / 'last-good').read_text(), 'preserved')
+44 -41
View File
@@ -1,14 +1,18 @@
from pathlib import Path
import pathlib
import tempfile
import unittest
from generator import harmony
from generator.model import rational
from generator.musicxml import MusicXMLError, load_musicxml
from generator.musicxml_writer import write_musicxml
from generator.practice.generate import practice_document
from generator.pitch import concert_document
from tests.test_musicxml import parse, xml_note, xml_score
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.types
import generator.harmony
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.pitch
import generator.practice.generate
import tests.rhythm_fixtures
import tests.test_musicxml
def chord_xml(text, kind='other', root='C', extra=''):
return f'<harmony><root><root-step>{root}</root-step></root><kind text="{text}">{kind}</kind>{extra}</harmony>'
@@ -20,82 +24,81 @@ class HarmonyTests(unittest.TestCase):
'C-maj7': 'CmM7', 'CmM': 'CmM7', 'C6': 'CM6', 'C-6': 'Cm6', 'Fmaj7#11/A': 'FM7#11/A', 'G7b9': 'G7b9'}
for spelling, canonical in cases.items():
with self.subTest(spelling=spelling):
chord = harmony.parse_symbol(spelling)
self.assertEqual(harmony.symbol(chord), canonical)
self.assertEqual(harmony.degrees(harmony.parse_symbol(canonical)), harmony.degrees(chord))
chord = generator.harmony.parse_symbol(spelling)
self.assertEqual(generator.harmony.symbol(chord), canonical)
self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol(canonical)), generator.harmony.degrees(chord))
def test_extended_chords_include_sevenths_but_added_ninths_do_not(self):
for prefix, seventh in (('', -1), ('m', -1), ('M', 0), ('mM', 0)):
for highest in (9, 11, 13):
with self.subTest(prefix=prefix, highest=highest):
chord = harmony.parse_symbol(f'C{prefix}{highest}')
values = harmony.degrees(chord)
chord = generator.harmony.parse_symbol(f'C{prefix}{highest}')
values = generator.harmony.degrees(chord)
self.assertEqual(values[7], seventh)
self.assertTrue(all(values[degree] == 0 for degree in range(9, highest + 1, 2)))
self.assertEqual(harmony.symbol(chord), f'C{prefix}{highest}')
self.assertEqual(harmony.degrees(harmony.parse_symbol('Cmadd9')), {1: 0, 3: -1, 5: 0, 9: 0})
self.assertEqual(harmony.degrees(harmony.parse_symbol('C7no5')), {1: 0, 3: 0, 7: -1})
self.assertEqual(harmony.degrees(harmony.parse_symbol('C7sus')), {1: 0, 4: 0, 5: 0, 7: -1})
self.assertEqual(generator.harmony.symbol(chord), f'C{prefix}{highest}')
self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('Cmadd9')), {1: 0, 3: -1, 5: 0, 9: 0})
self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('C7no5')), {1: 0, 3: 0, 7: -1})
self.assertEqual(generator.harmony.degrees(generator.harmony.parse_symbol('C7sus')), {1: 0, 4: 0, 5: 0, 7: -1})
def test_soundslice_text_only_chords_become_structured(self):
for text, kind, values in (('mM7', 'major-minor', {1: 0, 3: -1, 5: 0, 7: 0}), ('m7b5', 'half-diminished', {1: 0, 3: -1, 5: -1, 7: -1}), ('7b9', 'dominant', {1: 0, 3: 0, 5: 0, 7: -1, 9: -1})):
with self.subTest(text=text):
source = parse(xml_score(chord_xml(text) + xml_note()))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(text) + tests.test_musicxml.xml_note()))
chord = source['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['kind'], kind)
self.assertEqual(harmony.degrees(chord), values)
self.assertEqual(generator.harmony.degrees(chord), values)
self.assertEqual(chord['symbol'], 'C' + text)
self.assertNotIn('kind_text', chord)
source = parse(xml_score(chord_xml('m9', 'minor-ninth') + xml_note()))
self.assertEqual(harmony.degrees(source['staves'][0]['measures'][0]['harmonies'][0])[7], -1)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml('m9', 'minor-ninth') + tests.test_musicxml.xml_note()))
self.assertEqual(generator.harmony.degrees(source['staves'][0]['measures'][0]['harmonies'][0])[7], -1)
def test_parenthesized_full_symbols_transpose_and_roundtrip(self):
for label, transposed in (('(Fm7)', '(Gm7)'), ('(Bb7)', '(C7)'), ('(Fm7/Ab)', '(Gm7/Bb)')):
with self.subTest(label=label):
source = parse(xml_score(chord_xml(label) + xml_note()))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(label) + tests.test_musicxml.xml_note()))
chord = source['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['symbol'], label)
self.assertTrue(chord['chord_optional'])
self.assertTrue(chord['parenthesized'])
practice = practice_document(source, 'vocal')['staves'][0]['measures'][0]['harmonies'][0]
practice = generator.practice.generate.practice_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())['staves'][0]['measures'][0]['harmonies'][0]
self.assertNotIn('chord_optional', practice)
self.assertEqual(harmony.symbol(practice), label)
self.assertEqual(generator.harmony.symbol(practice), label)
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
shifted = concert_document(source)
shifted = generator.pitch.concert_document(source)
result = shifted['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(result['symbol'], transposed)
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'score.xml'
write_musicxml(path, shifted)
restored = load_musicxml(path)['staves'][0]['measures'][0]['harmonies'][0]
path = pathlib.Path(temporary) / 'score.xml'
generator.musicxml_writer.write_musicxml(path, shifted)
restored = generator.musicxml.load_musicxml(path)['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(restored, result)
def test_requested_labels_are_engraved_without_overlaps(self):
from generator.engraving import Fonts, Style, layout_score
labels = ('CM', 'Cm7', 'CM13#11', 'CmM7', 'CaugM7', 'Cdim7', 'Csus', 'Csus2', 'C7sus4', '(Fm7/Ab)')
for label in labels:
with self.subTest(label=label):
source = parse(xml_score(chord_xml(label) + xml_note()))
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml(label) + tests.test_musicxml.xml_note()))
layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')))
rendered = [p.value for page in layout.pages for p in page.primitives if p.role == 'harmony']
self.assertEqual(rendered, [harmony.symbol(harmony.parse_symbol(label))])
self.assertEqual(rendered, [generator.harmony.symbol(generator.harmony.parse_symbol(label))])
def test_unknown_or_contradictory_symbols_are_rejected(self):
for label in ('Cunknown', 'Cm79', 'C7b9#9', 'C7add7', 'C7no3no3', 'C7no#5', '(Cm7', 'Cm7)', 'C7/G/Ab'):
with self.subTest(label=label), self.assertRaises(ValueError):
harmony.parse_symbol(label)
generator.harmony.parse_symbol(label)
for content in (chord_xml('m7', 'major-seventh'), chord_xml('(Fm7)', 'minor-seventh'), chord_xml('(Fm7)', root='D'), chord_xml('m7/G', extra='<bass><bass-step>A</bass-step></bass>')):
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'contradictory'):
parse(xml_score(content + xml_note()))
with self.subTest(content=content), self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory'):
tests.test_musicxml.parse(tests.test_musicxml.xml_score(content + tests.test_musicxml.xml_note()))
def test_kind_text_cannot_override_canonical_output(self):
chord = harmony.parse_symbol('Cm7') | {'kind_text': '-7'}
self.assertEqual(harmony.symbol(chord), 'Cm7')
source = parse(xml_score(chord_xml('7', 'dominant', extra='<degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree>') + xml_note()))
chord = generator.harmony.parse_symbol('Cm7') | {'kind_text': '-7'}
self.assertEqual(generator.harmony.symbol(chord), 'Cm7')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord_xml('7', 'dominant', extra='<degree><degree-value>9</degree-value><degree-alter>-1</degree-alter><degree-type>add</degree-type></degree>') + tests.test_musicxml.xml_note()))
self.assertEqual(source['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'C7b9')
def test_current_exercise_and_song_exports_import_without_edits(self):
exercise = load_musicxml(Path('data/practice/0001.xml'))
exercise = generator.musicxml.load_musicxml(pathlib.Path('data/practice/0001.xml'))
measures = exercise['staves'][0]['measures']
self.assertEqual(len(measures), 20)
self.assertEqual(sum(len(n['pitches']) for m in measures for v in m['voices'] for n in v['notes']), 70)
self.assertEqual(measures[1]['harmonies'][0]['symbol'], 'Bm9')
self.assertEqual(measures[3]['harmonies'][0]['symbol'], 'DmM7')
self.assertEqual(measures[0]['voices'][0]['notes'][0]['duration'], rational(1))
self.assertEqual(measures[0]['voices'][0]['notes'][0]['duration'], generator.model.rational(1))
for name, count, key in (('autumn-leaves', 33, 'F'), ('it-could-happen-to-you', 32, 'Eb')):
source = load_musicxml(Path('data/songs') / f'{name}.xml')
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / f'{name}.xml')
self.assertEqual(len(source['staves'][0]['measures']), count)
self.assertEqual(source['metadata']['key_signature'], key)
+33 -5
View File
@@ -165,11 +165,16 @@ auto checksum(std::string const &bytes) -> std::string {
return result;
}
auto write_score(std::filesystem::path const &path, std::string part = "Lead") -> void {
auto write_score(std::filesystem::path const &path, std::filesystem::path const &assets, std::string part = "Lead") -> void {
auto score = boost::json::parse(R"({"music":{"metadata":{"title":"Fixture","composer":"Composer",
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[]}})");
score.at("music").at("staves").as_array().front().as_object()["name"] = part;
auto title = std::string{"VPTI\x28\x01\x40\x00", 8U};
title.resize(vamp_protocol::title_image_size, std::char_t{0x11});
auto title_image = checksum(title);
write_file(assets / "pico" / "titles" / (title_image + ".mask"), title);
score.at("presentation").as_object()["title_image"] = title_image;
auto &pages = score.at("presentation").at("pages").as_array();
for (auto index = std::uint32_t{1}; index <= 2U; ++index) {
auto relative = path.stem().string() + ".pages/" + std::to_string(index) + ".png";
@@ -182,12 +187,12 @@ auto write_score(std::filesystem::path const &path, std::string part = "Lead") -
auto make_assets(vamp_zero::config const &config) -> void {
auto root = std::filesystem::path{config.runtime.assets_path};
write_score(root / "songs" / "first.json");
write_score(root / "songs" / "second.json");
write_score(root / "songs" / "first.json", root);
write_score(root / "songs" / "second.json", root);
for (auto part_name : vamp_zero::practice_part_names()) {
auto name = std::string{part_name};
name.front() = static_cast<std::char_t>(name.front() - 'A' + 'a');
write_score(root / "practice" / "0001" / (name + ".json"), std::string{part_name});
write_score(root / "practice" / "0001" / (name + ".json"), root, std::string{part_name});
}
BOOST_REQUIRE(vamp_test::build_catalog(root, config.database.path));
}
@@ -387,6 +392,29 @@ BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_and_parts) {
}
}
BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_corrupted_pico_titles) {
for (auto corrupt : {false, true}) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto titles = std::filesystem::path{config.runtime.assets_path} / "pico" / "titles";
auto path = std::filesystem::directory_iterator {
titles
} -> path();
if (corrupt) {
auto bytes = read_file(path);
bytes.back() ^= 1;
write_file(path, bytes);
} else {
std::filesystem::remove(path);
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("Pico title") != std::string::npos);
require_no_probe(config);
}
}
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
@@ -395,7 +423,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
auto path = assets / "practice/0001/acoustic.json";
write_score(path, "Acoustic");
write_score(path, assets, "Acoustic");
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score));
+5 -5
View File
@@ -1,15 +1,15 @@
import json
import hashlib
import io
import json
import os
from pathlib import Path
import pathlib
import shutil
import subprocess
import tempfile
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
ROOT = pathlib.Path(__file__).resolve().parents[1]
MOCK = '''#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
@@ -50,7 +50,7 @@ class InstallZeroTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.root = pathlib.Path(self.temporary.name)
self.bundle = self.root / 'bundle'
self.zero = self.bundle / 'zero'
self.zero.mkdir(parents=True)
+39 -40
View File
@@ -1,40 +1,39 @@
import copy
from fractions import Fraction
import json
from pathlib import Path
import tempfile
import fractions
import pathlib
import unittest
from xml.etree import ElementTree
import xml.etree.ElementTree
from generator.engraving import Fonts, Style, engrave_tree, layout_score
from generator.engraving.attributes import staff_attributes
from generator.engraving.notation import ScoreIndex
from generator.model import key_from_fifths, parse_pitch_name, rational, validate_document
from generator.score import load_json, write_json
from generator.pitch import concert_document, shift_key
from tests.test_musicxml import parse, xml_note, xml_score
import generator.engraving.attributes
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.types
import generator.model
import generator.pitch
import tests.test_musicxml
FONT_ROOT = Path('package/assets/fonts/petaluma')
FONT_ROOT = pathlib.Path('package/assets/fonts/petaluma')
def instrument_score(document, steps, semitones):
source = copy.deepcopy(document)
for measure in source['staves'][0]['measures']:
measure['transpose'] = {'diatonic': steps, 'chromatic': semitones, 'octave_change': 0}
return concert_document(source)
return generator.pitch.concert_document(source)
def key_change(old, new, local=False, attributes=''):
initial = f'<key><fifths>{old}</fifths></key>' + attributes
change = f'<attributes><key><cancel>{old}</cancel><fifths>{new}</fifths></key></attributes>'
if local:
return parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'), initial))
root = ElementTree.fromstring(xml_score(xml_note(), initial))
root.find('part').append(ElementTree.fromstring('<measure number="2">' + change + xml_note() + '</measure>'))
return parse(ElementTree.tostring(root, encoding='unicode'))
return tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + change + tests.test_musicxml.xml_note(duration=2, kind='half'), initial))
root = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), initial))
root.find('part').append(xml.etree.ElementTree.fromstring('<measure number="2">' + change + tests.test_musicxml.xml_note() + '</measure>'))
return tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
class KeyCancellationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fonts = Fonts(FONT_ROOT)
cls.fonts = generator.engraving.fonts.Fonts(FONT_ROOT)
def test_sharp_flat_and_cross_sign_changes_keep_their_placement(self):
cases = [(1, 0, 'A', 3, 2, -54), (-2, -1, 'Ab', -4, -3, -36),
(-1, -2, 'G', 1, 0, -48), (1, 2, 'F', -1, 0, -24)]
@@ -44,58 +43,58 @@ class KeyCancellationTests(unittest.TestCase):
source = key_change(old, new, local)
before = copy.deepcopy(source)
result = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target]))
validate_document(result)
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(measures[0]['key'], key_from_fifths(expected_old))
self.assertEqual(measures[0]['key'], generator.model.key_from_fifths(expected_old))
change = measures[0]['attributes'][0] if local else measures[1]
self.assertEqual(change['key'], key_from_fifths(expected_new) | {'cancel': expected_old})
self.assertEqual(change['key'], generator.model.key_from_fifths(expected_new) | {'cancel': expected_old})
self.assertEqual(len(measures), 1 if local else 2)
if local:
self.assertEqual(len(measures[0]['attributes']), 1)
self.assertEqual(change['onset'], rational(2))
self.assertEqual(change['onset'], generator.model.rational(2))
else:
self.assertTrue(all(not m['attributes'] for m in measures))
self.assertEqual(change['number'], '2')
time = Fraction(2 if local else 4)
index = ScoreIndex(result)
glyphs = staff_attributes(index, index.state(time), time, self.fonts, 12, 'change', ('key',))
time = fractions.Fraction(2 if local else 4)
index = generator.engraving.notation.ScoreIndex(result)
glyphs = generator.engraving.attributes.staff_attributes(index, index.state(time), time, self.fonts, 12, 'change', ('key',))
naturals = [p for p in glyphs if p.value == 'accidentalNatural']
self.assertEqual(len(naturals), 1)
self.assertEqual(naturals[0].points[0][1], natural_y)
self.assertEqual([p.value for p in glyphs[1:]], ['accidentalSharp' if expected_new > 0 else 'accidentalFlat'] * abs(expected_new))
layout = layout_score(result, Style(), self.fonts)
layout = generator.engraving.layout.layout_score(result, generator.engraving.types.Style(), self.fonts)
reference = 'measure-1:attributes:0' if local else 'measure-2:initial'
drawn = [p for page in layout.pages for p in page.primitives if p.role == 'accidental' and p.sources == (reference,)]
self.assertEqual([p.value for p in drawn], [p.value for p in glyphs])
self.assertEqual([p.points[0][1] - drawn[0].points[0][1] for p in drawn], [p.points[0][1] - glyphs[0].points[0][1] for p in glyphs])
self.assertEqual(source, before)
def test_metadata_and_initial_signature_transpose_cancellation(self):
source = parse(xml_score(xml_note(), '<key><cancel>1</cancel><fifths>0</fifths></key>'))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), '<key><cancel>1</cancel><fifths>0</fifths></key>'))
result = instrument_score(source, 1, 2)
expected = key_from_fifths(2) | {'cancel': 3}
expected = generator.model.key_from_fifths(2) | {'cancel': 3}
self.assertEqual(result['metadata']['key'], expected)
self.assertEqual(result['staves'][0]['measures'][0]['key'], expected)
def test_zero_and_modal_cancellations_reference_the_old_signature(self):
for mode in ('major', 'minor', 'dorian', 'none'):
with self.subTest(mode=mode):
key = key_from_fifths(-1, mode) | {'cancel': 0}
result = shift_key(key, 1, 2)
key = generator.model.key_from_fifths(-1, mode) | {'cancel': 0}
result = generator.pitch.shift_key(key, 1, 2)
self.assertEqual(result['cancel'], 2)
self.assertEqual(result['mode'], mode)
self.assertEqual(result['alterations'], key_from_fifths(1)['alterations'])
self.assertEqual(result['alterations'], generator.model.key_from_fifths(1)['alterations'])
def test_unchanged_accidentals_do_not_receive_naturals(self):
for old, new, target in [(1, 0, 'F'), (-1, 0, 'G'), (1, 1, 'A')]:
with self.subTest(old=old, new=new, target=target):
result = instrument_score(key_change(old, new), *({'F': (-1, -2), 'G': (1, 2), 'A': (1, 2)}[target]))
index = ScoreIndex(result)
glyphs = staff_attributes(index, index.state(Fraction(4)), Fraction(4), self.fonts, 12, 'change', ('key',))
index = generator.engraving.notation.ScoreIndex(result)
glyphs = generator.engraving.attributes.staff_attributes(index, index.state(fractions.Fraction(4)), fractions.Fraction(4), self.fonts, 12, 'change', ('key',))
self.assertNotIn('accidentalNatural', [p.value for p in glyphs])
def test_unrepresentable_cancellation_fails_without_mutating_source(self):
for fifths, steps, semitones in [(14, 1, 2), (-14, -1, -2), (0, 0, Fraction(1, 2))]:
key = key_from_fifths(0) | {'cancel': fifths}
for fifths, steps, semitones in [(14, 1, 2), (-14, -1, -2), (0, 0, fractions.Fraction(1, 2))]:
key = generator.model.key_from_fifths(0) | {'cancel': fifths}
before = copy.deepcopy(key)
with self.subTest(fifths=fifths, semitones=semitones), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
shift_key(key, steps, semitones)
generator.pitch.shift_key(key, steps, semitones)
self.assertEqual(key, before)
for local in (False, True):
source = key_change(0, 0, local)
@@ -111,8 +110,8 @@ class KeyCancellationTests(unittest.TestCase):
for local in (False, True):
with self.subTest(local=local):
source = key_change(1, 0, local, attributes)
result = concert_document(source)
self.assertEqual(result, concert_document(result))
result = generator.pitch.concert_document(source)
self.assertEqual(result, generator.pitch.concert_document(result))
measures = result['staves'][0]['measures']
change = measures[0]['attributes'][0] if local else measures[1]
self.assertEqual(change['key'], key_from_fifths(2) | {'cancel': 3})
self.assertEqual(change['key'], generator.model.key_from_fifths(2) | {'cancel': 3})
+80 -80
View File
@@ -1,82 +1,88 @@
import argparse
import copy
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
import tempfile
import unittest
from unittest.mock import patch
import unittest.mock
from generator import harmony
from generator.__main__ import generate_assets
from generator.arrange.generate import arrange_document
from generator.parts import active_chord, generated_voice, propagated_chords
from generator.guitar import chord_pitches, chord_positions, chord_requirements
from generator.model import beat_lengths, fraction, key_from_fifths, pitch_number, rational, validate_document
from generator.musicxml import load_musicxml
from generator.styles import BUILT_IN_STYLES, canonical_style
from generator.pitch import concert_document, shift_pitch
from tests.test_musicxml import parse, notes, xml_note, xml_score
import generator.__main__
import generator.accompaniment
import generator.arrange.generate
import generator.guitar
import generator.harmony
import generator.model
import generator.musicxml
import generator.parts
import generator.pitch
import tests.rhythm_fixtures
import tests.test_musicxml
class MusicModelTests(unittest.TestCase):
def setUp(self):
engraving = patch('generator.__main__.engrave_tree')
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
engraving.start()
self.addCleanup(engraving.stop)
catalog = patch('generator.__main__.build_catalog')
catalog = unittest.mock.patch('generator.catalog.build_catalog')
catalog.start()
self.addCleanup(catalog.stop)
rhythms = unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library())
rhythms.start()
self.addCleanup(rhythms.stop)
artwork = unittest.mock.patch('generator.pico_artwork.generate_titles')
artwork.start()
self.addCleanup(artwork.stop)
def test_chord_semantics_are_not_voicing_heuristics(self):
cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7},
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}
for label, expected in cases.items():
self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))), expected, label)
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
definition = harmony.parse_symbol(label)
pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0])
self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label)
definition = generator.harmony.parse_symbol(label)
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(*generator.guitar.chord_requirements(definition))[0])
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
with self.assertRaises(ValueError):
harmony.parse_symbol('Cunrecognized')
generator.harmony.parse_symbol('Cunrecognized')
def test_transposition_preserves_spelling_octaves_modes(self):
for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}):
p['alter'] = rational(p['alter'])
self.assertEqual(shift_pitch(p, 0, 0), p)
self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2)
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key><transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'))
result = concert_document(source)
p['alter'] = generator.model.rational(p['alter'])
self.assertEqual(generator.pitch.shift_pitch(p, 0, 0), p)
self.assertEqual(generator.model.pitch_number(generator.pitch.shift_pitch(p, 1, 2)), generator.model.pitch_number(p) + 2)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key><transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'))
result = generator.pitch.concert_document(source)
self.assertEqual(result['metadata']['key_signature'], 'D minor')
self.assertEqual(result['metadata']['key']['alterations'], key_from_fifths(-1, 'minor')['alterations'])
self.assertEqual(result['metadata']['key']['alterations'], generator.model.key_from_fifths(-1, 'minor')['alterations'])
def test_additive_and_compound_grouping(self):
self.assertEqual(beat_lengths({'groups': [{'beats': [3, 2], 'unit': 8}]}), [Fraction(3, 2), Fraction(1)])
self.assertEqual(beat_lengths({'groups': [{'beats': [6], 'unit': 8}]}), [Fraction(3, 2)] * 2)
self.assertEqual(beat_lengths({'groups': [{'beats': [3], 'unit': 4}]}), [Fraction(1)] * 3)
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3, 2], 'unit': 8}]}), [fractions.Fraction(3, 2), fractions.Fraction(1)])
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [6], 'unit': 8}]}), [fractions.Fraction(3, 2)] * 2)
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3], 'unit': 4}]}), [fractions.Fraction(1)] * 3)
def test_generated_rhythm_follows_mid_measure_meter(self):
change = '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>'
source = parse(xml_score(xml_note(duration=1, kind='quarter') + change + xml_note(duration=3, kind='half', extra='<dot/>')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter') + change + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')))
measure = source['staves'][0]['measures'][0]
events = generated_voice(measure, 'slash', None)
self.assertEqual([fraction(n['onset']) for n in events], [0, 1, Fraction(5, 2)])
self.assertEqual([fraction(n['duration']) for n in events], [1, Fraction(3, 2), Fraction(3, 2)])
events = generator.parts.generated_voice(measure, 'slash', None)
self.assertEqual([generator.model.fraction(n['onset']) for n in events], [0, 1, fractions.Fraction(5, 2)])
self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, fractions.Fraction(3, 2), fractions.Fraction(3, 2)])
def test_harmony_carry_uses_last_event_and_never_future_event(self):
c = harmony.parse_symbol('C7') | {'onset': rational(0)}
f = harmony.parse_symbol('F7') | {'onset': rational(2)}
c = generator.harmony.parse_symbol('C7') | {'onset': generator.model.rational(0)}
f = generator.harmony.parse_symbol('F7') | {'onset': generator.model.rational(2)}
measures = [{'harmonies': [c, f]}, {'harmonies': []}]
self.assertEqual(propagated_chords(measures), [None, f])
self.assertIsNone(active_chord({'harmonies': [f]}, Fraction(0), None))
self.assertEqual(active_chord(measures[0], Fraction(1), None), c)
self.assertEqual(generator.accompaniment.carried_chords(measures), [None, f])
self.assertIsNone(generator.accompaniment.active_chord({'harmonies': [f]}, fractions.Fraction(0), None))
self.assertEqual(generator.accompaniment.active_chord(measures[0], fractions.Fraction(1), None), c)
def test_tuplet_ratios_and_tie_links_validated(self):
document = parse(xml_score(xml_note()))
notes(document)[0]['pitches'][0]['tie_to'] = 'missing'
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(document)[0]['pitches'][0]['tie_to'] = 'missing'
with self.assertRaisesRegex(ValueError, 'dangling tie'):
validate_document(document)
document = parse(xml_score(xml_note()))
notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2}
generator.model.validate_document(document)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2}
with self.assertRaisesRegex(ValueError, 'reduced'):
validate_document(document)
generator.model.validate_document(document)
def test_optional_turnaround_and_form(self):
source = load_musicxml(Path('data/songs/it-could-happen-to-you.xml'))
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/it-could-happen-to-you.xml'))
for part in ('acoustic', 'electric', 'vocal'):
result = arrange_document(source, part)
validate_document(result)
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(sum(bool(m.get('section_label')) for m in measures), 7)
self.assertFalse(measures[-1]['harmonies'])
@@ -87,68 +93,62 @@ class MusicModelTests(unittest.TestCase):
self.assertIn('(Bb7)', labels)
def test_legacy_repeat_model_fields_are_rejected(self):
for field in ('repeats', 'endings', 'initial_attributes'):
source = parse(xml_score(xml_note()))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
source['staves'][0]['measures'][0][field] = {}
with self.assertRaisesRegex(ValueError, 'repeat notation'):
validate_document(source)
generator.model.validate_document(source)
for field in ('sound_ties', 'printed_ties'):
source = parse(xml_score(xml_note()))
notes(source)[0]['pitches'][0][field] = []
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
tests.test_musicxml.notes(source)[0]['pitches'][0][field] = []
with self.assertRaisesRegex(ValueError, 'pass-specific tie'):
validate_document(source)
def test_player_styles_preserved(self):
self.assertEqual(len(BUILT_IN_STYLES), 50)
for text in ('Med.', 'Med. Swing', 'Medium', 'Medium Swing'):
self.assertEqual(canonical_style(text), 'Medium Swing')
with self.assertRaises(ValueError):
canonical_style('Unrecognized style')
generator.model.validate_document(source)
def test_failure_preserves_generated_assets(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
root = pathlib.Path(temp)
data = root / 'data'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note(duration=5)), encoding='utf-8')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=5)), encoding='utf-8')
output = root / 'output'
output.mkdir()
(output / 'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
with self.assertRaises(ValueError):
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved')
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note()), encoding='utf-8')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
args.check = True
with self.assertRaisesRegex(ValueError, 'summary'):
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved')
def test_instrument_transposition_is_converted_once(self):
source = parse(xml_score(xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
concert = concert_document(source)
p = notes(concert)[0]['pitches'][0]
self.assertEqual((p['letter'], fraction(p['alter']), p['octave']), ('b', -1, 3))
self.assertEqual(concert_document(concert), concert)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
concert = generator.pitch.concert_document(source)
p = tests.test_musicxml.notes(concert)[0]['pitches'][0]
self.assertEqual((p['letter'], generator.model.fraction(p['alter']), p['octave']), ('b', -1, 3))
self.assertEqual(generator.pitch.concert_document(concert), concert)
self.assertEqual(concert['metadata']['key_signature'], 'Bb')
def test_deterministic_generation_and_removal(self):
with tempfile.TemporaryDirectory() as temp:
data, output = Path(temp) / 'data', Path(temp) / 'output'
data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'songs' / 'fixture.xml'
source.write_text(xml_score(xml_note()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
generate_assets(args)
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.unlink()
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertFalse(list(output.rglob('*.json')))
self.assertFalse(list(output.rglob('*.png')))
def test_pickup_tie_survives_vocal_restoration(self):
source = xml_score(xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
result = arrange_document(parse(source), 'vocal')
validate_document(result)
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal', rhythms=tests.rhythm_fixtures.library())
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
end = measures[1]['voices'][0]['notes'][0]['pitches'][0]
+61 -46
View File
@@ -1,10 +1,13 @@
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
import tempfile
import unittest
import xml.etree.ElementTree
from generator.musicxml import MusicXMLError, load_musicxml
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number
import generator.harmony
import generator.model
import generator.musicxml
import generator.musicxml_writer
def xml_score(content, attributes='', *, divisions=1, implicit=False):
return ('<?xml version="1.0"?>'
@@ -22,39 +25,54 @@ def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, o
def parse(text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'fixture.xml'
path = pathlib.Path(directory) / 'fixture.xml'
path.write_text(text, encoding='utf-8')
return load_musicxml(path)
return generator.musicxml.load_musicxml(path)
def notes(document, measure=0):
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
class MusicXMLTests(unittest.TestCase):
def test_obsolete_style_hints_are_rejected(self):
for value in ('Medium Swing', 'Med.', 'Bossa Nova', 'Unrecognized style', ''):
with self.subTest(value=value), self.assertRaisesRegex(generator.musicxml.MusicXMLError, "fixture.xml: unsupported Vamp property 'vamp:style'"):
field = f'<identification><miscellaneous><miscellaneous-field name="vamp:style">{value}</miscellaneous-field></miscellaneous></identification>'
parse(xml_score(xml_note()).replace('<part-list>', field + '<part-list>'))
def test_style_free_roundtrip_keeps_barline_notation_and_fonts(self):
content = '<barline location="left"><bar-style>heavy-light</bar-style></barline>' + xml_note() + '<barline location="right"><bar-style>light-heavy</bar-style></barline>'
source = parse(xml_score(content))
self.assertNotIn('style', source['metadata'])
root = generator.musicxml_writer.musicxml_tree(source).getroot()
self.assertEqual(root.findall('identification/miscellaneous/miscellaneous-field'), [])
self.assertEqual(root.find('defaults/music-font').get('font-family'), 'Petaluma')
restored = parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
self.assertNotIn('style', restored['metadata'])
self.assertEqual(restored['staves'][0]['measures'][0]['barlines'], {'left': 'heavy-light', 'right': 'light-heavy'})
def test_measure_sound_tempo_matches_direction_sound(self):
sound = '<sound tempo="120"/>'
direct = parse(xml_score(sound + xml_note()))
direction = parse(xml_score('<direction>' + sound + '</direction>' + xml_note()))
self.assertEqual(direct, direction)
with self.assertRaises(MusicXMLError):
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score('<sound tempo="0"/>' + xml_note()))
with self.assertRaises(MusicXMLError):
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score('<sound dacapo="yes"/>' + xml_note()))
def test_regression_fixture(self):
score = load_musicxml(Path('tests/fixtures/notation.xml'))
score = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/notation.xml'))
self.assertEqual(len(score['staves'][0]['measures']), 4)
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
def test_catalog_imports(self):
sources = sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml'))
sources = sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml'))
self.assertTrue(sources)
for path in sources:
with self.subTest(path=path):
self.assertEqual(set(load_musicxml(path)), {'metadata', 'staves'})
self.assertEqual(set(generator.musicxml.load_musicxml(path)), {'metadata', 'staves'})
def test_all_written_values_and_dots(self):
for kind in NOTE_TYPES:
for kind in generator.model.NOTE_TYPES:
for dots in (0, 1, 2, 3):
value = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2**dots))
value = generator.model.TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2**dots))
score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='<dot/>' * dots), divisions=value.denominator, implicit=True))
self.assertEqual(fraction(notes(score)[0]['duration']), value)
self.assertEqual(generator.model.fraction(notes(score)[0]['duration']), value)
self.assertEqual(notes(score)[0]['written'], {'type': kind, 'dots': dots})
def test_divisions_do_not_change_music(self):
a = parse(xml_score(xml_note(duration=4)))
@@ -67,59 +85,57 @@ class MusicXMLTests(unittest.TestCase):
content = '<forward><duration>1</duration><voice>1</voice></forward>' + xml_note(duration=3, kind='half', extra='<dot/>')
events = notes(parse(xml_score(content)))
self.assertEqual([(n['id'], n['kind']) for n in events], [('spacer-1', 'spacer'), ('note-2', 'note')])
self.assertEqual([fraction(n['duration']) for n in events], [1, 3])
self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, 3])
def test_doctype_allowed_but_entities_rejected(self):
source = xml_score(xml_note())
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
self.assertEqual(parse(source), parse(xml_score(xml_note())))
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
with self.assertRaises(MusicXMLError):
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(malicious)
with self.assertRaises(MusicXMLError):
with self.assertRaises(generator.musicxml.MusicXMLError):
parse('<score-partwise')
def test_harmony_inside_sustained_note_remains_independent(self):
h = '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
score = parse(xml_score(xml_note() + h))
self.assertEqual(len(notes(score)), 1)
self.assertEqual(fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
self.assertEqual(generator.model.fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
def test_partial_chord_ties_and_unequal_tones(self):
source = xml_note(duration=2, kind='half', extra='<tie type="start"/>')
source += xml_note('E', 1, 'quarter', prefix='<chord/>')
source += xml_note(duration=2, kind='half', extra='<tie type="stop"/>')
source += xml_note('G', 2, 'half', prefix='<chord/>')
result = notes(parse(xml_score(source)))
self.assertEqual(fraction(result[0]['pitches'][1]['duration']), 1)
self.assertEqual(generator.model.fraction(result[0]['pitches'][1]['duration']), 1)
self.assertEqual(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id'])
self.assertNotIn('tie_to', result[0]['pitches'][1])
def test_standard_minor_harmonies_normalize_display_and_preserve_pitches(self):
from generator.harmony import pitch_classes
for text, kind, label, expected in (('-7', 'minor-seventh', 'm7', (0, 3, 7, 10)), ('-7b5', 'half-diminished', 'm7b5', (0, 3, 6, 10)), ('-maj7', 'major-minor', 'mM7', (0, 3, 7, 11)), ('-6', 'minor-sixth', 'm6', (0, 3, 7, 9))):
with self.subTest(text=text):
content = f'<harmony><root><root-step>C</root-step></root><kind text="{text}">{kind}</kind></harmony>'
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['symbol'], 'C' + label)
self.assertEqual(pitch_classes(chord), expected)
self.assertEqual(generator.harmony.pitch_classes(chord), expected)
def test_other_harmony_requires_degrees_or_recognized_text(self):
for kind in ('<kind>other</kind>', '<kind text="">other</kind>', '<kind text="unknown">other</kind>'):
with self.subTest(kind=kind), self.assertRaisesRegex(MusicXMLError, r'fixture.xml: part P1, measure 1:'):
with self.subTest(kind=kind), self.assertRaisesRegex(generator.musicxml.MusicXMLError, r'fixture.xml: part P1, measure 1:'):
parse(xml_score('<harmony><root><root-step>C</root-step></root>' + kind + '</harmony>' + xml_note()))
def test_other_harmony_with_explicit_degrees_remains_supported(self):
from generator.harmony import pitch_classes
degrees = ''.join(f'<degree><degree-value>{value}</degree-value><degree-alter>{alter}</degree-alter><degree-type>add</degree-type></degree>' for value, alter in ((1, 0), (3, -1), (5, 0), (7, 0)))
content = '<harmony><root><root-step>C</root-step></root><kind text="-7">other</kind>' + degrees + '</harmony>'
score = parse(xml_score(content + xml_note()))
chord = score['staves'][0]['measures'][0]['harmonies'][0]
self.assertEqual(chord['kind'], 'other')
self.assertEqual(chord['symbol'], 'Cm7')
self.assertEqual(pitch_classes(chord), (0, 3, 7, 10))
with self.assertRaisesRegex(MusicXMLError, 'contradictory harmony text'):
self.assertEqual(generator.harmony.pitch_classes(chord), (0, 3, 7, 10))
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory harmony text'):
parse(xml_score(content.replace('text="-7"', 'text="maj7"') + xml_note()))
def test_grace_cue_and_measured_tremolo(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
regular = xml_note(extra='<notations><ornaments><tremolo type="single">3</tremolo></ornaments></notations>', prefix='<cue/>')
result = notes(parse(xml_score(grace + regular)))
self.assertEqual(fraction(result[0]['duration']), 0)
self.assertEqual(generator.model.fraction(result[0]['duration']), 0)
self.assertTrue(result[0]['grace']['slash'])
self.assertTrue(result[1]['cue'])
self.assertEqual(result[1]['tremolo']['marks'], 3)
@@ -131,14 +147,14 @@ class MusicXMLTests(unittest.TestCase):
extra += '<notations>' + tuplet + '</notations>'
content += xml_note(duration=4, kind='quarter', extra=extra)
result = parse(xml_score(content, divisions=5))
self.assertEqual(sum(fraction(n['duration']) for n in notes(result)), 4)
self.assertTrue(all(fraction(n['duration']) == Fraction(4, 5) for n in notes(result)))
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes(result)), 4)
self.assertTrue(all(generator.model.fraction(n['duration']) == fractions.Fraction(4, 5) for n in notes(result)))
self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'], 5)
def test_inconsistent_written_timing_is_rejected(self):
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
parse(xml_score(xml_note(extra=modulation)))
with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'disagrees with written duration'):
parse(xml_score(xml_note(kind='half')))
def test_paired_tremolo_timing(self):
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
@@ -146,12 +162,12 @@ class MusicXMLTests(unittest.TestCase):
for step, action in [('C', 'start'), ('G', 'stop')])
result = notes(parse(xml_score(content)))
self.assertEqual(result[0]['tremolo']['partner'], result[1]['id'])
self.assertEqual(fraction(result[0]['duration']), 2)
self.assertEqual(generator.model.fraction(result[0]['duration']), 2)
def test_mid_measure_clef_and_key_changes(self):
change = '<attributes><key><fifths>-3</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>'
result = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
attr = result['staves'][0]['measures'][0]['attributes'][0]
self.assertEqual(fraction(attr['onset']), 2)
self.assertEqual(generator.model.fraction(attr['onset']), 2)
self.assertEqual(attr['key']['mode'], 'minor')
self.assertEqual(attr['clef']['sign'], 'C')
def test_optional_marker_contract(self):
@@ -160,7 +176,7 @@ class MusicXMLTests(unittest.TestCase):
score = parse(xml_score(marker + h + xml_note()))
self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional'])
for content in (marker + xml_note(), marker + marker + h + xml_note(), marker.replace('optional-chord', 'unknown') + xml_note()):
with self.assertRaisesRegex(MusicXMLError, 'measure 1'):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'measure 1'):
parse(xml_score(content))
def test_repeat_navigation_and_pass_specific_ties_are_rejected(self):
rejected = [
@@ -173,38 +189,37 @@ class MusicXMLTests(unittest.TestCase):
'<sound dacapo="yes"/>', '<sound dalsegno="segno"/>', '<sound tocoda="coda"/>',
]
for content in rejected:
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'measure 1'):
with self.subTest(content=content), self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'measure 1'):
parse(xml_score(content + xml_note()))
for passes in ('1', ''):
with self.assertRaisesRegex(MusicXMLError, 'pass-specific ties'):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'pass-specific ties'):
parse(xml_score(xml_note(extra=f'<tie type="start" time-only="{passes}"/>')))
def test_rejects_musical_content_outside_contract(self):
extras = ['<notations><slur type="start"/></notations>', '<notations><articulations><accent/></articulations></notations>',
'<voice>2</voice><staff>2</staff>', '<pitch><unpitched/></pitch>',
'<notations><ornaments><trill-mark/></ornaments></notations>']
for extra in extras:
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(extra=extra)))
for extra in ('<barline location="middle"><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
with self.subTest(extra=extra), self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note() + extra))
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
with self.assertRaises(MusicXMLError):
self.assertEqual(generator.model.fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), fractions.Fraction(1, 2))
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
with self.assertRaises(MusicXMLError):
with self.assertRaises(generator.musicxml.MusicXMLError):
parse(xml_score(xml_note(duration=5)))
def test_b_sharp_c_flat_preserve_octave(self):
b = notes(parse(xml_score(xml_note('B', alter=1))))[0]['pitches'][0]
c = notes(parse(xml_score(xml_note('C', alter=-1))))[0]['pitches'][0]
self.assertEqual(pitch_number(b), 72)
self.assertEqual(pitch_number(c), 59)
self.assertEqual(generator.model.pitch_number(b), 72)
self.assertEqual(generator.model.pitch_number(c), 59)
def test_musicxml_degree_alterations_are_relative(self):
h = '<harmony><root><root-step>C</root-step></root><kind>minor</kind><degree><degree-value>3</degree-value><degree-alter>1</degree-alter><degree-type>alter</degree-type></degree></harmony>'
from generator.harmony import degrees
score = parse(xml_score(h + xml_note()))
self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0)
self.assertEqual(generator.harmony.degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0)
h = h.replace('<kind>minor</kind>', '<kind text="maj7">minor</kind>')
with self.assertRaisesRegex(MusicXMLError, 'contradictory'):
with self.assertRaisesRegex(generator.musicxml.MusicXMLError, 'contradictory'):
parse(xml_score(h + xml_note()))
def test_nested_and_cross_bar_tuplets(self):
modulation = '<time-modification><actual-notes>9</actual-notes><normal-notes>4</normal-notes></time-modification>'
@@ -218,5 +233,5 @@ class MusicXMLTests(unittest.TestCase):
result = parse(text)
spans = result['staves'][0]['tuplets']
self.assertEqual([t['depth'] for t in spans], [0, 1])
self.assertEqual(fraction(notes(result, 1)[0]['duration']), Fraction(4, 9))
self.assertEqual(generator.model.fraction(notes(result, 1)[0]['duration']), fractions.Fraction(4, 9))
self.assertTrue(all(t['end'] == notes(result, 1)[0]['id'] for t in spans))
+140 -58
View File
@@ -1,114 +1,196 @@
import argparse
import copy
from fractions import Fraction
import fractions
import json
from pathlib import Path
import pathlib
import tempfile
import unittest
from unittest.mock import patch
import unittest.mock
import xml.etree.ElementTree
from generator.__main__ import generate_assets
from generator.arrange.generate import _arrange_document, arrange_document, generate_performance_scores
from generator.model import fraction, rational, validate_document
from generator.musicxml import load_musicxml
from generator.parts import PART_TYPES, generated_voice, prepare_document
from generator.pitch import concert_document
from generator.playback import compile_timeline
from generator.practice.generate import _practice_document, practice_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
import generator.__main__
import generator.accompaniment
import generator.arrange.generate
import generator.model
import generator.musicxml
import generator.parts
import generator.pitch
import generator.playback
import generator.practice.generate
import tests.rhythm_fixtures
import tests.test_musicxml
def xml_harmony(root='C', kind='major', offset=0):
return f'<harmony><root><root-step>{root}</root-step></root><kind>{kind}</kind><offset>{offset}</offset></harmony>'
class PartGenerationTests(unittest.TestCase):
def test_accompaniment_ignores_melody_attacks_and_sustains_to_changes(self):
source = parse(xml_score(xml_harmony() + xml_harmony('F', offset=3) + ''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
for result in (practice_document(source, 'acoustic'), arrange_document(source, 'electric')):
validate_document(result)
def assert_complete_measure(self, measure):
cursor = fractions.Fraction()
for note in measure['voices'][0]['notes']:
self.assertEqual(generator.model.fraction(note['onset']), cursor)
self.assertGreater(generator.model.fraction(note['duration']), 0)
cursor += generator.model.fraction(note['duration'])
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())):
generator.model.validate_document(result)
for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes']
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Guitar Solo'):
continue
self.assertEqual([fraction(n['onset']) for n in events], [0, Fraction(3, 2)])
self.assertEqual([fraction(n['duration']) for n in events], [Fraction(3, 2), Fraction(5, 2)])
self.assertTrue(all(n['pitches'] for n in events))
self.assertTrue(compile_timeline(result)['events'])
self.assert_complete_measure(measure)
sounding = [n for n in events if n['pitches']]
self.assertTrue(sounding)
self.assertLess(len(sounding), 8)
for note in sounding:
onset = generator.model.fraction(note['onset'])
if onset < fractions.Fraction(3, 2):
self.assertLessEqual(onset + generator.model.fraction(note['duration']), fractions.Fraction(3, 2))
self.assertLessEqual({generator.model.pitch_number(p) % 12 for p in note['pitches']}, {0, 4, 7} if onset < fractions.Fraction(3, 2) else {0, 5, 9})
self.assertTrue(generator.playback.compile_timeline(result)['events'])
def test_harmony_carry_and_explicit_silence(self):
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + xml_note()
source = xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + xml_note() + '</measure><measure number="4">' + xml_note() + '</measure></part>')
result = practice_document(parse(source), 'acoustic')
self.assertEqual([n['kind'] for n in notes(result)], ['rest', 'note', 'rest', 'note'])
self.assertEqual([fraction(n['onset']) for n in notes(result)], [0, 1, 2, 3])
self.assertEqual([n['kind'] for n in notes(result, 1)], ['note'])
self.assertEqual(fraction(notes(result, 1)[0]['duration']), 4)
self.assertEqual([n['kind'] for n in notes(result, 2)], ['rest'])
self.assertEqual([n['kind'] for n in notes(result, 3)], ['rest'])
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())
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']]
for start, end in ((1, 2), (3, 4)):
self.assertTrue(any(start <= generator.model.fraction(n['onset']) < end for n in sounding))
for note in sounding:
onset = generator.model.fraction(note['onset'])
self.assertTrue(1 <= onset < 2 or 3 <= onset < 4)
self.assertLessEqual(onset + generator.model.fraction(note['duration']), 2 if onset < 2 else 4)
carried = [n for n in tests.test_musicxml.notes(result, 1) if n['pitches']]
self.assertTrue(carried)
self.assertTrue(all(generator.model.pitch_number(p) % 12 in {0, 5, 9} for n in carried for p in n['pitches']))
for index in (2, 3):
self.assertTrue(all(n['kind'] == 'rest' and not n['pitches'] for n in tests.test_musicxml.notes(result, index)))
generator.model.validate_document(result)
def test_accompaniment_handles_pickups_meters_and_coincident_markers(self):
for duration, kind, attributes, implicit in ((1, 'quarter', '', True), (3, 'half', '<time><beats>3</beats><beat-type>4</beat-type></time>', False)):
source = parse(xml_score(xml_harmony() + xml_harmony('F') + xml_note(duration=duration, kind=kind, extra='<dot/>' if duration == 3 else ''), attributes, implicit=implicit))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(xml_harmony() + xml_harmony('F') + tests.test_musicxml.xml_note(duration=duration, kind=kind, extra='<dot/>' if duration == 3 else ''), attributes, implicit=implicit))
measure = source['staves'][0]['measures'][0]
events = generated_voice(measure, 'accompaniment', None)
self.assertEqual(len(events), 1)
self.assertEqual(fraction(events[0]['duration']), duration)
self.assertEqual(events[0]['_harmony']['root']['letter'], 'f')
source = parse(xml_score(xml_harmony() + xml_note(duration=1, kind='quarter') + '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>' + xml_note(duration=3, kind='half', extra='<dot/>')))
self.assertEqual(len(generated_voice(source['staves'][0]['measures'][0], 'accompaniment', None)), 1)
plan = generator.accompaniment.plan_section([measure], tests.rhythm_fixtures.library(), electric=False)
events = generator.accompaniment.realize(measure, plan.acoustic[0], None)
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in events), duration)
sounding = [n for n in events if n['kind'] == 'note']
self.assertTrue(sounding)
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 = practice_document(load_musicxml(Path('data/practice/0006.xml')), 'acoustic')
result = generator.practice.generate.practice_document(generator.musicxml.load_musicxml(pathlib.Path('data/practice/0006.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'])
validate_document(result)
self.assertTrue(compile_timeline(result)['events'])
generator.model.validate_document(result)
timeline = generator.playback.compile_timeline(result)
start = fractions.Fraction()
for measure in result['staves'][0]['measures']:
end = start + generator.model.fraction(measure['duration'])
self.assertTrue(any(generator.model.fraction(e['onset']) < end and generator.model.fraction(e['onset']) + generator.model.fraction(e['duration']) > start for e in timeline['events']), measure['id'])
start = end
def test_prepared_document_and_parts_have_independent_ownership(self):
source = parse(xml_score(xml_harmony() + xml_note()))
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
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 ((_practice_document, practice_document, True), (_arrange_document, arrange_document, False)):
prepared = prepare_document(source, practice=practice)
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)
parts = [builder(prepared, part) for part in PART_TYPES]
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(parts, [wrapper(source, part) for part in PART_TYPES])
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'
notes(parts[1])[0]['pitches'].clear()
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 = xml_harmony() + xml_harmony('F', offset=3)
content += tests.test_musicxml.xml_note(duration=2, kind='quarter', alter=1, extra='<tie type="start"/>')
content += tests.test_musicxml.xml_note(duration=6, kind='half', alter=1, extra='<dot/><tie type="stop"/><tie type="start"/>')
root = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(content, divisions=2))
second = tests.test_musicxml.xml_note(duration=4, kind='half', alter=1, extra='<tie type="stop"/>')
second += '<note><rest/><duration>1</duration><type>eighth</type></note>'
second += tests.test_musicxml.xml_note('D', duration=3, kind='quarter', alter=1, extra='<dot/>')
root.find('part').append(xml.etree.ElementTree.fromstring('<measure number="2">' + second + '</measure>'))
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 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')]
self.assertTrue(starts)
for start in starts:
notes = [n for m in measures[start:start + 2] for n in m['voices'][0]['notes']]
self.assertEqual(len(notes), len(expected))
for original, generated in zip(expected, notes):
for key in ('onset', 'duration', 'kind', 'written'):
self.assertEqual(generated[key], original[key])
self.assertEqual(len(generated['pitches']), len(original['pitches']))
for pitch, tone in zip(original['pitches'], generated['pitches']):
self.assertEqual(generator.model.pitch_number(tone), generator.model.pitch_number(pitch))
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:
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(source, before)
def test_song_slug_collisions_fail_before_writes(self):
for names in (('one-two', 'one_two'), ('Song', 'song'), ('!!!', '???')):
with self.subTest(names=names), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
root = pathlib.Path(directory)
source, output = root / 'songs', root / 'output'
source.mkdir()
output.mkdir()
(output / 'sentinel').write_text('preserved')
for name in names:
(source / (name + '.xml')).write_text(xml_score(xml_note()))
(source / (name + '.xml')).write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'conflicting output path') as raised:
generate_performance_scores(source, output)
generator.arrange.generate.generate_performance_scores(source, output, rhythms=tests.rhythm_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):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
root = pathlib.Path(directory)
for category, name in (('songs', 'song'), ('practice', '0001')):
(root / 'data' / category).mkdir(parents=True)
(root / 'data' / category / (name + '.xml')).write_text(xml_score(xml_harmony() + xml_note()))
args = argparse.Namespace(data_dir=root / 'data', output_dir=root / 'output', static_assets_dir=Path('package/assets'), check=False)
with patch('generator.arrange.generate.load_musicxml', wraps=load_musicxml) as song_load, patch('generator.practice.generate.load_musicxml', wraps=load_musicxml) as practice_load, patch('generator.parts.concert_document', wraps=concert_document) as normalize, patch('generator.__main__.engrave_tree'), patch('generator.__main__.build_catalog'), patch('generator.__main__.compile_tree'):
generate_assets(args)
song_load.assert_called_once()
practice_load.assert_called_once()
(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 (
unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()) as load_library,
unittest.mock.patch('generator.musicxml.load_musicxml', wraps=generator.musicxml.load_musicxml) as load,
unittest.mock.patch('generator.pitch.concert_document', wraps=generator.pitch.concert_document) as normalize,
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
unittest.mock.patch('generator.pico_artwork.generate_titles'),
unittest.mock.patch('generator.catalog.build_catalog'),
unittest.mock.patch('generator.playback.compile_tree'),
):
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(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 self.assertRaises(ValueError):
generate_assets(args)
with unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()), self.assertRaisesRegex(ValueError, '0002.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)
self.assertEqual(json.loads((args.output_dir / 'songs/song-vocal.json').read_text())['metadata']['title'], 'Fixture')
+208
View File
@@ -14,6 +14,7 @@
#include <vamp_pico/http_client.hpp>
#include <vamp_pico/input.hpp>
#include <vamp_pico/artwork.hpp>
namespace {
@@ -232,6 +233,123 @@ BOOST_AUTO_TEST_CASE(pico_http_client_has_stable_ownership) {
namespace {
auto title_bytes() -> std::string {
auto bytes = std::string(vamp_protocol::title_image_size, '\0');
bytes.replace(0U, 8U, "VPTI\x28\x01\x40\x00", 8U);
bytes[8] = static_cast<char>(0xa3);
return bytes;
}
auto title_request(char digit = 'a') -> vamp_pico::remote_request {
auto request = vamp_pico::remote_request{vamp_pico::request_kind::TITLE};
request.title_id.fill(digit);
request.title_id.back() = 0;
return request;
}
auto title_response() -> std::string {
return "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: " +
std::to_string(vamp_protocol::title_image_size) + "\r\nConnection: close\r\n\r\n" + title_bytes();
}
}
BOOST_AUTO_TEST_CASE(pico_title_identity_parser_is_strict_and_transactional) {
auto state = vamp_pico::remote_display_state{};
auto identifier = std::string(64U, 'a');
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\ntitle_image=" + identifier + "\npage=2\npages=3\n", state));
BOOST_TEST(std::string{state.title_image.data()} == identifier);
for (auto invalid : {identifier + "a", std::string(64U, 'A'), std::string{"../../title"}, std::string(63U, 'a')}) {
BOOST_CHECK(!vamp_pico::parse_remote_display_body("title_image=" + invalid + "\n", state));
BOOST_TEST(std::string{state.title_image.data()} == identifier);
BOOST_TEST(state.page == 2U);
}
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=0\ntitle_image=\n", state));
BOOST_TEST(state.title_image[0] == 0);
}
BOOST_AUTO_TEST_CASE(pico_title_cache_discards_old_selection_and_validates_before_copy) {
auto cache = vamp_pico::title_cache{};
auto first = std::string(64U, 'a');
auto second = std::string(64U, 'b');
auto image = title_bytes();
BOOST_CHECK(cache.select(first));
BOOST_CHECK(!cache.ready());
BOOST_REQUIRE(cache.accept(first, image));
image[8] = 0;
BOOST_TEST(cache.image().at(0U, 0U) == 10U);
BOOST_TEST(cache.image().at(1U, 0U) == 3U);
BOOST_TEST(cache.image().at(296U, 0U) == 0U);
BOOST_CHECK(!cache.select(first));
BOOST_CHECK(cache.ready());
BOOST_CHECK(cache.select(second));
BOOST_CHECK(!cache.ready());
BOOST_CHECK(!cache.accept(first, image));
BOOST_CHECK(!cache.accept(second, image.substr(1U)));
image[4] = 0;
BOOST_CHECK(!cache.accept(second, image));
BOOST_CHECK(cache.accept(second, title_bytes()));
BOOST_CHECK(cache.select(""));
BOOST_CHECK(!cache.ready());
BOOST_CHECK(!cache.accept(second, title_bytes()));
}
BOOST_AUTO_TEST_CASE(pico_title_failure_is_visible_until_success_or_selection_change) {
auto cache = vamp_pico::title_cache{};
auto first = std::string(64U, 'a');
auto second = std::string(64U, 'b');
cache.select(first);
BOOST_CHECK(cache.fail(first));
BOOST_CHECK(cache.failed());
BOOST_CHECK(!cache.ready());
BOOST_CHECK(!cache.select(first));
BOOST_CHECK(cache.failed());
BOOST_REQUIRE(cache.accept(first, title_bytes()));
BOOST_CHECK(!cache.failed());
BOOST_CHECK(cache.ready());
cache.select(second);
BOOST_CHECK(!cache.failed());
BOOST_CHECK(!cache.fail(first));
BOOST_CHECK(cache.fail(second));
cache.select("");
BOOST_CHECK(!cache.failed());
BOOST_CHECK(cache.fail(""));
BOOST_CHECK(cache.failed());
}
BOOST_AUTO_TEST_CASE(pico_page_counters_preserve_catalog_maximum_without_truncation) {
BOOST_TEST(std::string{vamp_pico::counter_text(10000U, 10000U).data()} == "10000/10000");
BOOST_TEST(std::string{vamp_pico::counter_text(1U, 10000U).data()} == "1/10000");
BOOST_TEST(std::string{vamp_pico::counter_text(0U, 10000U).data()}.empty());
BOOST_TEST(std::string{vamp_pico::counter_text(10001U, 10001U).data()}.empty());
}
BOOST_AUTO_TEST_CASE(pico_title_requests_follow_commands_and_do_not_postpone_status) {
auto scheduler = vamp_pico::request_scheduler{};
BOOST_REQUIRE(scheduler.next(0U));
scheduler.complete(10U);
scheduler.want_title(std::string(64U, 'a'));
auto title = scheduler.next(11U);
BOOST_REQUIRE(title);
BOOST_CHECK(title->kind == vamp_pico::request_kind::TITLE);
BOOST_CHECK(!scheduler.next(100U));
BOOST_CHECK(scheduler.enqueue(vamp_protocol::page_command::NEXT) == vamp_pico::enqueue_result::QUEUED);
scheduler.cancel_title();
take_command(scheduler, 100U, vamp_protocol::page_command::NEXT);
scheduler.complete(110U);
scheduler.want_title(std::string(64U, 'b'));
title = scheduler.next(111U);
BOOST_REQUIRE(title);
BOOST_CHECK(title->kind == vamp_pico::request_kind::TITLE);
BOOST_TEST(std::string{title->title_id.data()} == std::string(64U, 'b'));
scheduler.complete(1600U);
auto status = scheduler.next(1600U);
BOOST_REQUIRE(status);
BOOST_CHECK(status->kind == vamp_pico::request_kind::DISPLAY);
}
namespace {
struct fake_network {
std::array<tcp_pcb, 64> pcbs{};
std::size_t allocated{};
@@ -695,3 +813,93 @@ BOOST_FIXTURE_TEST_CASE(pico_http_oversized_response_is_rejected_without_truncat
BOOST_TEST(network.acknowledged == 1025U);
BOOST_TEST(network.aborts == 1U);
}
BOOST_FIXTURE_TEST_CASE(pico_http_title_binary_is_fragmented_bounded_and_owned, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
auto request = title_request();
BOOST_CHECK(client.start(request, 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[0];
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
BOOST_CHECK(network.written.find("POST /api/remote/title HTTP/1.1\r\n") == 0U);
auto marker = network.written.find("\r\n\r\n");
BOOST_REQUIRE(marker != std::string::npos);
BOOST_TEST(network.written.substr(marker + 4U) == "{\"id\":\"" + std::string(64U, 'a') + "\"}");
BOOST_TEST(network.written.size() - marker - 4U == 73U);
auto response = title_response();
for (auto offset = std::size_t{}; offset < response.size(); offset += 137U) {
BOOST_CHECK(deliver_data(pcb, std::string_view{response}.substr(offset, 137U)) == ERR_OK);
}
BOOST_CHECK(deliver_eof(pcb) == ERR_OK);
auto result = client.take_result();
BOOST_REQUIRE(result);
BOOST_REQUIRE(result->success);
BOOST_CHECK(result->request.kind == vamp_pico::request_kind::TITLE);
BOOST_CHECK(client.title_image() == title_bytes());
BOOST_TEST(network.acknowledged == response.size());
client.start({}, 1U);
BOOST_CHECK(client.title_image().empty());
}
BOOST_FIXTURE_TEST_CASE(pico_http_title_rejects_incorrect_binary_framing, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
auto valid = title_response();
auto extra = valid;
extra.insert(extra.find("\r\n\r\n"), "\r\nContent-Length: 9480");
auto chunked = valid;
chunked.insert(chunked.find("\r\n\r\n"), "\r\nTransfer-Encoding: chunked");
auto compressed = valid;
compressed.insert(compressed.find("\r\n\r\n"), "\r\nContent-Encoding: gzip");
auto wrong_type = valid;
wrong_type.replace(wrong_type.find("application/octet-stream"), 24U, "text/plain");
auto wrong_length = valid;
wrong_length.replace(wrong_length.find("9480"), 4U, "9479");
auto wrong_image = valid;
wrong_image[wrong_image.find("VPTI") + 4U] = 0;
for (auto const &response : {valid.substr(0U, valid.size() - 1U), valid + "x", extra, chunked, compressed, wrong_type, wrong_length, wrong_image}) {
BOOST_CHECK(client.start(title_request(), 0U) == vamp_pico::http_start_result::STARTED);
auto &pcb = network.pcbs[network.allocated - 1U];
BOOST_CHECK(deliver_connected(pcb) == ERR_OK);
BOOST_CHECK(deliver_data(pcb, response) == ERR_OK);
deliver_eof(pcb);
require_failure(client, 1U);
BOOST_CHECK(client.title_image().empty());
}
}
BOOST_FIXTURE_TEST_CASE(pico_http_title_timeout_overflow_and_bad_identifier_are_failures, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
BOOST_CHECK(client.start(title_request('z'), 0U) == vamp_pico::http_start_result::STARTED);
require_failure(client, 0U);
BOOST_TEST(network.allocated == 0U);
client.start(title_request(), 100U);
BOOST_CHECK(client.poll(1599U) == vamp_pico::http_status::IN_FLIGHT);
require_failure(client, 1600U);
client.start(title_request(), 1601U);
auto &pcb = network.pcbs[1];
deliver_connected(pcb);
BOOST_CHECK(deliver_data(pcb, std::string(vamp_protocol::title_image_size + 1025U, 'x')) == ERR_ABRT);
require_failure(client, 1602U);
}
BOOST_FIXTURE_TEST_CASE(pico_http_title_preemption_detaches_late_callbacks, fake_lwip_fixture) {
auto client = vamp_pico::http_client{};
auto scheduler = vamp_pico::request_scheduler{};
scheduler.want_title(std::string(64U, 'a'));
auto request = scheduler.next(0U);
BOOST_REQUIRE(request);
client.start(*request, 0U);
auto &old = network.pcbs[0];
deliver_connected(old);
deliver_data(old, std::string_view{title_response()}.substr(0U, 200U));
scheduler.enqueue(vamp_protocol::page_command::SELECT);
client.cancel();
scheduler.cancel_title();
request = scheduler.next(1U);
BOOST_REQUIRE(request);
BOOST_CHECK(request->kind == vamp_pico::request_kind::COMMAND);
client.start(*request, 1U);
deliver_data(old, title_response());
deliver_eof(old);
BOOST_CHECK(client.poll(2U) == vamp_pico::http_status::IN_FLIGHT);
BOOST_CHECK(client.title_image().empty());
}
+54 -57
View File
@@ -1,99 +1,98 @@
from fractions import Fraction
import fractions
import json
from pathlib import Path
import pathlib
import tempfile
import unittest
import wave
from generator.model import fraction
from generator.playback import PlaybackUnavailable, compile_timeline, generate_tone, validate_assets, write_pcm
from tests.test_musicxml import notes, parse, xml_note, xml_score
import generator.model
import generator.playback
import generator.score
import tests.test_musicxml
class PlaybackTests(unittest.TestCase):
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
content = grace.format('steal-time-following="25"', 'D') + xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + xml_note('F', 2, 'half')
result = compile_timeline(parse(xml_score(content)))
self.assertEqual([(fraction(e['pitch']), fraction(e['onset']), fraction(e['duration'])) for e in result['events']], [(62, 0, Fraction(1, 2)), (60, Fraction(1, 2), 1), (64, Fraction(3, 2), Fraction(1, 2)), (65, 2, 2)])
content = grace.format('steal-time-following="25"', 'D') + tests.test_musicxml.xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + tests.test_musicxml.xml_note('F', 2, 'half')
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)))
self.assertEqual([(generator.model.fraction(e['pitch']), generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], [(62, 0, fractions.Fraction(1, 2)), (60, fractions.Fraction(1, 2), 1), (64, fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (65, 2, 2)])
for percent in (50, 60):
with self.subTest(percent=percent), self.assertRaisesRegex(PlaybackUnavailable, 'grace timing consumes'):
compile_timeline(parse(xml_score(content.replace('"25"', f'"{percent}"'))))
chord = content.replace(xml_note(duration=2, kind='half'), xml_note(duration=2, kind='half') + xml_note('G', 1, 'quarter', prefix='<chord/>'))
with self.assertRaisesRegex(PlaybackUnavailable, 'chord tone'):
compile_timeline(parse(xml_score(chord)))
with self.subTest(percent=percent), self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'grace timing consumes'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content.replace('"25"', f'"{percent}"'))))
chord = content.replace(tests.test_musicxml.xml_note(duration=2, kind='half'), tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('G', 1, 'quarter', prefix='<chord/>'))
with self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'chord tone'):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord)))
def test_grace_previous_and_make_time_preserve_their_timing(self):
for mode, expected, total in [('steal-time-previous="25"', [(0, Fraction(3, 2)), (Fraction(3, 2), Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
for mode, expected, total in [('steal-time-previous="25"', [(0, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half') + grace + xml_note('E', 2, 'half'))))
self.assertEqual([(fraction(e['onset']), fraction(e['duration'])) for e in result['events']], expected)
self.assertEqual(fraction(result['duration']), total)
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + grace + tests.test_musicxml.xml_note('E', 2, 'half'))))
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], expected)
self.assertEqual(generator.model.fraction(result['duration']), total)
def test_chords_and_trailing_rest(self):
document = parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
result = compile_timeline(document)
self.assertEqual(fraction(result['duration']), 4)
self.assertEqual([fraction(e['duration']) for e in result['events']], [2, 1])
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
result = generator.playback.compile_timeline(document)
self.assertEqual(generator.model.fraction(result['duration']), 4)
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [2, 1])
def test_tie_is_one_attack(self):
result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half', extra='<tie type="start"/>') + xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
self.assertEqual(len(result['events']), 1)
self.assertEqual(fraction(result['events'][0]['duration']), 4)
self.assertEqual(generator.model.fraction(result['events'][0]['duration']), 4)
def test_octave_and_microtones(self):
result = compile_timeline(parse(xml_score(xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
self.assertEqual(fraction(result['events'][0]['pitch']), Fraction(97, 2))
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
self.assertEqual(generator.model.fraction(result['events'][0]['pitch']), fractions.Fraction(97, 2))
def test_grace_default_and_cues(self):
grace = '<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
result = compile_timeline(parse(xml_score(grace + xml_note())))
self.assertEqual([fraction(e['duration']) for e in result['events']], [1, 3])
self.assertEqual([fraction(e['onset']) for e in result['events']], [0, 1])
self.assertEqual(compile_timeline(parse(xml_score(xml_note(prefix='<cue/>'))))['events'], [])
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note())))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [1, 3])
self.assertEqual([generator.model.fraction(e['onset']) for e in result['events']], [0, 1])
self.assertEqual(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>'))))['events'], [])
def test_tremolo(self):
result = compile_timeline(parse(xml_score(xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
self.assertEqual(len(result['events']), 32)
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><ornaments><tremolo>1</tremolo></ornaments></notations>'
result = compile_timeline(parse(xml_score(xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
self.assertEqual([fraction(e['duration']) for e in result['events']], [Fraction(1, 6)] * 2)
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [fractions.Fraction(1, 6)] * 2)
def test_hidden_pitches_sound_and_unpitched_is_unavailable(self):
hidden = xml_note().replace('<note>', '<note print-object="no">')
self.assertEqual(len(compile_timeline(parse(xml_score(hidden)))['events']), 1)
hidden = tests.test_musicxml.xml_note().replace('<note>', '<note print-object="no">')
self.assertEqual(len(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(hidden)))['events']), 1)
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
with self.assertRaises(PlaybackUnavailable):
compile_timeline(parse(xml_score(unpitched)))
with self.assertRaises(generator.playback.PlaybackUnavailable):
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(unpitched)))
def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_path(self):
from generator.playback import compile_tree
from generator.score import write_json
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = parse(xml_score(xml_note()))
root = pathlib.Path(directory)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for part in ('acoustic', 'electric', 'vocal'):
source['staves'][0]['arrangement_part_type'] = part
source['staves'][0]['measures'][0]['transpose']['octave_change'] = -1 if part != 'vocal' else 0
write_json(root / f'{part}.json', source)
compile_tree(root)
generator.score.write_json(root / f'{part}.json', source)
generator.playback.compile_tree(root)
for part, pitch in [('acoustic', 48), ('electric', 48), ('vocal', 60)]:
playback = json.loads((root / f'{part}.json').read_text())['playback']
self.assertEqual(playback['samples'][0]['id'], generate_tone(root, Fraction(pitch))['id'])
self.assertEqual(playback['samples'][0]['id'], generator.playback.generate_tone(root, fractions.Fraction(pitch))['id'])
def test_audio_assets_validate_and_reject_corruption(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
root = pathlib.Path(directory)
generate_fixture(root)
manifest = json.loads((root / 'score.json').read_text())['playback']
validate_assets(manifest, root)
generator.playback.validate_assets(manifest, root)
sample = manifest['samples'][0]
path = root / sample['path']
path.write_bytes(path.read_bytes()[:-1])
with self.assertRaises(ValueError):
validate_assets(manifest, root)
generator.playback.validate_assets(manifest, root)
generate_fixture(root)
manifest['release']['sha256'] = write_pcm(root / 'audio/release.wav', [0.5] * 2400)
manifest['release']['sha256'] = generator.playback.write_pcm(root / 'audio/release.wav', [0.5] * 2400)
with self.assertRaisesRegex(ValueError, 'envelope'):
validate_assets(manifest, root)
generator.playback.validate_assets(manifest, root)
sample['path'] = '../outside.wav'
with self.assertRaises(ValueError):
validate_assets(manifest, root)
generator.playback.validate_assets(manifest, root)
def test_generated_tone_is_deterministic_and_loopable(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
a = generate_tone(root, Fraction(60))
b = generate_tone(root, Fraction(60))
root = pathlib.Path(directory)
a = generator.playback.generate_tone(root, fractions.Fraction(60))
b = generator.playback.generate_tone(root, fractions.Fraction(60))
self.assertEqual(a, b)
with wave.open(str(root / a['path'])) as audio:
self.assertEqual(audio.getframerate(), 48000)
@@ -102,11 +101,9 @@ class PlaybackTests(unittest.TestCase):
self.assertGreater(a['loop_end'], a['loop_start'])
def generate_fixture(directory):
from generator.score import write_json
from generator.playback import compile_tree
root = Path(directory)
write_json(root / 'score.json', parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
compile_tree(root)
root = pathlib.Path(directory)
generator.score.write_json(root / 'score.json', tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
generator.playback.compile_tree(root)
if __name__ == '__main__':
import argparse
+138
View File
@@ -0,0 +1,138 @@
#include <boost/test/unit_test.hpp>
#include <array>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <future>
#include <memory>
#include <string>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp>
#include <openssl/sha.h>
#include <vamp_pico/http_client.hpp>
#include <vamp_zero/portal.hpp>
namespace {
struct artwork_fixture {
std::filesystem::path root{std::filesystem::temp_directory_path() / ("vamp-title-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()))};
std::string bytes = std::string(vamp_protocol::title_image_size, static_cast<std::char_t>(0xa3));
std::string identifier{};
artwork_fixture() {
bytes.replace(0U, 8U, "VPTI\x28\x01\x40\x00", 8U);
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(reinterpret_cast<std::uchar_t const *>(bytes.data()), bytes.size(), digest.data());
for (auto byte : digest) {
identifier.push_back("0123456789abcdef"[byte >> 4U]);
identifier.push_back("0123456789abcdef"[byte & 15U]);
}
std::filesystem::create_directories(root / "pico/titles");
write(bytes);
}
~artwork_fixture() {
auto error = std::error_code{};
std::filesystem::remove_all(root, error);
}
auto path() const -> std::filesystem::path {
return root / "pico/titles" / (identifier + ".mask");
}
auto write(std::string const &value) -> void {
auto stream = std::ofstream{path(), std::ios::binary};
stream.write(value.data(), static_cast<std::streamsize>(value.size()));
}
auto config() const -> vamp_zero::config {
auto value = vamp_zero::config{};
value.runtime.assets_path = root.string();
return value;
}
auto state() const -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
catalog->performance.parts.push_back({.id = "part", .title = "Title", .part = "Acoustic", .total_pages = 10000U, .title_image = identifier});
auto value = vamp_zero::app_state{};
value.catalog = catalog;
value.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
value.active_part_id = "part";
value.page_index = 9999U;
return value;
}
};
auto call(ligo::service_router const &router, std::string method, std::string route, boost::json::value payload = {})
-> std::expected<ligo::service_response, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, router.call_response({.method = std::move(method), .route = std::move(route)}, std::move(payload)), boost::asio::use_future);
io.run();
return future.get();
}
}
BOOST_FIXTURE_TEST_CASE(portal_title_route_serves_registered_binary_and_remote_identity, artwork_fixture) {
auto control = vamp_zero::control_state{config(), state()};
auto router = vamp_zero::make_router(control);
auto status = call(router, "GET", vamp_protocol::remote_display_path);
BOOST_REQUIRE(status);
auto remote = vamp_pico::remote_display_state{};
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(status->body, remote));
BOOST_TEST(std::string{remote.title_image.data()} == identifier);
BOOST_TEST(remote.page == 10000U);
BOOST_TEST(remote.pages == 10000U);
auto response = call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}});
BOOST_REQUIRE(response);
BOOST_TEST(response->content_type == "application/octet-stream");
BOOST_TEST(response->body == bytes);
auto validated = vamp_zero::validated_title_path(root.string(), identifier);
BOOST_REQUIRE(validated);
BOOST_TEST(*validated == path().string());
BOOST_CHECK(!call(router, "GET", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
}
BOOST_FIXTURE_TEST_CASE(portal_title_route_rejects_arbitrary_unregistered_and_corrupt_assets, artwork_fixture) {
auto control = vamp_zero::control_state{config(), state()};
auto router = vamp_zero::make_router(control);
for (auto const &id : {std::string{"../title"}, std::string(64U, 'f'), std::string(64U, 'A'), identifier + "a"}) {
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", id}}));
}
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}, {"path", path().string()}}));
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", 1}}));
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path));
auto empty = vamp_zero::control_state{config(), {}};
BOOST_CHECK(!call(vamp_zero::make_router(empty), "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
for (auto const &corrupt : {bytes.substr(1U), bytes + "x", std::string(bytes.size(), 'x')}) {
write(corrupt);
BOOST_CHECK(!vamp_zero::validated_title_path(root.string(), identifier));
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
}
auto modified = bytes;
modified.back() ^= 1;
write(modified);
BOOST_CHECK(!vamp_zero::validated_title_path(root.string(), identifier));
BOOST_CHECK(!call(router, "POST", vamp_protocol::remote_title_path, boost::json::object{{"id", identifier}}));
}
BOOST_FIXTURE_TEST_CASE(portal_title_validation_rejects_symlink_outside_title_directory, artwork_fixture) {
auto other = root / "other.mask";
std::filesystem::rename(path(), other);
std::filesystem::create_symlink(other, path());
BOOST_CHECK(!vamp_zero::validated_title_path(root.string(), identifier));
}
BOOST_FIXTURE_TEST_CASE(portal_uses_and_serves_only_the_packaged_petaluma_font, artwork_fixture) {
std::filesystem::create_directories(root / "fonts/petaluma");
std::filesystem::copy_file(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package/assets/fonts/petaluma/PetalumaScript.otf", root / "fonts/petaluma/PetalumaScript.otf");
auto control = vamp_zero::control_state{config(), {}};
auto router = vamp_zero::make_router(control);
auto html = call(router, "GET", "/");
BOOST_REQUIRE(html);
BOOST_CHECK(html->body.find("font-family:PetalumaScript") != std::string::npos);
auto font = call(router, "GET", "/assets/PetalumaScript.otf");
BOOST_REQUIRE(font);
BOOST_TEST(font->content_type == "font/otf");
BOOST_TEST(font->body.substr(0U, 4U) == "OTTO");
BOOST_CHECK(!call(router, "GET", "/assets/other.otf"));
}
+31 -31
View File
@@ -1,82 +1,82 @@
import hashlib
import pathlib
import subprocess
import sys
import tarfile
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import unittest.mock
from package.scripts import publish_latest_generic_package
import package.scripts.publish_latest_generic_package
class PackagePublishingTests(unittest.TestCase):
def test_configured_packager_matches_publisher(self):
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
runtime = root / 'build/build_common/modulus'
if not (runtime / 'modulus/cli.py').is_file():
self.skipTest('Configure first to check the exported Modulus packager')
with patch.object(sys, 'path', [str(runtime), *sys.path]):
with unittest.mock.patch.object(sys, 'path', [str(runtime), *sys.path]):
import modulus.cli
import modulus.config
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
directory = pathlib.Path(temporary)
context = modulus.cli.build_context(directory, modulus.config.load_repo_config(root))
name, registry, targets = publish_latest_generic_package.read_config(root / 'modulus.json')
name, registry, targets = package.scripts.publish_latest_generic_package.read_config(root / 'modulus.json')
def fake_build(context, command, **kwargs):
if command[:2] == ['cmake', '--install']:
stage = Path(command[2]).parent / 'stage'
stage = pathlib.Path(command[2]).parent / 'stage'
(stage / 'manifest.json').write_text('{}\n')
return 0
with patch.object(modulus.cli, 'in_docker', return_value=True), \
patch.object(modulus.cli, 'run', side_effect=fake_build), \
patch.object(modulus.cli, 'run_capture', return_value=subprocess.CompletedProcess([], 0, stdout='fixture\n', stderr='')), \
patch.object(modulus.cli, 'upload_generic_artifact') as upload:
with unittest.mock.patch.object(modulus.cli, 'in_docker', return_value=True), \
unittest.mock.patch.object(modulus.cli, 'run', side_effect=fake_build), \
unittest.mock.patch.object(modulus.cli, 'run_capture', return_value=subprocess.CompletedProcess([], 0, stdout='fixture\n', stderr='')), \
unittest.mock.patch.object(modulus.cli, 'upload_generic_artifact') as upload:
self.assertEqual(modulus.cli.package_artifacts(context, ['--no-remote']), 0)
upload.assert_not_called()
try:
artifacts = publish_latest_generic_package.discover_artifacts(directory / 'build/packages', name, targets)
artifacts = package.scripts.publish_latest_generic_package.discover_artifacts(directory / 'build/packages', name, targets)
except SystemExit as error:
self.fail(str(error))
for archive, checksum in zip(artifacts[::2], artifacts[1::2]):
self.assertEqual(checksum.read_text(), f'{hashlib.sha256(archive.read_bytes()).hexdigest()} {archive.name}\n')
with tarfile.open(archive) as package:
self.assertIn('manifest.json', package.getnames())
with tarfile.open(archive) as contents:
self.assertIn('manifest.json', contents.getnames())
def test_only_configured_target_archives_and_checksums_are_uploaded(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
archive = root / 'product-vamp-arm_64_gcc12_release.tar.gz'
checksum = archive.with_name(archive.name + '.sha256')
for path in (archive, checksum, root / 'product-vamp-obsolete-arm_64_gcc12_release.tar.gz'):
path.write_bytes(b'fixture')
self.assertEqual(publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release']), [archive, checksum])
self.assertEqual(package.scripts.publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release']), [archive, checksum])
checksum.unlink()
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
package.scripts.publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
archive.unlink()
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
package.scripts.publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
def test_mismatched_filenames_report_actual_artifacts_without_uploading(self):
with tempfile.TemporaryDirectory() as temporary, patch.object(publish_latest_generic_package, 'request') as request:
archive = Path(temporary) / 'product-vamp-abc123-arm_64_gcc12_release.tar.gz'
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'request') as request:
archive = pathlib.Path(temporary) / 'product-vamp-abc123-arm_64_gcc12_release.tar.gz'
archive.write_bytes(b'fixture')
with self.assertRaises(SystemExit) as error:
publish_latest_generic_package.main(['--dist-dir', temporary, '--user', 'user', '--token', 'test-token'])
package.scripts.publish_latest_generic_package.main(['--dist-dir', temporary, '--user', 'user', '--token', 'test-token'])
self.assertIn(f'Found: {archive.name}', str(error.exception))
self.assertIn('Publish the current Modulus tooling', str(error.exception))
request.assert_not_called()
def test_missing_artifacts_do_not_touch_the_registry(self):
with tempfile.TemporaryDirectory() as temporary, patch.object(publish_latest_generic_package, 'request') as request:
with tempfile.TemporaryDirectory() as temporary, unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'request') as request:
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.main(['--dist-dir', temporary, '--user', 'user', '--token', 'test-token'])
package.scripts.publish_latest_generic_package.main(['--dist-dir', temporary, '--user', 'user', '--token', 'test-token'])
request.assert_not_called()
def test_publication_replaces_the_single_registry_slot(self):
archive = Path('product-vamp-arm_64_gcc12_release.tar.gz')
with patch.object(publish_latest_generic_package, 'discover_artifacts', return_value=[archive]), \
patch.object(publish_latest_generic_package, 'list_versions', return_value=['latest', 'obsolete']), \
patch.object(publish_latest_generic_package, 'delete_version') as delete, \
patch.object(publish_latest_generic_package, 'upload_file') as upload:
self.assertEqual(publish_latest_generic_package.main(['--user', 'user', '--token', 'test-token']), 0)
archive = pathlib.Path('product-vamp-arm_64_gcc12_release.tar.gz')
with unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'discover_artifacts', return_value=[archive]), \
unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'list_versions', return_value=['latest', 'obsolete']), \
unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'delete_version') as delete, \
unittest.mock.patch.object(package.scripts.publish_latest_generic_package, 'upload_file') as upload:
self.assertEqual(package.scripts.publish_latest_generic_package.main(['--user', 'user', '--token', 'test-token']), 0)
self.assertEqual([call.args[-2] for call in delete.call_args_list], ['latest', 'obsolete'])
self.assertEqual(upload.call_args.args[2], archive)
url = publish_latest_generic_package.file_upload_url('https://example.test/generic', 'product-vamp', archive)
url = package.scripts.publish_latest_generic_package.file_upload_url('https://example.test/generic', 'product-vamp', archive)
self.assertEqual(url, 'https://example.test/generic/product-vamp/latest/product-vamp-arm_64_gcc12_release.tar.gz')
+55 -54
View File
@@ -1,77 +1,78 @@
import copy
from fractions import Fraction
from pathlib import Path
import fractions
import pathlib
import unittest
from generator.engraving import Fonts, Style, layout_score
from generator.engraving.layout import make_system
from generator.engraving.notation import ScoreIndex
from generator.model import rational
from generator.playback import compile_timeline
from generator.scat import add_scat, pulse, syllable
from tests.test_musicxml import notes, parse, xml_note, xml_score
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.types
import generator.model
import generator.playback
import generator.scat
import tests.test_musicxml
class ScatTests(unittest.TestCase):
def test_syllables_use_duration_pulse_phrase_and_previous_consonant(self):
self.assertEqual(syllable(Fraction(2), Fraction(1), False, True, ''), 'doo')
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, False, ''), 'ba')
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, ''), 'dit')
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), False, False, 'dit'), 'da')
self.assertEqual(syllable(Fraction(1, 2), Fraction(1), True, True, ''), 'dat')
self.assertEqual(syllable(Fraction(1, 2), None, False, False, ''), 'ba')
self.assertEqual(generator.scat.syllable(fractions.Fraction(2), fractions.Fraction(1), False, True, ''), 'doo')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), True, False, ''), 'ba')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), False, False, ''), 'dit')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), False, False, 'dit'), 'da')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), fractions.Fraction(1), True, True, ''), 'dat')
self.assertEqual(generator.scat.syllable(fractions.Fraction(1, 2), None, False, False, ''), 'ba')
def test_compound_additive_meter_and_pickups(self):
measure = {'meter': {'groups': [{'beats': [6], 'unit': 8}]}, 'duration': rational(3)}
self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(3, 2), True))
measure = {'meter': {'groups': [{'beats': [6], 'unit': 8}]}, 'duration': generator.model.rational(3)}
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), True))
measure['meter']['groups'][0]['beats'] = [3, 2]
self.assertEqual(pulse(measure, Fraction(3, 2)), (Fraction(1), True))
measure.update(implicit=True, duration=rational(Fraction(1, 2)))
self.assertEqual(pulse(measure, Fraction()), (Fraction(1), False))
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction(3, 2)), (fractions.Fraction(1), True))
measure.update(implicit=True, duration=generator.model.rational(fractions.Fraction(1, 2)))
self.assertEqual(generator.scat.pulse(measure, fractions.Fraction()), (fractions.Fraction(1), False))
def test_ties_sustain_one_syllable_and_stop_before_rests(self):
content = xml_note(duration=1, kind='quarter', extra='<tie type="start"/>') + xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content = tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="stop"/>')
content += '<note><rest/><duration>2</duration><type>half</type></note>'
document = parse(xml_score(content))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
original = copy.deepcopy(document)
before = compile_timeline(document)
add_scat(document)
self.assertEqual(compile_timeline(document), before)
self.assertEqual(notes(document)[0]['lyric'], 'doo')
self.assertEqual(notes(document)[0]['lyrics'][0]['extend_to'], rational(2))
self.assertNotIn('lyric', notes(document)[1])
self.assertNotIn('lyrics', notes(document)[2])
before = generator.playback.compile_timeline(document)
generator.scat.add_scat(document)
self.assertEqual(generator.playback.compile_timeline(document), before)
self.assertEqual(tests.test_musicxml.notes(document)[0]['lyric'], 'doo')
self.assertEqual(tests.test_musicxml.notes(document)[0]['lyrics'][0]['extend_to'], generator.model.rational(2))
self.assertNotIn('lyric', tests.test_musicxml.notes(document)[1])
self.assertNotIn('lyrics', tests.test_musicxml.notes(document)[2])
again = copy.deepcopy(original)
add_scat(again)
generator.scat.add_scat(again)
self.assertEqual(document, again)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
layout = layout_score(document, Style(), fonts)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
system = layout.pages[0].systems[0]
rest = next(a.x for a in system.anchors if a.fragment.kind == 'rest')
self.assertTrue(all(p.box.right < rest for p in system.primitives if p.role == 'lyric-extension'))
def test_cross_bar_tie_has_one_lyric_and_page_continuations(self):
text = xml_score(xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = parse(text)
add_scat(document)
self.assertEqual(sum(bool(n.get('lyric')) for n in notes(document)), 1)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = tests.test_musicxml.parse(text)
generator.scat.add_scat(document)
self.assertEqual(sum(bool(n.get('lyric')) for n in tests.test_musicxml.notes(document)), 1)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
for start, end in ((0, 4), (4, 8)):
system = make_system(ScoreIndex(document), start, end, fonts, Style())
system = generator.engraving.layout.make_system(generator.engraving.notation.ScoreIndex(document), start, end, fonts, generator.engraving.types.Style())
self.assertTrue(any(p.role == 'lyric-extension' for p in system.primitives))
def test_long_tie_chain_draws_one_extender_per_system(self):
text = xml_score(xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/><tie type="start"/>') + '</measure><measure number="3">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = parse(text)
add_scat(document)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
index = ScoreIndex(document)
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/><tie type="start"/>') + '</measure><measure number="3">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
document = tests.test_musicxml.parse(text)
generator.scat.add_scat(document)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
index = generator.engraving.notation.ScoreIndex(document)
for start, end in ((0, 4), (4, 8), (8, 12), (0, 12)):
system = make_system(index, start, end, fonts, Style())
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertEqual(sum(p.role == 'lyric-extension' for p in system.primitives), 1)
def test_straight_offbeat_attacks_and_cues(self):
document = parse(xml_score(''.join(xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
before = compile_timeline(document)
add_scat(document)
self.assertEqual([n['lyric'] for n in notes(document)], ['ba', 'dit', 'ba', 'dit', 'ba', 'dit', 'ba', 'dat'])
self.assertEqual(compile_timeline(document), before)
document = parse(xml_score(xml_note(prefix='<cue/>')))
add_scat(document)
self.assertNotIn('lyrics', notes(document)[0])
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(duration=1, kind='eighth') for _ in range(8)), divisions=2))
before = generator.playback.compile_timeline(document)
generator.scat.add_scat(document)
self.assertEqual([n['lyric'] for n in tests.test_musicxml.notes(document)], ['ba', 'dit', 'ba', 'dit', 'ba', 'dit', 'ba', 'dat'])
self.assertEqual(generator.playback.compile_timeline(document), before)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>')))
generator.scat.add_scat(document)
self.assertNotIn('lyrics', tests.test_musicxml.notes(document)[0])
+164 -132
View File
@@ -1,191 +1,200 @@
import copy
import argparse
from fractions import Fraction
import copy
import fractions
import hashlib
import json
from pathlib import Path
import pathlib
import tempfile
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
import unittest.mock
import xml.etree.ElementTree
from generator.arrange.generate import arrange_document
from generator.__main__ import generate_assets
from generator.engraving import ENGINE_NAME, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
from tests.musicxml_roundtrip import audit_roundtrip
from generator.model import fraction, pitch_number, rational, validate_document
from generator.musicxml import load_musicxml
from generator.musicxml_writer import write_musicxml
from generator.practice.generate import practice_document
from generator.score import load_json
from generator.pitch import concert_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
import PIL.Image
import PIL.ImageChops
import generator.__main__
import generator.arrange.generate
import generator.engraving.fonts
import generator.engraving.layout
import generator.engraving.notation
import generator.engraving.pipeline
import generator.engraving.types
import generator.engraving.validation
import generator.model
import generator.musicxml
import generator.musicxml_writer
import generator.pico_artwork
import generator.pitch
import generator.playback
import generator.practice.generate
import generator.score
import tests.musicxml_roundtrip
import tests.rhythm_fixtures
import tests.test_musicxml
class ScoreBundleTests(unittest.TestCase):
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
self.enterContext(unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library()))
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
data, output = root / 'data', root / 'output'
(data / 'songs').mkdir(parents=True)
(data / 'practice').mkdir()
source = data / 'practice/0001.xml'
source.write_text(xml_score(xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
generate_assets(args)
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)
generator.__main__.generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
legacy_source = source.with_name('001.xml')
source.rename(legacy_source)
with self.assertRaisesRegex(ValueError, 'four-digit'):
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
legacy_source.rename(source)
(data / 'rhythms' / 'drafts').mkdir(parents=True)
for path in ('rhythms/0001.xml', 'rhythms/drafts/0002.xml'):
(data / path).write_text('<unfinished')
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
misplaced = data / '0001.xml'
misplaced.write_text(xml_score(xml_note()))
misplaced.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
with self.assertRaisesRegex(ValueError, 'belong directly under'):
generate_assets(args)
generator.__main__.generate_assets(args)
misplaced.unlink()
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
with unittest.mock.patch('generator.playback.compile_tree', side_effect=ValueError('invalid generated audio')):
with self.assertRaisesRegex(ValueError, 'audio'):
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note() + '<barline><repeat direction="backward"/></barline>'))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<barline><repeat direction="backward"/></barline>'))
with self.assertRaisesRegex(ValueError, 'write the music out'):
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
generate_assets(args)
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note()))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
renamed = data / 'practice/0127.xml'
source.rename(renamed)
source = renamed
legacy_output = output / 'practice/001'
legacy_output.mkdir()
(legacy_output / 'electric.json').write_text('{}')
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertFalse(legacy_output.exists())
self.assertFalse((output / 'practice/0001').exists())
self.assertEqual(len(list((output / 'practice/0127').glob('*.json'))), 3)
source.unlink()
generate_assets(args)
generator.__main__.generate_assets(args)
self.assertFalse(list(output.rglob('*.png')))
self.assertFalse(list(output.rglob('*.json')))
def round_trip(self, document):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'roundtrip.xml'
write_musicxml(path, document)
return load_musicxml(path)
path = pathlib.Path(temporary) / 'roundtrip.xml'
generator.musicxml_writer.write_musicxml(path, document)
return generator.musicxml.load_musicxml(path)
def test_score_structure_is_validated_without_schema_labels(self):
document = parse(xml_score(xml_note()))
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
self.assertEqual(set(document), {'metadata', 'staves'})
for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}):
with self.subTest(document=malformed), self.assertRaises(ValueError):
validate_document(malformed)
generator.model.validate_document(malformed)
def test_multiple_voices_keep_independent_ties_and_transformations(self):
def half(action):
return xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
return tests.test_musicxml.xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
content = half('start') + half('stop') + '<backup><duration>4</duration></backup>'
content += xml_note('C', extra='<voice>2</voice>')
source = parse(xml_score(content))
content += tests.test_musicxml.xml_note('C', extra='<voice>2</voice>')
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
measure = source['staves'][0]['measures'][0]
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
for document in (source, concert_document(source), arrange_document(source, 'vocal')):
validate_document(document)
for document in (source, generator.pitch.concert_document(source), generator.arrange.generate.arrange_document(source, 'vocal', rhythms=tests.rhythm_fixtures.library())):
generator.model.validate_document(document)
restored = self.round_trip(document)
validate_document(restored)
generator.model.validate_document(restored)
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
def test_contiguous_ties_continue_across_systems_without_repeat_state(self):
from generator.engraving.layout import make_system
from generator.engraving.notation import ScoreIndex
from generator.playback import compile_timeline
text = xml_score(xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
source = parse(text)
index = ScoreIndex(source)
fonts = Fonts(Path('package/assets/fonts/petaluma'))
text = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<tie type="start"/>'))
text = text.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '<barline><bar-style>light-heavy</bar-style></barline></measure></part>')
source = tests.test_musicxml.parse(text)
index = generator.engraving.notation.ScoreIndex(source)
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
for start, end in ((0, 4), (4, 8)):
system = make_system(index, start, end, fonts, Style())
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8])
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8])
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
def test_microtones_remain_exact_after_transposition_and_export(self):
source = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
source['staves'][0]['measures'][0]['transpose'] = {'diatonic': 1, 'chromatic': 2, 'octave_change': 0}
transposed = concert_document(source)
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
transposed = generator.pitch.concert_document(source)
self.assertEqual(generator.model.pitch_number(tests.test_musicxml.notes(transposed)[0]['pitches'][0]), fractions.Fraction(125, 2))
restored = self.round_trip(transposed)
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
unqualified = parse(xml_score(xml_note(alter='0.1')))
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
layout = layout_score(unqualified, Style(), Fonts(Path('package/assets/fonts/petaluma')))
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(transposed)[0]['pitches'])
unqualified = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.1')))
self.assertEqual(generator.model.fraction(tests.test_musicxml.notes(unqualified)[0]['pitches'][0]['alter']), fractions.Fraction(1, 10))
layout = generator.engraving.layout.layout_score(unqualified, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')))
self.assertTrue(any(p.role == 'accidental' and p.value == '(+1/10 st)' for page in layout.pages for p in page.primitives))
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
tree = ElementTree.fromstring(xml_score(xml_note()))
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
source = parse(ElementTree.tostring(tree, encoding='unicode'))
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': rational(0), 'end': rational(4), 'octaves': 1}]
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
xml.etree.ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_note()))
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': generator.model.rational(0), 'end': generator.model.rational(4), 'octaves': 1}]
restored = self.round_trip(source)
self.assertEqual(source['staves'][0]['octave_shifts'], restored['staves'][0]['octave_shifts'])
def test_unequal_chord_tones_are_retained_but_never_silently_flattened(self):
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>')))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
layout = generator.engraving.layout.layout_score(source, generator.engraving.types.Style(), generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma')))
heads = [p.value for page in layout.pages for p in page.primitives if p.role == 'head']
self.assertEqual(heads, ['noteheadWhole', 'noteheadHalf'])
def test_overlapping_notes_within_one_voice_are_rejected(self):
with self.assertRaisesRegex(ValueError, 'overlapping'):
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup>' + tests.test_musicxml.xml_note('E')))
def test_multiple_lyric_verses_and_elisions_round_trip(self):
lyrics = '<lyric number="1"><syllabic>begin</syllabic><text>one</text><elision> </elision><text>two</text><extend type="start"/></lyric>'
lyrics += '<lyric number="2"><syllabic>single</syllabic><text>three</text></lyric>'
source = parse(xml_score(xml_note(extra=lyrics)))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra=lyrics)))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['lyrics'], notes(source)[0]['lyrics'])
self.assertEqual(tests.test_musicxml.notes(restored)[0]['lyrics'], tests.test_musicxml.notes(source)[0]['lyrics'])
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(self):
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
with self.assertRaisesRegex(ValueError, 'authored TAB'):
parse(xml_score(xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<clef><sign>TAB</sign></clef>'))
for part in ('vocal', 'acoustic', 'electric'):
with self.assertRaisesRegex(ValueError, 'pitched'):
practice_document(source, part)
generator.practice.generate.practice_document(source, part, rhythms=tests.rhythm_fixtures.library())
with self.assertRaisesRegex(ValueError, 'pitched'):
arrange_document(source, part)
generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
tree = ElementTree.fromstring(xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
tree = xml.etree.ElementTree.fromstring(tests.test_musicxml.xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
definition = tree.find('part-list/score-part')
definition.append(ElementTree.fromstring('<score-instrument id="drum"><instrument-name>Snare</instrument-name><instrument-sound>drum.snare-drum</instrument-sound><solo/><virtual-instrument><virtual-name>Kit</virtual-name></virtual-instrument></score-instrument>'))
definition.append(ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
source = parse(ElementTree.tostring(tree, encoding='unicode'))
definition.append(xml.etree.ElementTree.fromstring('<score-instrument id="drum"><instrument-name>Snare</instrument-name><instrument-sound>drum.snare-drum</instrument-sound><solo/><virtual-instrument><virtual-name>Kit</virtual-name></virtual-instrument></score-instrument>'))
definition.append(xml.etree.ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(tree, encoding='unicode'))
self.assertEqual(self.round_trip(source)['staves'][0]['instruments'], source['staves'][0]['instruments'])
changed = copy.deepcopy(source)
changed['staves'][0]['instruments'][0]['midi']['midi-unpitched'] = '40'
with tempfile.TemporaryDirectory() as temporary:
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
write_musicxml(before, source)
write_musicxml(after, changed)
before, after = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
generator.musicxml_writer.write_musicxml(before, source)
generator.musicxml_writer.write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
audit_roundtrip(before, after)
tests.musicxml_roundtrip.audit_roundtrip(before, after)
source['staves'][0]['instruments'] = []
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
validate_document(source)
generator.model.validate_document(source)
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml')):
for path in sorted(path for category in ('practice', 'songs') for path in (pathlib.Path('data') / category).glob('*.xml')):
with self.subTest(path=path):
source = load_musicxml(path)
source = generator.musicxml.load_musicxml(path)
restored = self.round_trip(source)
a, b = source['staves'][0]['measures'], restored['staves'][0]['measures']
self.assertEqual(len(a), len(b))
@@ -195,95 +204,118 @@ class ScoreBundleTests(unittest.TestCase):
self.assertEqual(v['notes'], w['notes'])
def test_missing_font_does_not_produce_a_bundle(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = parse(xml_score(xml_note()))
root = pathlib.Path(temporary)
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
path = root / 'fixture.json'
path.write_text(json.dumps(source))
with self.assertRaisesRegex(EngravingError, 'cannot load bundled'):
engrave_tree(root, root / 'missing-fonts')
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'cannot load bundled'):
generator.engraving.pipeline.engrave_tree(root, root / 'missing-fonts')
self.assertEqual(json.loads(path.read_text()), source)
def test_blank_and_wrong_sized_pages_are_rejected(self):
from PIL import Image
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'page.png'
path = pathlib.Path(temporary) / 'page.png'
for size in ((976, 484), (100, 100)):
Image.new('RGB', size, 'white').save(path)
with self.assertRaises(EngravingError):
check_page(path)
PIL.Image.new('RGB', size, 'white').save(path)
with self.assertRaises(generator.engraving.types.EngravingError):
generator.engraving.validation.check_page(path)
def test_audit_rejects_an_engraver_pitch_change(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
source.write_text(xml_score(xml_note()))
output.write_text(xml_score(xml_note('D')))
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
output.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))
with self.assertRaisesRegex(ValueError, 'pitch letter'):
audit_roundtrip(source, output)
tests.musicxml_roundtrip.audit_roundtrip(source, output)
def test_audit_rejects_staff_details_and_barline_changes(self):
with tempfile.TemporaryDirectory() as temporary:
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
document = parse(xml_score(xml_note()))
source, output = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for field, value in (('staff_details', {'lines': 6, 'tunings': []}), ('barlines', {'right': 'light-light'})):
with self.subTest(field=field):
original = copy.deepcopy(document)
original['staves'][0]['measures'][0][field] = value
write_musicxml(source, original)
write_musicxml(output, document)
generator.musicxml_writer.write_musicxml(source, original)
generator.musicxml_writer.write_musicxml(output, document)
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
audit_roundtrip(source, output)
tests.musicxml_roundtrip.audit_roundtrip(source, output)
def test_audit_rejects_relocated_tuplet_brackets(self):
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
source = parse(xml_score(''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
span = {'id': 'tuplet-1', 'voice': '1', 'start': notes(source)[0]['id'], 'end': notes(source)[2]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
span = {'id': 'tuplet-1', 'voice': '1', 'start': tests.test_musicxml.notes(source)[0]['id'], 'end': tests.test_musicxml.notes(source)[2]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}
source['staves'][0]['tuplets'] = [span]
for note in notes(source)[:3]:
for note in tests.test_musicxml.notes(source)[:3]:
note['tuplet_ids'] = ['tuplet-1']
changed = copy.deepcopy(source)
changed['staves'][0]['tuplets'][0].update(start=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
for index, note in enumerate(notes(changed)):
changed['staves'][0]['tuplets'][0].update(start=tests.test_musicxml.notes(changed)[3]['id'], end=tests.test_musicxml.notes(changed)[5]['id'])
for index, note in enumerate(tests.test_musicxml.notes(changed)):
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
with tempfile.TemporaryDirectory() as temporary:
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
write_musicxml(before, source)
write_musicxml(after, changed)
before, after = pathlib.Path(temporary) / 'source.xml', pathlib.Path(temporary) / 'output.xml'
generator.musicxml_writer.write_musicxml(before, source)
generator.musicxml_writer.write_musicxml(after, changed)
with self.assertRaisesRegex(ValueError, 'members'):
audit_roundtrip(before, after)
tests.musicxml_roundtrip.audit_roundtrip(before, after)
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
assets = root / 'assets'
assets.mkdir()
data = Path('tests/fixtures/engraving.png').read_bytes()
data = pathlib.Path('tests/fixtures/engraving.png').read_bytes()
page = assets / 'page.png'
page.write_bytes(data)
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
path = assets / 'score.json'
path.write_text(json.dumps(bundle))
self.assertEqual(load_json(path), document)
self.assertEqual(generator.score.load_json(path), document)
page.write_bytes(data + b'corrupt')
with self.assertRaisesRegex(ValueError, 'checksum'):
load_json(path)
generator.score.load_json(path)
page.unlink()
outside = root / 'outside.png'
outside.write_bytes(data)
page.symlink_to(outside)
with self.assertRaisesRegex(ValueError, 'escapes'):
load_json(path)
def test_python_engraver_visual_golden_and_determinism(self):
from PIL import Image, ImageChops
from generator.score import write_json
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
generator.score.load_json(path)
def test_final_validation_reuses_content_checks_and_rechecks_files(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
root = pathlib.Path(temporary)
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
for index in range(3):
generator.score.write_json(root / 'practice' / f'{index}.json', document)
trusted = generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
generator.pico_artwork.generate_titles(root, pathlib.Path('package/assets/fonts/petaluma'))
with unittest.mock.patch('generator.engraving.validation.check_content', side_effect=AssertionError('already validated content')):
generator.score.validate_generated_tree(root, trusted)
page = root / 'practice/2.pages/0001.png'
original = page.read_bytes()
page.write_bytes(original + b'changed')
with self.assertRaisesRegex(ValueError, 'checksum'):
generator.score.validate_generated_tree(root, trusted)
page.write_bytes(original)
with unittest.mock.patch('generator.engraving.validation.check_content', wraps=generator.engraving.validation.check_content) as decode:
generator.score.validate_generated_tree(root)
decode.assert_called_once()
PIL.Image.new('RGB', (976, 484), 'white').save(page)
path = root / 'practice/2.json'
bundle = json.loads(path.read_text())
bundle['presentation']['pages'][0]['sha256'] = hashlib.sha256(page.read_bytes()).hexdigest()
path.write_text(json.dumps(bundle))
with self.assertRaisesRegex(ValueError, 'blank page'):
generator.score.validate_generated_tree(root, trusted)
def test_python_engraver_visual_golden_and_determinism(self):
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
before = None
for _ in range(2):
write_json(root / 'fixture.json', document)
engrave_tree(root, Path('package/assets/fonts/petaluma'))
generator.score.write_json(root / 'fixture.json', document)
generator.engraving.pipeline.engrave_tree(root, pathlib.Path('package/assets/fonts/petaluma'))
bundle = json.loads((root / 'fixture.json').read_text())
self.assertEqual(bundle['music'], document)
self.assertEqual(len(bundle['presentation']['pages']), 1)
page = root / bundle['presentation']['pages'][0]['path']
with Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected:
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
with PIL.Image.open(page) as actual, PIL.Image.open('tests/fixtures/engraving-python.png') as expected:
self.assertIsNone(PIL.ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
if before is not None:
self.assertEqual(bundle, before)
before = bundle
+120 -25
View File
@@ -1,36 +1,131 @@
from __future__ import annotations
import unittest
import unittest.mock
import hashlib
import json
import pathlib
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
import unittest
import unittest.mock
from generator.__main__ import copy_runtime_fonts
from generator import generate_pico_font
import generator.__main__
import generator.pico_artwork
class FontAssetsTests(unittest.TestCase):
def test_font_generator_requires_kerning_backend(self):
with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False):
with self.assertRaisesRegex(RuntimeError, "Raqm"):
generate_pico_font.generate()
def test_runtime_fonts_exclude_unused_assets(self):
root = Path(__file__).resolve().parents[1]
root = pathlib.Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory() as directory:
output = Path(directory)
copy_runtime_fonts(root / "package/assets", output)
files = {path.name for path in (output / "fonts/petaluma").iterdir()}
self.assertEqual(files, {"PetalumaScript.otf", "OFL.txt"})
def test_font_generator_uses_explicit_build_output(self):
root = Path(__file__).resolve().parents[1]
output = pathlib.Path(directory)
generator.__main__.copy_runtime_fonts(root / 'package/assets', output)
files = {path.name for path in (output / 'fonts/petaluma').iterdir()}
self.assertEqual(files, {'PetalumaScript.otf', 'OFL.txt'})
class PicoArtworkTests(unittest.TestCase):
def test_requires_shaping_and_rejects_unsupported_text(self):
with unittest.mock.patch.object(generator.pico_artwork.PIL.features, 'check_feature', return_value=False):
with self.assertRaisesRegex(RuntimeError, 'Raqm'):
generator.pico_artwork.Renderer(generator.pico_artwork.DEFAULT_FONT_ROOT / 'PetalumaScript.otf')
renderer = generator.pico_artwork.Renderer(generator.pico_artwork.DEFAULT_FONT_ROOT / 'PetalumaScript.otf')
for text in ('', 'x' * 201, 'line\nbreak', '\U0001f600'):
with self.subTest(text=text):
with self.assertRaises(ValueError):
renderer.title(text)
with self.assertRaisesRegex(ValueError, 'ellipsis'):
renderer.line('long title', 5, 32, 24)
def test_static_atlas_is_bounded_and_every_digit_is_visible_and_distinct(self):
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "generated/font.cpp"
command = [sys.executable, str(root / "generator/generate_pico_font.py"), "--output", str(output)]
subprocess.run(command, check=True, capture_output=True, text=True, cwd=directory)
self.assertNotIn("std::array<pair_adjustment, 0>", output.read_text(encoding="utf-8"))
before = output.stat().st_mtime_ns
subprocess.run([*command, "--check"], check=True, capture_output=True, text=True)
self.assertEqual(before, output.stat().st_mtime_ns)
output.write_text("stale", encoding="utf-8")
self.assertNotEqual(subprocess.run([*command, "--check"], capture_output=True).returncode, 0)
output = pathlib.Path(directory) / 'generated' / 'static.p4'
subprocess.run([sys.executable, '-m', 'generator.pico_artwork', '--static-output', str(output)], check=True)
atlas = output.read_bytes()
self.assertLess(len(atlas), 24000)
magic, count, reserved = struct.unpack_from('<4sHH', atlas)
self.assertEqual((magic, count, reserved), (b'VPSA', 24, 0))
digits = []
for index in range(count):
width, height, offset = struct.unpack_from('<HHI', atlas, 8 + index * 8)
self.assertEqual(height, 28)
self.assertLessEqual(width, 176)
self.assertGreaterEqual(offset, 8 + 8 * count)
size = (width * height + 1) // 2
coverage = atlas[offset:offset + size]
self.assertEqual(len(coverage), size)
levels = [value for byte in coverage for value in (byte >> 4, byte & 15)][:width * height]
self.assertGreater(sum(value > 0 for value in levels), 25)
self.assertTrue(any(0 < value < 15 for value in levels))
if index >= 13:
self.assertEqual(width, 22)
digits.append(coverage)
ink_rows = {pixel // width for pixel, value in enumerate(levels) if value}
self.assertGreater(len(ink_rows), 12)
self.assertEqual(len(set(digits)), 11)
page_width = struct.unpack_from('<H', atlas, 8 + 8 * 8)[0]
self.assertLessEqual(page_width + 8 + len('10000/10000') * generator.pico_artwork.DIGIT_WIDTH, 320 - 16)
before = atlas
generator.pico_artwork.generate_static(output)
self.assertEqual(output.read_bytes(), before)
self.assertEqual({path.name for path in output.parent.iterdir()}, {'static.p4'})
def test_titles_update_both_categories_share_across_parts_and_change_without_firmware(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
paths = []
for category, part in (('songs', 'Acoustic'), ('songs', 'Vocal'), ('practice', 'Electric')):
path = root / category / f'{part}.json'
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({'music': {'metadata': {'title': 'Evening Song'}, 'staves': [{'name': part}]},
'presentation': {'pages': [{'path': 'page.png'}]}}))
paths.append(path)
generator.pico_artwork.generate_titles(root)
documents = [json.loads(path.read_text()) for path in paths]
identifiers = {document['presentation']['title_image'] for document in documents}
self.assertEqual(len(identifiers), 1)
identifier = identifiers.pop()
title = root / 'pico' / 'titles' / f'{identifier}.mask'
self.assertEqual(len(title.read_bytes()), 9480)
self.assertEqual(title.read_bytes()[:8], b'VPTI\x28\x01\x40\x00')
self.assertEqual(hashlib.sha256(title.read_bytes()).hexdigest(), identifier)
self.assertGreater(sum(byte != 0 for byte in title.read_bytes()[8:]), 100)
generator.pico_artwork.validate_title(root, identifier)
self.assertEqual(documents[0]['presentation']['pages'], [{'path': 'page.png'}])
documents[0]['music']['metadata']['title'] = 'Morning Song'
paths[0].write_text(json.dumps(documents[0]))
generator.pico_artwork.generate_titles(root)
replacement = json.loads(paths[0].read_text())['presentation']['title_image']
self.assertNotEqual(replacement, identifier)
generator.pico_artwork.validate_title(root, replacement)
self.assertFalse((root / 'pico' / 'titles.json').exists())
def test_validator_rejects_bad_identifiers_corruption_size_and_symlink_escape(self):
renderer = generator.pico_artwork.Renderer(generator.pico_artwork.DEFAULT_FONT_ROOT / 'PetalumaScript.otf')
image = renderer.title('A complete title')
identifier = hashlib.sha256(image).hexdigest()
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) / 'assets'
titles = root / 'pico' / 'titles'
titles.mkdir(parents=True)
path = titles / f'{identifier}.mask'
path.write_bytes(image)
generator.pico_artwork.validate_title(root, identifier)
for invalid in ('', '../title', identifier.upper(), 'a' * 65, 'z' * 64):
with self.subTest(identifier=invalid):
with self.assertRaises(ValueError):
generator.pico_artwork.validate_title(root, invalid)
for corrupted in (image[:-1], image + b'x', b'BAD!' + image[4:], image[:8] + bytes(len(image) - 8)):
path.write_bytes(corrupted)
with self.assertRaises(ValueError):
generator.pico_artwork.validate_title(root, identifier)
path.unlink()
outside = pathlib.Path(directory) / 'outside.mask'
outside.write_bytes(image)
path.symlink_to(outside)
with self.assertRaisesRegex(ValueError, 'escapes'):
generator.pico_artwork.validate_title(root, identifier)
def test_long_titles_are_bounded_without_clipping_or_hanging(self):
renderer = generator.pico_artwork.Renderer(generator.pico_artwork.DEFAULT_FONT_ROOT / 'PetalumaScript.otf')
for title in ('A very long title ' * 11, 'W' * 200, 'A'):
image = renderer.title(title)
self.assertEqual(len(image), 9480)
levels = [value for byte in image[8:] for value in (byte >> 4, byte & 15)]
self.assertGreater(max(levels), 0)
self.assertFalse(any(levels[:296]))
self.assertFalse(any(levels[-296:]))