@@ -6,6 +6,7 @@ add_executable(product-vamp-test
|
||||
test_catalogue_runtime.cpp
|
||||
test_health.cpp
|
||||
test_pico.cpp
|
||||
test_playback.cpp
|
||||
test_score_loader.cpp
|
||||
test_touch_input.cpp
|
||||
)
|
||||
@@ -23,7 +24,18 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_ZERO_PORT=8080
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback-assets"
|
||||
)
|
||||
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json
|
||||
COMMAND ${Python3_EXECUTABLE} -m tests.test_playback --fixture ${CMAKE_CURRENT_BINARY_DIR}/playback-assets
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS test_playback.py test_musicxml.py ${VAMP_PLAYBACK_TEST_INPUTS}
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(vamp-playback-test-assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/playback-assets/score.json)
|
||||
add_dependencies(product-vamp-test vamp-playback-test-assets)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 180)
|
||||
add_test(
|
||||
|
||||
+13
-6
@@ -37,6 +37,7 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/parse.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
@@ -172,7 +173,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json"));
|
||||
BOOST_REQUIRE(defaults);
|
||||
BOOST_TEST(defaults->audio.enabled);
|
||||
BOOST_TEST(!defaults->audio.track_enabled);
|
||||
BOOST_TEST(!defaults->audio.playback_enabled);
|
||||
BOOST_TEST(!defaults->audio.metronome_enabled);
|
||||
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
|
||||
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
|
||||
@@ -181,7 +182,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
||||
"audio": {
|
||||
"enabled": false,
|
||||
"track_enabled": true,
|
||||
"playback_enabled": true,
|
||||
"metronome_enabled": true,
|
||||
"metronome_bpm": 90,
|
||||
"metronome_min_bpm": 50,
|
||||
@@ -194,7 +195,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
})json"));
|
||||
BOOST_REQUIRE(parsed);
|
||||
BOOST_TEST(!parsed->audio.enabled);
|
||||
BOOST_TEST(parsed->audio.track_enabled);
|
||||
BOOST_TEST(parsed->audio.playback_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
|
||||
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
|
||||
@@ -222,7 +223,7 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 2U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.track_enabled);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::MAIN_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_main_menu_index == 3U);
|
||||
@@ -251,13 +252,13 @@ BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
|
||||
BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) {
|
||||
auto state = navigation_state();
|
||||
state.audio.track_enabled = true;
|
||||
state.audio.playback_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
state.audio.metronome_bpm = 135;
|
||||
state.audio.tempo_editing = true;
|
||||
auto status = vamp_zero::app_status_json(state).as_object();
|
||||
auto const &audio = status.at("audio").as_object();
|
||||
BOOST_TEST(audio.at("track_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("playback_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_enabled").as_bool());
|
||||
BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U);
|
||||
BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U);
|
||||
@@ -339,6 +340,12 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d
|
||||
BOOST_TEST(remote.pages == 3U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.page_index == 1U);
|
||||
auto playing = state;
|
||||
playing.audio.playback_enabled = true;
|
||||
auto control = vamp_zero::control_state{{}, playing};
|
||||
control.finish_playback(playing.score_session);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(control.snapshot().selected_practice_key_index == 2U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU);
|
||||
BOOST_TEST(state.selected_practice_key_index == 2U);
|
||||
|
||||
+27
-1
@@ -114,7 +114,7 @@ auto config_file(std::filesystem::path const &root, vamp_zero::config const &con
|
||||
{"runtime", {{"assets_path", config.runtime.assets_path}, {"heartbeat_path", config.runtime.heartbeat_path}, {"hardware", "real"}, {"headless", false}, {"exit_after_startup", config.runtime.exit_after_startup}}},
|
||||
{"database", {{"path", config.database.path}}},
|
||||
{"control", {{"endpoint", ligo::to_json(config.control.endpoint)}, {"zero_ip", config.control.zero_ip}}},
|
||||
{"audio", {{"enabled", true}, {"track_enabled", true}, {"metronome_enabled", true}}},
|
||||
{"audio", {{"enabled", true}, {"playback_enabled", true}, {"metronome_enabled", true}}},
|
||||
};
|
||||
auto path = root / "config.json";
|
||||
write_file(path, boost::json::serialize(json));
|
||||
@@ -350,6 +350,32 @@ BOOST_AUTO_TEST_CASE(readiness_validates_later_pages_parts_and_practice_variants
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
|
||||
auto root = temporary_directory{};
|
||||
auto config = fixture_config(root.path);
|
||||
make_assets(config);
|
||||
auto assets = std::filesystem::path{config.runtime.assets_path};
|
||||
auto fixture = std::filesystem::path{VAMP_TEST_PLAYBACK_ASSETS_DIR};
|
||||
std::filesystem::copy(fixture / "audio", assets / "audio", std::filesystem::copy_options::recursive);
|
||||
auto path = assets / "practice/melody/practice/exercise/c.json";
|
||||
write_score(path);
|
||||
auto score = boost::json::parse(read_file(path));
|
||||
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
|
||||
write_file(path, boost::json::serialize(score));
|
||||
auto ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
|
||||
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
|
||||
auto audio = assets / std::string{sample.at("path").as_string()};
|
||||
auto corrupt = read_file(audio);
|
||||
corrupt[0] = 'X';
|
||||
sample["sha256"] = checksum(corrupt);
|
||||
write_file(audio, corrupt);
|
||||
write_file(path, boost::json::serialize(score));
|
||||
ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
|
||||
for (std::bool_t escape : {false, true}) {
|
||||
auto root = temporary_directory{};
|
||||
|
||||
@@ -137,7 +137,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
for extra in extras:
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note(extra=extra)))
|
||||
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
for extra in ('<barline location="middle"><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(xml_note() + extra))
|
||||
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <thread>
|
||||
|
||||
#include <vamp_zero/audio.hpp>
|
||||
#include <vamp_zero/config.hpp>
|
||||
#include <vamp_zero/playback.hpp>
|
||||
#include <vamp_zero/portal.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto playback_score() -> vamp_zero::score_document {
|
||||
auto input = std::ifstream{std::string{VAMP_TEST_PLAYBACK_ASSETS_DIR} + "/score.json"};
|
||||
auto score = vamp_zero::score_document{};
|
||||
score.source_json = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
return score;
|
||||
}
|
||||
|
||||
auto performance_state() -> vamp_zero::app_state {
|
||||
auto catalog = std::make_shared<vamp_zero::catalogue>();
|
||||
catalog->performance.parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
|
||||
catalog->performance.groups.push_back({.title = "Song", .parts = {0U}});
|
||||
auto state = vamp_zero::app_state{};
|
||||
state.catalog = std::move(catalog);
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
state.selected_song_index = 1U;
|
||||
state.selected_part_index = 1U;
|
||||
state.active_part_id = "part";
|
||||
state.score_session = 42U;
|
||||
state.audio.playback_enabled = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(generated_audio_plays_at_menu_bpm) {
|
||||
auto score = playback_score();
|
||||
auto normal = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U);
|
||||
auto slow = vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 60U);
|
||||
BOOST_REQUIRE_MESSAGE(normal, (normal ? "" : normal.error().describe()));
|
||||
BOOST_REQUIRE(slow);
|
||||
BOOST_TEST(normal->duration_frames == 96000U);
|
||||
BOOST_TEST(slow->duration_frames == normal->duration_frames * 2U);
|
||||
BOOST_TEST(normal->samples[0].frames == slow->samples[0].frames, boost::test_tools::per_element());
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_sequence(std::move(*normal));
|
||||
BOOST_REQUIRE(session);
|
||||
auto output = std::array<std::float32_t, 480>{};
|
||||
auto energy = std::float32_t{};
|
||||
for (auto block = std::uint32_t{}; block < 200U; ++block) {
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
for (auto value : output) {
|
||||
energy += value * value;
|
||||
}
|
||||
if (block == 150U) {
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::PLAYING);
|
||||
}
|
||||
}
|
||||
BOOST_TEST(energy > 0.1F);
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
BOOST_REQUIRE(vamp_zero::validate_playback_assets(score, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_completion_is_session_guarded) {
|
||||
auto state = vamp_zero::control_state{{}, performance_state()};
|
||||
state.finish_playback(41U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
state.finish_playback(42U, {}, std::make_shared<vamp_zero::catalogue const>());
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
state.finish_playback(42U, "sample missing");
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
BOOST_TEST(state.snapshot().audio.playback_error == "sample missing");
|
||||
state.finish_playback(42U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
BOOST_TEST(state.snapshot().selected_part_index == 1U);
|
||||
BOOST_TEST(state.snapshot().select_count == 0U);
|
||||
state.apply(vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.snapshot().score_session == 43U);
|
||||
state.finish_playback(42U);
|
||||
BOOST_CHECK(state.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
auto disabled = performance_state();
|
||||
disabled.audio.playback_enabled = false;
|
||||
auto quiet = vamp_zero::control_state{{}, disabled};
|
||||
quiet.finish_playback(42U);
|
||||
BOOST_CHECK(quiet.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_worker_discards_cancelled_preparation_and_reports_errors_once) {
|
||||
auto engine = sonus::engine::create({.no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto audio = vamp_zero::audio_controller{{}, std::move(*engine)};
|
||||
auto state = performance_state();
|
||||
auto invalid = std::make_shared<vamp_zero::score_document>();
|
||||
invalid->id = "part";
|
||||
invalid->source_json = "{}";
|
||||
auto loaded = vamp_zero::score_load_state{.document = invalid};
|
||||
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
for (auto attempt = std::uint32_t{}; attempt < 100U; ++attempt) {
|
||||
BOOST_CHECK(!audio.tick(state, {}, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
++state.score_session;
|
||||
auto reported = std::bool_t{};
|
||||
for (auto attempt = std::uint32_t{}; attempt < 1000U && !reported; ++attempt) {
|
||||
if (auto result = audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR)) {
|
||||
BOOST_CHECK(!*result);
|
||||
reported = true;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
BOOST_TEST(reported);
|
||||
BOOST_CHECK(!audio.tick(state, loaded, VAMP_TEST_PLAYBACK_ASSETS_DIR));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(playback_legacy_config_and_invalid_assets) {
|
||||
auto legacy = vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}}}});
|
||||
BOOST_REQUIRE(legacy);
|
||||
BOOST_TEST(legacy->audio.playback_enabled);
|
||||
BOOST_CHECK(!vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}, {"playback_enabled", false}}}}));
|
||||
auto score = playback_score();
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, "/missing-vamp-assets", 120U));
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 0U));
|
||||
score.source_json = "{}";
|
||||
BOOST_CHECK(!vamp_zero::prepare_playback(score, VAMP_TEST_PLAYBACK_ASSETS_DIR, 120U));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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_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])
|
||||
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"/>'))))
|
||||
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'), '<transpose><diatonic>0</diatonic><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>')))
|
||||
self.assertEqual(fraction(result['events'][0]['pitch']), 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'], [])
|
||||
def test_tremolo(self):
|
||||
result = compile_timeline(parse(xml_score(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)
|
||||
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)
|
||||
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)))
|
||||
def test_guitar_octave_depends_on_category_not_lesson_name(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 relative in ('practice/melody/chord/score.json', 'practice/chord/lesson/score.json'):
|
||||
write_json(root / relative, source)
|
||||
compile_tree(root)
|
||||
for relative, pitch in [('practice/melody/chord/score.json', 60), ('practice/chord/lesson/score.json', 48)]:
|
||||
playback = json.loads((root / relative).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='<chord/>') + '<note><rest/><duration>2</duration><type>half</type></note>')))
|
||||
compile_tree(root)
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--fixture', required=True)
|
||||
generate_fixture(parser.parse_args().fixture)
|
||||
@@ -0,0 +1,141 @@
|
||||
import argparse
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator.model import fraction, validate_document
|
||||
from generator.musicxml import MusicXMLError
|
||||
from generator.playback import compile_timeline
|
||||
from generator.repeats import expand_document, performed_order
|
||||
from tests.test_musicxml import parse, xml_note, xml_score
|
||||
|
||||
def score_bars(bars):
|
||||
return xml_score(bars[0]).replace('</measure></part>', '</measure>' + ''.join(f'<measure number="{i + 2}">{bar}</measure>' for i, bar in enumerate(bars[1:])) + '</part>')
|
||||
|
||||
def boundary(direction, times=None):
|
||||
side = 'left' if direction == 'forward' else 'right'
|
||||
count = f' times="{times}"' if times else ''
|
||||
return f'<barline location="{side}"><repeat direction="{direction}"{count}/></barline>'
|
||||
|
||||
def ending(numbers, action, repeat=False):
|
||||
side = 'left' if action == 'start' else 'right'
|
||||
return f'<barline location="{side}"><ending number="{numbers}" type="{action}"/>' + ('<repeat direction="backward"/>' if repeat else '') + '</barline>'
|
||||
|
||||
class RepeatTests(unittest.TestCase):
|
||||
def test_repeat_count_and_implicit_start(self):
|
||||
source = parse(score_bars([xml_note(), xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1] * 3)
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 6)
|
||||
def test_combined_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1,2', 'start') + xml_note('D') + ending('1,2', 'stop', True), ending('3', 'start') + xml_note('E') + ending('3', 'discontinue')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 0, 1, 0, 2])
|
||||
self.assertIn('endings', source['staves'][0]['measures'][1])
|
||||
self.assertNotIn('endings', expand_document(source)['staves'][0]['measures'][1])
|
||||
def test_nested_repeat(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), boundary('forward') + xml_note('D') + boundary('backward'), xml_note('E') + boundary('backward')]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 1, 1, 2] * 2)
|
||||
def test_sequential_and_expansion_limit(self):
|
||||
source = parse(score_bars([xml_note() + boundary('backward'), boundary('forward') + xml_note('D') + boundary('backward', 3)]))
|
||||
self.assertEqual([o.measure for o in performed_order(source['staves'][0]['measures'])], [0, 0, 1, 1, 1])
|
||||
with self.assertRaisesRegex(MusicXMLError, '10000'):
|
||||
parse(score_bars([boundary('forward') + xml_note(), xml_note() + boundary('backward', 10000)]))
|
||||
def test_bad_endings_and_open_repeat(self):
|
||||
for content in (boundary('forward') + xml_note(), ending('1', 'start') + xml_note(), ending('1', 'start') + xml_note() + ending('1', 'stop')):
|
||||
with self.assertRaises(MusicXMLError):
|
||||
parse(xml_score(content))
|
||||
def test_ties_reidentify_each_pass(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), xml_note(extra='<tie type="stop"/>') + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 2)
|
||||
def test_ties_across_repeat_jump_and_unreachable_pass(self):
|
||||
first = xml_note(extra='<tie type="stop" time-only="2"/>')
|
||||
last = xml_note(extra='<tie type="start" time-only="1"/>')
|
||||
source = parse(score_bars([boundary('forward') + first, last + boundary('backward')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [4, 8, 4])
|
||||
for bad in (first.replace('time-only="2"', ''), first.replace('time-only="2"', 'time-only="3"')):
|
||||
with self.assertRaisesRegex(MusicXMLError, 'tie'):
|
||||
parse(score_bars([boundary('forward') + bad, last + boundary('backward')]))
|
||||
def test_printed_tie_does_not_create_sound_tie(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 4)
|
||||
def test_ties_into_both_endings(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<tie type="start"/>'), ending('1', 'start') + xml_note(extra='<tie type="stop"/>') + ending('1', 'stop', True), ending('2', 'start') + xml_note(extra='<tie type="stop"/>') + ending('2', 'discontinue')]))
|
||||
self.assertEqual([fraction(e['duration']) for e in compile_timeline(source)['events']], [8, 8])
|
||||
def test_cross_measure_tuplets_and_tremolos_remap(self):
|
||||
extra = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||
bars = [boundary('forward'), '']
|
||||
for index in range(3):
|
||||
bars[0] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="start"/></notations>' if index == 0 else ''))
|
||||
bars[1] += xml_note(duration=4, kind='half', extra=extra + ('<notations><tuplet type="stop"/></notations>' if index == 2 else ''))
|
||||
bars[1] += boundary('backward')
|
||||
source = parse(score_bars(bars).replace('<divisions>1</divisions>', '<divisions>3</divisions>'))
|
||||
expanded = expand_document(source)
|
||||
validate_document(expanded)
|
||||
self.assertEqual(len(expanded['staves'][0]['tuplets']), 2)
|
||||
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
|
||||
bars = [xml_note(step, kind='breve', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')]]
|
||||
source = parse(score_bars([boundary('forward') + bars[0], bars[1] + boundary('backward')]))
|
||||
validate_document(expand_document(source))
|
||||
self.assertEqual(len(compile_timeline(source)['events']), 128)
|
||||
def test_no_repeat_is_unchanged(self):
|
||||
source = parse(xml_score(xml_note()))
|
||||
self.assertEqual(source, expand_document(source))
|
||||
def test_roundtrip_and_engraving(self):
|
||||
from generator.musicxml_writer import write_musicxml
|
||||
from generator.musicxml import load_musicxml
|
||||
from generator.engraving import Fonts, Style, layout_score
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'repeat.xml'
|
||||
write_musicxml(path, source)
|
||||
restored = load_musicxml(path)
|
||||
self.assertEqual(performed_order(source['staves'][0]['measures']), performed_order(restored['staves'][0]['measures']))
|
||||
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
|
||||
items = [p for page in layout.pages for p in page.primitives]
|
||||
self.assertEqual(sum(p.role == 'repeat-dot' for p in items), 4)
|
||||
self.assertEqual([p.value for p in items if p.role == 'ending-label'], ['1.', '2.'])
|
||||
def test_printed_tie_continues_across_system_boundary(self):
|
||||
from generator.engraving import Fonts, Style
|
||||
from generator.engraving.layout import make_system
|
||||
from generator.engraving.notation import ScoreIndex
|
||||
source = parse(score_bars([boundary('forward') + xml_note(extra='<notations><tied type="start"/></notations>'), xml_note(extra='<notations><tied type="stop"/></notations>') + boundary('backward')]))
|
||||
index = ScoreIndex(source)
|
||||
fonts = Fonts(Path('package/assets/fonts/petaluma'))
|
||||
for start, end in [(0, 4), (4, 8)]:
|
||||
system = make_system(index, start, end, fonts, Style())
|
||||
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
||||
def test_build_pipeline_keeps_compact_practice_and_compiles_performed_order(self):
|
||||
from generator.__main__ import generate_assets
|
||||
from generator.arrange.generate import arrange_document
|
||||
text = score_bars([boundary('forward') + xml_note(), xml_note('D') + boundary('backward')])
|
||||
source = parse(text)
|
||||
self.assertEqual(arrange_document(source, 'vocal'), arrange_document(expand_document(source), 'vocal'))
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
data = root / 'data'
|
||||
lesson = data / 'practice/melody/lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
(data / 'songs').mkdir()
|
||||
(lesson / 'repeat.xml').write_text(text)
|
||||
output = root / 'assets'
|
||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), check=False)
|
||||
generate_assets(args)
|
||||
bundle = json.loads((output / 'practice/melody/lesson/repeat/c.json').read_text())
|
||||
self.assertEqual(len(bundle['music']['staves'][0]['measures']), 2)
|
||||
self.assertEqual(len(bundle['playback']['events']), 4)
|
||||
self.assertEqual(fraction(bundle['playback']['duration']), 16)
|
||||
before = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
|
||||
with self.assertRaisesRegex(ValueError, 'audio'):
|
||||
generate_assets(args)
|
||||
self.assertEqual(before, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
def test_alternate_ending_does_not_inherit_skipped_key_change(self):
|
||||
source = parse(score_bars([boundary('forward') + xml_note(), ending('1', 'start') + '<attributes><key><fifths>2</fifths></key></attributes>' + xml_note('D') + ending('1', 'stop', True), ending('2', 'start') + xml_note('E') + ending('2', 'discontinue')]))
|
||||
expanded = expand_document(source)
|
||||
self.assertEqual([m['key']['fifths'] for m in expanded['staves'][0]['measures']], [0, 2, 0, 0])
|
||||
Reference in New Issue
Block a user