+80
-80
@@ -1,82 +1,88 @@
|
||||
import argparse
|
||||
import copy
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import fractions
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import unittest.mock
|
||||
|
||||
from generator import harmony
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import arrange_document
|
||||
from generator.parts import active_chord, generated_voice, propagated_chords
|
||||
from generator.guitar import chord_pitches, chord_positions, chord_requirements
|
||||
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.pitch import concert_document, shift_pitch
|
||||
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
||||
import generator.__main__
|
||||
import generator.accompaniment
|
||||
import generator.arrange.generate
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.pitch
|
||||
import tests.rhythm_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
class MusicModelTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
engraving = patch('generator.__main__.engrave_tree')
|
||||
engraving = unittest.mock.patch('generator.engraving.pipeline.engrave_tree')
|
||||
engraving.start()
|
||||
self.addCleanup(engraving.stop)
|
||||
catalog = patch('generator.__main__.build_catalog')
|
||||
catalog = unittest.mock.patch('generator.catalog.build_catalog')
|
||||
catalog.start()
|
||||
self.addCleanup(catalog.stop)
|
||||
rhythms = unittest.mock.patch('generator.rhythm.load_library', return_value=tests.rhythm_fixtures.library())
|
||||
rhythms.start()
|
||||
self.addCleanup(rhythms.stop)
|
||||
artwork = unittest.mock.patch('generator.pico_artwork.generate_titles')
|
||||
artwork.start()
|
||||
self.addCleanup(artwork.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)
|
||||
self.assertEqual(set(generator.harmony.pitch_classes(generator.harmony.parse_symbol(label))), expected, label)
|
||||
for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]:
|
||||
definition = harmony.parse_symbol(label)
|
||||
pitches = chord_pitches(definition, chord_positions(*chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label)
|
||||
definition = generator.harmony.parse_symbol(label)
|
||||
pitches = generator.guitar.chord_pitches(definition, generator.guitar.chord_positions(*generator.guitar.chord_requirements(definition))[0])
|
||||
self.assertTrue(essentials.issubset({generator.model.pitch_number(p) % 12 for p in pitches}), label)
|
||||
with self.assertRaises(ValueError):
|
||||
harmony.parse_symbol('Cunrecognized')
|
||||
generator.harmony.parse_symbol('Cunrecognized')
|
||||
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><transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'))
|
||||
result = concert_document(source)
|
||||
p['alter'] = generator.model.rational(p['alter'])
|
||||
self.assertEqual(generator.pitch.shift_pitch(p, 0, 0), p)
|
||||
self.assertEqual(generator.model.pitch_number(generator.pitch.shift_pitch(p, 1, 2)), generator.model.pitch_number(p) + 2)
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key><transpose><diatonic>1</diatonic><chromatic>2</chromatic></transpose>'))
|
||||
result = generator.pitch.concert_document(source)
|
||||
self.assertEqual(result['metadata']['key_signature'], 'D minor')
|
||||
self.assertEqual(result['metadata']['key']['alterations'], key_from_fifths(-1, 'minor')['alterations'])
|
||||
self.assertEqual(result['metadata']['key']['alterations'], generator.model.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)
|
||||
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3, 2], 'unit': 8}]}), [fractions.Fraction(3, 2), fractions.Fraction(1)])
|
||||
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [6], 'unit': 8}]}), [fractions.Fraction(3, 2)] * 2)
|
||||
self.assertEqual(generator.model.beat_lengths({'groups': [{'beats': [3], 'unit': 4}]}), [fractions.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/>')))
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter') + change + tests.test_musicxml.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)])
|
||||
events = generator.parts.generated_voice(measure, 'slash', None)
|
||||
self.assertEqual([generator.model.fraction(n['onset']) for n in events], [0, 1, fractions.Fraction(5, 2)])
|
||||
self.assertEqual([generator.model.fraction(n['duration']) for n in events], [1, fractions.Fraction(3, 2), fractions.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)}
|
||||
c = generator.harmony.parse_symbol('C7') | {'onset': generator.model.rational(0)}
|
||||
f = generator.harmony.parse_symbol('F7') | {'onset': generator.model.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)
|
||||
self.assertEqual(generator.accompaniment.carried_chords(measures), [None, f])
|
||||
self.assertIsNone(generator.accompaniment.active_chord({'harmonies': [f]}, fractions.Fraction(0), None))
|
||||
self.assertEqual(generator.accompaniment.active_chord(measures[0], fractions.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'
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
tests.test_musicxml.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}
|
||||
generator.model.validate_document(document)
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
tests.test_musicxml.notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2}
|
||||
with self.assertRaisesRegex(ValueError, 'reduced'):
|
||||
validate_document(document)
|
||||
generator.model.validate_document(document)
|
||||
def test_optional_turnaround_and_form(self):
|
||||
source = load_musicxml(Path('data/songs/it-could-happen-to-you.xml'))
|
||||
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/it-could-happen-to-you.xml'))
|
||||
for part in ('acoustic', 'electric', 'vocal'):
|
||||
result = arrange_document(source, part)
|
||||
validate_document(result)
|
||||
result = generator.arrange.generate.arrange_document(source, part, rhythms=tests.rhythm_fixtures.library())
|
||||
generator.model.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'])
|
||||
@@ -87,68 +93,62 @@ class MusicModelTests(unittest.TestCase):
|
||||
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 = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
source['staves'][0]['measures'][0][field] = {}
|
||||
with self.assertRaisesRegex(ValueError, 'repeat notation'):
|
||||
validate_document(source)
|
||||
generator.model.validate_document(source)
|
||||
for field in ('sound_ties', 'printed_ties'):
|
||||
source = parse(xml_score(xml_note()))
|
||||
notes(source)[0]['pitches'][0][field] = []
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
tests.test_musicxml.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'):
|
||||
self.assertEqual(canonical_style(text), 'Medium Swing')
|
||||
with self.assertRaises(ValueError):
|
||||
canonical_style('Unrecognized style')
|
||||
generator.model.validate_document(source)
|
||||
def test_failure_preserves_generated_assets(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
root = pathlib.Path(temp)
|
||||
data = root / 'data'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / 'practice').mkdir()
|
||||
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note(duration=5)), encoding='utf-8')
|
||||
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.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'), check=False)
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
||||
with self.assertRaises(ValueError):
|
||||
generate_assets(args)
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
(data / 'songs' / 'bad.xml').write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
|
||||
args.check = True
|
||||
with self.assertRaisesRegex(ValueError, 'summary'):
|
||||
generate_assets(args)
|
||||
generator.__main__.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)
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
|
||||
concert = generator.pitch.concert_document(source)
|
||||
p = tests.test_musicxml.notes(concert)[0]['pitches'][0]
|
||||
self.assertEqual((p['letter'], generator.model.fraction(p['alter']), p['octave']), ('b', -1, 3))
|
||||
self.assertEqual(generator.pitch.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, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
(data / 'practice').mkdir()
|
||||
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'), check=False)
|
||||
generate_assets(args)
|
||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8')
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
|
||||
generator.__main__.generate_assets(args)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
generate_assets(args)
|
||||
generator.__main__.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)
|
||||
generator.__main__.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)
|
||||
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
|
||||
source = source.replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||
result = generator.arrange.generate.arrange_document(tests.test_musicxml.parse(source), 'vocal', rhythms=tests.rhythm_fixtures.library())
|
||||
generator.model.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]
|
||||
|
||||
Reference in New Issue
Block a user