Files
product-vamp/tests/test_music_model.py
T
khodowany 1d1bd3e871
ci / checks (push) Successful in 43m19s
Music Notation
2026-09-15 01:30:42 -05:00

136 lines
7.8 KiB
Python

import argparse
import copy
from fractions import Fraction
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from generator import harmony
from generator.__main__ import generate_assets
from generator.arrange.generate import active_chord, arrange_document, generated_voice, propagated_chords
from generator.arrange.fretboard import pitches_for_voicing
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.transposition import concert_document, shift_pitch, transpose_document
from tests.test_musicxml import parse, notes, xml_note, xml_score
class MusicModelTests(unittest.TestCase):
def setUp(self):
engraving = patch('generator.__main__.engrave_tree')
engraving.start()
self.addCleanup(engraving.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)
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
pitches = pitches_for_voicing(label)
self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label)
with self.assertRaises(ValueError):
harmony.parse_symbol('Cunrecognised')
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>'))
result = transpose_document(source, 'D')
self.assertEqual(result['metadata']['key_signature'], 'D minor')
self.assertEqual(result['metadata']['key']['alterations'], 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)
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/>')))
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)])
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)}
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)
def test_tuplet_ratios_and_tie_links_validated(self):
document = parse(xml_score(xml_note()))
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}
with self.assertRaisesRegex(ValueError, 'reduced'):
validate_document(document)
def test_optional_turnaround_and_form(self):
source = load_musicxml(Path('data/songs/it-could-happen-to-you.xml'))
for part in ('acoustic', 'electric', 'vocal'):
result = arrange_document(source, part)
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'])
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']))
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('Unrecognised style')
def test_failure_preserves_generated_assets(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
data = root / 'data'
(data / 'songs').mkdir(parents=True)
(data / 'songs' / 'bad.xml').write_text(xml_score(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'), clean=True, check=False)
with self.assertRaises(ValueError):
generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved')
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note()), encoding='utf-8')
args.check = True
with self.assertRaisesRegex(ValueError, 'summary'):
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)
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 / 'songs').mkdir(parents=True)
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'), clean=True, check=False)
generate_assets(args)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
args.clean = False
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)
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)
measures = result['staves'][0]['measures']
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
end = measures[1]['voices'][0]['notes'][0]['pitches'][0]
self.assertEqual(start['tie_to'], end['id'])