122 lines
6.2 KiB
Python
122 lines
6.2 KiB
Python
import generator.audio_assets
|
|
import argparse
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
import pathlib
|
|
import shutil
|
|
import tempfile
|
|
import unittest
|
|
import wave
|
|
|
|
import generator.performance
|
|
import generator.model
|
|
import tests.test_musicxml
|
|
|
|
def fixture(root):
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
|
first = music['staves'][0]['measures'][0]
|
|
music['staves'][0]['measures'].append(copy.deepcopy(first))
|
|
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
|
|
music['metadata']['song_id'] = 'fixture'
|
|
|
|
def system(first, count, y):
|
|
return dict(first_measure=first, measure_count=count, start_beat=generator.model.rational((first - 1) * 4),
|
|
end_beat=generator.model.rational((first + count - 1) * 4),
|
|
labels=[dict(measure=m, bounds=[30 + 40 * (m - first), y, 42 + 40 * (m - first), y + 12]) for m in range(first, first + count)])
|
|
|
|
def page(index, systems):
|
|
return dict(path=f'{index}.png', sha256='0' * 64, first_measure=systems[0]['first_measure'],
|
|
measure_count=sum(s['measure_count'] for s in systems), start_beat=systems[0]['start_beat'], end_beat=systems[-1]['end_beat'], systems=systems)
|
|
|
|
pages = [page(1, [system(1, 1, 70), system(2, 1, 290)]), page(2, [system(3, 3, 70)])]
|
|
playback = [pages[0], page(3, [system(2, 1, 70), system(3, 3, 290)]), pages[1]]
|
|
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages, playback_pages=playback), performance=generator.performance.timing(music))
|
|
(root / 'score.json').write_text(json.dumps(bundle))
|
|
entries = []
|
|
# Constant PCM makes accents, recorded count-in and the silent click tail observable.
|
|
for name, frames, value, song_id, bpm in [('sounds/beat1.wav', 2400, 8192, '', 0), ('sounds/beat2.wav', 2400, 4096, '', 0), ('backing/fixture_120.wav', 576000, 1024, 'fixture', 120)]:
|
|
path = root / name
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with wave.open(str(path), 'wb') as output:
|
|
output.setparams((2, 2, 48000, 0, 'NONE', ''))
|
|
output.writeframes(value.to_bytes(2, 'little', signed=True) * 2 * frames)
|
|
entries.append(generator.audio_assets.describe_wav(path, root, song_id=song_id, bpm=bpm))
|
|
(root / 'manifest.audio').write_text(json.dumps(entries))
|
|
|
|
class PerformanceTests(unittest.TestCase):
|
|
def test_supplied_audio_is_copied_without_transformation(self):
|
|
source = pathlib.Path('package/assets')
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
entries = generator.audio_assets.copy_audio(source, root, {'all_of_me', 'autumn_leaves'})
|
|
self.assertEqual(len(entries), 4)
|
|
for entry in entries:
|
|
self.assertEqual((source / entry['path']).read_bytes(), (root / entry['path']).read_bytes())
|
|
self.assertEqual(entry['sha256'], hashlib.sha256((root / entry['path']).read_bytes()).hexdigest())
|
|
def test_recording_names_and_truncation_are_rejected(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
fixture(root)
|
|
path = root / 'backing/fixture_120.wav'
|
|
path.write_bytes(path.read_bytes()[:-2])
|
|
with self.assertRaisesRegex(ValueError, 'truncated'):
|
|
generator.audio_assets.describe_wav(path, root)
|
|
path.rename(root / 'backing/fixture_115.wav')
|
|
with self.assertRaisesRegex(ValueError, 'unsupported'):
|
|
generator.audio_assets.copy_audio(root, root / 'out', {'fixture'})
|
|
def test_short_backing_cannot_publish(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
fixture(root)
|
|
(root / 'songs').mkdir()
|
|
bundle = json.loads((root / 'score.json').read_text())
|
|
(root / 'songs/fixture.json').write_text(json.dumps(bundle['music']))
|
|
entries = json.loads((root / 'manifest.audio').read_text())
|
|
generator.audio_assets.validate_recording_lengths(root, entries)
|
|
entries[-1]['frames'] = 1
|
|
with self.assertRaisesRegex(ValueError, 'shorter'):
|
|
generator.audio_assets.validate_recording_lengths(root, entries)
|
|
def test_page_coverage_and_measure_accents(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
fixture(root)
|
|
bundle = json.loads((root / 'score.json').read_text())
|
|
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
|
|
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
|
|
self.assertEqual(bundle['performance']['duration'], generator.model.rational(20))
|
|
self.assertEqual(bundle['performance']['sustain_start'], generator.model.rational(8))
|
|
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
|
|
with self.assertRaisesRegex(ValueError, 'timing'):
|
|
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
|
|
def test_playback_metadata_rejects_missing_views_wrong_pairs_and_bad_labels(self):
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
root = pathlib.Path(temporary)
|
|
fixture(root)
|
|
bundle = json.loads((root / 'score.json').read_text())
|
|
pages = bundle['presentation']['pages']
|
|
playback = bundle['presentation']['playback_pages']
|
|
generator.performance.validate_views(bundle['music'], pages, playback)
|
|
for fault in ('missing', 'pair', 'bounds', 'label', 'timing'):
|
|
altered = copy.deepcopy(playback)
|
|
if fault == 'missing':
|
|
altered.pop()
|
|
elif fault == 'pair':
|
|
altered[1] = copy.deepcopy(altered[0])
|
|
elif fault == 'bounds':
|
|
altered[0]['systems'][0]['labels'][0]['bounds'][0] = -1
|
|
elif fault == 'label':
|
|
altered[0]['systems'][0]['labels'][0]['measure'] = 2
|
|
else:
|
|
altered[1]['start_beat'] = generator.model.rational(3)
|
|
with self.subTest(fault=fault), self.assertRaises(ValueError):
|
|
generator.performance.validate_views(bundle['music'], pages, altered)
|
|
|
|
if __name__ == '__main__':
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--fixture', type=pathlib.Path)
|
|
args = parser.parse_args()
|
|
if args.fixture:
|
|
fixture(args.fixture)
|