+54
-57
@@ -1,99 +1,98 @@
|
||||
from fractions import Fraction
|
||||
import fractions
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
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
|
||||
import generator.model
|
||||
import generator.playback
|
||||
import generator.score
|
||||
import tests.test_musicxml
|
||||
|
||||
class PlaybackTests(unittest.TestCase):
|
||||
def test_grace_steals_accumulate_at_both_ends_of_a_donor(self):
|
||||
grace = '<note><grace {}/><pitch><step>{}</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
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)])
|
||||
content = grace.format('steal-time-following="25"', 'D') + tests.test_musicxml.xml_note(duration=2, kind='half') + grace.format('steal-time-previous="25"', 'E') + tests.test_musicxml.xml_note('F', 2, 'half')
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)))
|
||||
self.assertEqual([(generator.model.fraction(e['pitch']), generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], [(62, 0, fractions.Fraction(1, 2)), (60, fractions.Fraction(1, 2), 1), (64, fractions.Fraction(3, 2), fractions.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='<chord/>'))
|
||||
with self.assertRaisesRegex(PlaybackUnavailable, 'chord tone'):
|
||||
compile_timeline(parse(xml_score(chord)))
|
||||
with self.subTest(percent=percent), self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'grace timing consumes'):
|
||||
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content.replace('"25"', f'"{percent}"'))))
|
||||
chord = content.replace(tests.test_musicxml.xml_note(duration=2, kind='half'), tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('G', 1, 'quarter', prefix='<chord/>'))
|
||||
with self.assertRaisesRegex(generator.playback.PlaybackUnavailable, 'chord tone'):
|
||||
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.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)]:
|
||||
for mode, expected, total in [('steal-time-previous="25"', [(0, fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(1, 2)), (2, 2)], 4), ('make-time="1"', [(0, 2), (2, 1), (3, 2)], 5)]:
|
||||
grace = f'<note><grace {mode}/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
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)
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + grace + tests.test_musicxml.xml_note('E', 2, 'half'))))
|
||||
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration'])) for e in result['events']], expected)
|
||||
self.assertEqual(generator.model.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='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
|
||||
result = compile_timeline(document)
|
||||
self.assertEqual(fraction(result['duration']), 4)
|
||||
self.assertEqual([fraction(e['duration']) for e in result['events']], [2, 1])
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 1, 'quarter', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>'))
|
||||
result = generator.playback.compile_timeline(document)
|
||||
self.assertEqual(generator.model.fraction(result['duration']), 4)
|
||||
self.assertEqual([generator.model.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='<tie type="start"/>') + xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note(duration=2, kind='half', extra='<tie type="stop"/>'))))
|
||||
self.assertEqual(len(result['events']), 1)
|
||||
self.assertEqual(fraction(result['events'][0]['duration']), 4)
|
||||
self.assertEqual(generator.model.fraction(result['events'][0]['duration']), 4)
|
||||
def test_octave_and_microtones(self):
|
||||
result = compile_timeline(parse(xml_score(xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
|
||||
self.assertEqual(fraction(result['events'][0]['pitch']), Fraction(97, 2))
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(alter='0.5'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
|
||||
self.assertEqual(generator.model.fraction(result['events'][0]['pitch']), fractions.Fraction(97, 2))
|
||||
def test_grace_default_and_cues(self):
|
||||
grace = '<note><grace/><pitch><step>D</step><octave>4</octave></pitch><type>eighth</type></note>'
|
||||
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='<cue/>'))))['events'], [])
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(grace + tests.test_musicxml.xml_note())))
|
||||
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [1, 3])
|
||||
self.assertEqual([generator.model.fraction(e['onset']) for e in result['events']], [0, 1])
|
||||
self.assertEqual(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(prefix='<cue/>'))))['events'], [])
|
||||
def test_tremolo(self):
|
||||
result = compile_timeline(parse(xml_score(xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<notations><ornaments><tremolo>3</tremolo></ornaments></notations>'))))
|
||||
self.assertEqual(len(result['events']), 32)
|
||||
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><ornaments><tremolo>1</tremolo></ornaments></notations>'
|
||||
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)
|
||||
result = generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='eighth', extra=extra), divisions=3, implicit=True)))
|
||||
self.assertEqual([generator.model.fraction(e['duration']) for e in result['events']], [fractions.Fraction(1, 6)] * 2)
|
||||
def test_hidden_pitches_sound_and_unpitched_is_unavailable(self):
|
||||
hidden = xml_note().replace('<note>', '<note print-object="no">')
|
||||
self.assertEqual(len(compile_timeline(parse(xml_score(hidden)))['events']), 1)
|
||||
hidden = tests.test_musicxml.xml_note().replace('<note>', '<note print-object="no">')
|
||||
self.assertEqual(len(generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.xml_score(hidden)))['events']), 1)
|
||||
unpitched = '<note><unpitched><display-step>C</display-step><display-octave>4</display-octave></unpitched><duration>4</duration><type>whole</type></note>'
|
||||
with self.assertRaises(PlaybackUnavailable):
|
||||
compile_timeline(parse(xml_score(unpitched)))
|
||||
with self.assertRaises(generator.playback.PlaybackUnavailable):
|
||||
generator.playback.compile_timeline(tests.test_musicxml.parse(tests.test_musicxml.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()))
|
||||
root = pathlib.Path(directory)
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.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)
|
||||
generator.score.write_json(root / f'{part}.json', source)
|
||||
generator.playback.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'])
|
||||
self.assertEqual(playback['samples'][0]['id'], generator.playback.generate_tone(root, fractions.Fraction(pitch))['id'])
|
||||
def test_audio_assets_validate_and_reject_corruption(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
root = pathlib.Path(directory)
|
||||
generate_fixture(root)
|
||||
manifest = json.loads((root / 'score.json').read_text())['playback']
|
||||
validate_assets(manifest, root)
|
||||
generator.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)
|
||||
generator.playback.validate_assets(manifest, root)
|
||||
generate_fixture(root)
|
||||
manifest['release']['sha256'] = write_pcm(root / 'audio/release.wav', [0.5] * 2400)
|
||||
manifest['release']['sha256'] = generator.playback.write_pcm(root / 'audio/release.wav', [0.5] * 2400)
|
||||
with self.assertRaisesRegex(ValueError, 'envelope'):
|
||||
validate_assets(manifest, root)
|
||||
generator.playback.validate_assets(manifest, root)
|
||||
sample['path'] = '../outside.wav'
|
||||
with self.assertRaises(ValueError):
|
||||
validate_assets(manifest, root)
|
||||
generator.playback.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))
|
||||
root = pathlib.Path(directory)
|
||||
a = generator.playback.generate_tone(root, fractions.Fraction(60))
|
||||
b = generator.playback.generate_tone(root, fractions.Fraction(60))
|
||||
self.assertEqual(a, b)
|
||||
with wave.open(str(root / a['path'])) as audio:
|
||||
self.assertEqual(audio.getframerate(), 48000)
|
||||
@@ -102,11 +101,9 @@ class PlaybackTests(unittest.TestCase):
|
||||
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='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
|
||||
compile_tree(root)
|
||||
root = pathlib.Path(directory)
|
||||
generator.score.write_json(root / 'score.json', tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half') + tests.test_musicxml.xml_note('E', 2, 'half', prefix='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
|
||||
generator.playback.compile_tree(root)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
Reference in New Issue
Block a user