Tone Generation
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-18 01:17:36 -05:00
parent 64eac2f11e
commit 80873a0896
39 changed files with 1624 additions and 52 deletions
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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{};
+1 -1
View File
@@ -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))
+133
View File
@@ -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));
}
+96
View File
@@ -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)
+141
View File
@@ -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])