from fractions import Fraction import json from pathlib import Path import tempfile import unittest import wave from generator.model import fraction from generator.playback import PlaybackUnavailable, compile_timeline, generate_tone, validate_assets, write_pcm from tests.test_musicxml import notes, parse, xml_note, xml_score class PlaybackTests(unittest.TestCase): def test_grace_steals_accumulate_at_both_ends_of_a_donor(self): grace = '{}4eighth' content = grace.format('steal-time-following="25"', 'D') + xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + xml_note('F', 2, 'half') result = compile_timeline(parse(xml_score(content))) self.assertEqual([(fraction(e['pitch']), fraction(e['onset']), fraction(e['duration'])) for e in result['events']], [(62, 0, Fraction(1, 2)), (60, Fraction(1, 2), 1), (64, Fraction(3, 2), Fraction(1, 2)), (65, 2, 2)]) for percent in (50, 60): with self.subTest(percent=percent), self.assertRaisesRegex(PlaybackUnavailable, 'grace timing consumes'): compile_timeline(parse(xml_score(content.replace('"25"', f'"{percent}"')))) chord = content.replace(xml_note(duration=2, kind='half'), xml_note(duration=2, kind='half') + xml_note('G', 1, 'quarter', prefix='')) with self.assertRaisesRegex(PlaybackUnavailable, 'chord tone'): compile_timeline(parse(xml_score(chord))) def test_grace_previous_and_make_time_preserve_their_timing(self): for mode, expected, total in [('steal-time-previous="25"', [(0, Fraction(3, 2)), (Fraction(3, 2), Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]: grace = f'D4eighth' result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half') + grace + xml_note('E', 2, 'half')))) self.assertEqual([(fraction(e['onset']), fraction(e['duration'])) for e in result['events']], expected) self.assertEqual(fraction(result['duration']), total) def test_chords_and_trailing_rest(self): document = parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 1, 'quarter', prefix='') + '2half')) result = compile_timeline(document) self.assertEqual(fraction(result['duration']), 4) self.assertEqual([fraction(e['duration']) for e in result['events']], [2, 1]) def test_tie_is_one_attack(self): result = compile_timeline(parse(xml_score(xml_note(duration=2, kind='half', extra='') + xml_note(duration=2, kind='half', extra='')))) self.assertEqual(len(result['events']), 1) self.assertEqual(fraction(result['events'][0]['duration']), 4) def test_octave_and_microtones(self): result = compile_timeline(parse(xml_score(xml_note(alter='0.5'), '00-1'))) self.assertEqual(fraction(result['events'][0]['pitch']), Fraction(97, 2)) def test_grace_default_and_cues(self): grace = 'D4eighth' result = compile_timeline(parse(xml_score(grace + xml_note()))) self.assertEqual([fraction(e['duration']) for e in result['events']], [1, 3]) self.assertEqual([fraction(e['onset']) for e in result['events']], [0, 1]) self.assertEqual(compile_timeline(parse(xml_score(xml_note(prefix=''))))['events'], []) def test_tremolo(self): result = compile_timeline(parse(xml_score(xml_note(extra='3')))) self.assertEqual(len(result['events']), 32) extra = '321' result = compile_timeline(parse(xml_score(xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True))) self.assertEqual([fraction(e['duration']) for e in result['events']], [Fraction(1, 6)] * 2) def test_hidden_pitches_sound_and_unpitched_is_unavailable(self): hidden = xml_note().replace('', '') self.assertEqual(len(compile_timeline(parse(xml_score(hidden)))['events']), 1) unpitched = 'C44whole' with self.assertRaises(PlaybackUnavailable): compile_timeline(parse(xml_score(unpitched))) def test_sounding_pitch_depends_on_instrument_transposition_not_part_or_path(self): from generator.playback import compile_tree from generator.score import write_json with tempfile.TemporaryDirectory() as directory: root = Path(directory) source = parse(xml_score(xml_note())) for part in ('acoustic', 'electric', 'vocal'): source['staves'][0]['arrangement_part_type'] = part source['staves'][0]['measures'][0]['transpose']['octave_change'] = -1 if part != 'vocal' else 0 write_json(root / f'{part}.json', source) compile_tree(root) for part, pitch in [('acoustic', 48), ('electric', 48), ('vocal', 60)]: playback = json.loads((root / f'{part}.json').read_text())['playback'] self.assertEqual(playback['samples'][0]['id'], generate_tone(root, Fraction(pitch))['id']) def test_audio_assets_validate_and_reject_corruption(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) generate_fixture(root) manifest = json.loads((root / 'score.json').read_text())['playback'] validate_assets(manifest, root) sample = manifest['samples'][0] path = root / sample['path'] path.write_bytes(path.read_bytes()[:-1]) with self.assertRaises(ValueError): validate_assets(manifest, root) generate_fixture(root) manifest['release']['sha256'] = write_pcm(root / 'audio/release.wav', [0.5] * 2400) with self.assertRaisesRegex(ValueError, 'envelope'): validate_assets(manifest, root) sample['path'] = '../outside.wav' with self.assertRaises(ValueError): validate_assets(manifest, root) def test_generated_tone_is_deterministic_and_loopable(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) a = generate_tone(root, Fraction(60)) b = generate_tone(root, Fraction(60)) self.assertEqual(a, b) with wave.open(str(root / a['path'])) as audio: self.assertEqual(audio.getframerate(), 48000) self.assertEqual(audio.getnframes(), a['loop_end']) self.assertEqual(audio.getnchannels(), 1) self.assertGreater(a['loop_end'], a['loop_start']) def generate_fixture(directory): from generator.score import write_json from generator.playback import compile_tree root = Path(directory) write_json(root / 'score.json', parse(xml_score(xml_note(duration=2, kind='half') + xml_note('E', 2, 'half', prefix='') + '2half'))) compile_tree(root) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser() parser.add_argument('--fixture', required=True) generate_fixture(parser.parse_args().fixture)