Files
product-vamp/tests/test_key_metadata.py
T
2026-09-17 00:44:19 -05:00

173 lines
10 KiB
Python

import copy
from fractions import Fraction
import json
from pathlib import Path
import tempfile
import unittest
from xml.etree import 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.practice.generate import generate_practice_scores, key_file_name, practice_document
from generator.score import load_json, write_json
from generator.transposition import TARGET_KEYS, concert_document, shift_key, transpose_document
from tests.test_musicxml import parse, xml_note, xml_score
FONT_ROOT = Path('package/assets/fonts/petaluma')
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'))
class KeyCancellationTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.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)]
for old, new, target, expected_old, expected_new, natural_y in cases:
for local in (False, True):
with self.subTest(old=old, new=new, target=target, local=local):
source = key_change(old, new, local)
before = copy.deepcopy(source)
result = transpose_document(source, target)
validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(measures[0]['key'], 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(len(measures), 1 if local else 2)
if local:
self.assertEqual(len(measures[0]['attributes']), 1)
self.assertEqual(change['onset'], 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',))
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)
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>'))
result = transpose_document(source, 'D', octave_shift=1)
expected = 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)
self.assertEqual(result['cancel'], 2)
self.assertEqual(result['mode'], mode)
self.assertEqual(result['alterations'], 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 = transpose_document(key_change(old, new), target)
index = ScoreIndex(result)
glyphs = staff_attributes(index, index.state(Fraction(4)), 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}
before = copy.deepcopy(key)
with self.subTest(fifths=fifths, semitones=semitones), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
shift_key(key, steps, semitones)
self.assertEqual(key, before)
for local in (False, True):
source = key_change(0, 0, local)
measures = source['staves'][0]['measures']
change = measures[0]['attributes'][0] if local else measures[1]
change['key']['cancel'] = 14
before = copy.deepcopy(source)
with self.subTest(local=local), self.assertRaisesRegex(ValueError, 'key cancellation.*represented'):
transpose_document(source, 'D')
self.assertEqual(source, before)
def test_concert_transposition_uses_the_same_interval_for_cancellation(self):
attributes = '<transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'
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))
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})
class AuthoredKeyMetadataTests(unittest.TestCase):
def test_eb_authored_key_survives_every_generated_practice_variant(self):
with tempfile.TemporaryDirectory() as temp:
source, output = Path(temp) / 'source', Path(temp) / 'output'
text = xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')
for practice_type in ('chord', 'melody'):
path = source / practice_type / 'lesson' / 'exercise.xml'
path.parent.mkdir(parents=True)
path.write_text(text, encoding='utf-8')
self.assertEqual(generate_practice_scores(source, output), 24)
for practice_type in ('chord', 'melody'):
for key in TARGET_KEYS:
with self.subTest(practice_type=practice_type, key=key):
result = load_json(output / practice_type / 'lesson' / 'exercise' / key_file_name(key))
self.assertEqual(result['schema_version'], 3)
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
self.assertEqual(result['metadata']['key_signature'], key)
self.assertEqual(result['metadata']['variant_tonic'], key)
self.assertEqual((source / practice_type / 'lesson' / 'exercise.xml').read_text(encoding='utf-8'), text)
def test_modes_and_keyless_authored_labels_survive_variants(self):
for mode, fifths, label in [('minor', -3, 'C minor'), ('dorian', -2, 'C dorian'), ('mixolydian', -1, 'C mixolydian'), ('none', 0, 'No key')]:
source = parse(xml_score(xml_note(), f'<key><fifths>{fifths}</fifths><mode>{mode}</mode></key>'))
if mode == 'none':
source['metadata']['transpose_origin'] = parse_pitch_name('C')
before = copy.deepcopy(source)
base = practice_document(source)
self.assertEqual(base['metadata']['authored_key_signature'], label)
for key in TARGET_KEYS:
with self.subTest(mode=mode, key=key):
result = transpose_document(base, key)
validate_document(result)
self.assertEqual(result['metadata']['authored_key_signature'], label)
self.assertEqual(result['metadata']['key_signature'], 'No key' if mode == 'none' else f'{key} {mode}')
self.assertEqual(result['metadata']['variant_tonic'], key)
self.assertEqual(source, before)
def test_existing_authored_key_is_retained_when_practice_is_rebuilt(self):
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
before = copy.deepcopy(source)
variant = transpose_document(practice_document(source), 'D')
result = transpose_document(practice_document(variant), 'F')
self.assertEqual(result['metadata']['authored_key_signature'], 'Eb')
self.assertEqual(result['metadata']['key_signature'], 'F')
self.assertEqual(source, before)
def test_schema3_bundles_accept_optional_authored_key(self):
source = parse(xml_score(xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertNotIn('authored_key_signature', source['metadata'])
for authored in (False, True):
with self.subTest(authored=authored), tempfile.TemporaryDirectory() as temp:
result = transpose_document(practice_document(source) if authored else source, 'D')
root = Path(temp)
path = root / 'variant.json'
write_json(path, result)
engrave_tree(root, FONT_ROOT)
bundle = json.loads(path.read_text(encoding='utf-8'))
self.assertEqual(bundle['schema_version'], 3)
self.assertEqual(bundle['music']['metadata']['key_signature'], 'D')
self.assertEqual(bundle['music']['metadata'].get('authored_key_signature'), 'Eb' if authored else None)
self.assertEqual(load_json(path), result)