Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
@@ -79,7 +79,7 @@ class GuitarPracticeTests(unittest.TestCase):
|
||||
source = parse(xml_score(harmony + xml_note('E', alter=-1), '<key><fifths>-3</fifths><mode>major</mode></key>'))
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
self.assertEqual(pitch_number(notes(result)[0]['pitches'][0]), 62)
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'D/F#')
|
||||
self.assertEqual(result['staves'][0]['measures'][0]['harmonies'][0]['symbol'], 'DM/F#')
|
||||
for mode, fifths in [('minor', -3), ('dorian', -2)]:
|
||||
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
|
||||
result = transpose_guitar_practice(source, 'D')
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from pathlib import Path
|
||||
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.transposition import transpose_document
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
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>'
|
||||
|
||||
class HarmonyTests(unittest.TestCase):
|
||||
def test_quality_spellings_are_canonical(self):
|
||||
cases = {'C': 'CM', 'Cmaj': 'CM', 'Cmin': 'Cm', 'C-': 'Cm', 'C+': 'Caug', 'Co': 'Cdim', 'Csus4': 'Csus', 'Csus2': 'Csus2',
|
||||
'Cmaj7': 'CM7', 'C-7': 'Cm7', 'C+7': 'Caug7', 'C+maj7': 'CaugM7', 'Co7': 'Cdim7', 'C-7b5': 'Cm7b5',
|
||||
'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))
|
||||
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)
|
||||
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})
|
||||
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()))
|
||||
chord = source['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertEqual(chord['kind'], kind)
|
||||
self.assertEqual(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)
|
||||
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()))
|
||||
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)['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertNotIn('chord_optional', practice)
|
||||
self.assertEqual(harmony.symbol(practice), label)
|
||||
shifted = transpose_document(source, 'D')
|
||||
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]
|
||||
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')))
|
||||
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))])
|
||||
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)
|
||||
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()))
|
||||
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()))
|
||||
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/001.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))
|
||||
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')
|
||||
self.assertEqual(len(source['staves'][0]['measures']), count)
|
||||
self.assertEqual(source['metadata']['key_signature'], key)
|
||||
@@ -80,6 +80,20 @@ class MusicModelTests(unittest.TestCase):
|
||||
self.assertFalse(measures[-1]['harmonies'])
|
||||
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
|
||||
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
|
||||
labels = [h['symbol'] for m in measures[:-1] for h in m['harmonies']]
|
||||
self.assertIn('(Fm7)', labels)
|
||||
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['staves'][0]['measures'][0][field] = {}
|
||||
with self.assertRaisesRegex(ValueError, 'repeat notation'):
|
||||
validate_document(source)
|
||||
for field in ('sound_ties', 'printed_ties'):
|
||||
source = parse(xml_score(xml_note()))
|
||||
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'):
|
||||
|
||||
+23
-7
@@ -91,18 +91,18 @@ class MusicXMLTests(unittest.TestCase):
|
||||
self.assertEqual(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_preserve_display_and_pitches(self):
|
||||
def test_standard_minor_harmonies_normalize_display_and_preserve_pitches(self):
|
||||
from generator.harmony import pitch_classes
|
||||
for text, kind, expected in (('-7', 'minor-seventh', (0, 3, 7, 10)), ('-7b5', 'half-diminished', (0, 3, 6, 10)), ('-maj7', 'major-minor', (0, 3, 7, 11)), ('-6', 'minor-sixth', (0, 3, 7, 9))):
|
||||
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' + text)
|
||||
self.assertEqual(chord['symbol'], 'C' + label)
|
||||
self.assertEqual(pitch_classes(chord), expected)
|
||||
def test_other_harmony_requires_explicit_degrees(self):
|
||||
for kind in ('<kind>other</kind>', '<kind text="">other</kind>', '<kind text="-7">other</kind>', '<kind text="unknown">other</kind>'):
|
||||
with self.subTest(kind=kind), self.assertRaisesRegex(MusicXMLError, r"fixture.xml: part P1, measure 1: harmony kind 'other' requires explicit added degrees"):
|
||||
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:'):
|
||||
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
|
||||
@@ -111,7 +111,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
score = parse(xml_score(content + xml_note()))
|
||||
chord = score['staves'][0]['measures'][0]['harmonies'][0]
|
||||
self.assertEqual(chord['kind'], 'other')
|
||||
self.assertEqual(chord['symbol'], 'C-7')
|
||||
self.assertEqual(chord['symbol'], 'Cm7')
|
||||
self.assertEqual(pitch_classes(chord), (0, 3, 7, 10))
|
||||
with self.assertRaisesRegex(MusicXMLError, 'contradictory harmony text'):
|
||||
parse(xml_score(content.replace('text="-7"', 'text="maj7"') + xml_note()))
|
||||
@@ -162,6 +162,22 @@ class MusicXMLTests(unittest.TestCase):
|
||||
for content in (marker + xml_note(), marker + marker + h + xml_note(), marker.replace('optional-chord', 'unknown') + xml_note()):
|
||||
with self.assertRaisesRegex(MusicXMLError, 'measure 1'):
|
||||
parse(xml_score(content))
|
||||
def test_repeat_navigation_and_pass_specific_ties_are_rejected(self):
|
||||
rejected = [
|
||||
'<barline location="left"><repeat direction="forward"/></barline>',
|
||||
'<barline><repeat direction="backward" times="3"/></barline>',
|
||||
'<barline location="left"><ending number="1" type="start"/></barline>',
|
||||
'<barline><ending number="2" type="discontinue" print-object="no"/></barline>',
|
||||
'<direction><direction-type><segno/></direction-type></direction>',
|
||||
'<direction><direction-type><coda/></direction-type></direction>',
|
||||
'<sound dacapo="yes"/>', '<sound dalsegno="segno"/>', '<sound tocoda="coda"/>',
|
||||
]
|
||||
for content in rejected:
|
||||
with self.subTest(content=content), self.assertRaisesRegex(MusicXMLError, 'measure 1'):
|
||||
parse(xml_score(content + xml_note()))
|
||||
for passes in ('1', ''):
|
||||
with self.assertRaisesRegex(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>',
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import argparse
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator.model import fraction, validate_document
|
||||
from generator.musicxml import MusicXMLError
|
||||
from generator.playback import compile_timeline
|
||||
from generator.repeats import expand_document, performed_order
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
def score_bars(bars):
|
||||
return xml_score(bars[0]).replace('</measure></part>', '</measure>' + ''.join(f'<measure number="{i + 2}">{bar}</measure>' for i, bar in enumerate(bars[1:])) + '</part>')
|
||||
|
||||
def boundary(direction, times=None):
|
||||
side = 'left' if direction == 'forward' else 'right'
|
||||
count = f' times="{times}"' if times else ''
|
||||
return f'<barline location="{side}"><repeat direction="{direction}"{count}/></barline>'
|
||||
|
||||
def ending(numbers, action, repeat=False):
|
||||
side = 'left' if action == 'start' else 'right'
|
||||
return f'<barline location="{side}"><ending number="{numbers}" type="{action}"/>' + ('<repeat direction="backward"/>' if repeat else '') + '</barline>'
|
||||
|
||||
class RepeatTests(unittest.TestCase):
|
||||
def test_repeat_count_and_implicit_start(self):
|
||||
source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3)
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 6)
|
||||
def test_combined_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2])
|
||||
self.assertIn('endings', source['staves'][0]['measures'][1])
|
||||
self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1])
|
||||
def test_nested_repeat(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2)
|
||||
def test_sequential_and_expansion_limit(self):
|
||||
source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1])
|
||||
with self.assertRaisesRegex(MusicXMLError, '10000'):
|
||||
parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)]))
|
||||
def test_bad_endings_and_open_repeat(self):
|
||||
for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')):
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(content))
|
||||
def test_ties_reidentify_each_pass(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), xml_note(extra='<tie type="stop"/>') + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 2)
|
||||
def test_ties_across_repeat_jump_and_unreachable_pass(self):
|
||||
first = xml_note(extra='<tie type="stop" time-only="2"/>')
|
||||
last = xml_note(extra='<tie type="start" time-only="1"/>')
|
||||
source = parse(score_bars([boundary('forward') + first, last + boundary('backward')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4])
|
||||
for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')):
|
||||
with self.assertRaisesRegex(MusicXMLError, 'tie'):
|
||||
parse(score_bars([boundary('forward') + bad, last + boundary('backward')]))
|
||||
def test_printed_tie_does_not_create_sound_tie(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 4)
|
||||
def test_ties_into_both_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), ending('1', 'start') + xml_note(extra='<tie type="stop"/>') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='<tie type="stop"/>') + ending('2', 'discontinue')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8])
|
||||
def test_cross_measure_tuplets_and_tremolos_remap(self):
|
||||
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
bars = [boundary('forward'), '']
|
||||
for index in range(3):
|
||||
bars[0] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="start"/></notations>' if index == 0 else ''))
|
||||
bars[1] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="stop"/></notations>' if index == 2 else ''))
|
||||
bars[1] += boundary('backward')
|
||||
source = parse(score_bars(bars).replace('<divisions>1</divisions>', '<divisions>3</divisions>'))
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(expanded['staves'][0]['tuplets']), 2)
|
||||
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||
bars = [xml_note(step, kind='breve', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')]]
|
||||
source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 128)
|
||||
def test_no_repeat_is_unchanged(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
self.assertEqual(source, expand_document(source))
|
||||
def test_roundtrip_and_engraving(self):
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import Fonts, Style, layout_score
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'repeat.xml'
|
||||
write_musicxml(path, source)
|
||||
restored = load_musicxml(path)
|
||||
self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures']))
|
||||
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||
items = [p for page in layout.pages for p in page.primitives]
|
||||
self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4)
|
||||
self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.'])
|
||||
def test_printed_tie_continues_across_system_boundary(self):
|
||||
from generator.engraving import Fonts, Style
|
||||
from generator.engraving.layout import make_system
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
index = ScoreIndex(source)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
for start, end in [(0, 4), (4, 8)]:
|
||||
system = make_system(index, start, end, fonts, Style())
|
||||
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
||||
def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self):
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import arrange_document
|
||||
text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')])
|
||||
source = parse(text)
|
||||
self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal'))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
data = root / 'data'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / '001.xml').write_text(text)
|
||||
output = root / 'assets'
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
bundle = json.loads((output / 'practice/001/c.json').read_text())
|
||||
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
|
||||
self.assertEqual(len(bundle['playback']['events']), 4)
|
||||
self.assertEqual(fraction(bundle['playback']['duration']), 16)
|
||||
before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with self.assertRaisesRegex(ValueError, 'audio'):
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
def test_alternate_ending_does_not_inherit_skipped_key_change(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '<attributes><key><fifths>2</fifths></key></attributes>' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
expanded = expand_document(source)
|
||||
self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from generator.arrange.generate import arrange_document
|
||||
@@ -34,6 +35,14 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
self.assertTrue(list(output.rglob('*.png')))
|
||||
generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with self.assertRaisesRegex(ValueError, 'audio'):
|
||||
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>'))
|
||||
with self.assertRaisesRegex(ValueError, 'write the music out'):
|
||||
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)
|
||||
@@ -68,6 +77,22 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
restored = self.round_trip(document)
|
||||
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'))
|
||||
for start, end in ((0, 4), (4, 8)):
|
||||
system = make_system(index, start, end, fonts, 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])
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(notes(restored)[0]['pitches'], 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>')))
|
||||
transposed = transpose_document(source, 'D')
|
||||
|
||||
Reference in New Issue
Block a user