@@ -9,7 +9,7 @@ add_executable(product_vamp_test
|
||||
test_menu.cpp
|
||||
test_pico.cpp
|
||||
test_control_artwork.cpp
|
||||
test_playback.cpp
|
||||
test_performance.cpp
|
||||
test_score_loader.cpp
|
||||
test_touch_input.cpp
|
||||
test_ui.cpp
|
||||
@@ -29,7 +29,7 @@ target_compile_definitions(product_vamp_test PRIVATE
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated_assets"
|
||||
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback_assets"
|
||||
VAMP_TEST_PERFORMANCE_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/performance_assets"
|
||||
VAMP_TEST_UI_DIR="${CMAKE_CURRENT_BINARY_DIR}/ui_previews"
|
||||
)
|
||||
add_custom_command(
|
||||
@@ -41,16 +41,16 @@ add_custom_command(
|
||||
)
|
||||
add_custom_target(vamp_ui_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/ui_previews/static.p4)
|
||||
add_dependencies(product_vamp_test vamp_ui_test_assets)
|
||||
file(GLOB_RECURSE VAMP_PLAYBACK_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
|
||||
file(GLOB_RECURSE VAMP_PERFORMANCE_TEST_INPUTS CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/generator/*.py)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/playback_assets/score.json
|
||||
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_playback --fixture ${CMAKE_CURRENT_BINARY_DIR}/playback_assets
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json
|
||||
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.test_performance --fixture ${CMAKE_CURRENT_BINARY_DIR}/performance_assets
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS test_playback.py test_musicxml.py ${VAMP_PLAYBACK_TEST_INPUTS}
|
||||
DEPENDS test_performance.py test_musicxml.py ${VAMP_PERFORMANCE_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_custom_target(vamp_performance_test_assets DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/performance_assets/score.json)
|
||||
add_dependencies(product_vamp_test vamp_performance_test_assets)
|
||||
add_test(NAME product_vamp COMMAND product_vamp_test)
|
||||
set_tests_properties(product_vamp PROPERTIES TIMEOUT 180)
|
||||
add_test(
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import unittest.mock
|
||||
from generator import comping, vocabulary, parts
|
||||
from generator.arrange import form, generate
|
||||
import generator.comping
|
||||
import generator.vocabulary
|
||||
import generator.parts
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
|
||||
def section_document(document,part_type):
|
||||
prepared=parts.prepare_document(document)
|
||||
role={'acoustic':'accompaniment','electric':'melody','vocal':'vocal'}[part_type]
|
||||
section=form.ArrangementSection('',0,len(prepared['staves'][0]['measures']),0,role)
|
||||
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections',return_value=[section]), vocabulary.using(vocabulary.load(),part_type+'_accompaniment'):
|
||||
backing=comping.Canonical(prepared,part_type,'fixture') if part_type=='acoustic' else None
|
||||
return generate._arrange_document(prepared,part_type,accompaniment=backing)
|
||||
def section_document(document, part_type):
|
||||
prepared = generator.parts.prepare_document(document)
|
||||
role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type]
|
||||
section = generator.arrange.form.ArrangementSection('', 0, len(prepared['staves'][0]['measures']), 0, role)
|
||||
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
|
||||
backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
|
||||
return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
|
||||
|
||||
@@ -8,12 +8,42 @@
|
||||
#include <unistd.h>
|
||||
|
||||
#include <boost/test/unit_test.hpp>
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
|
||||
namespace vamp_test {
|
||||
|
||||
inline auto add_timing(boost::json::value &value) -> void {
|
||||
auto &music = value.at("music");
|
||||
music.at("metadata").as_object()["song_id"] = "fixture";
|
||||
auto &measures = music.at("staves").as_array().front().at("measures").as_array();
|
||||
auto &pages = value.at("presentation").at("pages").as_array();
|
||||
while (measures.size() < pages.size()) {
|
||||
measures.push_back(boost::json::object{});
|
||||
}
|
||||
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
|
||||
auto clicks = boost::json::array{};
|
||||
for (std::size_t i = 0; i < measures.size(); ++i) {
|
||||
measures[i].as_object()["duration"] = beat(4);
|
||||
for (std::size_t b = 0; b < 4; ++b) {
|
||||
clicks.push_back(boost::json::object{{"beat", beat(i * 4 + b)}, {"downbeat", b == 0}});
|
||||
}
|
||||
}
|
||||
std::size_t cursor{};
|
||||
for (std::size_t i = 0; i < pages.size(); ++i) {
|
||||
auto count = i + 1 == pages.size() ? measures.size() - cursor : 1U;
|
||||
auto &page = pages[i].as_object();
|
||||
page["first_measure"] = cursor + 1;
|
||||
page["measure_count"] = count;
|
||||
page["start_beat"] = beat(cursor * 4);
|
||||
page["end_beat"] = beat((cursor + count) * 4);
|
||||
cursor += count;
|
||||
}
|
||||
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
|
||||
}
|
||||
|
||||
inline auto build_catalog(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
|
||||
auto source = assets.string();
|
||||
auto destination = database.string();
|
||||
|
||||
+12
-725
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"all_of_me_acoustic.json": {
|
||||
"title": "all-of-me",
|
||||
"title": "All Of Me",
|
||||
"composer": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "acoustic",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -29,10 +29,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -1824,10 +1820,6 @@
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(Bbdim7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
@@ -1836,110 +1828,6 @@
|
||||
{
|
||||
"measure": 168,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "(Am7)"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(D7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "CM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "Bm7b5/F"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "E7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Am7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "D7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
@@ -1947,11 +1835,11 @@
|
||||
]
|
||||
},
|
||||
"all_of_me_electric.json": {
|
||||
"title": "all-of-me",
|
||||
"title": "All Of Me",
|
||||
"composer": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "electric",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -1976,10 +1864,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -3771,10 +3655,6 @@
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(Bbdim7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
@@ -3783,110 +3663,6 @@
|
||||
{
|
||||
"measure": 168,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "(Am7)"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(D7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "CM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "Bm7b5/F"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "E7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Am7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "D7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
@@ -3894,11 +3670,11 @@
|
||||
]
|
||||
},
|
||||
"all_of_me_vocal.json": {
|
||||
"title": "all-of-me",
|
||||
"title": "All Of Me",
|
||||
"composer": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "vocal",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -3923,10 +3699,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -6322,10 +6094,6 @@
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(Bbdim7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
@@ -6338,145 +6106,6 @@
|
||||
{
|
||||
"measure": 168,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "(Am7)"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "(D7)"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note G4 ",
|
||||
"2+2 half note G4 "
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "CM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note A4 You",
|
||||
"2+1 quarter note G4 took",
|
||||
"3+1 quarter note F#4 the"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+3 half. note A4 part",
|
||||
"3+1 quarter note G4 that"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "Bm7b5/F"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note F#4 once",
|
||||
"2+1 quarter note B3 was",
|
||||
"3+1 quarter note D4 my"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "E7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+3 half. note F#4 heart,",
|
||||
"3+1 quarter note D4 so"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Am7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note G4 why",
|
||||
"2+1 quarter note E4 not",
|
||||
"3+1 quarter note G4 take"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "D7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note B4 all",
|
||||
"2+2 half note B4 of"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "GM6"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note G4 me?",
|
||||
"2+2 half note G4 "
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
@@ -6488,11 +6117,11 @@
|
||||
]
|
||||
},
|
||||
"autumn_leaves_acoustic.json": {
|
||||
"title": "autumn_leaves",
|
||||
"title": "Autumn Leaves",
|
||||
"composer": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "acoustic",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -6517,10 +6146,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -8457,114 +8082,15 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "G7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "F7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"autumn_leaves_electric.json": {
|
||||
"title": "autumn_leaves",
|
||||
"title": "Autumn Leaves",
|
||||
"composer": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "electric",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -8589,10 +8115,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -10529,114 +10051,15 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "G7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "F7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
"autumn_leaves_vocal.json": {
|
||||
"title": "autumn_leaves",
|
||||
"title": "Autumn Leaves",
|
||||
"composer": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "vocal",
|
||||
"measure_count": 176,
|
||||
"measure_count": 168,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -10661,10 +10084,6 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -13209,138 +12628,6 @@
|
||||
"vocal_skeleton": [
|
||||
"0+4 whole rest "
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"section_label": "Ending",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+1 quarter note Bb4 miss",
|
||||
"1+1 quarter note Bb4 you",
|
||||
"2+1 quarter note G4 most",
|
||||
"3+1 quarter note G4 of"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 170,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+3 half. note E4 all,",
|
||||
"3+1 quarter note Bb4 my"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 171,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "G7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note A4 dar-",
|
||||
"2+2 half note A4 ling,"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 172,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Cm7"
|
||||
},
|
||||
{
|
||||
"onset": "2",
|
||||
"symbol": "F7"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note A4 ",
|
||||
"2+2 half note D4 when"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 173,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Em7b5"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+3 half. note G4 au-",
|
||||
"3+1 quarter note F4 tumn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 174,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "A7b9"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+2 half note E4 leaves",
|
||||
"2+1 quarter note F4 start",
|
||||
"3+1 quarter note A3 to"
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 175,
|
||||
"section_label": "",
|
||||
"harmonies": [
|
||||
{
|
||||
"onset": "0",
|
||||
"symbol": "Dm"
|
||||
}
|
||||
],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+4 whole note D4 fall."
|
||||
]
|
||||
},
|
||||
{
|
||||
"measure": 176,
|
||||
"section_label": "",
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"slash_count": 0,
|
||||
"vocal_skeleton": [
|
||||
"0+4 whole rest "
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,39 +2,49 @@
|
||||
|
||||
Learned decoding and authored-repertoire tests separately use the real path.
|
||||
"""
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from generator import production,vocabulary,harmonic_time
|
||||
from generator.solo import types,placement,profiles,decoder,learned,harmonic
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest.mock
|
||||
import generator.production
|
||||
import generator.vocabulary
|
||||
import generator.harmonic_time
|
||||
import generator.solo.types
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.decoder
|
||||
import generator.solo.learned
|
||||
import generator.solo.harmonic
|
||||
import dataclasses
|
||||
import generator.solo.expressions
|
||||
import generator.harmony
|
||||
|
||||
|
||||
def compose(analysis,measures,part,end,*,initial=None,start=F(),**kwargs):
|
||||
import dataclasses
|
||||
spans=tuple(dataclasses.replace(s,start=s.start+start,end=s.end+start) for s in harmonic_time.read({'measures':measures},initial=initial))
|
||||
h=harmonic.Harmony(spans);notes=[]
|
||||
def compose(analysis, measures, part, end, *, initial=None, start=fractions.Fraction(), **kwargs):
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
h = generator.solo.harmonic.Harmony(spans)
|
||||
notes = []
|
||||
for s in h.spans:
|
||||
a,b=max(start,s.start),min(end,s.end)
|
||||
if a>=b or s.chord=='N.C.':continue
|
||||
number=next(n for n in range(55,72) if n%12 in s.roles.basic)
|
||||
from generator.solo import expressions
|
||||
chord=expressions.parsed_chord(s.chord)
|
||||
from generator import harmony
|
||||
pitch=next(expressions.degree_pitch(chord,d,x,number) for d,x in harmony.degrees(chord).items() if expressions.degree_pitch(chord,d,x,number).number()==number)
|
||||
notes.append(types.Note(a,b,pitch,event_id=str(a)))
|
||||
placed=placement.place(tuple(notes),analysis.tempo)[1] if notes else ()
|
||||
return types.Solo(part,spans,(types.Phrase('fixture',start,end,placed,'test','test','handoff'),),analysis.tempo,end,profile=profiles.for_part(part),start=start)
|
||||
|
||||
a, b = max(start, s.start), min(end, s.end)
|
||||
if a >= b or s.chord == 'N.C.':
|
||||
continue
|
||||
number = next(n for n in range(55, 72) if n % 12 in s.roles.basic)
|
||||
chord = generator.solo.expressions.parsed_chord(s.chord)
|
||||
pitch = next(generator.solo.expressions.degree_pitch(chord, d, x, number) for d, x in generator.harmony.degrees(chord).items() if generator.solo.expressions.degree_pitch(chord, d, x, number).number() == number)
|
||||
notes.append(generator.solo.types.Note(a, b, pitch, event_id=str(a)))
|
||||
placed = generator.solo.placement.place(tuple(notes), analysis.tempo)[1] if notes else ()
|
||||
return generator.solo.types.Solo(part, spans, (generator.solo.types.Phrase('fixture', start, end, placed, 'test', 'test', 'handoff'),), analysis.tempo, end, profile=generator.solo.profiles.for_part(part), start=start)
|
||||
|
||||
def install(test):
|
||||
resources=vocabulary.load();predictor=learned.Model.load(Path(__file__).resolve().parents[1]/'data/models/solo.json')
|
||||
tree=production.generate_tree
|
||||
def serial(*args,**kwargs):
|
||||
kwargs['jobs']=1
|
||||
return tree(*args,**kwargs)
|
||||
test.enterContext(patch('generator.production.generate_tree',side_effect=serial))
|
||||
test.enterContext(patch('generator.vocabulary.load',return_value=resources))
|
||||
test.enterContext(patch('generator.production.repertoire.lookup',return_value=None))
|
||||
test.enterContext(patch('generator.production._solo_model',return_value=predictor))
|
||||
test.enterContext(patch('generator.production._resource_key',return_value={'model':'synthetic infrastructure fixture'}))
|
||||
test.enterContext(patch('generator.solo.composer.compose',side_effect=compose))
|
||||
resources = generator.vocabulary.load()
|
||||
predictor = generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[1] / 'data/models/solo.json')
|
||||
tree = generator.production.generate_tree
|
||||
|
||||
def serial(*args, **kwargs):
|
||||
kwargs['jobs'] = 1
|
||||
return tree(*args, **kwargs)
|
||||
|
||||
test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial))
|
||||
test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources))
|
||||
test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None))
|
||||
test.enterContext(unittest.mock.patch('generator.production._solo_model', return_value=predictor))
|
||||
test.enterContext(unittest.mock.patch('generator.production._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
|
||||
test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Exact notated attacks used to compare arrangements; no audio rendering."""
|
||||
import fractions
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
def timeline(document):
|
||||
events, linked = [], {}
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for voice in measure['voices']:
|
||||
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
|
||||
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
|
||||
change = next(changes, None)
|
||||
for note in voice['notes']:
|
||||
onset = generator.model.fraction(note['onset'])
|
||||
while change is not None and generator.model.fraction(change['onset']) <= onset:
|
||||
state.update({key: change[key] for key in state if key in change})
|
||||
change = next(changes, None)
|
||||
if note.get('cue') or note.get('grace'):
|
||||
continue
|
||||
for pitch in note['pitches']:
|
||||
if pitch.get('cue'):
|
||||
continue
|
||||
length = generator.model.fraction(pitch['duration'])
|
||||
value = generator.pitch.sounding_pitch(pitch, state)
|
||||
if pitch.get('tie_from'):
|
||||
previous = linked[pitch['tie_from']]
|
||||
assert generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) == absolute + onset
|
||||
assert generator.model.fraction(previous['pitch']) == value
|
||||
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
|
||||
linked[pitch['id']] = previous
|
||||
else:
|
||||
event = dict(id=pitch['id'], pitch=generator.model.rational(value), onset=generator.model.rational(absolute + onset), duration=generator.model.rational(length))
|
||||
events.append(event)
|
||||
linked[pitch['id']] = event
|
||||
absolute += generator.model.fraction(measure['duration'])
|
||||
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
|
||||
return dict(duration=generator.model.rational(absolute), events=events)
|
||||
+172
-255
@@ -1,308 +1,225 @@
|
||||
"""Read optional reference MusicXML literally, without repairing it or composing."""
|
||||
from __future__ import annotations
|
||||
import argparse,array,collections,html,os,sys,wave
|
||||
import argparse, collections, html, os
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
from generator.training_io import read
|
||||
from generator import fretboard
|
||||
from generator.solo import placement,types
|
||||
import pathlib
|
||||
import fractions
|
||||
import xml.etree.ElementTree
|
||||
|
||||
import generator.training_io
|
||||
import generator.fretboard
|
||||
import generator.solo.placement
|
||||
import generator.solo.types
|
||||
|
||||
def events(review):
|
||||
offset = F(review['alignment']['encoded_offset'] or 0)
|
||||
return [dict(start=str(F(n['start'])-offset), end=str(F(n['end'])-offset),
|
||||
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
|
||||
return [dict(start=str(fractions.Fraction(n['start']) - offset), end=str(fractions.Fraction(n['end']) - offset),
|
||||
pitch=n['sounding_midi'], spelling=n['pitch'], measure=n['measure']) for n in review['sounding_events']]
|
||||
|
||||
|
||||
def rests(review):
|
||||
offset = F(review['alignment']['encoded_offset'] or 0)
|
||||
offset = fractions.Fraction(review['alignment']['encoded_offset'] or 0)
|
||||
cursor = -offset
|
||||
result = []
|
||||
for n in sorted(events(review), key=lambda n:F(n['start'])):
|
||||
start,end = F(n['start']),F(n['end'])
|
||||
if start > cursor: result.append((str(cursor),str(start)))
|
||||
cursor = max(cursor,end)
|
||||
finish = max((F(m['start'])+F(m['duration']) for m in review['measures']),default=F())-offset
|
||||
if cursor < finish: result.append((str(cursor),str(finish)))
|
||||
for n in sorted(events(review), key=lambda n: fractions.Fraction(n['start'])):
|
||||
start, end = fractions.Fraction(n['start']), fractions.Fraction(n['end'])
|
||||
if start > cursor:
|
||||
result.append((str(cursor), str(start)))
|
||||
cursor = max(cursor, end)
|
||||
finish = max((fractions.Fraction(m['start']) + fractions.Fraction(m['duration']) for m in review['measures']), default=fractions.Fraction()) - offset
|
||||
if cursor < finish:
|
||||
result.append((str(cursor), str(finish)))
|
||||
return result
|
||||
|
||||
|
||||
def playability(review, tempo=F(120)):
|
||||
notes=[];conflicts=[]
|
||||
for i,n in enumerate(review['sounding_events']):
|
||||
p=n['pitch'];shift=F(n['sounding_midi'])-F(n['encoded_midi'])
|
||||
if shift%12:
|
||||
raise ValueError('Guitar audition expects octave-only transposition')
|
||||
pitch=types.Pitch(p['step'].lower(),F(p['alter']),p['octave']+int(shift/12))
|
||||
note=types.Note(F(n['start']),F(n['end']),pitch,event_id=str(i))
|
||||
def playability(review, tempo=fractions.Fraction(120)):
|
||||
notes = []
|
||||
conflicts = []
|
||||
for i, n in enumerate(review['sounding_events']):
|
||||
p = n['pitch']
|
||||
shift = fractions.Fraction(n['sounding_midi']) - fractions.Fraction(n['encoded_midi'])
|
||||
if shift % 12:
|
||||
raise ValueError('Guitar comparison expects octave-only transposition')
|
||||
pitch = generator.solo.types.Pitch(p['step'].lower(), fractions.Fraction(p['alter']), p['octave'] + int(shift / 12))
|
||||
note = generator.solo.types.Note(fractions.Fraction(n['start']), fractions.Fraction(n['end']), pitch, event_id=str(i))
|
||||
notes.append(note)
|
||||
if not fretboard.single_positions_for_pitch(F(pitch.number())):
|
||||
conflicts.append({'measure':n['measure'],'written':p,'sounding_midi':pitch.number(),
|
||||
'constraint':'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
|
||||
overlaps=[{'first':a.event_id,'second':b.event_id} for a,b in zip(notes,notes[1:]) if a.end>b.start]
|
||||
placed=placement.place(tuple(notes),tempo) if notes and not conflicts and not overlaps else None
|
||||
return {'conflicts':conflicts,'overlaps':overlaps,'complete_path_found':placed is not None,
|
||||
'range':[min(n.pitch.number() for n in notes),max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'positions':[list(n.position[:2]) for n in placed[1]] if placed else [],
|
||||
'limitation':'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
|
||||
|
||||
if not generator.fretboard.single_positions_for_pitch(fractions.Fraction(pitch.number())):
|
||||
conflicts.append({'measure': n['measure'], 'written': p, 'sounding_midi': pitch.number(),
|
||||
'constraint': 'No standard-tuning position on strings 1–6, frets 1–12, without open strings.'})
|
||||
overlaps = [{'first': a.event_id, 'second': b.event_id} for a, b in zip(notes, notes[1:]) if a.end > b.start]
|
||||
placed = generator.solo.placement.place(tuple(notes), tempo) if notes and not conflicts and not overlaps else None
|
||||
return {'conflicts': conflicts, 'overlaps': overlaps, 'complete_path_found': placed is not None,
|
||||
'range': [min(n.pitch.number() for n in notes), max(n.pitch.number() for n in notes)] if notes else [],
|
||||
'positions': [list(n.position[:2]) for n in placed[1]] if placed else [],
|
||||
'limitation': 'Legal placement only. Shift cost is a preference, not a verified human speed limit.'}
|
||||
|
||||
def musical_summary(review):
|
||||
ns=events(review)
|
||||
durations=collections.Counter(str(F(n['end'])-F(n['start'])) for n in ns)
|
||||
intervals=collections.Counter(str(F(b['pitch'])-F(a['pitch'])) for a,b in zip(ns,ns[1:]))
|
||||
holds=[]
|
||||
for event,n in zip(review['sounding_events'],ns):
|
||||
if F(n['end'])-F(n['start'])<2:continue
|
||||
spans=[s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
|
||||
holds.append({**n,'harmonic_spans':spans})
|
||||
return {'attacks':len(ns),'duration_counts':dict(durations),'interval_counts':dict(intervals),
|
||||
'rests':rests(review),'sustained_events':holds,
|
||||
'annotations':review['annotations'],'interpretation':'Post-generation diagnostics; does not train or reselect music.'}
|
||||
ns = events(review)
|
||||
durations = collections.Counter(str(fractions.Fraction(n['end']) - fractions.Fraction(n['start'])) for n in ns)
|
||||
intervals = collections.Counter(str(fractions.Fraction(b['pitch']) - fractions.Fraction(a['pitch'])) for a, b in zip(ns, ns[1:]))
|
||||
holds = []
|
||||
for event, n in zip(review['sounding_events'], ns):
|
||||
if fractions.Fraction(n['end']) - fractions.Fraction(n['start']) < 2:
|
||||
continue
|
||||
spans = [s for i in event['notation_events'] for s in review['notes'][i]['harmonic_spans']]
|
||||
holds.append({**n, 'harmonic_spans': spans})
|
||||
return {'attacks': len(ns), 'duration_counts': dict(durations), 'interval_counts': dict(intervals),
|
||||
'rests': rests(review), 'sustained_events': holds,
|
||||
'annotations': review['annotations'], 'interpretation': 'Post-generation diagnostics; does not train or reselect music.'}
|
||||
|
||||
def compare(generated, reference):
|
||||
a, b = events(generated), events(reference)
|
||||
|
||||
def compare(generated,reference):
|
||||
a,b=events(generated),events(reference)
|
||||
def grouped(ns):
|
||||
result=collections.defaultdict(list)
|
||||
for n in ns:result[n['start']].append(n)
|
||||
result = collections.defaultdict(list)
|
||||
for n in ns:
|
||||
result[n['start']].append(n)
|
||||
return result
|
||||
left,right=grouped(a),grouped(b)
|
||||
differences=[];spelling=[];equal=0
|
||||
for start in sorted(left.keys()|right.keys(),key=F):
|
||||
x,y=left[start],right[start]
|
||||
sounded=lambda ns:sorted((n['pitch'],n['end']) for n in ns)
|
||||
if sounded(x)!=sounded(y):
|
||||
differences.append({'time':start,'generated':x,'reference':y,
|
||||
'context':'reference lead-in before form' if F(start)<0 else 'aligned form'})
|
||||
|
||||
left, right = grouped(a), grouped(b)
|
||||
differences = []
|
||||
spelling = []
|
||||
equal = 0
|
||||
for start in sorted(left.keys() | right.keys(), key=fractions.Fraction):
|
||||
x, y = left[start], right[start]
|
||||
|
||||
def sounded(ns):
|
||||
return sorted((n['pitch'], n['end']) for n in ns)
|
||||
|
||||
if sounded(x) != sounded(y):
|
||||
differences.append({'time': start, 'generated': x, 'reference': y,
|
||||
'context': 'reference lead-in before form' if fractions.Fraction(start) < 0 else 'aligned form'})
|
||||
else:
|
||||
equal+=len(x)
|
||||
if [n['spelling'] for n in x]!=[n['spelling'] for n in y]:
|
||||
spelling.append({'time':start,'generated':x,'reference':y})
|
||||
rest_difference=rests(generated)!=rests(reference)
|
||||
return {'exact_musical_match':not differences and not rest_difference,'exact_matching_attacks':equal,
|
||||
'relationships':relationships(a,b),
|
||||
'differences':differences,'spelling_differences':spelling,'rest_difference':rest_difference,
|
||||
'generated':musical_summary(generated),'reference':musical_summary(reference),
|
||||
'reference_playability':playability(reference),'reference_original':reference}
|
||||
equal += len(x)
|
||||
if [n['spelling'] for n in x] != [n['spelling'] for n in y]:
|
||||
spelling.append({'time': start, 'generated': x, 'reference': y})
|
||||
rest_difference = rests(generated) != rests(reference)
|
||||
return {'exact_musical_match': not differences and not rest_difference, 'exact_matching_attacks': equal,
|
||||
'relationships': relationships(a, b),
|
||||
'differences': differences, 'spelling_differences': spelling, 'rest_difference': rest_difference,
|
||||
'generated': musical_summary(generated), 'reference': musical_summary(reference),
|
||||
'reference_playability': playability(reference), 'reference_original': reference}
|
||||
|
||||
|
||||
def relationships(a,b):
|
||||
def relationships(a, b):
|
||||
"""Read-only comparisons on the aligned clock; no fitted aggregate loss."""
|
||||
def distance(left,right):
|
||||
row=list(range(len(right)+1))
|
||||
for i,x in enumerate(left,1):
|
||||
next_row=[i]
|
||||
for j,y in enumerate(right,1):
|
||||
next_row.append(min(next_row[-1]+1,row[j]+1,row[j-1]+(x!=y)))
|
||||
row=next_row
|
||||
|
||||
def distance(left, right):
|
||||
row = list(range(len(right) + 1))
|
||||
for i, x in enumerate(left, 1):
|
||||
next_row = [i]
|
||||
for j, y in enumerate(right, 1):
|
||||
next_row.append(min(next_row[-1] + 1, row[j] + 1, row[j - 1] + (x != y)))
|
||||
row = next_row
|
||||
return row[-1]
|
||||
ns=[[dict(start=F(n['start']),end=F(n['end']),pitch=F(n['pitch'])) for n in notes] for notes in (a,b)]
|
||||
points=sorted({n[k] for notes in ns for n in notes for k in ('start','end')})
|
||||
occupied=equal=classes=F()
|
||||
for start,end in zip(points,points[1:]):
|
||||
pitches=[{n['pitch'] for n in notes if n['start']<=start<n['end']} for notes in ns]
|
||||
|
||||
ns = [[dict(start=fractions.Fraction(n['start']), end=fractions.Fraction(n['end']), pitch=fractions.Fraction(n['pitch'])) for n in notes] for notes in (a, b)]
|
||||
points = sorted({n[k] for notes in ns for n in notes for k in ('start', 'end')})
|
||||
occupied = equal = classes = fractions.Fraction()
|
||||
for start, end in zip(points, points[1:]):
|
||||
pitches = [{n['pitch'] for n in notes if n['start'] <= start < n['end']} for notes in ns]
|
||||
if any(pitches):
|
||||
occupied+=end-start
|
||||
if pitches[0]==pitches[1]:equal+=end-start
|
||||
if {p%12 for p in pitches[0]}=={p%12 for p in pitches[1]}:classes+=end-start
|
||||
pitches=[[n['pitch'] for n in notes] for notes in ns]
|
||||
rhythms=[[(n['start'],n['end']-n['start']) for n in notes] for notes in ns]
|
||||
return {'pitch_sequence_edit_distance':distance(*pitches),
|
||||
'timed_attack_edit_distance':distance(*rhythms),
|
||||
'normalization_attacks':max(len(a),len(b)),
|
||||
'same_sounding_pitch_beats':str(equal),'same_pitch_class_beats':str(classes),
|
||||
'either_sounding_beats':str(occupied),
|
||||
'interpretation':'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
|
||||
occupied += end - start
|
||||
if pitches[0] == pitches[1]:
|
||||
equal += end - start
|
||||
if {p % 12 for p in pitches[0]} == {p % 12 for p in pitches[1]}:
|
||||
classes += end - start
|
||||
pitches = [[n['pitch'] for n in notes] for notes in ns]
|
||||
rhythms = [[(n['start'], n['end'] - n['start']) for n in notes] for notes in ns]
|
||||
return {'pitch_sequence_edit_distance': distance(*pitches),
|
||||
'timed_attack_edit_distance': distance(*rhythms),
|
||||
'normalization_attacks': max(len(a), len(b)),
|
||||
'same_sounding_pitch_beats': str(equal), 'same_pitch_class_beats': str(classes),
|
||||
'either_sounding_beats': str(occupied),
|
||||
'interpretation': 'Separate descriptive comparisons, not a quality score. Pitch distance ignores timing; timing distance ignores pitch. Held overlap uses the original aligned register and includes lead-ins.'}
|
||||
|
||||
|
||||
def render_events(notes, duration, tempo, root, cache):
|
||||
"""Use the production neutral note envelopes; no expression edits."""
|
||||
from generator import playback
|
||||
factor=F(60*playback.SAMPLE_RATE)/tempo
|
||||
output=array.array('f',[0])* (round(duration*factor)+2400)
|
||||
for start,end,pitch in notes:
|
||||
if pitch not in cache:
|
||||
sample=playback.generate_tone(root,pitch)
|
||||
with wave.open(str(root/sample['path'])) as audio:
|
||||
values=array.array('h',audio.readframes(audio.getnframes()))
|
||||
if sys.byteorder!='little':values.byteswap()
|
||||
cache[pitch]=(sample,values)
|
||||
sample,values=cache[pitch]
|
||||
first,last=round(start*factor),round(end*factor)
|
||||
for frame in range(max(0,first),min(len(output),last+2400)):
|
||||
age=frame-first
|
||||
index=age if age<sample['loop_end'] else sample['loop_start']+(age-sample['loop_start'])%(sample['loop_end']-sample['loop_start'])
|
||||
release=1 if frame<last else (1-(frame-last)/2399)**2
|
||||
output[frame]+=release*values[index]/32767
|
||||
return output
|
||||
|
||||
|
||||
def audition_bounds(*reviews):
|
||||
points=[(F(m['start'])-F(r['alignment']['encoded_offset'] or 0),F(m['duration']))
|
||||
for r in reviews for m in r['measures']]
|
||||
return min(a for a,b in points),max(a+b for a,b in points)
|
||||
|
||||
|
||||
def audition_pair(actual,expected,role,assets,output,cache,baseline=None):
|
||||
from generator import model, playback, score
|
||||
from generator.solo.export import excerpt,region
|
||||
song,part=role.split('/')
|
||||
other='electric' if part=='acoustic' else 'acoustic'
|
||||
completed=score.load_json(Path(assets)/'songs'/f'{song}_{part}.json')
|
||||
first,count=region(completed,part)
|
||||
leadin=actual['alignment']['form_start_measure']-1
|
||||
backing=excerpt(score.load_json(Path(assets)/'songs'/f'{song}_{other}.json'),first-leadin,count+leadin)
|
||||
backing_offset=F(actual['alignment']['encoded_offset'])
|
||||
tempo=next((model.fraction(t['bpm'])*model.written_length(t['written']) for m in completed['staves'][0]['measures'] for t in m.get('tempos',[]) if 'bpm' in t),F(120))
|
||||
# Include the entire notated lead-in, even when it is a full bar of rest.
|
||||
# Using the first attack here would silently erase a silent initial measure.
|
||||
origin,end=audition_bounds(actual,expected)
|
||||
duration=end-origin
|
||||
timeline=playback.compile_timeline(backing)
|
||||
back=render_events([(model.fraction(e['onset'])-backing_offset-origin,model.fraction(e['onset'])+model.fraction(e['duration'])-backing_offset-origin,model.fraction(e['pitch'])) for e in timeline['events']],duration,tempo,output,cache)
|
||||
(output/'audio').mkdir(parents=True,exist_ok=True)
|
||||
clips=[]
|
||||
for label,r in (('generated',actual),('reference',expected),*((('baseline',baseline),) if baseline else ())):
|
||||
audio=render_events([(F(n['start'])-origin,F(n['end'])-origin,F(n['pitch'])) for n in events(r)],duration,tempo,output,cache)
|
||||
combined=array.array('f',(a+.55*b for a,b in zip(audio,back)))
|
||||
for context,values in (('solo',audio),('fixed_backing',combined)):
|
||||
peak=.65*max(map(abs,values),default=0)
|
||||
if peak>=1:raise ValueError('Comparison would clip at the shared rendering gain')
|
||||
name='audio/'+role.replace('/','_')+'_'+label+'_'+context+'.wav'
|
||||
playback.write_pcm(output/name,(.65*v for v in values))
|
||||
clips.append({'file':name,'version':label,'context':context,'peak':peak})
|
||||
if context=='fixed_backing':
|
||||
# An excerpt uses the completed source meter, never reference-derived
|
||||
# rhythm statistics. Full references and their lead-ins remain above.
|
||||
beats=sum((model.fraction(m['duration']) for m in backing['staves'][0]['measures'][leadin:leadin+8]),F())
|
||||
stop=round((min(end,beats)-origin)*60*playback.SAMPLE_RATE/tempo)
|
||||
short=name.replace('.wav','_opening.wav')
|
||||
playback.write_pcm(output/short,(.65*v for v in values[:stop]))
|
||||
clips.append({'file':short,'version':label,'context':'opening with lead-in context','peak':peak})
|
||||
return {'clips':clips,'tempo':str(tempo),'gain':.65,'backing_gain':.55,'form_begins_at_beat':str(-origin),
|
||||
'backing':'Exact independently generated other-guitar part, including the production lead-in. Fixed unchanged for all three comparison versions.',
|
||||
'alignment':'Shared audition clock includes reference-only lead-in; no harmony or production notes were added.',
|
||||
'listening_status':'Rendered and signal-checked, not subjectively auditioned.'}
|
||||
|
||||
|
||||
def candidate_clips(candidates,role,tempo,output,cache):
|
||||
"""Render the already completed source-only alternatives, never reselect."""
|
||||
from generator import playback
|
||||
clips=[]
|
||||
for index,candidate in enumerate(candidates,1):
|
||||
origin,end=F(candidate['start']),F(candidate['end'])
|
||||
audio=render_events([(F(a)-origin,F(b)-origin,F(p)) for a,b,p in candidate['events']],end-origin,tempo,output,cache)
|
||||
peak=.65*max(map(abs,audio),default=0)
|
||||
if peak>=1:raise ValueError('Candidate excerpt would clip at comparison gain')
|
||||
name='audio/'+role.replace('/','_')+f'_candidate_{index}.wav'
|
||||
playback.write_pcm(output/name,(.65*v for v in audio))
|
||||
selected=candidate['selected_in_final_solo']
|
||||
clips.append({'file':name,'version':f'Completed opening {index}'+(' — selected in final solo' if selected else ' — alternative'),
|
||||
'context':'source-only candidate, solo','peak':peak})
|
||||
return clips
|
||||
|
||||
|
||||
def run(generated_dir,reference_paths,starts,pitch_convention,output,assets=None,baseline_dir=None):
|
||||
generated_dir,output=Path(generated_dir),Path(output)
|
||||
manifest=json.loads((generated_dir/'manifest.solo').read_text())
|
||||
output.mkdir(parents=True,exist_ok=True)
|
||||
tone_cache={}
|
||||
baseline_manifest=json.loads((Path(baseline_dir)/'manifest.solo').read_text()) if baseline_dir else None
|
||||
result={'comparison_only':True,'feeds_generation':False,'solos':{}}
|
||||
for role,path in sorted(reference_paths.items()):
|
||||
item=manifest['solos'][role]
|
||||
actual=read(generated_dir/item['file'],role,item['form_start_measure'])
|
||||
expected=read(path,role,starts.get(role),pitch_convention)
|
||||
review=compare(actual,expected)
|
||||
baseline=None
|
||||
def run(generated_dir, reference_paths, starts, pitch_convention, output, baseline_dir=None):
|
||||
generated_dir, output = pathlib.Path(generated_dir), pathlib.Path(output)
|
||||
manifest = json.loads((generated_dir / 'manifest.solo').read_text())
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
baseline_manifest = json.loads((pathlib.Path(baseline_dir) / 'manifest.solo').read_text()) if baseline_dir else None
|
||||
result = {'comparison_only': True, 'feeds_generation': False, 'solos': {}}
|
||||
for role, path in sorted(reference_paths.items()):
|
||||
item = manifest['solos'][role]
|
||||
actual = generator.training_io.read(generated_dir / item['file'], role, item['form_start_measure'])
|
||||
expected = generator.training_io.read(path, role, starts.get(role), pitch_convention)
|
||||
review = compare(actual, expected)
|
||||
baseline = None
|
||||
if baseline_manifest:
|
||||
old=baseline_manifest['solos'][role]
|
||||
baseline=read(Path(baseline_dir)/old['file'],role,old['form_start_measure'])
|
||||
review['baseline']=musical_summary(baseline)
|
||||
review['baseline_relationships']=relationships(events(baseline),events(expected))
|
||||
if assets:review['audition']=audition_pair(actual,expected,role,assets,output,tone_cache,baseline)
|
||||
review['production_trace']=item['trace']
|
||||
review['production_phrases']=item.get('phrase_report',{})
|
||||
candidates=review['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
|
||||
if assets and candidates:
|
||||
review['audition']['clips']+=candidate_clips(candidates,role,F(review['audition']['tempo']),output,tone_cache)
|
||||
old = baseline_manifest['solos'][role]
|
||||
baseline = generator.training_io.read(pathlib.Path(baseline_dir) / old['file'], role, old['form_start_measure'])
|
||||
review['baseline'] = musical_summary(baseline)
|
||||
review['baseline_relationships'] = relationships(events(baseline), events(expected))
|
||||
review['production_trace'] = item['trace']
|
||||
review['production_phrases'] = item.get('phrase_report', {})
|
||||
candidates = review['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
|
||||
# Preserve the real role cutoff; reference material beyond it is not trimmed.
|
||||
phrases=review['production_phrases'].get('phrases',[])
|
||||
phrases = review['production_phrases'].get('phrases', [])
|
||||
if phrases:
|
||||
cutoff=max(F(p['end']) for p in phrases)
|
||||
review['production_end']=str(cutoff)
|
||||
review['reference_events_after_production_end']=[n for n in events(expected) if F(n['end'])>cutoff]
|
||||
result['solos'][role]=review
|
||||
output.mkdir(parents=True,exist_ok=True)
|
||||
(output/'comparison.json').write_text(json.dumps(result,indent=2)+'\n')
|
||||
rows=['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||||
cutoff = max(fractions.Fraction(p['end']) for p in phrases)
|
||||
review['production_end'] = str(cutoff)
|
||||
review['reference_events_after_production_end'] = [n for n in events(expected) if fractions.Fraction(n['end']) > cutoff]
|
||||
result['solos'][role] = review
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / 'comparison.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
rows = ['<!doctype html><meta charset="utf-8"><title>Solo comparison</title>',
|
||||
'<style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}td,th{padding:.5rem;text-align:left}pre{white-space:pre-wrap}</style>',
|
||||
'<h1>Completed solos and reference scores</h1><p>Differences are diagnostic. This comparison is read-only; registered authored scores are explicit repertoire inputs, and pooled examples supply training evidence. No listening verdict is claimed.</p>',
|
||||
'<p><a href="comparison.json">Exact timing, notes, harmony, annotations, placement, and phrase reports</a></p>']
|
||||
for role,r in result['solos'].items():
|
||||
actual_path=generated_dir/manifest['solos'][role]['file']
|
||||
reference_path=Path(reference_paths[role])
|
||||
rows += ['<h2>'+html.escape(role)+'</h2>',f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||||
'<p><a href="'+html.escape(os.path.relpath(actual_path,output))+'">Generated MusicXML</a> · <a href="'+html.escape(os.path.relpath(reference_path,output))+'">Untouched reference MusicXML</a></p>']
|
||||
for role, r in result['solos'].items():
|
||||
actual_path = generated_dir / manifest['solos'][role]['file']
|
||||
reference_path = pathlib.Path(reference_paths[role])
|
||||
rows += ['<h2>' + html.escape(role) + '</h2>', f'<p>Exact event match: <strong>{"yes" if r["exact_musical_match"] else "no"}</strong>. {r["exact_matching_attacks"]} exactly matching attacks; {len(r["differences"])} differing onset positions. This is not a musical-quality score.</p>',
|
||||
'<p><a href="' + html.escape(os.path.relpath(actual_path, output)) + '">Generated MusicXML</a> · <a href="' + html.escape(os.path.relpath(reference_path, output)) + '">Untouched reference MusicXML</a></p>']
|
||||
if baseline_manifest:
|
||||
baseline_path=Path(baseline_dir)/baseline_manifest['solos'][role]['file']
|
||||
rows.append('<p><a href="'+html.escape(os.path.relpath(baseline_path,output))+'">Previous generated MusicXML</a></p>')
|
||||
baseline_path = pathlib.Path(baseline_dir) / baseline_manifest['solos'][role]['file']
|
||||
rows.append('<p><a href="' + html.escape(os.path.relpath(baseline_path, output)) + '">Previous generated MusicXML</a></p>')
|
||||
rows.append('<table><tr><th>Diagnostic against reference</th><th>Current</th><th>Baseline</th></tr>')
|
||||
for key in ('pitch_sequence_edit_distance','timed_attack_edit_distance','same_sounding_pitch_beats','same_pitch_class_beats','either_sounding_beats'):
|
||||
rows.append('<tr><td>'+key.replace('_',' ')+'</td><td>'+str(r['relationships'][key])+'</td><td>'+str(r.get('baseline_relationships',{}).get(key,'—'))+'</td></tr>')
|
||||
for key in ('pitch_sequence_edit_distance', 'timed_attack_edit_distance', 'same_sounding_pitch_beats', 'same_pitch_class_beats', 'either_sounding_beats'):
|
||||
rows.append('<tr><td>' + key.replace('_', ' ') + '</td><td>' + str(r['relationships'][key]) + '</td><td>' + str(r.get('baseline_relationships', {}).get(key, '—')) + '</td></tr>')
|
||||
rows.append('</table><p>Lower edit distance means fewer insertions, deletions or substitutions on that axis. Sounding agreement is measured in quarter-note beats, without octave shifting or fitted alignment. These do not measure musical quality.</p>')
|
||||
selected=collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions',[]))
|
||||
rows.append('<p>Validated production treatments: '+html.escape(str(dict(selected)))+'. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</p>')
|
||||
selected = collections.Counter(c['kind'] for c in r['production_phrases'].get('expressions', []))
|
||||
rows.append('<p>Validated production treatments: ' + html.escape(str(dict(selected))) + '. Registered authored music is preserved; harmonic relationships are recognized from notes and chords without requiring explanatory annotations.</p>')
|
||||
if r.get('reference_events_after_production_end'):
|
||||
rows.append('<p>Reference material extends beyond this exported production allocation: '+str(len(r['reference_events_after_production_end']))+' events. The reference and source timing remain unchanged.</p>')
|
||||
funnel=r['production_phrases'].get('selection_trace',{}).get('operation_coverage',{})
|
||||
rows.append('<p>Reference material extends beyond this exported production allocation: ' + str(len(r['reference_events_after_production_end'])) + ' events. The reference and source timing remain unchanged.</p>')
|
||||
funnel = r['production_phrases'].get('selection_trace', {}).get('operation_coverage', {})
|
||||
if funnel:
|
||||
stages=tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
|
||||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>'+''.join('<th>'+k.replace('_',' ')+'</th>' for k in stages)+'</tr>')
|
||||
for purpose,counts in sorted(funnel.items()):
|
||||
rows.append('<tr><td>'+html.escape(purpose)+'</td>'+''.join('<td>'+str(counts.get(k,0))+'</td>' for k in stages)+'</tr>')
|
||||
stages = tuple(dict.fromkeys(k for counts in funnel.values() for k in counts))
|
||||
rows.append('<details><summary>Construction coverage (not similarity scoring)</summary><table><tr><th>Purpose</th>' + ''.join('<th>' + k.replace('_', ' ') + '</th>' for k in stages) + '</tr>')
|
||||
for purpose, counts in sorted(funnel.items()):
|
||||
rows.append('<tr><td>' + html.escape(purpose) + '</td>' + ''.join('<td>' + str(counts.get(k, 0)) + '</td>' for k in stages) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
candidates=r['production_phrases'].get('selection_trace',{}).get('opening_candidates',[])
|
||||
candidates = r['production_phrases'].get('selection_trace', {}).get('opening_candidates', [])
|
||||
if candidates:
|
||||
rows.append('<details open><summary>Completed source-only opening candidates</summary><p>All candidates were composed without the references. Selection accounts for later phrases; the final choice need not have the cheapest opening alone.</p><table><tr><th>Candidate</th><th>Final choice</th><th>Purpose</th><th>Score contributions</th></tr>')
|
||||
for index,c in enumerate(candidates,1):
|
||||
rows.append('<tr>'+''.join('<td>'+html.escape(str(v))+'</td>' for v in (index,c['selected_in_final_solo'],c['purpose'],c['evaluation']['contributions']))+'</tr>')
|
||||
for index, c in enumerate(candidates, 1):
|
||||
rows.append('<tr>' + ''.join('<td>' + html.escape(str(v)) + '</td>' for v in (index, c['selected_in_final_solo'], c['purpose'], c['evaluation']['contributions'])) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
for clip in r.get('audition',{}).get('clips',[]):
|
||||
rows.append('<p>'+html.escape(clip['version']+' / '+clip['context'])+'<br><audio controls preload="none" src="'+html.escape(clip['file'])+'"></audio></p>')
|
||||
rows.append('<details><summary>Exact note differences</summary><table><tr><th>Beat relative to form</th><th>Generated</th><th>Reference</th></tr>')
|
||||
for d in r['differences']:
|
||||
def describe(ns):return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
|
||||
rows.append('<tr>'+''.join('<td>'+html.escape(v)+'</td>' for v in (d['time'],describe(d['generated']),describe(d['reference'])))+'</tr>')
|
||||
|
||||
def describe(ns): return '; '.join(f'MIDI {n["pitch"]}, until {n["end"]}' for n in ns) or 'No attack'
|
||||
|
||||
rows.append('<tr>' + ''.join('<td>' + html.escape(v) + '</td>' for v in (d['time'], describe(d['generated']), describe(d['reference']))) + '</tr>')
|
||||
rows.append('</table></details>')
|
||||
(output/'index.html').write_text('\n'.join(rows))
|
||||
(output / 'index.html').write_text('\n'.join(rows))
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--generated-dir',type=Path,required=True)
|
||||
parser.add_argument('--reference',action='append',default=[],metavar='SONG/ROLE=PATH')
|
||||
parser.add_argument('--form-start',action='append',default=[],metavar='SONG/ROLE=MEASURE')
|
||||
parser.add_argument('--reference-pitch',choices=('encoded','guitar-written'),default='encoded')
|
||||
parser.add_argument('--output',type=Path,required=True)
|
||||
parser.add_argument('--baseline-dir',type=Path,help='Optional previous completed solo exports; never generation inputs.')
|
||||
parser.add_argument('--assets',type=Path,help='Optional completed assets for neutral reference/generated audio with identical backing.')
|
||||
parser.add_argument('--require-exact',action='store_true',help='Fail unless every requested reference has identical complete sounding events and rests.')
|
||||
args=parser.parse_args()
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--generated-dir', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--reference', action='append', default=[], metavar='SONG/ROLE=PATH')
|
||||
parser.add_argument('--form-start', action='append', default=[], metavar='SONG/ROLE=MEASURE')
|
||||
parser.add_argument('--reference-pitch', choices=('encoded', 'guitar-written'), default='encoded')
|
||||
parser.add_argument('--output', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--baseline-dir', type=pathlib.Path, help='Optional previous completed solo exports; never generation inputs.')
|
||||
parser.add_argument('--require-exact', action='store_true', help='Fail unless every requested reference has identical complete sounding events and rests.')
|
||||
args = parser.parse_args()
|
||||
if args.require_exact and not args.reference:
|
||||
parser.error('--require-exact requires at least one explicit --reference')
|
||||
result=run(args.generated_dir,dict(x.split('=',1) for x in args.reference),
|
||||
{k:int(v) for k,v in (x.split('=',1) for x in args.form_start)},args.reference_pitch,args.output,args.assets,args.baseline_dir)
|
||||
for role,review in result['solos'].items():
|
||||
result = run(args.generated_dir, dict(x.split('=', 1) for x in args.reference),
|
||||
{k: int(v) for k, v in (x.split('=', 1) for x in args.form_start)}, args.reference_pitch, args.output, args.baseline_dir)
|
||||
for role, review in result['solos'].items():
|
||||
print(f'{role}: exact={review["exact_musical_match"]}; matching attacks={review["exact_matching_attacks"]}; differing onsets={len(review["differences"])}; rests differ={review["rest_difference"]}')
|
||||
return 1 if args.require_exact and any(not r['exact_musical_match'] for r in result['solos'].values()) else 0
|
||||
|
||||
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -2,6 +2,7 @@ import copy, fractions
|
||||
import generator.model, generator.parts, generator.harmony
|
||||
import generator.solo.analysis, generator.solo.composer, generator.solo.types
|
||||
import tests.test_musicxml
|
||||
import generator.harmonic_time
|
||||
|
||||
def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(''.join(tests.test_musicxml.xml_note(step, 1, 'quarter') for step in ('C', 'D', 'E', 'G'))))
|
||||
@@ -24,9 +25,8 @@ def compose(document, part='acoustic', cutoff=None):
|
||||
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
|
||||
return generator.solo.composer.compose(analysis, measures, part, end)
|
||||
|
||||
def note(start,end,letter='g',alter=0,octave=4,**kwargs):
|
||||
return generator.solo.types.Note(fractions.Fraction(start),fractions.Fraction(end),generator.solo.types.Pitch(letter,fractions.Fraction(alter),octave),**kwargs)
|
||||
def note(start, end, letter='g', alter=0, octave=4, **kwargs):
|
||||
return generator.solo.types.Note(fractions.Fraction(start), fractions.Fraction(end), generator.solo.types.Pitch(letter, fractions.Fraction(alter), octave), **kwargs)
|
||||
|
||||
def choices(symbols,duration=4):
|
||||
from generator import harmonic_time
|
||||
return tuple(harmonic_time.ChordSpan(fractions.Fraction(i*duration),fractions.Fraction((i+1)*duration),i+1,str(i+1),fractions.Fraction(1),generator.model.key_from_fifths(-1),generator.harmony.parse_symbol(symbol)) for i,symbol in enumerate(symbols))
|
||||
def choices(symbols, duration=4):
|
||||
return tuple(generator.harmonic_time.ChordSpan(fractions.Fraction(i * duration), fractions.Fraction((i + 1) * duration), i + 1, str(i + 1), fractions.Fraction(1), generator.model.key_from_fifths(-1), generator.harmony.parse_symbol(symbol)) for i, symbol in enumerate(symbols))
|
||||
|
||||
+19
-30
@@ -1,4 +1,5 @@
|
||||
#include <test.hpp>
|
||||
#include "catalog_fixture.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
@@ -59,6 +60,7 @@ auto score_asset_json(std::string title, std::string key = "C", std::string part
|
||||
metadata["title"] = std::move(title);
|
||||
metadata["key_signature"] = std::move(key);
|
||||
value.at("music").at("staves").as_array().front().as_object()["name"] = std::move(part);
|
||||
vamp_test::add_timing(value);
|
||||
return boost::json::serialize(value);
|
||||
}
|
||||
|
||||
@@ -108,35 +110,24 @@ 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.playback_enabled);
|
||||
BOOST_TEST(!defaults->audio.backing_enabled);
|
||||
BOOST_TEST(!defaults->audio.metronome_enabled);
|
||||
BOOST_TEST(defaults->audio.metronome_bpm == 120U);
|
||||
BOOST_TEST(defaults->audio.metronome_min_bpm == 40U);
|
||||
BOOST_TEST(defaults->audio.metronome_max_bpm == 240U);
|
||||
BOOST_TEST(defaults->audio.metronome_step == 5U);
|
||||
BOOST_TEST(defaults->audio.tempo_bpm == 120U);
|
||||
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
||||
"audio": {
|
||||
"enabled": false,
|
||||
"playback_enabled": true,
|
||||
"backing_enabled": true,
|
||||
"metronome_enabled": true,
|
||||
"metronome_bpm": 90,
|
||||
"metronome_min_bpm": 50,
|
||||
"metronome_max_bpm": 180,
|
||||
"metronome_step": 10,
|
||||
"beats_per_bar": 3,
|
||||
"tempo_bpm": 90,
|
||||
"master_volume": 0.5,
|
||||
"metronome_volume": 0.25
|
||||
}
|
||||
})json"));
|
||||
BOOST_REQUIRE(parsed);
|
||||
BOOST_TEST(!parsed->audio.enabled);
|
||||
BOOST_TEST(parsed->audio.playback_enabled);
|
||||
BOOST_TEST(parsed->audio.backing_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_enabled);
|
||||
BOOST_TEST(parsed->audio.metronome_bpm == 90U);
|
||||
BOOST_TEST(parsed->audio.metronome_min_bpm == 50U);
|
||||
BOOST_TEST(parsed->audio.metronome_max_bpm == 180U);
|
||||
BOOST_TEST(parsed->audio.metronome_step == 10U);
|
||||
BOOST_TEST(parsed->audio.beats_per_bar == 3U);
|
||||
BOOST_TEST(parsed->audio.tempo_bpm == 90U);
|
||||
BOOST_TEST(parsed->audio.master_volume == 0.5F);
|
||||
BOOST_TEST(parsed->audio.metronome_volume == 0.25F);
|
||||
}
|
||||
@@ -144,9 +135,7 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) {
|
||||
BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) {
|
||||
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({
|
||||
"audio": {
|
||||
"metronome_bpm": 120,
|
||||
"metronome_min_bpm": 160,
|
||||
"metronome_max_bpm": 80
|
||||
"tempo_bpm": 125
|
||||
}
|
||||
})json"));
|
||||
BOOST_TEST(!parsed);
|
||||
@@ -160,7 +149,7 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 1U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(!state.audio.backing_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_settings_index == 2U);
|
||||
@@ -172,17 +161,17 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.tempo_editing);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.audio.metronome_bpm == 125U);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 120U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
BOOST_TEST(state.audio.metronome_bpm == 120U);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 110U);
|
||||
for (auto index = std::int32_t{}; index < 40; ++index) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS);
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == 40U);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 60U);
|
||||
for (auto index = std::int32_t{}; index < 80; ++index) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == 240U);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 120U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
}
|
||||
@@ -191,17 +180,17 @@ BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
|
||||
auto state = navigation_state();
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.selected_settings_index = 3U;
|
||||
state.audio.playback_enabled = true;
|
||||
state.audio.backing_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
state.audio.metronome_bpm = 135;
|
||||
state.audio.tempo_bpm = 110;
|
||||
state.audio.tempo_editing = true;
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
BOOST_TEST(std::string{remote.focus} == "Tempo");
|
||||
BOOST_TEST(std::string{remote.mode} == "settings_menu");
|
||||
BOOST_TEST(remote.playback);
|
||||
BOOST_TEST(remote.backing);
|
||||
BOOST_TEST(remote.metronome);
|
||||
BOOST_TEST(remote.bpm == 135U);
|
||||
BOOST_TEST(remote.bpm == 110U);
|
||||
BOOST_TEST(remote.editing);
|
||||
}
|
||||
|
||||
@@ -279,7 +268,7 @@ BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display
|
||||
for (auto index = std::uint32_t{}; index < 4U; ++index) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
BOOST_TEST(state.page_index == 2U);
|
||||
BOOST_TEST(state.page_index == 0U);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
BOOST_TEST(state.selected_part_index == selection);
|
||||
|
||||
@@ -11,14 +11,14 @@ import generator.engraving.fonts
|
||||
import generator.engraving.layout
|
||||
import generator.engraving.types
|
||||
import generator.model
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.test_accompaniment
|
||||
import tests.test_musicxml
|
||||
import tests.test_parts
|
||||
|
||||
def sounding(document):
|
||||
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in generator.playback.compile_timeline(document)['events'])
|
||||
return sorted(tuple(generator.model.fraction(event[key]) for key in ('onset', 'duration', 'pitch')) for event in tests.score_events.timeline(document)['events'])
|
||||
|
||||
def acoustic_document(bars):
|
||||
document = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * len(bars)))
|
||||
|
||||
@@ -9,7 +9,7 @@ import generator.arrange.pickup
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import generator.score
|
||||
import tests.test_musicxml
|
||||
import tests.test_parts
|
||||
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 176)
|
||||
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 177)])
|
||||
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
|
||||
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 704)
|
||||
self.assertEqual(len(measures), 168)
|
||||
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)])
|
||||
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137'])
|
||||
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672)
|
||||
timelines.append([m['duration'] for m in measures])
|
||||
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
|
||||
for boundary in ([8, 136] if part == 'vocal' else [40]):
|
||||
@@ -49,7 +49,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
|
||||
document = source(opening, implicit=False)
|
||||
result = generator.arrange.generate.arrange_document(document, 'vocal')
|
||||
self.assertEqual(len(result['staves'][0]['measures']), 14)
|
||||
self.assertEqual(len(result['staves'][0]['measures']), 12)
|
||||
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
|
||||
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
|
||||
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
|
||||
@@ -59,7 +59,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 7)
|
||||
self.assertEqual(len(measures), 6)
|
||||
for boundary in boundaries:
|
||||
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
|
||||
downbeat = generator.score.measure_notes(measures[boundary])[0]
|
||||
@@ -67,7 +67,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
self.assertEqual(pickup['pitches'][0]['tie_to'], downbeat['pitches'][0]['id'])
|
||||
self.assertEqual(downbeat['pitches'][0]['tie_from'], pickup['pitches'][0]['id'])
|
||||
self.assertTrue(any(generator.model.fraction(s['start']) == boundary * 4 - 1 and generator.model.fraction(s['end']) == boundary * 4 for s in result['staves'][0]['octave_shifts']))
|
||||
self.assertTrue(generator.playback.compile_timeline(result)['events'])
|
||||
self.assertTrue(tests.score_events.timeline(result)['events'])
|
||||
def test_pickup_truncates_outgoing_music_without_discarding_earlier_notes(self):
|
||||
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note('D', 3, 'half', extra='<dot/><lyric><text>doo</text></lyric>')
|
||||
document = source(opening)
|
||||
|
||||
@@ -45,7 +45,7 @@ class CatalogTests(unittest.TestCase):
|
||||
with contextlib.closing(sqlite3.connect(database)) as connection:
|
||||
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], generator.catalog.APPLICATION_ID)
|
||||
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
|
||||
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('songs',)])
|
||||
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('resources',), ('songs',)])
|
||||
columns = {row[1]: row for row in connection.execute('PRAGMA table_info(songs)')}
|
||||
self.assertEqual((columns['title_image'][2], columns['title_image'][3]), ('TEXT', 1))
|
||||
self.assertFalse(pathlib.Path(str(database) + '-wal').exists())
|
||||
|
||||
@@ -90,6 +90,7 @@ auto catalog_bundle(std::string_view title, std::string_view key = "C", std::str
|
||||
metadata["title"] = title;
|
||||
metadata["key_signature"] = key;
|
||||
value.at("music").at("staves").as_array().front().as_object()["name"] = part;
|
||||
vamp_test::add_timing(value);
|
||||
return boost::json::serialize(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import generator.engraving.types
|
||||
import generator.guitar
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.test_accompaniment
|
||||
import tests.test_musicxml
|
||||
@@ -77,7 +77,7 @@ class EngravingTests(unittest.TestCase):
|
||||
for measure in without_tempos['staves'][0]['measures']:
|
||||
measure.pop('tempos', None)
|
||||
self.assertEqual(layout, self.layout(without_tempos))
|
||||
self.assertEqual(generator.playback.compile_timeline(source), generator.playback.compile_timeline(without_tempos))
|
||||
self.assertEqual(tests.score_events.timeline(source), tests.score_events.timeline(without_tempos))
|
||||
def test_long_credits_wrap_without_overflow_or_page_specific_sizing(self):
|
||||
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * 32))
|
||||
source['metadata']['composer'] = 'A moderately long composer credit ' * 6
|
||||
@@ -398,7 +398,7 @@ class EngravingTests(unittest.TestCase):
|
||||
self.assertLess(max(p.box.bottom for p in system.primitives if not p.role.startswith('tab')), min(p.box.top for p in system.primitives if p.role.startswith('tab')))
|
||||
self.assertEqual({p.sources[-1] for p in heads}, {p.sources[-1] for p in tabs})
|
||||
self.assertTrue(all(any(abs(head.points[0][0] - tab.points[0][0]) < 0.01 for head in heads if head.sources[-1] == tab.sources[-1]) for tab in tabs))
|
||||
self.assertEqual(len(generator.playback.compile_timeline(source)['events']), 64)
|
||||
self.assertEqual(len(tests.score_events.timeline(source)['events']), 64)
|
||||
def test_generated_tab_ties_and_barline_styles(self):
|
||||
content = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') + '<barline location="right"><bar-style>dashed</bar-style></barline>'
|
||||
source = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)), 'electric')
|
||||
|
||||
@@ -198,15 +198,17 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
with unittest.mock.patch('generator.engraving.raster.rasterize_page', side_effect=AssertionError('cache miss')):
|
||||
generator.engraving.pipeline.engrave_tree(warm, self.fonts, cache_dir=self.cache)
|
||||
self.assertEqual(self.files(cold), self.files(warm))
|
||||
def test_playback_is_not_restored_from_page_cache(self):
|
||||
self.seed_cache()
|
||||
changed = copy.deepcopy(self.document)
|
||||
changed['playback'] = {'new-playback': 'independent of page rendering'}
|
||||
def test_invalid_cached_page_timing_is_rebuilt(self):
|
||||
entry = self.seed_cache()
|
||||
manifest = entry / 'pages.json'
|
||||
pages = json.loads(manifest.read_text())
|
||||
pages[0]['measure_count'] += 1
|
||||
manifest.write_text(json.dumps(pages))
|
||||
root = self.root / 'warm'
|
||||
self.write_scores(root, [changed])
|
||||
with unittest.mock.patch('generator.engraving.layout.layout_systems', side_effect=AssertionError('cache miss')):
|
||||
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
|
||||
self.assertEqual(json.loads((root / '000.json').read_text())['playback'], changed['playback'])
|
||||
self.write_scores(root, [self.document])
|
||||
generator.engraving.pipeline.engrave_tree(root, self.fonts, cache_dir=self.cache)
|
||||
bundle = json.loads((root / '000.json').read_text())
|
||||
self.assertEqual(bundle['presentation']['pages'][0]['measure_count'], 1)
|
||||
def test_changed_music_and_renderer_invalidate_pages(self):
|
||||
self.seed_cache()
|
||||
changed = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')).replace('Fixture', 'Changed'))
|
||||
|
||||
+10
-10
@@ -17,7 +17,7 @@ import generator.harmony
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.pitch
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
@@ -137,8 +137,8 @@ class GuitarTests(unittest.TestCase):
|
||||
vocal = tests.arrangement_fixtures.section_document(source, 'vocal')
|
||||
generator.model.validate_document(electric)
|
||||
self.assertEqual(source, before)
|
||||
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in generator.playback.compile_timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in generator.playback.compile_timeline(vocal)['events']])
|
||||
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in generator.playback.compile_timeline(source)['events']])
|
||||
self.assertEqual([(e['onset'], e['duration'], generator.model.fraction(e['pitch']) - 12) for e in tests.score_events.timeline(electric)['events']], [(e['onset'], e['duration'], generator.model.fraction(e['pitch'])) for e in tests.score_events.timeline(vocal)['events']])
|
||||
self.assertEqual([(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(electric)['events']], [(e['pitch'], e['onset'], e['duration']) for e in tests.score_events.timeline(source)['events']])
|
||||
for original, generated in zip(tests.test_musicxml.notes(source), tests.test_musicxml.notes(electric)):
|
||||
self.assertEqual(generator.model.pitch_number(generated['pitches'][0]), generator.model.pitch_number(original['pitches'][0]) + 12)
|
||||
self.assertIn('tablature', generated['pitches'][0])
|
||||
@@ -147,7 +147,7 @@ class GuitarTests(unittest.TestCase):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(), attributes))
|
||||
for part in ('electric', 'vocal'):
|
||||
document = tests.arrangement_fixtures.section_document(source, part)
|
||||
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
|
||||
self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected + (12 if part == 'electric' else 0))
|
||||
def test_written_middle_c_and_c_sharp_six_have_matching_tab_and_audio(self):
|
||||
for octave, alter, expected, tab in ((4, 0, 48, {'string': 5, 'fret': 3}), (6, 1, 73, {'string': 1, 'fret': 9})):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=octave - 1, alter=alter)))
|
||||
@@ -155,7 +155,7 @@ class GuitarTests(unittest.TestCase):
|
||||
tone = tests.test_musicxml.notes(document)[0]['pitches'][0]
|
||||
self.assertEqual(generator.model.pitch_number(tone), 12 * (octave + 1) + alter)
|
||||
self.assertEqual(tone['tablature'], tab)
|
||||
self.assertEqual(generator.model.fraction(generator.playback.compile_timeline(document)['events'][0]['pitch']), expected)
|
||||
self.assertEqual(generator.model.fraction(tests.score_events.timeline(document)['events'][0]['pitch']), expected)
|
||||
generator.model.validate_document(document)
|
||||
def test_guitar_normalization_is_idempotent_and_respects_timed_transposition(self):
|
||||
change = '<attributes><transpose><chromatic>0</chromatic></transpose></attributes>'
|
||||
@@ -165,7 +165,7 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertEqual(generator.pitch.concert_document(concert, octave_transposing=True), concert)
|
||||
self.assertEqual(source, before)
|
||||
document = tests.arrangement_fixtures.section_document(source, 'electric')
|
||||
self.assertEqual([generator.model.fraction(e['pitch']) for e in generator.playback.compile_timeline(document)['events']], [60, 72])
|
||||
self.assertEqual([generator.model.fraction(e['pitch']) for e in tests.score_events.timeline(document)['events']], [60, 72])
|
||||
def test_all_parts_share_written_octave_convention(self):
|
||||
chord = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(chord + tests.test_musicxml.xml_note()))
|
||||
@@ -175,7 +175,7 @@ class GuitarTests(unittest.TestCase):
|
||||
document = generate(source, part)
|
||||
generator.model.validate_document(document)
|
||||
written = {p['id']: generator.model.pitch_number(p) for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
events = generator.playback.compile_timeline(document)['events']
|
||||
events = tests.score_events.timeline(document)['events']
|
||||
self.assertTrue(events)
|
||||
self.assertTrue(all(generator.model.fraction(e['pitch']) == written[e['id']] - 12 for e in events))
|
||||
if part == 'vocal':
|
||||
@@ -189,7 +189,7 @@ class GuitarTests(unittest.TestCase):
|
||||
first, continuation, other = [p['tablature'] for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
self.assertEqual(first, continuation)
|
||||
self.assertNotEqual(continuation['string'], other['string'])
|
||||
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 2)
|
||||
self.assertEqual(len(tests.score_events.timeline(document)['events']), 2)
|
||||
def test_overlapping_tones_cannot_expand_the_span_or_relocate_a_tie(self):
|
||||
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
@@ -229,7 +229,7 @@ class GuitarTests(unittest.TestCase):
|
||||
generator.model.validate_document(document)
|
||||
self.assertEqual(melody['pitches'][0]['tablature'], {'string': 1, 'fret': 12})
|
||||
self.assertNotIn(1, [p['tablature']['string'] for p in chord['pitches']])
|
||||
self.assertEqual(len(generator.playback.compile_timeline(document)['events']), 3)
|
||||
self.assertEqual(len(tests.score_events.timeline(document)['events']), 3)
|
||||
def test_more_than_six_simultaneous_tones_fail_without_omission(self):
|
||||
content = '<backup><duration>4</duration></backup>'.join(tests.test_musicxml.xml_note(extra=f'<voice>{i}</voice>') for i in range(7))
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||
@@ -269,7 +269,7 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertEqual([p.name for p in output.iterdir()], ['last-good'])
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
with unittest.mock.patch('generator.comping.voicings', side_effect=ValueError('no legal sparse voicing')):
|
||||
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>'+tests.test_musicxml.xml_note()))
|
||||
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>dominant</kind></harmony>' + tests.test_musicxml.xml_note()))
|
||||
with self.assertRaisesRegex(ValueError, 'no legal sparse voicing'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual((output / 'last-good').read_text(), 'preserved')
|
||||
|
||||
@@ -10,7 +10,7 @@ import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.test_accompaniment
|
||||
import tests.test_musicxml
|
||||
@@ -47,12 +47,12 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
|
||||
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
|
||||
def test_sparse_acoustic_uses_compact_playable_chords(self):
|
||||
result=self.section(tests.test_parts.xml_harmony()+tests.test_musicxml.xml_note(),'acoustic')
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
sounding=[n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
|
||||
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
|
||||
self.assertTrue(sounding)
|
||||
self.assertTrue(all(2<=len(n['pitches'])<=3 for n in sounding))
|
||||
self.assertTrue(all(2 <= len(n['pitches']) <= 3 for n in sounding))
|
||||
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
@@ -77,7 +77,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
for before, after in zip(first['pitches'], second['pitches']):
|
||||
self.assertEqual(before['tie_to'], after['id'])
|
||||
self.assertEqual(after['tablature'], before['tablature'])
|
||||
self.assertEqual({generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(result)['events']}, {2})
|
||||
self.assertEqual({generator.model.fraction(e['duration']) for e in tests.score_events.timeline(result)['events']}, {2})
|
||||
def test_chord_change_reattacks_even_for_final_eighth(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=7) + tests.test_musicxml.xml_note('G', 8, 'whole', octave=4)
|
||||
result = self.section(content, divisions=2)
|
||||
@@ -87,7 +87,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertIn(len(note['pitches']), (3, 4))
|
||||
self.assertFalse(any('tie_from' in p or 'tie_to' in p for p in note['pitches']))
|
||||
self.assertEqual(generator.model.pitch_number(note['pitches'][0]), 79)
|
||||
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
|
||||
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0, fractions.Fraction(7, 2)})
|
||||
def test_cross_measure_ties_continue_only_without_a_chord_change(self):
|
||||
for root in ('C', 'F'):
|
||||
with self.subTest(root=root):
|
||||
@@ -99,7 +99,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
|
||||
self.assertTrue(all(len(n['pitches']) in (3, 4) for n in notes))
|
||||
self.assertEqual(bool(notes[0]['pitches'][0].get('tie_to')), root == 'C')
|
||||
self.assertEqual({generator.model.fraction(e['onset']) for e in generator.playback.compile_timeline(result)['events']}, {0} if root == 'C' else {0, 4})
|
||||
self.assertEqual({generator.model.fraction(e['onset']) for e in tests.score_events.timeline(result)['events']}, {0} if root == 'C' else {0, 4})
|
||||
def test_split_tuplet_keeps_exact_timing_and_span_end(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_parts.xml_harmony('F', offset=10)
|
||||
for index in range(3):
|
||||
@@ -120,7 +120,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertIn(len(first['pitches']), (3, 4))
|
||||
self.assertEqual(len(second['pitches']), 1)
|
||||
self.assertEqual(first['pitches'][0]['tie_to'], second['pitches'][0]['id'])
|
||||
timeline = generator.playback.compile_timeline(result)['events']
|
||||
timeline = tests.score_events.timeline(result)['events']
|
||||
self.assertEqual(sum(generator.model.fraction(e['duration']) == 4 for e in timeline), 1)
|
||||
self.assertTrue(all(generator.model.fraction(e['onset']) == 0 for e in timeline))
|
||||
def test_unplayable_harmonization_preserves_low_melody(self):
|
||||
|
||||
+132
-127
@@ -1,163 +1,168 @@
|
||||
"""Contextual musical validity needs notes and chords, not author annotations."""
|
||||
import dataclasses
|
||||
import json
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
from generator import train,training_io,vocabulary,harmonic_time,harmony as chords
|
||||
from generator.solo import harmonic,types,decoder,learned,analysis,expressions
|
||||
from tests.solo_fixtures import choices,note,song
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
import generator.train
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.harmonic_time
|
||||
import generator.harmony
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.types
|
||||
import generator.solo.decoder
|
||||
import generator.solo.learned
|
||||
import generator.solo.analysis
|
||||
import generator.solo.expressions
|
||||
import tests.solo_fixtures
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.solo.authored
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
class ContextualHarmony(unittest.TestCase):
|
||||
def test_approved_corpus_passes_without_annotations_or_authored_privileges(self):
|
||||
examples,_=train.load_examples(ROOT/'data/training/manifest.json')
|
||||
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
|
||||
from generator import musicxml,parts
|
||||
from generator.solo.authored import sounding
|
||||
examples, _ = generator.train.load_examples(ROOT / 'data/training/manifest.json')
|
||||
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
|
||||
for entry in manifest['examples']:
|
||||
raw=training_io.read(ROOT/'data/training'/entry['solo'],'',pitch_convention=entry['pitch_convention'])
|
||||
self.assertEqual(raw['annotations'],[])
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
|
||||
h=harmonic.Harmony(harmonic_time.solo_source(doc['staves'][0]))
|
||||
ns=tuple(dataclasses.replace(n,role='ordinary',sources=(),event_id='') for n in sounding(raw))
|
||||
checked=h.evaluate(ns,complete=True)
|
||||
self.assertTrue(checked.valid,(entry['id'],checked.error))
|
||||
raw = generator.training_io.read(ROOT / 'data/training' / entry['solo'], '', pitch_convention=entry['pitch_convention'])
|
||||
self.assertEqual(raw['annotations'], [])
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(doc['staves'][0]))
|
||||
ns = tuple(dataclasses.replace(n, role='ordinary', sources=(), event_id='') for n in generator.solo.authored.sounding(raw))
|
||||
checked = h.evaluate(ns, complete=True)
|
||||
self.assertTrue(checked.valid, (entry['id'], checked.error))
|
||||
self.assertTrue(checked.contracts)
|
||||
self.assertEqual(len(examples),4)
|
||||
|
||||
self.assertEqual(len(examples), 4)
|
||||
def test_passing_to_nonchord_scale_note(self):
|
||||
h=harmonic.Harmony(choices(('G6',)))
|
||||
notes=(note(0,F(1,2),'g'),note(F(1,2),1,'g',alter=1),note(1,2,'a'))
|
||||
result=h.evaluate(notes)
|
||||
self.assertTrue(result.valid,result.error)
|
||||
self.assertEqual(result.contracts[0].kind,'passing')
|
||||
self.assertFalse(h.evaluate(notes,infer=False).valid)
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
|
||||
notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1), tests.solo_fixtures.note(1, 2, 'a'))
|
||||
result = h.evaluate(notes)
|
||||
self.assertTrue(result.valid, result.error)
|
||||
self.assertEqual(result.contracts[0].kind, 'passing')
|
||||
self.assertFalse(h.evaluate(notes, infer=False).valid)
|
||||
self.assertFalse(h.evaluate(notes[:-1]).valid)
|
||||
leap=(*notes[:2],dataclasses.replace(notes[2],pitch=types.Pitch('b',F(),4)))
|
||||
leap = (*notes[:2], dataclasses.replace(notes[2], pitch=generator.solo.types.Pitch('b', fractions.Fraction(), 4)))
|
||||
self.assertFalse(h.evaluate(leap).valid)
|
||||
long=(notes[0],dataclasses.replace(notes[1],end=F(3,2)),dataclasses.replace(notes[2],start=F(3,2)))
|
||||
long = (notes[0], dataclasses.replace(notes[1], end=fractions.Fraction(3, 2)), dataclasses.replace(notes[2], start=fractions.Fraction(3, 2)))
|
||||
self.assertFalse(h.evaluate(long).valid)
|
||||
|
||||
def test_passing_contract_requires_its_actual_preparation(self):
|
||||
h=harmonic.Harmony(choices(('G6',)))
|
||||
a=note(0,F(1,2),'g',event_id='a');b=note(F(1,2),1,'g',alter=1,event_id='b');c=note(1,2,'a',event_id='c')
|
||||
contract=types.ExpressionContract('p','passing','chromatic_passing',(b,),c,'GM6',preparation=a)
|
||||
self.assertTrue(h.evaluate((a,b,c),expressions=(contract,)).valid)
|
||||
changed=dataclasses.replace(a,end=F(1,4))
|
||||
wrong=dataclasses.replace(contract,preparation=changed)
|
||||
self.assertFalse(h.evaluate((changed,b,c),expressions=(wrong,)).valid)
|
||||
high=dataclasses.replace(c,pitch=dataclasses.replace(c.pitch,octave=5))
|
||||
self.assertFalse(h.evaluate((a,b,high),expressions=(dataclasses.replace(contract,target=high),)).valid)
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('G6',)))
|
||||
a = tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g', event_id='a')
|
||||
b = tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'g', alter=1, event_id='b')
|
||||
c = tests.solo_fixtures.note(1, 2, 'a', event_id='c')
|
||||
contract = generator.solo.types.ExpressionContract('p', 'passing', 'chromatic_passing', (b,), c, 'GM6', preparation=a)
|
||||
self.assertTrue(h.evaluate((a, b, c), expressions=(contract,)).valid)
|
||||
changed = dataclasses.replace(a, end=fractions.Fraction(1, 4))
|
||||
wrong = dataclasses.replace(contract, preparation=changed)
|
||||
self.assertFalse(h.evaluate((changed, b, c), expressions=(wrong,)).valid)
|
||||
high = dataclasses.replace(c, pitch=dataclasses.replace(c.pitch, octave=5))
|
||||
self.assertFalse(h.evaluate((a, b, high), expressions=(dataclasses.replace(contract, target=high),)).valid)
|
||||
def test_color_resolution_can_have_intervening_valid_motion(self):
|
||||
h=harmonic.Harmony(choices(('C7',)))
|
||||
notes=(note(0,F(3,2),'f',alter=1),note(F(3,2),2,'a'),note(2,4,'g'))
|
||||
checked=h.evaluate(notes)
|
||||
self.assertTrue(checked.valid,checked.error)
|
||||
self.assertEqual(checked.contracts[0].rule,'lydian_color')
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
|
||||
notes = (tests.solo_fixtures.note(0, fractions.Fraction(3, 2), 'f', alter=1), tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'a'), tests.solo_fixtures.note(2, 4, 'g'))
|
||||
checked = h.evaluate(notes)
|
||||
self.assertTrue(checked.valid, checked.error)
|
||||
self.assertEqual(checked.contracts[0].rule, 'lydian_color')
|
||||
self.assertFalse(h.evaluate(notes[:2]).valid)
|
||||
bad_mid=dataclasses.replace(notes[1],pitch=types.Pitch('g',F(1),5))
|
||||
self.assertFalse(h.evaluate((notes[0],bad_mid,notes[2])).valid)
|
||||
|
||||
bad_mid = dataclasses.replace(notes[1], pitch=generator.solo.types.Pitch('g', fractions.Fraction(1), 5))
|
||||
self.assertFalse(h.evaluate((notes[0], bad_mid, notes[2])).valid)
|
||||
def test_anticipated_stable_and_resolving_colors(self):
|
||||
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
|
||||
n=note(F(7,2),6,'d')
|
||||
result=h.evaluate((n,))
|
||||
self.assertTrue(result.valid,result.error)
|
||||
self.assertEqual(result.contracts[0].kind,'anticipated_color')
|
||||
self.assertFalse(h.evaluate((dataclasses.replace(n,start=F(2)),)).valid)
|
||||
h=harmonic.Harmony(choices(('Gm7','C7')))
|
||||
color=note(F(7,2),F(11,2),'f',alter=1)
|
||||
target=note(F(11,2),7,'g')
|
||||
self.assertTrue(h.evaluate((color,target)).valid)
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
|
||||
n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
|
||||
result = h.evaluate((n,))
|
||||
self.assertTrue(result.valid, result.error)
|
||||
self.assertEqual(result.contracts[0].kind, 'anticipated_color')
|
||||
self.assertFalse(h.evaluate((dataclasses.replace(n, start=fractions.Fraction(2)),)).valid)
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
|
||||
color = tests.solo_fixtures.note(fractions.Fraction(7, 2), fractions.Fraction(11, 2), 'f', alter=1)
|
||||
target = tests.solo_fixtures.note(fractions.Fraction(11, 2), 7, 'g')
|
||||
self.assertTrue(h.evaluate((color, target)).valid)
|
||||
self.assertFalse(h.evaluate((color,)).valid)
|
||||
flat=dataclasses.replace(color,pitch=types.Pitch('g',F(-1),4))
|
||||
self.assertTrue(h.evaluate((flat,target)).valid)
|
||||
|
||||
flat = dataclasses.replace(color, pitch=generator.solo.types.Pitch('g', fractions.Fraction(-1), 4))
|
||||
self.assertTrue(h.evaluate((flat, target)).valid)
|
||||
def test_repeated_harmony_cannot_reset_color_duration(self):
|
||||
h=harmonic.Harmony(choices(('C7','C7')))
|
||||
color=note(3,6,'f',alter=1);target=note(6,7,'g')
|
||||
self.assertFalse(h.evaluate((color,target)).valid)
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'C7')))
|
||||
color = tests.solo_fixtures.note(3, 6, 'f', alter=1)
|
||||
target = tests.solo_fixtures.note(6, 7, 'g')
|
||||
self.assertFalse(h.evaluate((color, target)).valid)
|
||||
def test_held_color_is_independent_of_notation_partition(self):
|
||||
h=harmonic.Harmony(choices(('Bbdim7','Am7')))
|
||||
whole=note(F(7,2),6,'d')
|
||||
split=(dataclasses.replace(whole,end=F(4)),dataclasses.replace(whole,start=F(4),continuation=True))
|
||||
self.assertEqual(h.evaluate((whole,)),h.evaluate(split))
|
||||
self.assertEqual(h.describe((whole,)),h.describe(split))
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Bbdim7', 'Am7')))
|
||||
whole = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'd')
|
||||
split = (dataclasses.replace(whole, end=fractions.Fraction(4)), dataclasses.replace(whole, start=fractions.Fraction(4), continuation=True))
|
||||
self.assertEqual(h.evaluate((whole,)), h.evaluate(split))
|
||||
self.assertEqual(h.describe((whole,)), h.describe(split))
|
||||
def test_unsupported_notes_are_not_authorized_by_labels(self):
|
||||
h=harmonic.Harmony(choices(('C7',)))
|
||||
for role in ('ordinary','authored','quote','color','Lead in'):
|
||||
self.assertFalse(h.evaluate((note(0,3,'c',alter=1,role=role),)).valid)
|
||||
missing=harmonic.Harmony(())
|
||||
self.assertFalse(missing.evaluate((note(0,1,'c'),)).valid)
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
|
||||
for role in ('ordinary', 'authored', 'quote', 'color', 'Lead in'):
|
||||
self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 3, 'c', alter=1, role=role),)).valid)
|
||||
missing = generator.solo.harmonic.Harmony(())
|
||||
self.assertFalse(missing.evaluate((tests.solo_fixtures.note(0, 1, 'c'),)).valid)
|
||||
def test_fully_expired_partial_treatments_remain_invalid(self):
|
||||
h=harmonic.Harmony(choices(('C7',)))
|
||||
notes=(note(0,F(1,2),'d',alter=1,event_id='a'),note(F(1,2),2,'e',event_id='b'))
|
||||
contract=types.ExpressionContract('a','approach','below',(notes[0],),notes[1],'C7')
|
||||
partial=h.evaluate(notes[:1],until=F(1,2),expressions=(contract,))
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
|
||||
notes = (tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'd', alter=1, event_id='a'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 2, 'e', event_id='b'))
|
||||
contract = generator.solo.types.ExpressionContract('a', 'approach', 'below', (notes[0],), notes[1], 'C7')
|
||||
partial = h.evaluate(notes[:1], until=fractions.Fraction(1, 2), expressions=(contract,))
|
||||
self.assertTrue(partial.valid)
|
||||
self.assertEqual(len(partial.pending),1)
|
||||
self.assertFalse(h.evaluate(notes[:1],until=F(2),expressions=(contract,)).valid)
|
||||
|
||||
self.assertEqual(len(partial.pending), 1)
|
||||
self.assertFalse(h.evaluate(notes[:1], until=fractions.Fraction(2), expressions=(contract,)).valid)
|
||||
def test_pickup_uses_source_form_context_without_overriding_written_harmony(self):
|
||||
doc=song(count=3,chords=('C','Dm','G7'));staff=doc['staves'][0]
|
||||
pickup=staff['measures'][0];pickup['implicit']=True;pickup['harmonies']=[]
|
||||
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'G7')
|
||||
self.assertIsNone(harmonic_time.read(staff)[0].chord)
|
||||
pickup['harmonies']=[dict(chords.parse_symbol('F'),onset={'numerator':0,'denominator':1})]
|
||||
self.assertEqual(chords.symbol(harmonic_time.solo_source(staff)[0].chord),'FM')
|
||||
pickup['harmonies']=[{'kind':'none','onset':{'numerator':0,'denominator':1}}]
|
||||
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
|
||||
pickup['harmonies']=[];pickup['implicit']=False
|
||||
self.assertIsNone(harmonic_time.solo_source(staff)[0].chord)
|
||||
|
||||
doc = tests.solo_fixtures.song(count=3, chords=('C', 'Dm', 'G7'))
|
||||
staff = doc['staves'][0]
|
||||
pickup = staff['measures'][0]
|
||||
pickup['implicit'] = True
|
||||
pickup['harmonies'] = []
|
||||
self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'G7')
|
||||
self.assertIsNone(generator.harmonic_time.read(staff)[0].chord)
|
||||
pickup['harmonies'] = [dict(generator.harmony.parse_symbol('F'), onset={'numerator': 0, 'denominator': 1})]
|
||||
self.assertEqual(generator.harmony.symbol(generator.harmonic_time.solo_source(staff)[0].chord), 'FM')
|
||||
pickup['harmonies'] = [{'kind': 'none', 'onset': {'numerator': 0, 'denominator': 1}}]
|
||||
self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
|
||||
pickup['harmonies'] = []
|
||||
pickup['implicit'] = False
|
||||
self.assertIsNone(generator.harmonic_time.solo_source(staff)[0].chord)
|
||||
|
||||
class PredictionReachability(unittest.TestCase):
|
||||
def engine(self,chords):
|
||||
doc=song(count=2,chords=chords)
|
||||
return decoder.Decoder(analysis.analyze(doc),harmonic.Harmony(choices(chords)),
|
||||
learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
|
||||
|
||||
def engine(self, chords):
|
||||
doc = tests.solo_fixtures.song(count=2, chords=chords)
|
||||
return generator.solo.decoder.Decoder(generator.solo.analysis.analyze(doc), generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(chords)), generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
|
||||
def test_joint_resolved_color_reaches_the_real_action_builder(self):
|
||||
d=self.engine(('C7','FM7'))
|
||||
n=d.note(F(),F(3,2),66)
|
||||
actions=list(d.resolutions(n))
|
||||
self.assertTrue(any([p for _,_,p in events]==[66,69,67] for events,_,_ in actions))
|
||||
for _,notes,contracts in actions:
|
||||
self.assertTrue(d.harmony.evaluate(notes,expressions=contracts,infer=False).valid)
|
||||
|
||||
d = self.engine(('C7', 'FM7'))
|
||||
n = d.note(fractions.Fraction(), fractions.Fraction(3, 2), 66)
|
||||
actions = list(d.resolutions(n))
|
||||
self.assertTrue(any([p for _, _, p in events] == [66, 69, 67] for events, _, _ in actions))
|
||||
for _, notes, contracts in actions:
|
||||
self.assertTrue(d.harmony.evaluate(notes, expressions=contracts, infer=False).valid)
|
||||
def test_anticipated_color_is_a_normal_single_event_option(self):
|
||||
d=self.engine(('Bbdim7','Am7'));n=d.note(F(7,2),F(6),62)
|
||||
support=d.permission(n)
|
||||
d = self.engine(('Bbdim7', 'Am7'))
|
||||
n = d.note(fractions.Fraction(7, 2), fractions.Fraction(6), 62)
|
||||
support = d.permission(n)
|
||||
self.assertTrue(support)
|
||||
self.assertEqual(support[0].kind,'anticipated_color')
|
||||
d.expressions_enabled=False;d._permissions.clear()
|
||||
self.assertEqual(support[0].kind, 'anticipated_color')
|
||||
d.expressions_enabled = False
|
||||
d._permissions.clear()
|
||||
self.assertIsNone(d.permission(n))
|
||||
|
||||
def test_delayed_action_retains_space_and_intervening_motion(self):
|
||||
d=self.engine(('C7','FM7'));n=d.note(F(),F(1,2),63)
|
||||
actions=list(d.resolutions(n))
|
||||
self.assertTrue(any(c[0].kind=='delayed_approach' and any(p is None for _,_,p in events) for events,_,c in actions))
|
||||
self.assertTrue(any(c[0].kind=='delayed_approach' and len(notes)==3 for _,notes,c in actions))
|
||||
|
||||
d = self.engine(('C7', 'FM7'))
|
||||
n = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 63)
|
||||
actions = list(d.resolutions(n))
|
||||
self.assertTrue(any(c[0].kind == 'delayed_approach' and any(p is None for _, _, p in events) for events, _, c in actions))
|
||||
self.assertTrue(any(c[0].kind == 'delayed_approach' and len(notes) == 3 for _, notes, c in actions))
|
||||
def test_passing_action_uses_incoming_state(self):
|
||||
d=self.engine(('G6','G6'));first=d.note(F(),F(1,2),67)
|
||||
state=d.extend(decoder.State(),(((first.start,first.end,67),),(first,),()))
|
||||
actions=[a for a in d.actions(F(1,2)) if a[2] and a[2][0].kind=='passing'
|
||||
and a[0]==((F(1,2),F(1),68),(F(1),F(2),69))]
|
||||
d = self.engine(('G6', 'G6'))
|
||||
first = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 67)
|
||||
state = d.extend(generator.solo.decoder.State(), (((first.start, first.end, 67),), (first,), ()))
|
||||
actions = [a for a in d.actions(fractions.Fraction(1, 2)) if a[2] and a[2][0].kind == 'passing'
|
||||
and a[0] == ((fractions.Fraction(1, 2), fractions.Fraction(1), 68), (fractions.Fraction(1), fractions.Fraction(2), 69))]
|
||||
self.assertTrue(actions)
|
||||
self.assertIsNotNone(d.extend(state,actions[0]))
|
||||
self.assertIsNone(d.extend(decoder.State(),actions[0]))
|
||||
wrong=d.note(F(),F(1,2),64)
|
||||
prior=d.extend(decoder.State(),(((wrong.start,wrong.end,64),),(wrong,),()))
|
||||
self.assertIsNone(d.extend(prior,actions[0]))
|
||||
self.assertIsNotNone(d.extend(state, actions[0]))
|
||||
self.assertIsNone(d.extend(generator.solo.decoder.State(), actions[0]))
|
||||
wrong = d.note(fractions.Fraction(), fractions.Fraction(1, 2), 64)
|
||||
prior = d.extend(generator.solo.decoder.State(), (((wrong.start, wrong.end, 64),), (wrong,), ()))
|
||||
self.assertIsNone(d.extend(prior, actions[0]))
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+10
-18
@@ -135,7 +135,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}, {"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}, {"playback_enabled", true}, {"metronome_enabled", true}}},
|
||||
{"audio", {{"enabled", true}, {"backing_enabled", false}, {"metronome_enabled", true}}},
|
||||
};
|
||||
auto path = root / "config.json";
|
||||
write_file(path, boost::json::serialize(json));
|
||||
@@ -182,6 +182,7 @@ auto write_score(std::filesystem::path const &path, std::filesystem::path const
|
||||
write_file(path.parent_path() / relative, bytes);
|
||||
pages.push_back(boost::json::object{{"path", relative}, {"sha256", checksum(bytes)}});
|
||||
}
|
||||
vamp_test::add_timing(score);
|
||||
write_file(path, boost::json::serialize(score));
|
||||
}
|
||||
|
||||
@@ -405,32 +406,23 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_corrupted_pico_titles) {
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_root) {
|
||||
BOOST_AUTO_TEST_CASE(readiness_validates_supplied_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 / "songs/acoustic.json";
|
||||
write_score(path, assets, "Acoustic");
|
||||
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 fixture = std::filesystem::path{VAMP_TEST_PERFORMANCE_ASSETS_DIR};
|
||||
std::filesystem::copy(fixture / "sounds", assets / "sounds", std::filesystem::copy_options::recursive);
|
||||
BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
|
||||
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));
|
||||
BOOST_REQUIRE(vamp_test::build_catalog(assets, config.database.path));
|
||||
auto path = assets / "sounds/beat1.wav";
|
||||
auto bytes = read_file(path);
|
||||
bytes.back() ^= 1;
|
||||
write_file(path, bytes);
|
||||
ready = run_once(root.path, config);
|
||||
BOOST_REQUIRE(!ready);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
|
||||
BOOST_TEST(std::string{ready.error().message()}.find("checksum mismatch") != std::string::npos);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(readiness_rejects_corrupt_checksums_and_escaping_pages) {
|
||||
|
||||
@@ -5,189 +5,193 @@ import contextlib
|
||||
import dataclasses
|
||||
import json
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from fractions import Fraction as F
|
||||
from unittest.mock import patch
|
||||
|
||||
from generator import production, model, vocabulary
|
||||
from generator.solo import expressions, types, harmonic, placement, notation, composer
|
||||
from tests.solo_fixtures import song
|
||||
from tests.solo_fixtures import choices
|
||||
from tests.solo_fixtures import note
|
||||
import fractions
|
||||
import unittest.mock
|
||||
|
||||
import generator.production
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.solo.notation
|
||||
import generator.solo.composer
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
def music(result):
|
||||
return result['document']
|
||||
|
||||
|
||||
class IndependentRoles(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.source=production.SourceAnalysis.read(song(count=1,chords=('CM7',)))
|
||||
cls.root=Path('data').resolve()
|
||||
|
||||
cls.source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
|
||||
cls.root = pathlib.Path('data').resolve()
|
||||
def test_alone_order_parallel_and_assembly(self):
|
||||
serial={role:production.generate_role(self.source,role,self.root) for role in production.ROLES}
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2,mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
jobs={role:pool.submit(production._job,(self.source,role,self.root,{},None)) for role in reversed(production.ROLES)}
|
||||
parallel={role:future.result() for role,future in jobs.items()}
|
||||
for role in production.ROLES:self.assertEqual(music(serial[role]),music(parallel[role]),role)
|
||||
for doc in production.assemble(self.source,serial).values():model.validate_document(doc)
|
||||
|
||||
serial = {role: generator.production.generate_role(self.source, role, self.root) for role in generator.production.ROLES}
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
jobs = {role: pool.submit(generator.production._job, (self.source, role, self.root, {}, None)) for role in reversed(generator.production.ROLES)}
|
||||
parallel = {role: future.result() for role, future in jobs.items()}
|
||||
for role in generator.production.ROLES:
|
||||
self.assertEqual(music(serial[role]), music(parallel[role]), role)
|
||||
for doc in generator.production.assemble(self.source, serial).values():
|
||||
generator.model.validate_document(doc)
|
||||
def test_role_setting_and_cache_are_independent(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
a=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory)
|
||||
b=production.generate_role(self.source,'electric_solo',self.root,{'seed':'changed'},directory)
|
||||
again=production.generate_role(self.source,'acoustic_solo',self.root,cache_dir=directory)
|
||||
clean=production.generate_role(self.source,'acoustic_solo',self.root)
|
||||
self.assertTrue(again['cache_hit']);self.assertEqual(music(a),music(again));self.assertEqual(music(a),music(clean))
|
||||
self.assertEqual(json.dumps(music(a)),json.dumps(music(again)))
|
||||
self.assertNotEqual(a['trace']['cache_key'],b['trace']['cache_key'])
|
||||
|
||||
a = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
|
||||
b = generator.production.generate_role(self.source, 'electric_solo', self.root, {'seed': 'changed'}, directory)
|
||||
again = generator.production.generate_role(self.source, 'acoustic_solo', self.root, cache_dir=directory)
|
||||
clean = generator.production.generate_role(self.source, 'acoustic_solo', self.root)
|
||||
self.assertTrue(again['cache_hit'])
|
||||
self.assertEqual(music(a), music(again))
|
||||
self.assertEqual(music(a), music(clean))
|
||||
self.assertEqual(json.dumps(music(a)), json.dumps(music(again)))
|
||||
self.assertNotEqual(a['trace']['cache_key'], b['trace']['cache_key'])
|
||||
def test_optional_inputs_cannot_be_opened_by_composition(self):
|
||||
original=Path.open
|
||||
def guarded(path,*args,**kwargs):
|
||||
self.assertNotIn('audition',str(path));self.assertNotIn('reference',str(path));self.assertNotIn('generated_assets',str(path))
|
||||
return original(path,*args,**kwargs)
|
||||
with patch.object(Path,'open',guarded):
|
||||
result=production.generate_role(self.source,'acoustic_solo',self.root)
|
||||
self.assertNotIn('reference',json.dumps(result['trace']))
|
||||
|
||||
original = pathlib.Path.open
|
||||
def guarded(path, *args, **kwargs):
|
||||
self.assertNotIn('audition', str(path))
|
||||
self.assertNotIn('reference', str(path))
|
||||
self.assertNotIn('generated_assets', str(path))
|
||||
return original(path, *args, **kwargs)
|
||||
with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
|
||||
result = generator.production.generate_role(self.source, 'acoustic_solo', self.root)
|
||||
self.assertNotIn('reference', json.dumps(result['trace']))
|
||||
def test_titles_and_export_ids_do_not_supply_musical_identity(self):
|
||||
document=song(count=1,chords=('CM7',));other=copy.deepcopy(document)
|
||||
other['metadata'].update(title='A completely different title',composer='Different')
|
||||
for m in other['staves'][0]['measures']:m['id']='exporter_123';m['number']='99'
|
||||
self.assertEqual(production.SourceAnalysis.read(document),production.SourceAnalysis.read(other))
|
||||
|
||||
document = tests.solo_fixtures.song(count=1, chords=('CM7',))
|
||||
other = copy.deepcopy(document)
|
||||
other['metadata'].update(title='A completely different title', composer='Different')
|
||||
for m in other['staves'][0]['measures']:
|
||||
m['id'] = 'exporter_123'
|
||||
m['number'] = '99'
|
||||
self.assertEqual(generator.production.SourceAnalysis.read(document), generator.production.SourceAnalysis.read(other))
|
||||
def test_reference_presence_and_changes_do_not_change_clean_music(self):
|
||||
# No repository reference is a fixture or prerequisite. These deliberately
|
||||
# invalid optional files test the absence of a discovery/fallback branch.
|
||||
with tempfile.TemporaryDirectory() as directory, contextlib.chdir(directory):
|
||||
reference_dir=Path(directory)/'build';reference_dir.mkdir()
|
||||
references=[reference_dir/(song+'-solo-'+str(index)+suffix+'.xml')
|
||||
for song in ('all-of-me','autumn-leaves') for index in (1,2) for suffix in ('','(4)')]
|
||||
before=production.generate_role(self.source,'electric_solo',self.root)
|
||||
for reference in references:reference.write_text('<not-a-production-source/>')
|
||||
present=production.generate_role(self.source,'electric_solo',self.root)
|
||||
for reference in references:reference.write_text('changed optional target, still not MusicXML')
|
||||
changed=production.generate_role(self.source,'electric_solo',self.root)
|
||||
for reference in references:reference.unlink()
|
||||
absent=production.generate_role(self.source,'electric_solo',self.root)
|
||||
self.assertTrue(all(music(before)==music(v) for v in (present,changed,absent)))
|
||||
|
||||
reference_dir = pathlib.Path(directory) / 'build'
|
||||
reference_dir.mkdir()
|
||||
references = [reference_dir / (source_name + '-solo-' + str(index) + suffix + '.xml')
|
||||
for source_name in ('all-of-me', 'autumn-leaves') for index in (1, 2) for suffix in ('', '(4)')]
|
||||
before = generator.production.generate_role(self.source, 'electric_solo', self.root)
|
||||
for reference in references:
|
||||
reference.write_text('<not-a-production-source/>')
|
||||
present = generator.production.generate_role(self.source, 'electric_solo', self.root)
|
||||
for reference in references:
|
||||
reference.write_text('changed optional target, still not MusicXML')
|
||||
changed = generator.production.generate_role(self.source, 'electric_solo', self.root)
|
||||
for reference in references:
|
||||
reference.unlink()
|
||||
absent = generator.production.generate_role(self.source, 'electric_solo', self.root)
|
||||
self.assertTrue(all(music(before) == music(v) for v in (present, changed, absent)))
|
||||
def test_original_melody_changes_the_theme_with_unchanged_harmony(self):
|
||||
source=song(count=4,chords=('CM7',))
|
||||
changed=copy.deepcopy(source)
|
||||
source = tests.solo_fixtures.song(count=4, chords=('CM7',))
|
||||
changed = copy.deepcopy(source)
|
||||
for m in changed['staves'][0]['measures']:
|
||||
for v in m['voices']:
|
||||
for n in v['notes']:
|
||||
for p in n['pitches']:p['letter']={'c':'e','d':'f','e':'g','f':'a'}[p['letter']]
|
||||
a=production.generate_role(production.SourceAnalysis.read(source),'acoustic_solo',self.root)
|
||||
b=production.generate_role(production.SourceAnalysis.read(changed),'acoustic_solo',self.root)
|
||||
self.assertNotEqual(a['details']['phrases'][0]['notes'],b['details']['phrases'][0]['notes'])
|
||||
|
||||
for p in n['pitches']:
|
||||
p['letter'] = {'c': 'e', 'd': 'f', 'e': 'g', 'f': 'a'}[p['letter']]
|
||||
a = generator.production.generate_role(generator.production.SourceAnalysis.read(source), 'acoustic_solo', self.root)
|
||||
b = generator.production.generate_role(generator.production.SourceAnalysis.read(changed), 'acoustic_solo', self.root)
|
||||
self.assertNotEqual(a['details']['phrases'][0]['notes'], b['details']['phrases'][0]['notes'])
|
||||
def test_source_tuplet_lanes_survive_assembly(self):
|
||||
serial={r:production.generate_role(self.source,r,self.root) for r in production.ROLES}
|
||||
serial = {r: generator.production.generate_role(self.source, r, self.root) for r in generator.production.ROLES}
|
||||
# Exercise a different original lane name, as in the imported Autumn head.
|
||||
doc=serial['electric_melody']['document'];staff=doc['staves'][0]
|
||||
raw=tuple(note(F(i,3),F(i+1,3),'c',event_id=str(i)) for i in range(3))
|
||||
placed=placement.place(raw,F(120))[1]
|
||||
index=next(i for i,m in enumerate(staff['measures']) if m.get('section_label')=='Guitar Melody')
|
||||
replacement=notation.measures(staff['measures'][index:index+1],placed,'source_triplet')
|
||||
notation.replace_region(doc,index,replacement)
|
||||
doc = serial['electric_melody']['document']
|
||||
staff = doc['staves'][0]
|
||||
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(i, 3), fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
|
||||
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
|
||||
index = next(i for i, m in enumerate(staff['measures']) if m.get('section_label') == 'Guitar Melody')
|
||||
replacement = generator.solo.notation.measures(staff['measures'][index:index + 1], placed, 'source_triplet')
|
||||
generator.solo.notation.replace_region(doc, index, replacement)
|
||||
for m in staff['measures']:
|
||||
for v in m['voices']:
|
||||
v['id']='voice_source'
|
||||
for n in v['notes']:n['voice']='voice_source'
|
||||
for t in staff['tuplets']:t['voice']='voice_source'
|
||||
model.validate_document(doc)
|
||||
model.validate_document(production.assemble(self.source,serial)['electric'])
|
||||
|
||||
v['id'] = 'voice_source'
|
||||
for n in v['notes']:
|
||||
n['voice'] = 'voice_source'
|
||||
for t in staff['tuplets']:
|
||||
t['voice'] = 'voice_source'
|
||||
generator.model.validate_document(doc)
|
||||
generator.model.validate_document(generator.production.assemble(self.source, serial)['electric'])
|
||||
def test_sparse_parts_reuse_the_canonical_form(self):
|
||||
result=production.generate_role(production.SourceAnalysis.read(song(count=4,chords=('Dm7','G7','CM7'))),'acoustic_accompaniment',self.root)
|
||||
measures=result['document']['staves'][0]['measures']
|
||||
result = generator.production.generate_role(generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('Dm7', 'G7', 'CM7'))), 'acoustic_accompaniment', self.root)
|
||||
measures = result['document']['staves'][0]['measures']
|
||||
def signature(m):
|
||||
return [(n['onset'],n['duration'],[(model.pitch_number(p),p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
|
||||
self.assertEqual([signature(m) for m in measures[4:8]],[signature(m) for m in measures[8:12]])
|
||||
self.assertTrue(all(len(n['pitches'])<=3 for m in measures for v in m['voices'] for n in v['notes']))
|
||||
|
||||
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
|
||||
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
|
||||
self.assertTrue(all(len(n['pitches']) <= 3 for m in measures for v in m['voices'] for n in v['notes']))
|
||||
|
||||
class TypedExpressionContracts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.h=harmonic.Harmony(choices(('C7','FM7')))
|
||||
|
||||
def make(self,rule='upper_lower'):
|
||||
target=note(2,4,'e',event_id='target')
|
||||
upper=note(1,F(3,2),'f',event_id='upper')
|
||||
lower=dataclasses.replace(note(F(3,2),2,'d',event_id='lower'),pitch=types.Pitch('d',F(1),4))
|
||||
c=types.ExpressionContract('gesture','enclosure',rule,(upper,lower),target,'C7')
|
||||
return (upper,lower,target),c
|
||||
|
||||
self.h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'FM7')))
|
||||
def make(self, rule='upper_lower'):
|
||||
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
|
||||
upper = tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'f', event_id='upper')
|
||||
lower = dataclasses.replace(tests.solo_fixtures.note(fractions.Fraction(3, 2), 2, 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
|
||||
c = generator.solo.types.ExpressionContract('gesture', 'enclosure', rule, (upper, lower), target, 'C7')
|
||||
return (upper, lower, target), c
|
||||
def test_complete_enclosure_and_orphans(self):
|
||||
notes,c=self.make()
|
||||
self.assertTrue(self.h.evaluate(notes,expressions=(c,)).valid)
|
||||
notes, c = self.make()
|
||||
self.assertTrue(self.h.evaluate(notes, expressions=(c,)).valid)
|
||||
self.assertTrue(self.h.evaluate(notes).valid)
|
||||
self.assertFalse(self.h.evaluate(notes,infer=False).valid)
|
||||
self.assertFalse(self.h.evaluate(notes[:-1],expressions=(c,)).valid)
|
||||
changed=(*notes[:-1],dataclasses.replace(notes[-1],pitch=dataclasses.replace(notes[-1].pitch,octave=5)))
|
||||
self.assertFalse(self.h.evaluate(changed,expressions=(c,)).valid)
|
||||
|
||||
self.assertFalse(self.h.evaluate(notes, infer=False).valid)
|
||||
self.assertFalse(self.h.evaluate(notes[:-1], expressions=(c,)).valid)
|
||||
changed = (*notes[:-1], dataclasses.replace(notes[-1], pitch=dataclasses.replace(notes[-1].pitch, octave=5)))
|
||||
self.assertFalse(self.h.evaluate(changed, expressions=(c,)).valid)
|
||||
def test_anticipation_validates_actual_future_harmony(self):
|
||||
n=note(F(7,2),6,'a',event_id='early')
|
||||
c=types.ExpressionContract('anticipation','anticipation','early_chord_tone',(n,),n,'FM7',boundary=F(4))
|
||||
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
|
||||
n = tests.solo_fixtures.note(fractions.Fraction(7, 2), 6, 'a', event_id='early')
|
||||
c = generator.solo.types.ExpressionContract('anticipation', 'anticipation', 'early_chord_tone', (n,), n, 'FM7', boundary=fractions.Fraction(4))
|
||||
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
|
||||
self.assertTrue(self.h.evaluate((n,)).valid)
|
||||
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
|
||||
self.assertFalse(self.h.evaluate((n,),expressions=(dataclasses.replace(c,boundary=F(3)),)).valid)
|
||||
|
||||
self.assertFalse(self.h.evaluate((n,), infer=False).valid)
|
||||
self.assertFalse(self.h.evaluate((n,), expressions=(dataclasses.replace(c, boundary=fractions.Fraction(3)),)).valid)
|
||||
def test_optional_color_is_not_a_written_chord_requirement(self):
|
||||
n=note(0,2,'d',event_id='ninth')
|
||||
c=types.ExpressionContract('color','color','dominant_ninth',(n,),n,'C7')
|
||||
self.assertTrue(self.h.evaluate((n,),expressions=(c,)).valid)
|
||||
n = tests.solo_fixtures.note(0, 2, 'd', event_id='ninth')
|
||||
c = generator.solo.types.ExpressionContract('color', 'color', 'dominant_ninth', (n,), n, 'C7')
|
||||
self.assertTrue(self.h.evaluate((n,), expressions=(c,)).valid)
|
||||
self.assertTrue(self.h.evaluate((n,)).valid)
|
||||
self.assertFalse(self.h.evaluate((n,),infer=False).valid)
|
||||
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.basic)
|
||||
self.assertNotIn(n.pitch.number()%12,self.h.spans[0].roles.specified)
|
||||
|
||||
self.assertFalse(self.h.evaluate((n,), infer=False).valid)
|
||||
self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.basic)
|
||||
self.assertNotIn(n.pitch.number() % 12, self.h.spans[0].roles.specified)
|
||||
def test_labels_do_not_authorize_unresolved_notes(self):
|
||||
n=dataclasses.replace(note(0,2,'f',event_id='bad'),pitch=types.Pitch('f',F(1),4),role='color')
|
||||
n = dataclasses.replace(tests.solo_fixtures.note(0, 2, 'f', event_id='bad'), pitch=generator.solo.types.Pitch('f', fractions.Fraction(1), 4), role='color')
|
||||
self.assertFalse(self.h.evaluate((n,)).valid)
|
||||
c=types.ExpressionContract('bad','color','anything',(n,),n,'C7')
|
||||
self.assertFalse(self.h.evaluate((n,),expressions=(c,)).valid)
|
||||
|
||||
c = generator.solo.types.ExpressionContract('bad', 'color', 'anything', (n,), n, 'C7')
|
||||
self.assertFalse(self.h.evaluate((n,), expressions=(c,)).valid)
|
||||
def test_delayed_approach_allows_only_the_declared_rest(self):
|
||||
lower=dataclasses.replace(note(1,F(3,2),'d',event_id='lower'),pitch=types.Pitch('d',F(1),4))
|
||||
target=note(2,4,'e',event_id='target')
|
||||
c=types.ExpressionContract('delay','approach','delayed_below',(lower,),target,'C7')
|
||||
self.assertTrue(self.h.evaluate((lower,target),expressions=(c,)).valid)
|
||||
wrong=dataclasses.replace(c,rule='below')
|
||||
self.assertFalse(self.h.evaluate((lower,target),expressions=(wrong,)).valid)
|
||||
|
||||
lower = dataclasses.replace(tests.solo_fixtures.note(1, fractions.Fraction(3, 2), 'd', event_id='lower'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(1), 4))
|
||||
target = tests.solo_fixtures.note(2, 4, 'e', event_id='target')
|
||||
c = generator.solo.types.ExpressionContract('delay', 'approach', 'delayed_below', (lower,), target, 'C7')
|
||||
self.assertTrue(self.h.evaluate((lower, target), expressions=(c,)).valid)
|
||||
wrong = dataclasses.replace(c, rule='below')
|
||||
self.assertFalse(self.h.evaluate((lower, target), expressions=(wrong,)).valid)
|
||||
def test_altered_dominant_color_requires_function_and_resolution(self):
|
||||
color=dataclasses.replace(note(0,1,'d',event_id='color'),pitch=types.Pitch('d',F(-1),4))
|
||||
target=note(1,2,'c',event_id='target')
|
||||
c=types.ExpressionContract('flat9','color','dominant_flat_ninth',(color,),target,'C7')
|
||||
self.assertTrue(self.h.evaluate((color,target),expressions=(c,)).valid)
|
||||
unresolved=harmonic.Harmony(choices(('C7','Dm7')))
|
||||
self.assertFalse(unresolved.evaluate((color,target),expressions=(c,)).valid)
|
||||
self.assertFalse(self.h.evaluate((color,),expressions=(c,)).valid)
|
||||
|
||||
color = dataclasses.replace(tests.solo_fixtures.note(0, 1, 'd', event_id='color'), pitch=generator.solo.types.Pitch('d', fractions.Fraction(-1), 4))
|
||||
target = tests.solo_fixtures.note(1, 2, 'c', event_id='target')
|
||||
c = generator.solo.types.ExpressionContract('flat9', 'color', 'dominant_flat_ninth', (color,), target, 'C7')
|
||||
self.assertTrue(self.h.evaluate((color, target), expressions=(c,)).valid)
|
||||
unresolved = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7', 'Dm7')))
|
||||
self.assertFalse(unresolved.evaluate((color, target), expressions=(c,)).valid)
|
||||
self.assertFalse(self.h.evaluate((color,), expressions=(c,)).valid)
|
||||
def test_mapped_semitone_needs_no_expression_exception(self):
|
||||
h=harmonic.Harmony(choices(('A7','Dm7')))
|
||||
a=dataclasses.replace(note(0,F(1,2),'c',event_id='csharp'),pitch=types.Pitch('c',F(1),4))
|
||||
b=note(F(1,2),1,'d',event_id='d')
|
||||
self.assertTrue(h.evaluate((a,b)).valid)
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('A7', 'Dm7')))
|
||||
a = dataclasses.replace(tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'c', event_id='csharp'), pitch=generator.solo.types.Pitch('c', fractions.Fraction(1), 4))
|
||||
b = tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'd', event_id='d')
|
||||
self.assertTrue(h.evaluate((a, b)).valid)
|
||||
def test_ties_and_barlines_do_not_restart_triplets(self):
|
||||
raw=tuple(note(F(7,2)+F(i,3),F(7,2)+F(i+1,3),'c',event_id=str(i)) for i in range(3))
|
||||
placed=placement.place(raw,F(120))[1]
|
||||
doc=song(count=2,chords=('CM7',));measures=notation.measures(doc['staves'][0]['measures'],placed,'triplet')
|
||||
spans=notation.tuplets(measures)
|
||||
self.assertEqual(len(spans),1)
|
||||
raw = tuple(tests.solo_fixtures.note(fractions.Fraction(7, 2) + fractions.Fraction(i, 3), fractions.Fraction(7, 2) + fractions.Fraction(i + 1, 3), 'c', event_id=str(i)) for i in range(3))
|
||||
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
|
||||
doc = tests.solo_fixtures.song(count=2, chords=('CM7',))
|
||||
measures = generator.solo.notation.measures(doc['staves'][0]['measures'], placed, 'triplet')
|
||||
spans = generator.solo.notation.tuplets(measures)
|
||||
self.assertEqual(len(spans), 1)
|
||||
self.assertTrue(any('tie_from' in p for m in measures for v in m['voices'] for n in v['notes'] for p in n['pitches']))
|
||||
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+127
-111
@@ -2,143 +2,159 @@
|
||||
import copy
|
||||
import dataclasses
|
||||
import json
|
||||
from pathlib import Path
|
||||
from fractions import Fraction as F
|
||||
import pathlib
|
||||
import fractions
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import unittest.mock
|
||||
|
||||
from generator import production,parts,musicxml,train,training_io,vocabulary
|
||||
from generator.solo import authored,learned,composer,decoder,harmonic,analysis,export
|
||||
from tests.solo_fixtures import song
|
||||
from tests.test_musicxml import xml_score,xml_note
|
||||
import generator.production
|
||||
import generator.parts
|
||||
import generator.musicxml
|
||||
import generator.train
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.solo.authored
|
||||
import generator.solo.learned
|
||||
import generator.solo.composer
|
||||
import generator.solo.decoder
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.analysis
|
||||
import generator.solo.export
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
import generator.harmonic_time
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def music(events):return tuple((F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in events)
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
def music(events): return tuple((fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in events)
|
||||
|
||||
class TrainingAndRepertoire(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.examples,cls.provenance=train.load_examples(ROOT/'data/training/manifest.json')
|
||||
|
||||
cls.examples, cls.provenance = generator.train.load_examples(ROOT / 'data/training/manifest.json')
|
||||
def test_all_four_examples_and_intentional_pickup_forms(self):
|
||||
self.assertEqual(len(self.examples),4)
|
||||
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']),[32,32,33,33])
|
||||
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']),['0','0','4','4'])
|
||||
|
||||
self.assertEqual(len(self.examples), 4)
|
||||
self.assertEqual(sorted(p['measures'] for p in self.provenance['examples']), [32, 32, 33, 33])
|
||||
self.assertEqual(sorted(p['pickup_beats'] for p in self.provenance['examples']), ['0', '0', '4', '4'])
|
||||
def test_training_is_pooled_and_deterministic(self):
|
||||
baseline=learned.fit(self.examples,{})
|
||||
swapped=[dict(e,role='electric' if e['role']=='acoustic' else 'acoustic') for e in reversed(self.examples)]
|
||||
self.assertEqual(baseline,learned.fit(swapped,{}))
|
||||
self.assertNotIn('acoustic',json.dumps(baseline))
|
||||
self.assertNotIn('electric',json.dumps(baseline))
|
||||
frozen=json.loads((ROOT/'data/models/solo.json').read_text())
|
||||
self.assertEqual(frozen,learned.fit(self.examples,self.provenance))
|
||||
|
||||
baseline = generator.solo.learned.fit(self.examples, {})
|
||||
swapped = [dict(e, role='electric' if e['role'] == 'acoustic' else 'acoustic') for e in reversed(self.examples)]
|
||||
self.assertEqual(baseline, generator.solo.learned.fit(swapped, {}))
|
||||
self.assertNotIn('acoustic', json.dumps(baseline))
|
||||
self.assertNotIn('electric', json.dumps(baseline))
|
||||
frozen = json.loads((ROOT / 'data/models/solo.json').read_text())
|
||||
self.assertEqual(frozen, generator.solo.learned.fit(self.examples, self.provenance))
|
||||
def test_repertoire_exports_exact_sounding_music_for_every_registered_pair(self):
|
||||
manifest=json.loads((ROOT/'data/training/manifest.json').read_text())
|
||||
manifest = json.loads((ROOT / 'data/training/manifest.json').read_text())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for entry in manifest['examples']:
|
||||
part='acoustic' if entry['slot']==1 else 'electric'
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
|
||||
source=production.SourceAnalysis.read(doc)
|
||||
with patch('generator.solo.composer.compose',side_effect=AssertionError('authored pair must not be predicted')):
|
||||
result=production.generate_role(source,part+'_solo',ROOT/'data')
|
||||
path=Path(directory)/(part+'.xml');export.write_role(result,path)
|
||||
actual=training_io.read(path,part);expected=training_io.read(ROOT/'data/training'/entry['solo'],part,pitch_convention=entry['pitch_convention'])
|
||||
self.assertEqual(music(actual['sounding_events']),music(expected['sounding_events']),entry['id'])
|
||||
self.assertEqual([(m['start'],m['duration']) for m in actual['measures']],[(m['start'],m['duration']) for m in expected['measures']])
|
||||
self.assertEqual(actual['discrepancies'],[])
|
||||
part = 'acoustic' if entry['slot'] == 1 else 'electric'
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
||||
source = generator.production.SourceAnalysis.read(doc)
|
||||
with unittest.mock.patch('generator.solo.composer.compose', side_effect=AssertionError('authored pair must not be predicted')):
|
||||
result = generator.production.generate_role(source, part + '_solo', ROOT / 'data')
|
||||
path = pathlib.Path(directory) / (part + '.xml')
|
||||
generator.solo.export.write_role(result, path)
|
||||
actual = generator.training_io.read(path, part)
|
||||
expected = generator.training_io.read(ROOT / 'data/training' / entry['solo'], part, pitch_convention=entry['pitch_convention'])
|
||||
self.assertEqual(music(actual['sounding_events']), music(expected['sounding_events']), entry['id'])
|
||||
self.assertEqual([(m['start'], m['duration']) for m in actual['measures']], [(m['start'], m['duration']) for m in expected['measures']])
|
||||
self.assertEqual(actual['discrepancies'], [])
|
||||
self.assertTrue(all(n['legal_positions_at_sounding_pitch'] for n in actual['notes'] if n['pitch']))
|
||||
|
||||
def test_lookup_uses_source_music_and_slot_not_title_or_path(self):
|
||||
entry=json.loads((ROOT/'data/training/manifest.json').read_text())['examples'][0]
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(ROOT/'data/training'/entry['source']))
|
||||
source=production.SourceAnalysis.read(doc)
|
||||
part='acoustic' if entry['slot']==1 else 'electric'
|
||||
self.assertIsNotNone(authored.lookup(source,part,ROOT/'data'))
|
||||
doc['metadata'].update(title='Renamed standard',composer='Display only')
|
||||
self.assertEqual(source,production.SourceAnalysis.read(doc))
|
||||
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave']+=1
|
||||
self.assertIsNone(authored.lookup(production.SourceAnalysis.read(doc),part,ROOT/'data'))
|
||||
|
||||
entry = json.loads((ROOT / 'data/training/manifest.json').read_text())['examples'][0]
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(ROOT / 'data/training' / entry['source']))
|
||||
source = generator.production.SourceAnalysis.read(doc)
|
||||
part = 'acoustic' if entry['slot'] == 1 else 'electric'
|
||||
self.assertIsNotNone(generator.solo.authored.lookup(source, part, ROOT / 'data'))
|
||||
doc['metadata'].update(title='Renamed standard', composer='Display only')
|
||||
self.assertEqual(source, generator.production.SourceAnalysis.read(doc))
|
||||
doc['staves'][0]['measures'][0]['voices'][0]['notes'][0]['pitches'][0]['octave'] += 1
|
||||
self.assertIsNone(generator.solo.authored.lookup(generator.production.SourceAnalysis.read(doc), part, ROOT / 'data'))
|
||||
def test_adding_a_new_pair_requires_no_code_or_existing_song_names(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);source=root/'new-standard.xml';solo=root/'interpretation.xml'
|
||||
content=xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'+xml_note('E',octave=5))
|
||||
source.write_text(content);solo.write_text(content)
|
||||
manifest=root/'data/training/manifest.json'
|
||||
train.register_example(manifest,source,solo,1,'guitar-written')
|
||||
examples,_=train.load_examples(manifest)
|
||||
self.assertEqual(len(examples),1)
|
||||
doc=parts.prepare_document(musicxml.load_musicxml(root/'data/songs'/source.name))
|
||||
context=production.SourceAnalysis.read(doc)
|
||||
self.assertIsNotNone(authored.lookup(context,'acoustic',root/'data'))
|
||||
self.assertIsNone(authored.lookup(context,'electric',root/'data'))
|
||||
self.assertEqual(solo.read_bytes(),(manifest.parent/solo.name).read_bytes())
|
||||
before=manifest.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError,'already registered'):train.register_example(manifest,source,solo,1,'guitar-written')
|
||||
self.assertEqual(manifest.read_bytes(),before)
|
||||
|
||||
root = pathlib.Path(directory)
|
||||
source = root / 'new-standard.xml'
|
||||
solo = root / 'interpretation.xml'
|
||||
content = tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', octave=5))
|
||||
source.write_text(content)
|
||||
solo.write_text(content)
|
||||
manifest = root / 'data/training/manifest.json'
|
||||
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
|
||||
examples, _ = generator.train.load_examples(manifest)
|
||||
self.assertEqual(len(examples), 1)
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(root / 'data/songs' / source.name))
|
||||
context = generator.production.SourceAnalysis.read(doc)
|
||||
self.assertIsNotNone(generator.solo.authored.lookup(context, 'acoustic', root / 'data'))
|
||||
self.assertIsNone(generator.solo.authored.lookup(context, 'electric', root / 'data'))
|
||||
self.assertEqual(solo.read_bytes(), (manifest.parent / solo.name).read_bytes())
|
||||
before = manifest.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, 'already registered'):
|
||||
generator.train.register_example(manifest, source, solo, 1, 'guitar-written')
|
||||
self.assertEqual(manifest.read_bytes(), before)
|
||||
def test_incorrect_form_registration_rolls_back(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);source=root/'song.xml';solo=root/'solo.xml'
|
||||
source.write_text(xml_score(xml_note()));solo.write_text(xml_score(xml_note(duration=2,kind='half')))
|
||||
manifest=root/'data/training/manifest.json'
|
||||
with self.assertRaisesRegex(ValueError,'timing differs'):train.register_example(manifest,source,solo,2,'guitar-written')
|
||||
self.assertFalse(manifest.exists());self.assertFalse((root/'data/songs/song.xml').exists());self.assertFalse((root/'data/training/solo.xml').exists())
|
||||
|
||||
root = pathlib.Path(directory)
|
||||
source = root / 'song.xml'
|
||||
solo = root / 'solo.xml'
|
||||
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
solo.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=2, kind='half')))
|
||||
manifest = root / 'data/training/manifest.json'
|
||||
with self.assertRaisesRegex(ValueError, 'timing differs'):
|
||||
generator.train.register_example(manifest, source, solo, 2, 'guitar-written')
|
||||
self.assertFalse(manifest.exists())
|
||||
self.assertFalse((root / 'data/songs/song.xml').exists())
|
||||
self.assertFalse((root / 'data/training/solo.xml').exists())
|
||||
|
||||
class LearnedInference(unittest.TestCase):
|
||||
def test_model_is_required_without_repertoire(self):
|
||||
with self.assertRaisesRegex(ValueError,'run python -m generator.train'):learned.Model.load('/missing-model-file')
|
||||
|
||||
with self.assertRaisesRegex(ValueError, 'run python -m generator.train'):
|
||||
generator.solo.learned.Model.load('/missing-model-file')
|
||||
def test_decoder_runs_without_training_files_and_is_deterministic(self):
|
||||
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
|
||||
predictor=learned.Model.load(ROOT/'data/models/solo.json')
|
||||
with vocabulary.using(vocabulary.load(),'acoustic_solo',{'attempts':2}):
|
||||
original=Path.open
|
||||
def guarded(path,*args,**kwargs):
|
||||
self.assertNotIn('training',str(path));return original(path,*args,**kwargs)
|
||||
with patch.object(Path,'open',guarded):
|
||||
first=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
|
||||
second=composer.compose(a,source['staves'][0]['measures'],'acoustic',F(4),learned_model=predictor)
|
||||
self.assertEqual(first.notes(),second.notes());composer.validate(first)
|
||||
self.assertTrue(first.notes());self.assertEqual(first.diagnostics['construction'],'learned_joint_events')
|
||||
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
a = generator.solo.analysis.analyze(source)
|
||||
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
|
||||
with generator.vocabulary.using(generator.vocabulary.load(), 'acoustic_solo', {'attempts': 2}):
|
||||
original = pathlib.Path.open
|
||||
def guarded(path, *args, **kwargs):
|
||||
self.assertNotIn('training', str(path))
|
||||
return original(path, *args, **kwargs)
|
||||
with unittest.mock.patch.object(pathlib.Path, 'open', guarded):
|
||||
first = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
|
||||
second = generator.solo.composer.compose(a, source['staves'][0]['measures'], 'acoustic', fractions.Fraction(4), learned_model=predictor)
|
||||
self.assertEqual(first.notes(), second.notes())
|
||||
generator.solo.composer.validate(first)
|
||||
self.assertTrue(first.notes())
|
||||
self.assertEqual(first.diagnostics['construction'], 'learned_joint_events')
|
||||
def test_event_score_does_not_depend_on_action_grouping(self):
|
||||
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
|
||||
from generator.harmonic_time import read
|
||||
h=harmonic.Harmony(read(source['staves'][0]));predictor=learned.Model.load(ROOT/'data/models/solo.json')
|
||||
engine=decoder.Decoder(a,h,predictor,'acoustic',F(),F(4))
|
||||
notes=(engine.note(F(),F(1),60),engine.note(F(1),F(2),64))
|
||||
events=tuple((n.start,n.end,n.pitch.number()) for n in notes)
|
||||
combined=engine.extend(decoder.State(),(events,notes,()))
|
||||
split=engine.extend(engine.extend(decoder.State(),(events[:1],notes[:1],())),(events[1:],notes[1:],()))
|
||||
self.assertEqual(combined,split)
|
||||
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
a = generator.solo.analysis.analyze(source)
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
|
||||
predictor = generator.solo.learned.Model.load(ROOT / 'data/models/solo.json')
|
||||
engine = generator.solo.decoder.Decoder(a, h, predictor, 'acoustic', fractions.Fraction(), fractions.Fraction(4))
|
||||
notes = (engine.note(fractions.Fraction(), fractions.Fraction(1), 60), engine.note(fractions.Fraction(1), fractions.Fraction(2), 64))
|
||||
events = tuple((n.start, n.end, n.pitch.number()) for n in notes)
|
||||
combined = engine.extend(generator.solo.decoder.State(), (events, notes, ()))
|
||||
split = engine.extend(engine.extend(generator.solo.decoder.State(), (events[:1], notes[:1], ())), (events[1:], notes[1:], ()))
|
||||
self.assertEqual(combined, split)
|
||||
def test_source_features_include_actual_melodic_intervals(self):
|
||||
source=song(count=1,chords=('C7',));a=analysis.analyze(source)
|
||||
changed=dataclasses.replace(a,melody=tuple(dataclasses.replace(n,pitch=dataclasses.replace(n.pitch,octave=n.pitch.octave+(i==1))) for i,n in enumerate(a.melody)))
|
||||
self.assertNotEqual(learned.source_feature(a,F(1)),learned.source_feature(changed,F(1)))
|
||||
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
a = generator.solo.analysis.analyze(source)
|
||||
changed = dataclasses.replace(a, melody=tuple(dataclasses.replace(n, pitch=dataclasses.replace(n.pitch, octave=n.pitch.octave + (i == 1))) for i, n in enumerate(a.melody)))
|
||||
self.assertNotEqual(generator.solo.learned.source_feature(a, fractions.Fraction(1)), generator.solo.learned.source_feature(changed, fractions.Fraction(1)))
|
||||
def test_generated_long_nonchord_notes_still_need_support(self):
|
||||
from tests.solo_fixtures import choices,note
|
||||
h=harmonic.Harmony(choices(('C7',)))
|
||||
self.assertTrue(h.evaluate((note(0,2,'e'),)).valid)
|
||||
self.assertFalse(h.evaluate((note(0,2,'f'),)).valid)
|
||||
self.assertTrue(h.evaluate((note(0,F(1,2),'g'),note(F(1,2),1,'f'),note(1,2,'e'))).valid)
|
||||
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C7',)))
|
||||
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, 2, 'e'),)).valid)
|
||||
self.assertFalse(h.evaluate((tests.solo_fixtures.note(0, 2, 'f'),)).valid)
|
||||
self.assertTrue(h.evaluate((tests.solo_fixtures.note(0, fractions.Fraction(1, 2), 'g'), tests.solo_fixtures.note(fractions.Fraction(1, 2), 1, 'f'), tests.solo_fixtures.note(1, 2, 'e'))).valid)
|
||||
def test_held_chord_member_can_become_a_supported_color(self):
|
||||
from tests.solo_fixtures import choices
|
||||
source=song(count=2,chords=('C','Dm'));a=analysis.analyze(source)
|
||||
h=harmonic.Harmony(choices(('C','Dm')))
|
||||
engine=decoder.Decoder(a,h,learned.Model.load(ROOT/'data/models/solo.json'),'acoustic',F(),F(8))
|
||||
n=engine.note(F(3),F(6),64) # E: C's third becomes D minor's ninth.
|
||||
support=engine.permission(n)
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C', 'Dm'))
|
||||
a = generator.solo.analysis.analyze(source)
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('C', 'Dm')))
|
||||
engine = generator.solo.decoder.Decoder(a, h, generator.solo.learned.Model.load(ROOT / 'data/models/solo.json'), 'acoustic', fractions.Fraction(), fractions.Fraction(8))
|
||||
n = engine.note(fractions.Fraction(3), fractions.Fraction(6), 64) # E: C's third becomes D minor's ninth.
|
||||
support = engine.permission(n)
|
||||
self.assertIsNotNone(support)
|
||||
self.assertEqual(support[0].rule,'minor_ninth')
|
||||
self.assertTrue(h.evaluate((n,),complete=True,expressions=support).valid)
|
||||
self.assertEqual(support[0].rule, 'minor_ninth')
|
||||
self.assertTrue(h.evaluate((n,), complete=True, expressions=support).valid)
|
||||
|
||||
+10
-10
@@ -41,7 +41,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 1U);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 1U));
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(!state.audio.backing_enabled);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 2U));
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U));
|
||||
@@ -53,7 +53,7 @@ BOOST_AUTO_TEST_CASE(menu_actions_share_navigation_and_preserve_settings_focus)
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(state.selected_settings_index == 3U);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
BOOST_TEST(state.audio.playback_enabled);
|
||||
BOOST_TEST(!state.audio.backing_enabled);
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
BOOST_REQUIRE(vamp_zero::validate_app_state(state));
|
||||
}
|
||||
@@ -68,7 +68,7 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
|
||||
BOOST_REQUIRE(tapped);
|
||||
state = vamp_zero::apply_menu_action(state, *tapped);
|
||||
BOOST_CHECK(state.mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
BOOST_TEST(!state.audio.playback_enabled);
|
||||
BOOST_TEST(!state.audio.backing_enabled);
|
||||
BOOST_CHECK(!touch.handle(point_event(luma::touch_phase::UP, point, now), menu_bounds, state));
|
||||
point = row_center(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP);
|
||||
touch.handle(point_event(luma::touch_phase::DOWN, point, now), menu_bounds, state);
|
||||
@@ -76,16 +76,16 @@ BOOST_AUTO_TEST_CASE(menu_touch_activates_once_on_release_and_stepper_has_priori
|
||||
BOOST_REQUIRE(tapped);
|
||||
BOOST_CHECK(tapped->kind == vamp_zero::menu_action_kind::TEMPO_UP);
|
||||
state = vamp_zero::apply_menu_action(state, *tapped);
|
||||
BOOST_TEST(state.audio.metronome_bpm == 125U);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 120U);
|
||||
BOOST_TEST(!state.audio.tempo_editing);
|
||||
for (auto i = 0U; i < 100U; ++i) {
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_DOWN));
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_min_bpm);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 60U);
|
||||
for (auto i = 0U; i < 100U; ++i) {
|
||||
state = vamp_zero::apply_menu_action(state, action(state, 3U, vamp_zero::menu_action_kind::TEMPO_UP));
|
||||
}
|
||||
BOOST_TEST(state.audio.metronome_bpm == state.audio.metronome_max_bpm);
|
||||
BOOST_TEST(state.audio.tempo_bpm == 120U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(menu_touch_cancels_drag_hold_multitouch_and_stale_press) {
|
||||
@@ -121,14 +121,14 @@ BOOST_AUTO_TEST_CASE(control_rejects_delayed_actions_after_remote_navigation_or_
|
||||
control.apply(tapped);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::SETTINGS_MENU);
|
||||
control.apply(tapped);
|
||||
BOOST_TEST(!control.snapshot().audio.playback_enabled);
|
||||
BOOST_TEST(!control.snapshot().audio.backing_enabled);
|
||||
auto settings = control.snapshot();
|
||||
tapped = action(settings, 1U);
|
||||
control.apply(vamp_protocol::page_command::NEXT);
|
||||
auto before = control.snapshot();
|
||||
control.apply(tapped);
|
||||
BOOST_TEST(control.snapshot().input_revision == before.input_revision);
|
||||
BOOST_TEST(!control.snapshot().audio.playback_enabled);
|
||||
BOOST_TEST(!control.snapshot().audio.backing_enabled);
|
||||
tapped = action(before, 1U);
|
||||
tapped.catalog = std::make_shared<vamp_zero::catalog const>();
|
||||
control.apply(tapped);
|
||||
@@ -165,9 +165,9 @@ BOOST_AUTO_TEST_CASE(menu_scroll_reaches_every_song_and_remote_shows_menu_titles
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_settings_parser_rejects_invalid_values_without_mutating_state) {
|
||||
auto state = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Playback\nplayback=1\nbpm=135\n", state));
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("mode=settings_menu\nfocus=Backing\nbacking=1\nbpm=135\n", state));
|
||||
auto before = state;
|
||||
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "playback=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
|
||||
for (auto text : {"bpm=0\n", "bpm=401\n", "bpm=-1\n", "editing=yes\n", "backing=2\n", "metronome=true\n", "focus=abcdefghijklmnopqrstuvwxyz0123456789\n"}) {
|
||||
BOOST_CHECK(!vamp_pico::parse_remote_display_body(text, state));
|
||||
BOOST_CHECK(state == before);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(source, part)
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
|
||||
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody'])
|
||||
self.assertFalse(measures[-1]['harmonies'])
|
||||
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
|
||||
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
|
||||
|
||||
+7
-7
@@ -16,7 +16,7 @@ import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.pitch
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
@@ -51,7 +51,7 @@ class PartGenerationTests(unittest.TestCase):
|
||||
if onset < fractions.Fraction(3, 2):
|
||||
self.assertLessEqual(onset + generator.model.fraction(note['duration']), fractions.Fraction(3, 2))
|
||||
self.assertLessEqual({generator.model.pitch_number(p) % 12 for p in note['pitches']}, {0, 4, 7} if onset < fractions.Fraction(3, 2) else {0, 5, 9})
|
||||
self.assertTrue(generator.playback.compile_timeline(result)['events'])
|
||||
self.assertTrue(tests.score_events.timeline(result)['events'])
|
||||
def test_harmony_carry_and_explicit_silence(self):
|
||||
content = xml_harmony(offset=1) + xml_harmony(kind='none', offset=2) + xml_harmony('F', offset=3) + tests.test_musicxml.xml_note()
|
||||
source = tests.test_musicxml.xml_score(content).replace('</measure></part>', '</measure><measure number="2">' + tests.test_musicxml.xml_note() + '</measure><measure number="3">' + xml_harmony(kind='none') + tests.test_musicxml.xml_note() + '</measure><measure number="4">' + tests.test_musicxml.xml_note() + '</measure></part>')
|
||||
@@ -75,10 +75,10 @@ class PartGenerationTests(unittest.TestCase):
|
||||
bar = xml_harmony() + ''.join(tests.test_musicxml.xml_note(duration=1, kind='quarter') for _ in range(4))
|
||||
xml = tests.test_musicxml.xml_score(bar).replace('</part>', ''.join(f'<measure number="{i}">{bar}</measure>' for i in range(2, 13)) + '</part>')
|
||||
result = tests.arrangement_fixtures.section_document(tests.test_musicxml.parse(xml), 'acoustic')
|
||||
self.assertEqual(len(result['staves'][0]['measures']),12)
|
||||
self.assertEqual(len(result['staves'][0]['measures']), 12)
|
||||
generator.model.validate_document(result)
|
||||
self.assertTrue(generator.playback.compile_timeline(result)['events'])
|
||||
self.assertTrue(any(n['kind']=='rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
|
||||
self.assertTrue(tests.score_events.timeline(result)['events'])
|
||||
self.assertTrue(any(n['kind'] == 'rest' for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m)))
|
||||
def test_melody_written_pitch_rhythm_rests_and_cross_bar_ties_are_preserved(self):
|
||||
content = ''
|
||||
content += tests.test_musicxml.xml_note(duration=2, kind='quarter', alter=1, extra='<tie type="start"/>')
|
||||
@@ -115,7 +115,7 @@ class PartGenerationTests(unittest.TestCase):
|
||||
self.assertEqual(notes[0]['pitches'][0]['tie_to'], notes[1]['pitches'][0]['id'])
|
||||
self.assertEqual(notes[1]['pitches'][0]['tie_to'], notes[2]['pitches'][0]['id'])
|
||||
if builder is tests.arrangement_fixtures.section_document:
|
||||
events = generator.playback.compile_timeline(result)['events']
|
||||
events = tests.score_events.timeline(result)['events']
|
||||
self.assertEqual([(generator.model.fraction(e['onset']), generator.model.fraction(e['duration']), generator.model.fraction(e['pitch'])) for e in events], [(0, 6, 61 if part == 'electric' else 49), (fractions.Fraction(13, 2), fractions.Fraction(3, 2), 63 if part == 'electric' else 51)])
|
||||
self.assertEqual(source, before)
|
||||
def test_song_slug_collisions_fail_before_writes(self):
|
||||
@@ -146,7 +146,7 @@ class PartGenerationTests(unittest.TestCase):
|
||||
unittest.mock.patch('generator.engraving.pipeline.engrave_tree'),
|
||||
unittest.mock.patch('generator.pico_artwork.generate_titles'),
|
||||
unittest.mock.patch('generator.catalog.build_catalog'),
|
||||
unittest.mock.patch('generator.playback.compile_tree'),
|
||||
unittest.mock.patch('generator.performance.copy_audio', return_value=[]),
|
||||
):
|
||||
generator.__main__.generate_assets(args)
|
||||
# Role catalog loading is replaced by this infrastructure fixture. The
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#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/control.hpp>
|
||||
#include <vamp_zero/notation.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/performance.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto read(std::string const &name) -> std::string {
|
||||
auto input = std::ifstream{std::string{VAMP_TEST_PERFORMANCE_ASSETS_DIR} + "/" + name};
|
||||
return {std::istreambuf_iterator<char>{input}, {}};
|
||||
}
|
||||
|
||||
auto score() -> vamp_zero::score_document {
|
||||
auto parsed = vamp_zero::parse_notation_document(read("score.json"), "score.json");
|
||||
BOOST_REQUIRE_MESSAGE(parsed, (parsed ? "" : parsed.error().describe()));
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state {
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>();
|
||||
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
|
||||
BOOST_REQUIRE(audio);
|
||||
catalog->audio_assets = *audio;
|
||||
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture"});
|
||||
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
|
||||
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
|
||||
result.selected_song_index = 1;
|
||||
result.selected_part_index = 1;
|
||||
result.active_part_id = "score";
|
||||
result.score_session = 1;
|
||||
result.audio.metronome_enabled = metronome;
|
||||
result.audio.backing_enabled = backing;
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
|
||||
for (auto metronome : {false, true}) {
|
||||
for (auto backing : {false, true}) {
|
||||
auto app = state(metronome, backing);
|
||||
auto document = score();
|
||||
auto prepared = vamp_zero::prepare_performance(document, app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
|
||||
if (!metronome && !backing) {
|
||||
BOOST_CHECK(!prepared);
|
||||
continue;
|
||||
}
|
||||
BOOST_REQUIRE_MESSAGE(prepared, (prepared ? "" : prepared.error().describe()));
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_transport(std::move(*prepared));
|
||||
BOOST_REQUIRE(session);
|
||||
auto frames = backing ? 576000U : 288000U;
|
||||
auto buffer = std::array<float, 960>{};
|
||||
for (unsigned cursor = 0; cursor < frames; cursor += 480) {
|
||||
BOOST_REQUIRE(engine->render(buffer.data(), 480));
|
||||
BOOST_TEST(session->frames() == cursor + 480);
|
||||
if (cursor % 24000 == 0) {
|
||||
auto expected = backing ? 0.03125F : 0.F;
|
||||
if (metronome && cursor < 288000 && (!backing || cursor >= 96000)) {
|
||||
expected += cursor % 96000 == 0 ? .25F : .125F;
|
||||
}
|
||||
BOOST_TEST(buffer.front() == expected, boost::test_tools::tolerance(0.0001F));
|
||||
}
|
||||
// Let the background stream worker refill between accelerated renders.
|
||||
if (backing && cursor % 48000 == 0) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{2});
|
||||
}
|
||||
}
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
BOOST_TEST(vamp_zero::performance_at(document, 95999, 120).phase == "Count-in");
|
||||
BOOST_TEST(vamp_zero::performance_at(document, 96000, 120).page == 0U);
|
||||
BOOST_TEST(vamp_zero::performance_at(document, 191999, 120).page == 0U);
|
||||
BOOST_TEST(vamp_zero::performance_at(document, 192000, 120).page == 1U);
|
||||
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(manual_navigation_requires_both_toggles_off) {
|
||||
for (auto metro : {false, true}) {
|
||||
for (auto backing : {false, true}) {
|
||||
auto app = state(metro, backing);
|
||||
auto next = vamp_zero::apply_app_command(app, vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(next.page_index == (metro || backing ? 0U : 1U));
|
||||
next = vamp_zero::apply_app_command(next, vamp_protocol::page_command::SELECT);
|
||||
BOOST_CHECK(next.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(tempo_steps_use_global_recording_intersection_and_nearest_slower_tie) {
|
||||
auto app = state();
|
||||
BOOST_TEST(vamp_zero::available_tempos(app).size() == 7U);
|
||||
app.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
app.selected_settings_index = 1;
|
||||
app.audio.tempo_bpm = 80;
|
||||
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(app.audio.backing_enabled);
|
||||
BOOST_TEST(app.audio.tempo_bpm == 120U);
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
|
||||
auto entry = catalog->audio_assets.back();
|
||||
entry.bpm = 100;
|
||||
entry.path = "backing/fixture_100.wav";
|
||||
catalog->audio_assets.push_back(entry);
|
||||
app.catalog = catalog;
|
||||
app.audio.backing_enabled = false;
|
||||
app.audio.tempo_bpm = 110;
|
||||
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(app.audio.tempo_bpm == 100U);
|
||||
catalog->parts.push_back({.song_id = "other"});
|
||||
catalog->groups.push_back({.parts = {1}});
|
||||
BOOST_TEST(vamp_zero::available_tempos(app).empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
|
||||
auto app = state(true, false);
|
||||
auto control = vamp_zero::control_state{{}, app};
|
||||
control.update_performance(99, 1, "Playing", app.catalog);
|
||||
BOOST_TEST(control.snapshot().page_index == 0U);
|
||||
control.update_performance(1, 1, "Playing", app.catalog);
|
||||
BOOST_TEST(control.snapshot().page_index == 1U);
|
||||
control.finish_playback(99, {}, app.catalog);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
|
||||
control.finish_playback(1, "Recording failed", app.catalog);
|
||||
control.update_performance(1, 0, "Playing", app.catalog);
|
||||
BOOST_TEST(control.snapshot().page_index == 1U);
|
||||
BOOST_TEST(control.snapshot().audio.phase == "Error");
|
||||
control.apply(vamp_protocol::page_command::SELECT);
|
||||
control.finish_playback(1, {}, app.catalog);
|
||||
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
|
||||
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
|
||||
BOOST_REQUIRE(prepared);
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_transport(std::move(*prepared));
|
||||
BOOST_REQUIRE(session);
|
||||
session->stop();
|
||||
std::array<float, 960> buffer{};
|
||||
BOOST_REQUIRE(engine->render(buffer.data(), 480));
|
||||
BOOST_TEST(session->frames() == 0U);
|
||||
BOOST_TEST(buffer.front() == 0.F);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
|
||||
auto document = boost::json::parse(read("score.json"));
|
||||
document.at("presentation").at("pages").as_array()[1].at("first_measure") = 1;
|
||||
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(document), "score.json"));
|
||||
auto app = state(true);
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
|
||||
catalog->audio_assets.front().sha256 = std::string(64, '0');
|
||||
app.catalog = catalog;
|
||||
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto controller = vamp_zero::audio_controller{{}, std::move(*engine)};
|
||||
auto app = state(true);
|
||||
auto document = std::make_shared<vamp_zero::score_document const>(score());
|
||||
auto loaded = vamp_zero::score_load_state{.document = document};
|
||||
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
|
||||
BOOST_CHECK(!controller.position(*document, 120));
|
||||
loaded.pages_ready = true;
|
||||
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
|
||||
auto exited = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
|
||||
controller.sync(exited);
|
||||
for (unsigned i = 0; i < 20; ++i) {
|
||||
BOOST_CHECK(!controller.tick(exited, {}, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
BOOST_CHECK(!controller.position(*document, 120));
|
||||
++app.score_session;
|
||||
for (unsigned i = 0; i < 200 && !controller.position(*document, 120); ++i) {
|
||||
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{1});
|
||||
}
|
||||
BOOST_REQUIRE(controller.position(*document, 120));
|
||||
controller.sync(exited);
|
||||
BOOST_CHECK(!controller.position(*document, 120));
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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['metadata']['song_id'] = 'fixture'
|
||||
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1,
|
||||
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)]
|
||||
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), 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.performance.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.performance.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.performance.describe_wav(path, root)
|
||||
path.rename(root / 'backing/fixture_115.wav')
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported'):
|
||||
generator.performance.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.performance.validate_recording_lengths(root, entries)
|
||||
entries[-1]['frames'] = 1
|
||||
with self.assertRaisesRegex(ValueError, 'shorter'):
|
||||
generator.performance.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(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'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--fixture', type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
if args.fixture:
|
||||
fixture(args.fixture)
|
||||
@@ -1,133 +0,0 @@
|
||||
#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/control.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::catalog>();
|
||||
catalog->parts.push_back({.id = "part", .title = "Song", .part = "Vocal", .total_pages = 2U});
|
||||
catalog->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::catalog 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().score_session == 42U);
|
||||
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_canceled_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_rejects_legacy_config_and_invalid_assets) {
|
||||
auto legacy = vamp_zero::parse_config(boost::json::object{{"audio", {{"track_enabled", true}}}});
|
||||
BOOST_REQUIRE(!legacy);
|
||||
BOOST_TEST(std::string{legacy.error().message()}.find("use audio.playback_enabled") != std::string::npos);
|
||||
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));
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import fractions
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
|
||||
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') + 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(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, 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 = 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 = 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 = 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(generator.model.fraction(result['events'][0]['duration']), 4)
|
||||
def test_octave_and_microtones(self):
|
||||
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 = 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 = 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 = 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 = 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(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):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
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
|
||||
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'], generator.playback.generate_tone(root, fractions.Fraction(pitch))['id'])
|
||||
def test_audio_assets_validate_and_reject_corruption(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = pathlib.Path(directory)
|
||||
generate_fixture(root)
|
||||
manifest = json.loads((root / 'score.json').read_text())['playback']
|
||||
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):
|
||||
generator.playback.validate_assets(manifest, root)
|
||||
generate_fixture(root)
|
||||
manifest['release']['sha256'] = generator.playback.write_pcm(root / 'audio/release.wav', [0.5] * 2400)
|
||||
with self.assertRaisesRegex(ValueError, 'envelope'):
|
||||
generator.playback.validate_assets(manifest, root)
|
||||
sample['path'] = '../outside.wav'
|
||||
with self.assertRaises(ValueError):
|
||||
generator.playback.validate_assets(manifest, root)
|
||||
def test_generated_tone_is_deterministic_and_loopable(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
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)
|
||||
self.assertEqual(audio.getnframes(), a['loop_end'])
|
||||
self.assertEqual(audio.getnchannels(), 1)
|
||||
self.assertGreater(a['loop_end'], a['loop_start'])
|
||||
|
||||
def generate_fixture(directory):
|
||||
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
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--fixture', required=True)
|
||||
generate_fixture(parser.parse_args().fixture)
|
||||
@@ -1,73 +1,69 @@
|
||||
import dataclasses, unittest
|
||||
from fractions import Fraction as F
|
||||
from generator.solo import types, harmonic, groups
|
||||
from tests.solo_fixtures import note,choices
|
||||
import fractions
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.groups
|
||||
import tests.solo_fixtures
|
||||
import tests.test_independent_generation
|
||||
import tempfile
|
||||
import pathlib
|
||||
import generator.input_audit
|
||||
|
||||
def melody(rows): return tuple(tests.solo_fixtures.note(a, b, p, event_id=str(i)) for i, (a, b, p) in enumerate(rows))
|
||||
|
||||
def melody(rows):return tuple(note(a,b,p,event_id=str(i)) for i,(a,b,p) in enumerate(rows))
|
||||
class RhythmGroups(unittest.TestCase):
|
||||
|
||||
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
|
||||
pattern = ((F(1, 2), F(5, 6)), (F(5, 6), F(7, 6)), (F(7, 6), F(3, 2)), (F(3, 2), F(5, 2)))
|
||||
result = groups.describe(pattern)
|
||||
self.assertEqual(result[0].family, 'triplet')
|
||||
self.assertEqual(result[-1].family, 'binary')
|
||||
self.assertEqual(result[0].displacement, F(1, 2))
|
||||
|
||||
def test_partial_triplet_does_not_drift_into_binary(self):
|
||||
self.assertFalse(groups.valid(((F(), F(1, 3)), (F(1, 3), F(5, 6)))))
|
||||
self.assertFalse(groups.valid(((F(1, 3), F(4, 3)),)))
|
||||
self.assertEqual(groups.describe(((F(1, 3), F(4, 3)),), imported=True)[0].family, 'source')
|
||||
|
||||
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
|
||||
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
|
||||
self.assertEqual(len(set(groups.signature(original))), 1)
|
||||
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
|
||||
self.assertEqual(groups.signature(original), groups.signature(moved))
|
||||
|
||||
def test_broadening_never_cuts_inside_tuplet(self):
|
||||
pattern = ((F(), F(1, 3)), (F(1, 3), F(2, 3)), (F(2, 3), F(1)))
|
||||
self.assertEqual(groups.broaden(pattern, F(1, 2), F(4)), ())
|
||||
def test_complete_triplet_can_be_displaced_without_offsetting_binary_tail(self):
|
||||
pattern = ((fractions.Fraction(1, 2), fractions.Fraction(5, 6)), (fractions.Fraction(5, 6), fractions.Fraction(7, 6)), (fractions.Fraction(7, 6), fractions.Fraction(3, 2)), (fractions.Fraction(3, 2), fractions.Fraction(5, 2)))
|
||||
result = generator.solo.groups.describe(pattern)
|
||||
self.assertEqual(result[0].family, 'triplet')
|
||||
self.assertEqual(result[-1].family, 'binary')
|
||||
self.assertEqual(result[0].displacement, fractions.Fraction(1, 2))
|
||||
def test_partial_triplet_does_not_drift_into_binary(self):
|
||||
self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(5, 6)))))
|
||||
self.assertFalse(generator.solo.groups.valid(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),)))
|
||||
self.assertEqual(generator.solo.groups.describe(((fractions.Fraction(1, 3), fractions.Fraction(4, 3)),), imported=True)[0].family, 'source')
|
||||
def test_relative_rhythm_identity_does_not_count_absolute_onsets(self):
|
||||
original = melody(((0, 1, 'c'), (1, 2, 'e'), (2, 3, 'd')))
|
||||
self.assertEqual(len(set(generator.solo.groups.signature(original))), 1)
|
||||
moved = tuple((dataclasses.replace(n, start=n.start + 13, end=n.end + 13) for n in original))
|
||||
self.assertEqual(generator.solo.groups.signature(original), generator.solo.groups.signature(moved))
|
||||
def test_broadening_never_cuts_inside_tuplet(self):
|
||||
pattern = ((fractions.Fraction(), fractions.Fraction(1, 3)), (fractions.Fraction(1, 3), fractions.Fraction(2, 3)), (fractions.Fraction(2, 3), fractions.Fraction(1)))
|
||||
self.assertEqual(generator.solo.groups.broaden(pattern, fractions.Fraction(1, 2), fractions.Fraction(4)), ())
|
||||
|
||||
class PendingTreatments(unittest.TestCase):
|
||||
|
||||
def test_complete_route_can_cross_a_search_frontier(self):
|
||||
from tests.test_independent_generation import TypedExpressionContracts
|
||||
fixture = TypedExpressionContracts()
|
||||
fixture.setUp()
|
||||
notes, contract = fixture.make()
|
||||
partial = fixture.h.evaluate(notes[:2], until=F(2), complete=False, expressions=(contract,))
|
||||
self.assertTrue(partial.valid, partial.error)
|
||||
self.assertEqual(partial.pending, (contract,))
|
||||
self.assertFalse(fixture.h.evaluate(notes[:2], until=F(3), expressions=(contract,)).valid)
|
||||
complete = fixture.h.evaluate(notes, until=F(4), complete=True, expressions=(contract,))
|
||||
self.assertTrue(complete.valid)
|
||||
self.assertFalse(complete.pending)
|
||||
def test_complete_route_can_cross_a_search_frontier(self):
|
||||
fixture = tests.test_independent_generation.TypedExpressionContracts()
|
||||
fixture.setUp()
|
||||
notes, contract = fixture.make()
|
||||
partial = fixture.h.evaluate(notes[:2], until=fractions.Fraction(2), complete=False, expressions=(contract,))
|
||||
self.assertTrue(partial.valid, partial.error)
|
||||
self.assertEqual(partial.pending, (contract,))
|
||||
self.assertFalse(fixture.h.evaluate(notes[:2], until=fractions.Fraction(3), expressions=(contract,)).valid)
|
||||
complete = fixture.h.evaluate(notes, until=fractions.Fraction(4), complete=True, expressions=(contract,))
|
||||
self.assertTrue(complete.valid)
|
||||
self.assertFalse(complete.pending)
|
||||
|
||||
class InputFirewall(unittest.TestCase):
|
||||
|
||||
def test_read_write_handles_cannot_bypass_boundary(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from generator.input_audit import composition
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
forbidden = Path(folder) / 'target.xml'
|
||||
forbidden.write_text('not musical input')
|
||||
for mode in ('r', 'r+'):
|
||||
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
|
||||
with composition(Path('data'), 'electric_solo'):
|
||||
with forbidden.open(mode) as stream:
|
||||
stream.read()
|
||||
def test_read_write_handles_cannot_bypass_boundary(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
forbidden = pathlib.Path(folder) / 'target.xml'
|
||||
forbidden.write_text('not musical input')
|
||||
for mode in ('r', 'r+'):
|
||||
with self.assertRaisesRegex(ValueError, 'Forbidden composition input'):
|
||||
with generator.input_audit.composition(pathlib.Path('data'), 'electric_solo'):
|
||||
with forbidden.open(mode) as stream:
|
||||
stream.read()
|
||||
|
||||
class PreparedSuspensions(unittest.TestCase):
|
||||
|
||||
def test_preparation_and_delayed_resolution_are_required(self):
|
||||
h = harmonic.Harmony(choices(('Gm7', 'C7')))
|
||||
held = note(F(5, 2), F(9, 2), 'f', event_id='held')
|
||||
target = note(F(9, 2), 6, 'e', event_id='resolution')
|
||||
contract = types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=F(4))
|
||||
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
|
||||
self.assertTrue(h.evaluate((held, target)).valid)
|
||||
self.assertFalse(h.evaluate((held, target),infer=False).valid)
|
||||
changed = dataclasses.replace(held, start=F(15, 4))
|
||||
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
|
||||
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
|
||||
def test_preparation_and_delayed_resolution_are_required(self):
|
||||
h = generator.solo.harmonic.Harmony(tests.solo_fixtures.choices(('Gm7', 'C7')))
|
||||
held = tests.solo_fixtures.note(fractions.Fraction(5, 2), fractions.Fraction(9, 2), 'f', event_id='held')
|
||||
target = tests.solo_fixtures.note(fractions.Fraction(9, 2), 6, 'e', event_id='resolution')
|
||||
contract = generator.solo.types.ExpressionContract('prepared', 'suspension', 'prepared_step_down', (held,), target, 'C7', boundary=fractions.Fraction(4))
|
||||
self.assertTrue(h.evaluate((held, target), expressions=(contract,)).valid)
|
||||
self.assertTrue(h.evaluate((held, target)).valid)
|
||||
self.assertFalse(h.evaluate((held, target), infer=False).valid)
|
||||
changed = dataclasses.replace(held, start=fractions.Fraction(15, 4))
|
||||
self.assertFalse(h.evaluate((changed, target), expressions=(dataclasses.replace(contract, events=(changed,)),)).valid)
|
||||
self.assertFalse(h.evaluate((held,), expressions=(contract,)).valid)
|
||||
|
||||
@@ -26,8 +26,9 @@ import generator.model
|
||||
import generator.musicxml
|
||||
import generator.musicxml_writer
|
||||
import generator.pico_artwork
|
||||
import generator.performance
|
||||
import generator.pitch
|
||||
import generator.playback
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import generator.score
|
||||
import generator.theme
|
||||
@@ -71,7 +72,7 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
for start, end in ((0, 4), (4, 8)):
|
||||
system = generator.engraving.layout.make_system(index, start, end, fonts, generator.engraving.types.Style())
|
||||
self.assertTrue(any(p.role == 'tie' and len(p.sources) == 2 for p in system.primitives))
|
||||
self.assertEqual([generator.model.fraction(e['duration']) for e in generator.playback.compile_timeline(source)['events']], [8])
|
||||
self.assertEqual([generator.model.fraction(e['duration']) for e in tests.score_events.timeline(source)['events']], [8])
|
||||
restored = self.round_trip(source)
|
||||
self.assertEqual(tests.test_musicxml.notes(restored)[0]['pitches'], tests.test_musicxml.notes(source)[0]['pitches'])
|
||||
self.assertEqual(restored['staves'][0]['measures'][1]['barlines'], {'right': 'light-heavy'})
|
||||
@@ -212,6 +213,9 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
page.write_bytes(data)
|
||||
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
|
||||
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
||||
times = generator.performance.boundaries(document)
|
||||
bundle['presentation']['pages'][0].update(first_measure=1, measure_count=len(times) - 1, start_beat=generator.model.rational(0), end_beat=generator.model.rational(times[-1]))
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path = assets / 'score.json'
|
||||
path.write_text(json.dumps(bundle))
|
||||
self.assertEqual(generator.score.load_json(path), document)
|
||||
|
||||
+29
-27
@@ -1,6 +1,6 @@
|
||||
import copy
|
||||
import dataclasses
|
||||
from fractions import Fraction as F
|
||||
import fractions
|
||||
import unittest
|
||||
|
||||
import generator.arrange.generate
|
||||
@@ -8,38 +8,40 @@ import generator.arrange.form
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.solo.arrangement
|
||||
from generator.solo import analysis, profiles, placement, types, harmonic
|
||||
from tests.solo_fixtures import song
|
||||
from tests.solo_fixtures import note
|
||||
from tests.solo_fixtures import choices
|
||||
|
||||
import generator.solo.analysis
|
||||
import generator.solo.profiles
|
||||
import generator.solo.placement
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
def events(measure):
|
||||
return tuple((generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((p['letter'],p['alter']['numerator'],p['octave'],p['tablature']['string'],p['tablature']['fret']) for p in n['pitches']))
|
||||
tuple((p['letter'], p['alter']['numerator'], p['octave'], p['tablature']['string'], p['tablature']['fret']) for p in n['pitches']))
|
||||
for v in measure['voices'] for n in v['notes'])
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class SoloInstrumentAndIdentity(unittest.TestCase):
|
||||
def test_role_ranges_and_no_open_strings(self):
|
||||
acoustic=profiles.for_part('acoustic');electric=profiles.for_part('electric')
|
||||
self.assertEqual(acoustic.register,(41,76));self.assertEqual(electric.register,(41,76))
|
||||
self.assertTrue(acoustic.permits(41));self.assertTrue(electric.permits(50))
|
||||
acoustic = generator.solo.profiles.for_part('acoustic')
|
||||
electric = generator.solo.profiles.for_part('electric')
|
||||
self.assertEqual(acoustic.register, (41, 76))
|
||||
self.assertEqual(electric.register, (41, 76))
|
||||
self.assertTrue(acoustic.permits(41))
|
||||
self.assertTrue(electric.permits(50))
|
||||
self.assertTrue(acoustic.permits(72))
|
||||
for profile in (acoustic,electric):
|
||||
for midi in range(40,78):
|
||||
for p in placement.positions(midi,profile):
|
||||
self.assertIn(p.string_number,profile.strings)
|
||||
self.assertTrue(1<=p.fret<=12)
|
||||
self.assertTrue(any(p.string_number==1 for p in placement.positions(65,electric)))
|
||||
self.assertTrue(any(p.string_number==1 for p in placement.positions(65,acoustic)))
|
||||
|
||||
for profile in (acoustic, electric):
|
||||
for midi in range(40, 78):
|
||||
for p in generator.solo.placement.positions(midi, profile):
|
||||
self.assertIn(p.string_number, profile.strings)
|
||||
self.assertTrue(1 <= p.fret <= 12)
|
||||
self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, electric)))
|
||||
self.assertTrue(any(p.string_number == 1 for p in generator.solo.placement.positions(65, acoustic)))
|
||||
def test_soft_overlapping_registers_do_not_inherit_a_mandatory_octave(self):
|
||||
source=analysis.analyze(generator.parts.prepare_document(song(count=4,chords=('CM7',))))
|
||||
acoustic=profiles.for_part('acoustic',source);electric=profiles.for_part('electric',source)
|
||||
self.assertIsNone(electric.reference);self.assertIsNone(acoustic.reference)
|
||||
self.assertEqual(acoustic.melody_range,electric.melody_range)
|
||||
source = generator.solo.analysis.analyze(generator.parts.prepare_document(tests.solo_fixtures.song(count=4, chords=('CM7',))))
|
||||
acoustic = generator.solo.profiles.for_part('acoustic', source)
|
||||
electric = generator.solo.profiles.for_part('electric', source)
|
||||
self.assertIsNone(electric.reference)
|
||||
self.assertIsNone(acoustic.reference)
|
||||
self.assertEqual(acoustic.melody_range, electric.melody_range)
|
||||
|
||||
+104
-130
@@ -2,163 +2,137 @@
|
||||
import copy
|
||||
import dataclasses
|
||||
import json
|
||||
from fractions import Fraction as F
|
||||
from pathlib import Path
|
||||
import fractions
|
||||
import pathlib
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import unittest.mock
|
||||
import contextlib
|
||||
import io
|
||||
|
||||
from tests import solo_compare
|
||||
reference_review = solo_compare
|
||||
from generator import vocabulary, production
|
||||
from generator.solo import export, harmonic, notation, placement, types
|
||||
from tests.solo_fixtures import song
|
||||
from tests.solo_fixtures import note
|
||||
from tests.solo_fixtures import choices
|
||||
from tests import solo_fixtures
|
||||
|
||||
import tests.solo_compare
|
||||
import generator.score
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.production
|
||||
import generator.solo.export
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.notation
|
||||
import generator.solo.placement
|
||||
import generator.solo.types
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
import tests.solo_fixtures
|
||||
|
||||
def xml(notes, *, transpose=False, leadin=''):
|
||||
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">'+leadin+'<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>'+('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '')+'</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>'+notes+'</measure></part></score-partwise>'
|
||||
|
||||
return '<score-partwise version="4.0"><part-list><score-part id="P"><part-name>Vocal</part-name></score-part></part-list><part id="P">' + leadin + '<measure number="1"><attributes><divisions>6</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef>' + ('<transpose><chromatic>0</chromatic><octave-change>-1</octave-change></transpose>' if transpose else '') + '</attributes><harmony><root><root-step>C</root-step></root><kind>major-seventh</kind></harmony>' + notes + '</measure></part></score-partwise>'
|
||||
|
||||
def pitched(duration, tie='', octave=5):
|
||||
return f'<note><pitch><step>C</step><octave>{octave}</octave></pitch><duration>{duration}</duration>{tie}<type>quarter</type></note>'
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_comparison_separates_pitch_timing_and_sounding_duration(self):
|
||||
events=lambda pitch,end: [dict(start='0',end=end,pitch=pitch)]
|
||||
a=events(60,'2')
|
||||
octave=solo_compare.relationships(a,events(72,'2'))
|
||||
self.assertEqual(octave['pitch_sequence_edit_distance'],1)
|
||||
self.assertEqual(octave['timed_attack_edit_distance'],0)
|
||||
self.assertEqual(octave['same_sounding_pitch_beats'],'0')
|
||||
self.assertEqual(octave['same_pitch_class_beats'],'2')
|
||||
shorter=solo_compare.relationships(a,events(60,'1'))
|
||||
self.assertEqual(shorter['pitch_sequence_edit_distance'],0)
|
||||
self.assertEqual(shorter['timed_attack_edit_distance'],1)
|
||||
self.assertEqual(shorter['same_sounding_pitch_beats'],'1')
|
||||
self.assertEqual(shorter['either_sounding_beats'],'2')
|
||||
|
||||
def events(pitch, end):
|
||||
return [dict(start='0', end=end, pitch=pitch)]
|
||||
a = events(60, '2')
|
||||
octave = tests.solo_compare.relationships(a, events(72, '2'))
|
||||
self.assertEqual(octave['pitch_sequence_edit_distance'], 1)
|
||||
self.assertEqual(octave['timed_attack_edit_distance'], 0)
|
||||
self.assertEqual(octave['same_sounding_pitch_beats'], '0')
|
||||
self.assertEqual(octave['same_pitch_class_beats'], '2')
|
||||
shorter = tests.solo_compare.relationships(a, events(60, '1'))
|
||||
self.assertEqual(shorter['pitch_sequence_edit_distance'], 0)
|
||||
self.assertEqual(shorter['timed_attack_edit_distance'], 1)
|
||||
self.assertEqual(shorter['same_sounding_pitch_beats'], '1')
|
||||
self.assertEqual(shorter['either_sounding_beats'], '2')
|
||||
def test_exact_cli_is_a_real_exit_gate(self):
|
||||
args=['solo_compare','--generated-dir','unused','--output','unused','--reference','fixture/acoustic=unused','--require-exact']
|
||||
review={'exact_musical_match':False,'exact_matching_attacks':0,'differences':[{}],'rest_difference':True}
|
||||
with patch('sys.argv',args),patch.object(solo_compare,'run',return_value={'solos':{'fixture/acoustic':review}}),contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(solo_compare.main(),1)
|
||||
review.update(exact_musical_match=True,differences=[],rest_difference=False)
|
||||
self.assertEqual(solo_compare.main(),0)
|
||||
with patch('sys.argv',args[:-3]+['--require-exact']),contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as error:solo_compare.main()
|
||||
self.assertEqual(error.exception.code,2)
|
||||
|
||||
args = ['solo_compare', '--generated-dir', 'unused', '--output', 'unused', '--reference', 'fixture/acoustic=unused', '--require-exact']
|
||||
review = {'exact_musical_match': False, 'exact_matching_attacks': 0, 'differences': [{}], 'rest_difference': True}
|
||||
with unittest.mock.patch('sys.argv', args), unittest.mock.patch.object(tests.solo_compare, 'run', return_value={'solos': {'fixture/acoustic': review}}), contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(tests.solo_compare.main(), 1)
|
||||
review.update(exact_musical_match=True, differences=[], rest_difference=False)
|
||||
self.assertEqual(tests.solo_compare.main(), 0)
|
||||
with unittest.mock.patch('sys.argv', args[:-3] + ['--require-exact']), contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
tests.solo_compare.main()
|
||||
self.assertEqual(error.exception.code, 2)
|
||||
def test_note_container_does_not_turn_color_label_into_fingering(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
note_xml=pitched(24).replace('</note>','<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
r=self.review(root,'annotation.xml',xml(note_xml),form_start=1)
|
||||
self.assertEqual(r['annotations'][0]['text'],'6')
|
||||
self.assertEqual(r['annotations'][0]['container'],'notations/technical/fingering')
|
||||
note_xml = pitched(24).replace('</note>', '<notations><technical><fingering>6</fingering></technical></notations></note>')
|
||||
r = self.review(root, 'annotation.xml', xml(note_xml), form_start=1)
|
||||
self.assertEqual(r['annotations'][0]['text'], '6')
|
||||
self.assertEqual(r['annotations'][0]['container'], 'notations/technical/fingering')
|
||||
self.assertIsNone(r['notes'][0]['tablature'])
|
||||
|
||||
def review(self,directory,name,content,**kwargs):
|
||||
path=Path(directory)/name;path.write_text(content)
|
||||
return reference_review.read(path,'fixture/acoustic',**kwargs)
|
||||
|
||||
def review(self, directory, name, content, **kwargs):
|
||||
path = pathlib.Path(directory) / name
|
||||
path.write_text(content)
|
||||
return generator.training_io.read(path, 'fixture/acoustic', **kwargs)
|
||||
def test_ties_not_reattacks_and_one_guitar_transposition(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
|
||||
b=self.review(root,'reference.xml',xml(pitched(12,'<tie type="start"/>')+pitched(12,'<tie type="stop"/>')),form_start=1,pitch_convention='guitar-written')
|
||||
self.assertTrue(solo_compare.compare(a,b)['exact_musical_match'])
|
||||
self.assertEqual(a['sounding_events'][0]['sounding_midi'],'60')
|
||||
repeated=self.review(root,'repeat.xml',xml(pitched(12)+pitched(12)),form_start=1,pitch_convention='guitar-written')
|
||||
self.assertFalse(solo_compare.compare(a,repeated)['exact_musical_match'])
|
||||
|
||||
a = self.review(root, 'actual.xml', xml(pitched(24), transpose=True), form_start=1)
|
||||
b = self.review(root, 'reference.xml', xml(pitched(12, '<tie type="start"/>') + pitched(12, '<tie type="stop"/>')), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertTrue(tests.solo_compare.compare(a, b)['exact_musical_match'])
|
||||
self.assertEqual(a['sounding_events'][0]['sounding_midi'], '60')
|
||||
repeated = self.review(root, 'repeat.xml', xml(pitched(12) + pitched(12)), form_start=1, pitch_convention='guitar-written')
|
||||
self.assertFalse(tests.solo_compare.compare(a, repeated)['exact_musical_match'])
|
||||
def test_leadin_is_reported_without_harmony_or_alignment_padding(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(6)+'<note><rest/><duration>18</duration></note></measure>'
|
||||
a=self.review(root,'a.xml',xml(pitched(24),transpose=True),form_start=1)
|
||||
b=self.review(root,'b.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
|
||||
result=solo_compare.compare(a,b)
|
||||
self.assertEqual(result['differences'][0]['time'],'-4')
|
||||
self.assertEqual(result['exact_matching_attacks'],1)
|
||||
self.assertIn('uncertainty',b['notes'][0]['harmonic_spans'][0])
|
||||
|
||||
prelude = '<measure number="0"><attributes><divisions>6</divisions></attributes>' + pitched(6) + '<note><rest/><duration>18</duration></note></measure>'
|
||||
a = self.review(root, 'a.xml', xml(pitched(24), transpose=True), form_start=1)
|
||||
b = self.review(root, 'b.xml', xml(pitched(24), leadin=prelude), form_start=2, pitch_convention='guitar-written')
|
||||
result = tests.solo_compare.compare(a, b)
|
||||
self.assertEqual(result['differences'][0]['time'], '-4')
|
||||
self.assertEqual(result['exact_matching_attacks'], 1)
|
||||
self.assertIn('uncertainty', b['notes'][0]['harmonic_spans'][0])
|
||||
def test_unplayable_pitch_is_reported_not_folded(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
r=self.review(root,'high.xml',xml(pitched(24,octave=7)),form_start=1,pitch_convention='guitar-written')
|
||||
checked=solo_compare.playability(r)
|
||||
self.assertEqual(checked['conflicts'][0]['sounding_midi'],84)
|
||||
self.assertEqual(r['notes'][0]['pitch']['octave'],7)
|
||||
|
||||
def test_silent_reference_leadin_remains_in_audition_clock(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes><note><rest/><duration>24</duration></note></measure>'
|
||||
a=self.review(root,'actual.xml',xml(pitched(24),transpose=True),form_start=1)
|
||||
b=self.review(root,'reference.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
|
||||
self.assertEqual(solo_compare.audition_bounds(a,b),(F(-4),F(4)))
|
||||
|
||||
def test_fixed_backing_includes_the_production_leadin(self):
|
||||
import array
|
||||
from generator import playback, score
|
||||
document=song(count=2,chords=('CM7',))
|
||||
document['staves'][0]['measures'][1]['section_label']='Acoustic Solo'
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
prelude='<measure number="0"><attributes><divisions>6</divisions></attributes>'+pitched(24)+'</measure>'
|
||||
actual=self.review(root,'actual.xml',xml(pitched(24),leadin=prelude),form_start=2,pitch_convention='guitar-written')
|
||||
with patch.object(score,'load_json',return_value=document),patch.object(solo_compare,'render_events',return_value=array.array('f',[0]*10)) as render,patch.object(playback,'write_pcm'):
|
||||
solo_compare.audition_pair(actual,actual,'fixture/acoustic',root,Path(root),{})
|
||||
# Backing's preceding bar starts at the same audition clock as the lead.
|
||||
self.assertEqual(render.call_args_list[0].args[0][0][0],0)
|
||||
self.assertTrue(any(start>=4 for start,end,pitch in render.call_args_list[0].args[0]))
|
||||
|
||||
r = self.review(root, 'high.xml', xml(pitched(24, octave=7)), form_start=1, pitch_convention='guitar-written')
|
||||
checked = tests.solo_compare.playability(r)
|
||||
self.assertEqual(checked['conflicts'][0]['sounding_midi'], 84)
|
||||
self.assertEqual(r['notes'][0]['pitch']['octave'], 7)
|
||||
def test_triplet_time_and_annotations_are_literal(self):
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
triplet=pitched(2).replace('<type>quarter</type>','<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
||||
r=self.review(root,'thirds.xml',xml('<direction><direction-type><words>Lead in</words></direction-type></direction>'+triplet*3+'<note><rest/><duration>18</duration></note>'),form_start=1)
|
||||
self.assertEqual([n['start'] for n in r['sounding_events']],['0','1/3','2/3'])
|
||||
self.assertEqual(r['annotations'][0]['text'],'Lead in')
|
||||
|
||||
triplet = pitched(2).replace('<type>quarter</type>', '<type>eighth</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>')
|
||||
r = self.review(root, 'thirds.xml', xml('<direction><direction-type><words>Lead in</words></direction-type></direction>' + triplet * 3 + '<note><rest/><duration>18</duration></note>'), form_start=1)
|
||||
self.assertEqual([n['start'] for n in r['sounding_events']], ['0', '1/3', '2/3'])
|
||||
self.assertEqual(r['annotations'][0]['text'], 'Lead in')
|
||||
def test_export_roundtrip_preserves_actual_sounding_events_and_tab(self):
|
||||
document=song(count=2,chords=('CM7',))
|
||||
raw=(note(0,F(1,3),'c'),note(F(1,3),F(2,3),'d'),note(F(2,3),1,'e'),note(F(7,2),5,'g'),note(5,6,'g'))
|
||||
placed=placement.place(raw,F(120))[1]
|
||||
notation.replace_region(document,0,notation.measures(document['staves'][0]['measures'],placed,'export'))
|
||||
document['staves'][0]['measures'][0]['section_label']='Acoustic Solo'
|
||||
before=copy.deepcopy(document)
|
||||
document = tests.solo_fixtures.song(count=2, chords=('CM7',))
|
||||
raw = (tests.solo_fixtures.note(0, fractions.Fraction(1, 3), 'c'), tests.solo_fixtures.note(fractions.Fraction(1, 3), fractions.Fraction(2, 3), 'd'), tests.solo_fixtures.note(fractions.Fraction(2, 3), 1, 'e'), tests.solo_fixtures.note(fractions.Fraction(7, 2), 5, 'g'), tests.solo_fixtures.note(5, 6, 'g'))
|
||||
placed = generator.solo.placement.place(raw, fractions.Fraction(120))[1]
|
||||
generator.solo.notation.replace_region(document, 0, generator.solo.notation.measures(document['staves'][0]['measures'], placed, 'export'))
|
||||
document['staves'][0]['measures'][0]['section_label'] = 'Acoustic Solo'
|
||||
before = copy.deepcopy(document)
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
path=Path(root)/'fixture-solo1.xml'
|
||||
info=export.write_role({'role':'acoustic_solo','document':document,'trace':{'source':'fixture'}},path)
|
||||
loaded=reference_review.read(path,'fixture/acoustic',1)
|
||||
sounded=[(F(n['start']),F(n['end']),F(n['sounding_midi'])) for n in loaded['sounding_events']]
|
||||
self.assertEqual([(n.start,n.end,n.pitch.number()) for n in raw],sounded)
|
||||
from generator import score
|
||||
tab=lambda d:[p['tablature'] for m in d['staves'][0]['measures'] for n in score.measure_notes(m) for p in n['pitches']]
|
||||
self.assertEqual(tab(document),[n['tablature'] for n in loaded['notes'] if n['pitch']])
|
||||
self.assertEqual(info['role'],'acoustic_solo')
|
||||
self.assertEqual(before,document)
|
||||
|
||||
path = pathlib.Path(root) / 'fixture-solo1.xml'
|
||||
info = generator.solo.export.write_role({'role': 'acoustic_solo', 'document': document, 'trace': {'source': 'fixture'}}, path)
|
||||
loaded = generator.training_io.read(path, 'fixture/acoustic', 1)
|
||||
sounded = [(fractions.Fraction(n['start']), fractions.Fraction(n['end']), fractions.Fraction(n['sounding_midi'])) for n in loaded['sounding_events']]
|
||||
self.assertEqual([(n.start, n.end, n.pitch.number()) for n in raw], sounded)
|
||||
def tab(d):
|
||||
return [p['tablature'] for m in d['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches']]
|
||||
self.assertEqual(tab(document), [n['tablature'] for n in loaded['notes'] if n['pitch']])
|
||||
self.assertEqual(info['role'], 'acoustic_solo')
|
||||
self.assertEqual(before, document)
|
||||
def test_export_manifests_are_not_discovered_as_runtime_scores(self):
|
||||
from generator import score
|
||||
document=song(count=1,chords=('CM7',))
|
||||
document = tests.solo_fixtures.song(count=1, chords=('CM7',))
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
root=Path(root);(root/'analysis').mkdir()
|
||||
roles={}
|
||||
for part in ('acoustic','electric'):
|
||||
doc=copy.deepcopy(document)
|
||||
doc['staves'][0]['measures'][0]['section_label']=part.capitalize()+' Solo'
|
||||
roles[part+'_solo']={'role':part+'_solo','document':doc,'trace':{'source':'synthetic'}}
|
||||
(root/'analysis'/'fixture.roles').write_text(json.dumps(roles))
|
||||
score.write_json(root/'score.json',document)
|
||||
export.export_tree(root)
|
||||
self.assertEqual(score.count_json_files(root),1)
|
||||
score.validate_generated_tree(root)
|
||||
self.assertTrue((root/'solo_xml'/'fixture-solo1.xml').is_file())
|
||||
self.assertTrue((root/'solo_xml'/'fixture-solo2.xml').is_file())
|
||||
root = pathlib.Path(root)
|
||||
(root / 'analysis').mkdir()
|
||||
roles = {}
|
||||
for part in ('acoustic', 'electric'):
|
||||
doc = copy.deepcopy(document)
|
||||
doc['staves'][0]['measures'][0]['section_label'] = part.capitalize() + ' Solo'
|
||||
roles[part + '_solo'] = {'role': part + '_solo', 'document': doc, 'trace': {'source': 'synthetic'}}
|
||||
(root / 'analysis' / 'fixture.roles').write_text(json.dumps(roles))
|
||||
generator.score.write_json(root / 'score.json', document)
|
||||
generator.solo.export.export_tree(root)
|
||||
self.assertEqual(generator.score.count_json_files(root), 1)
|
||||
generator.score.validate_generated_tree(root)
|
||||
self.assertTrue((root / 'solo_xml' / 'fixture-solo1.xml').is_file())
|
||||
self.assertTrue((root / 'solo_xml' / 'fixture-solo2.xml').is_file())
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+32
-9
@@ -132,7 +132,16 @@ auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pi
|
||||
std::filesystem::create_directories(directory);
|
||||
auto context = image_context{};
|
||||
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
||||
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto source = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
||||
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / std::string{source.at("presentation").at("pages").as_array().front().at("path").as_string()};
|
||||
auto sheet = LoadImage(path.c_str());
|
||||
BOOST_REQUIRE(sheet.data);
|
||||
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
|
||||
UnloadImage(sheet);
|
||||
} else {
|
||||
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
|
||||
}
|
||||
BOOST_REQUIRE(ExportImage(context.image, (directory / ("zero_" + name + ".png")).c_str()));
|
||||
auto remote = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
|
||||
@@ -169,7 +178,18 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
|
||||
preview(state, "busy", vamp_pico::link_status::CONNECTED, true);
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.pico_connected = true;
|
||||
state.audio.playback_enabled = true;
|
||||
preview(state, "backing_unavailable");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
preview(state, "empty");
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>();
|
||||
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
||||
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
||||
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves"});
|
||||
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
||||
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
|
||||
state.catalog = catalog;
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.audio.backing_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
preview(state, "settings");
|
||||
state.selected_settings_index = 3U;
|
||||
@@ -177,18 +197,21 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
|
||||
preview(state, "tempo");
|
||||
state.audio.tempo_editing = false;
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
preview(state, "empty");
|
||||
auto catalog = std::make_shared<vamp_zero::catalog>();
|
||||
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
|
||||
auto title = std::string{score.at("presentation").at("title_image").as_string()};
|
||||
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = 32U, .title_image = title});
|
||||
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
|
||||
state.catalog = catalog;
|
||||
state.selected_song_index = 1U;
|
||||
preview(state, "songs");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.selected_part_index = 1U;
|
||||
preview(state, "parts");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
state.active_part_id = "electric";
|
||||
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
|
||||
state.audio.phase = phase;
|
||||
preview(state, phase);
|
||||
}
|
||||
state.audio.phase = "Error";
|
||||
state.audio.playback_error = "Recording unavailable";
|
||||
preview(state, "audio_error");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
catalog->groups[0].title = std::string(200U, 'M');
|
||||
catalog->groups[0].composer = std::string(200U, 'W');
|
||||
preview(state, "long_title");
|
||||
|
||||
Reference in New Issue
Block a user