119 lines
6.6 KiB
Python
119 lines
6.6 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.score import load_json, write_json
|
|
from generator.pitch import concert_document, shift_key
|
|
from tests.test_musicxml import parse, xml_note, xml_score
|
|
|
|
FONT_ROOT = 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)
|
|
|
|
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 = instrument_score(source, *({'A': (1, 2), 'Ab': (-1, -2), 'G': (1, 2), 'F': (-1, -2)}[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 = instrument_score(source, 1, 2)
|
|
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 = 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',))
|
|
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'):
|
|
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 = 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})
|