Files
product-vamp/tests/test_key_metadata.py
T
khodowany 09656a9d60
ci / checks (push) Successful in 48m34s
Package
2026-09-30 19:46:37 -05:00

118 lines
7.1 KiB
Python

import copy
import fractions
import pathlib
import unittest
import xml.etree.ElementTree
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 = 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 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 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 = 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)]
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 = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[target]))
generator.model.validate_document(result)
measures = result['staves'][0]['measures']
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'], 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'], generator.model.rational(2))
else:
self.assertTrue(all(not m['attributes'] for m in measures))
self.assertEqual(change['number'], '2')
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 = 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 = 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 = 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 = 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'], 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 = 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, 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'):
generator.pitch.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'):
instrument_score(source, 1, 2)
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 = 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'], generator.model.key_from_fifths(2) | {'cancel': 3})