@@ -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
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 57 KiB |
@@ -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')
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
@@ -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
@@ -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']
|
||||
|
||||
@@ -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')))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:]))
|
||||
|
||||
Reference in New Issue
Block a user