import argparse import copy from fractions import Fraction from pathlib import Path import tempfile import unittest 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 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}): 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='-3minor')) 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 = '' source = parse(xml_score(xml_note(duration=1,kind='quarter')+change+xml_note(duration=3,kind='half',extra=''))) 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.musicxml')) 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.musicxml').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.musicxml').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='-1-2')) concert = concert_document(source) p = notes(concert)[0]['pitches'][0] self.assertEqual((p['letter'],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.musicxml' 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()} 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'))) def test_pickup_tie_survives_vocal_restoration(self): source = xml_score(xml_note(duration=1,kind='quarter',extra=''),implicit=True) source = source.replace('',''+xml_note(extra='')+'') 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'])