+3
-1
@@ -34,7 +34,9 @@ At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-v
|
||||
|
||||
## Score Generation
|
||||
|
||||
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The current catalogue produces six performance scores and 48 practice variants (54 JSON bundles).
|
||||
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code.
|
||||
|
||||
Practice sources live at `data/practice/<chord|melody>/<lesson_group>/<exercise>.xml`. For example, `chord/001_three_note_shell_voicings/001-cycles.xml` generates `practice/chord/001_three_note_shell_voicings/001-cycles/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Keep numeric prefixes zero-padded to the same width for menu ordering. Lesson folders determine lesson labels; MusicXML work/movement titles determine exercise labels, falling back to the filename stem when absent. Repeated titles do not merge exercises: identity includes the practice type, lesson folder, and normalized filename stem. Invalid nesting, unknown practice types, and colliding normalized filenames fail generation. Adding or renaming a source needs no registry edit; the next build discovers it, replaces obsolete generated paths, and runtime catalogue import removes obsolete managed database entries.
|
||||
|
||||
The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML.
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<sound tempo="120"/>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>G</step>
|
||||
@@ -63,22 +64,33 @@
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>D</root-step>
|
||||
<root-alter>-1</root-alter>
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>G</step>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>B</step>
|
||||
<octave>4</octave>
|
||||
<step>C</step>
|
||||
<alter>-1</alter>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
@@ -123,13 +135,22 @@
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>G</root-step>
|
||||
<root-alter>-1</root-alter>
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>C</step>
|
||||
<step>G</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
@@ -144,11 +165,13 @@
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>E</step>
|
||||
<step>F</step>
|
||||
<alter>-1</alter>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
</measure>
|
||||
<measure number="2">
|
||||
@@ -186,10 +209,16 @@
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>B</root-step>
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>F</step>
|
||||
<octave>4</octave>
|
||||
<step>B</step>
|
||||
<octave>3</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
@@ -206,12 +235,13 @@
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>E</step>
|
||||
<alter>-1</alter>
|
||||
<step>D</step>
|
||||
<alter>1</alter>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>sharp</accidental>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
@@ -249,12 +279,18 @@
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>natural</accidental>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>E</root-step>
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>B</step>
|
||||
<alter>-1</alter>
|
||||
<octave>3</octave>
|
||||
<step>E</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
@@ -262,12 +298,13 @@
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>A</step>
|
||||
<alter>-1</alter>
|
||||
<step>G</step>
|
||||
<alter>1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>sharp</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
@@ -318,225 +355,10 @@
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>E</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>G</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>A</root-step>
|
||||
<root-alter>-1</root-alter>
|
||||
</root>
|
||||
<kind text="7">dominant</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>A</step>
|
||||
<alter>-1</alter>
|
||||
<octave>3</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>G</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>C</step>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>A</step>
|
||||
<alter>-1</alter>
|
||||
<octave>3</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>G</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>C</step>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
</measure>
|
||||
<measure number="4">
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>D</root-step>
|
||||
<root-alter>-1</root-alter>
|
||||
</root>
|
||||
<kind text="maj7">major-seventh</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>F</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>C</step>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>F</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>C</step>
|
||||
<octave>5</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<harmony>
|
||||
<root>
|
||||
<root-step>D</root-step>
|
||||
<root-alter>-1</root-alter>
|
||||
</root>
|
||||
<kind text="6">major-sixth</kind>
|
||||
</harmony>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>F</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>B</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<accidental>flat</accidental>
|
||||
</note>
|
||||
<note>
|
||||
<pitch>
|
||||
<step>D</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>F</step>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
</note>
|
||||
<note>
|
||||
<chord/>
|
||||
<pitch>
|
||||
<step>B</step>
|
||||
<alter>-1</alter>
|
||||
<octave>4</octave>
|
||||
</pitch>
|
||||
<duration>4</duration>
|
||||
<type>quarter</type>
|
||||
<rest/>
|
||||
<duration>12</duration>
|
||||
<type>half</type>
|
||||
<dot/>
|
||||
</note>
|
||||
</measure>
|
||||
</part>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<supports element="accidental" type="yes"/>
|
||||
<supports element="transpose" type="yes"/>
|
||||
<software>Soundslice MusicXML exporter</software>
|
||||
<encoding-date>2026-09-14</encoding-date>
|
||||
<encoding-date>2026-09-16</encoding-date>
|
||||
</encoding>
|
||||
</identification>
|
||||
<part-list>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<supports element="accidental" type="yes"/>
|
||||
<supports element="transpose" type="yes"/>
|
||||
<software>Soundslice MusicXML exporter</software>
|
||||
<encoding-date>2026-09-14</encoding-date>
|
||||
<encoding-date>2026-09-16</encoding-date>
|
||||
</encoding>
|
||||
</identification>
|
||||
<part-list>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<supports element="accidental" type="yes"/>
|
||||
<supports element="transpose" type="yes"/>
|
||||
<software>Soundslice MusicXML exporter</software>
|
||||
<encoding-date>2026-09-14</encoding-date>
|
||||
<encoding-date>2026-09-16</encoding-date>
|
||||
</encoding>
|
||||
</identification>
|
||||
<part-list>
|
||||
|
||||
@@ -63,13 +63,18 @@ def generate_key_variants(source_path: Path, source_root: Path, output_dir: Path
|
||||
def generate_practice_scores(source_dir: Path, output_dir: Path) -> int:
|
||||
if not source_dir.exists():
|
||||
return 0
|
||||
sources = sorted(source_dir.rglob('*.xml'))
|
||||
destinations: dict[Path, Path] = {}
|
||||
for path in sources:
|
||||
parts = path.relative_to(source_dir).parts
|
||||
if len(parts) != 3 or parts[0] not in ('chord', 'melody'):
|
||||
raise ValueError(f'{path}: practice source must be stored as practice/<chord|melody>/<lesson_group>/<exercise>.xml')
|
||||
destination = Path(parts[0]) / parts[1] / slugify(path.stem)
|
||||
if destination in destinations:
|
||||
raise ValueError(f'{path}: practice output path collides with {destinations[destination]}: {destination}')
|
||||
destinations[destination] = path
|
||||
count = 0
|
||||
chord_root = source_dir / "chord"
|
||||
if chord_root.exists():
|
||||
for path in sorted(chord_root.rglob("*.xml")):
|
||||
count += generate_chord_practice(path, chord_root, output_dir)
|
||||
melody_root = source_dir / "melody"
|
||||
if melody_root.exists():
|
||||
for path in sorted(melody_root.rglob("*.xml")):
|
||||
count += generate_melody_practice(path, melody_root, output_dir)
|
||||
for destination, path in destinations.items():
|
||||
practice_type = destination.parts[0]
|
||||
count += generate_key_variants(path, source_dir / practice_type, output_dir, practice_type)
|
||||
return count
|
||||
|
||||
+48
-16
@@ -40,6 +40,24 @@ auto text_value(noto::sqlite_value const &value) -> std::string {
|
||||
return text == nullptr ? std::string{} : *text;
|
||||
}
|
||||
|
||||
auto close_catalogue_query(noto::connection &connection, noto::statement &statement, std::expected<void, modulus::error> status) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto finalized = co_await statement.async_finalize();
|
||||
if (!finalized) {
|
||||
modulus::log_error("catalogue query finalization failed: " + finalized.error().describe());
|
||||
if (status) {
|
||||
status = std::unexpected(finalized.error());
|
||||
}
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
if (!closed) {
|
||||
modulus::log_error("catalogue connection close failed: " + closed.error().describe());
|
||||
if (status) {
|
||||
status = std::unexpected(closed.error());
|
||||
}
|
||||
}
|
||||
co_return status;
|
||||
}
|
||||
|
||||
auto execute_schema_statement(noto::connection &connection, std::string statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto result = co_await connection.async_execute(std::move(statement));
|
||||
if (!result) {
|
||||
@@ -465,7 +483,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected =
|
||||
co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM "
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, source_json, key_signature FROM "
|
||||
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
@@ -475,18 +493,25 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
auto parts = std::vector<vamp_zero::library_part>{};
|
||||
for (auto const &row : selected->rows) {
|
||||
auto status = std::expected<void, modulus::error>{};
|
||||
for (;;) {
|
||||
auto next = co_await selected->async_step();
|
||||
if (!next) {
|
||||
status = std::unexpected(next.error());
|
||||
break;
|
||||
}
|
||||
if (!*next) {
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() < 7U) {
|
||||
continue;
|
||||
}
|
||||
auto source_json = text_value(row[5]);
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[4]));
|
||||
if (!parsed) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after parse error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(parsed.error());
|
||||
status = std::unexpected(parsed.error());
|
||||
break;
|
||||
}
|
||||
parts.push_back({
|
||||
.id = text_value(row[0]),
|
||||
@@ -500,7 +525,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||
});
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
|
||||
if (!closed) {
|
||||
co_return std::unexpected(closed.error());
|
||||
}
|
||||
@@ -514,7 +539,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected = co_await connection.async_execute("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, "
|
||||
auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, "
|
||||
"key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
|
||||
"lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE");
|
||||
if (!selected) {
|
||||
@@ -525,18 +550,25 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
auto exercises = std::vector<vamp_zero::practice_exercise>{};
|
||||
for (auto const &row : selected->rows) {
|
||||
auto status = std::expected<void, modulus::error>{};
|
||||
for (;;) {
|
||||
auto next = co_await selected->async_step();
|
||||
if (!next) {
|
||||
status = std::unexpected(next.error());
|
||||
break;
|
||||
}
|
||||
if (!*next) {
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() < 10U) {
|
||||
continue;
|
||||
}
|
||||
auto source_json = text_value(row[9]);
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, text_value(row[8]));
|
||||
if (!parsed) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after practice parse error: " + close.error().describe());
|
||||
}
|
||||
co_return std::unexpected(parsed.error());
|
||||
status = std::unexpected(parsed.error());
|
||||
break;
|
||||
}
|
||||
auto type_id = text_value(row[1]);
|
||||
auto lesson_id = text_value(row[3]);
|
||||
@@ -566,7 +598,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
.variants = {std::move(variant)},
|
||||
});
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
auto closed = co_await close_catalogue_query(connection, *selected, std::move(status));
|
||||
if (!closed) {
|
||||
co_return std::unexpected(closed.error());
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||
)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 180)
|
||||
add_test(
|
||||
NAME product-vamp-generator
|
||||
COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p test_*.py
|
||||
|
||||
+83
-3
@@ -377,6 +377,13 @@ BOOST_AUTO_TEST_CASE(chord_and_melody_practice_share_key_navigation_and_remote_d
|
||||
BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-keys-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
auto expected_exercises = std::size_t{};
|
||||
for (auto const &entry : std::filesystem::recursive_directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data/practice"}) {
|
||||
if (entry.is_regular_file() && entry.path().extension() == ".xml") {
|
||||
++expected_exercises;
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(expected_exercises > 0U);
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
|
||||
BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR));
|
||||
@@ -384,14 +391,15 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
||||
io.run();
|
||||
auto library = future.get();
|
||||
BOOST_REQUIRE(library);
|
||||
BOOST_TEST(library->exercises.size() == 4U);
|
||||
BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe()));
|
||||
BOOST_TEST(library->exercises.size() == expected_exercises);
|
||||
for (auto const &exercise : library->exercises) {
|
||||
BOOST_TEST(vamp_zero::supports_key_selection(exercise));
|
||||
BOOST_REQUIRE_EQUAL(exercise.variants.size(), 12U);
|
||||
auto keys = vamp_zero::practice_key_names();
|
||||
for (auto index = std::size_t{}; index < keys.size(); ++index) {
|
||||
BOOST_TEST(exercise.variants[index].key_signature == keys[index]);
|
||||
auto const &key = exercise.variants[index].key_signature;
|
||||
BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]);
|
||||
BOOST_REQUIRE(vamp_zero::parse_score_document(exercise.variants[index].source_json, exercise.variants[index].source_path));
|
||||
}
|
||||
}
|
||||
@@ -399,6 +407,78 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_refresh_after_rename) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-paths-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto assets = root / "assets";
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
std::filesystem::create_directories(assets / "songs");
|
||||
for (auto name : {"chord/001_lesson/010-cycles", "chord/001_lesson/002-cycles", "chord/002_lesson/001-cycles", "melody/001_lesson/127-cycles"}) {
|
||||
write_text(assets / "practice" / name / "c.json", score_asset_json("Shared Title"));
|
||||
}
|
||||
auto library = vamp_zero::load_practice_library(assets.string());
|
||||
BOOST_REQUIRE(library);
|
||||
BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U);
|
||||
BOOST_REQUIRE_EQUAL(library->types.size(), 2U);
|
||||
BOOST_TEST(library->types.front().id == "chord");
|
||||
BOOST_REQUIRE_EQUAL(library->types.front().lesson_groups.size(), 2U);
|
||||
auto const &lesson = library->types.front().lesson_groups.front();
|
||||
BOOST_TEST(lesson.id == "001_lesson");
|
||||
BOOST_REQUIRE_EQUAL(lesson.exercises.size(), 2U);
|
||||
BOOST_TEST(lesson.exercises[0].id == "chord-001_lesson-002-cycles");
|
||||
BOOST_TEST(lesson.exercises[1].id == "chord-001_lesson-010-cycles");
|
||||
for (auto const &exercise : library->exercises) {
|
||||
BOOST_TEST(exercise.title == "Shared Title");
|
||||
}
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
std::filesystem::rename(assets / "practice/chord/001_lesson", assets / "practice/chord/003_renamed_lesson");
|
||||
std::filesystem::rename(assets / "practice/chord/003_renamed_lesson/002-cycles", assets / "practice/chord/003_renamed_lesson/099-renamed");
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id");
|
||||
BOOST_REQUIRE(rows);
|
||||
BOOST_REQUIRE_EQUAL(rows->rows.size(), 4U);
|
||||
BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-002-cycles-c"));
|
||||
BOOST_TEST(!has_value(*rows, 0U, "chord-001_lesson-010-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-099-renamed-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-003_renamed_lesson-010-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "chord-002_lesson-001-cycles-c"));
|
||||
BOOST_TEST(has_value(*rows, 0U, "melody-001_lesson-127-cycles-c"));
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalogue_queries_handle_empty_results_and_invalid_stored_scores) {
|
||||
auto root = std::filesystem::temp_directory_path() / ("product-vamp-query-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto config = test_database_config(root / "vamp.db");
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
for (auto invalid_rows : {false, true}) {
|
||||
if (invalid_rows) {
|
||||
for (auto table : {"songs", "practice_exercises"}) {
|
||||
BOOST_REQUIRE(query_database(root / "vamp.db", std::string{"INSERT INTO "} + table + "(id, title, source_json, created_at_ms, updated_at_ms) VALUES('invalid', 'Invalid', '{}', 0, 0)"));
|
||||
}
|
||||
}
|
||||
auto io = boost::asio::io_context{};
|
||||
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config), boost::asio::use_future);
|
||||
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
|
||||
io.run();
|
||||
auto performance = performance_future.get();
|
||||
auto practice = practice_future.get();
|
||||
if (invalid_rows) {
|
||||
BOOST_REQUIRE(!performance);
|
||||
BOOST_REQUIRE(!practice);
|
||||
auto invalid_score = vamp_zero::parse_score_document("{}", "");
|
||||
BOOST_REQUIRE(!invalid_score);
|
||||
BOOST_TEST(performance.error().message() == invalid_score.error().message());
|
||||
BOOST_TEST(practice.error().message() == invalid_score.error().message());
|
||||
} else {
|
||||
BOOST_REQUIRE(performance);
|
||||
BOOST_REQUIRE(practice);
|
||||
BOOST_TEST(performance->parts.empty());
|
||||
BOOST_TEST(practice->exercises.empty());
|
||||
}
|
||||
}
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) {
|
||||
auto source = boost::json::parse(score_asset_json("Keyless", "No key"));
|
||||
auto &music = source.as_object()["music"].as_object();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from generator.practice.generate import generate_practice_scores, key_file_name
|
||||
from generator.score import slugify, validate_generated_tree
|
||||
from generator.summary import validate_practice_variants
|
||||
from generator.transposition import TARGET_KEYS
|
||||
from tests.test_musicxml import xml_note, xml_score
|
||||
|
||||
class CatalogueTests(unittest.TestCase):
|
||||
def test_every_practice_source_has_its_own_complete_output(self):
|
||||
source = Path('data/practice')
|
||||
sources = sorted(source.rglob('*.xml'))
|
||||
self.assertTrue(sources)
|
||||
expected = set()
|
||||
for path in sources:
|
||||
relative = path.relative_to(source)
|
||||
self.assertEqual(len(relative.parts), 3, str(path))
|
||||
self.assertIn(relative.parts[0], ('chord', 'melody'), str(path))
|
||||
for key in TARGET_KEYS:
|
||||
expected.add(relative.parent / slugify(path.stem) / key_file_name(key))
|
||||
self.assertEqual(len(expected), len(sources) * len(TARGET_KEYS))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
self.assertEqual(generate_practice_scores(source, output), len(expected))
|
||||
self.assertEqual({p.relative_to(output) for p in output.rglob('*.json')}, expected)
|
||||
validate_practice_variants(output)
|
||||
validate_generated_tree(output)
|
||||
def test_numbered_paths_and_duplicate_titles_remain_distinct(self):
|
||||
names = ('chord/001_lesson/001-cycles.xml', 'chord/001_lesson/010-cycles.xml', 'chord/002_lesson/001-cycles.xml', 'melody/001_lesson/127-cycles.xml')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
for name in names:
|
||||
path = source / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), len(names) * len(TARGET_KEYS))
|
||||
for name in names:
|
||||
directory = output / Path(name).with_suffix('')
|
||||
self.assertEqual({p.name for p in directory.glob('*.json')}, {key_file_name(key) for key in TARGET_KEYS})
|
||||
self.assertEqual(json.loads((directory / 'c.json').read_text())['metadata']['title'], 'Fixture')
|
||||
def test_invalid_source_locations_fail_before_writing(self):
|
||||
for name in ('chords/001_lesson/001-exercise.xml', 'chord/001-exercise.xml', 'melody/001_lesson/extra/001-exercise.xml'):
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
for relative in ('chord/001_lesson/001-valid.xml', name):
|
||||
path = source / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'practice source must be stored'):
|
||||
generate_practice_scores(source, output)
|
||||
self.assertFalse(output.exists())
|
||||
def test_normalized_filename_collisions_fail_before_writing(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = Path(temporary) / 'source', Path(temporary) / 'output'
|
||||
lesson = source / 'chord' / '001_lesson'
|
||||
lesson.mkdir(parents=True)
|
||||
for name in ('001-Cycles.xml', '001_cycles.xml'):
|
||||
(lesson / name).write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
with self.assertRaisesRegex(ValueError, 'output path collides'):
|
||||
generate_practice_scores(source, output)
|
||||
self.assertFalse(output.exists())
|
||||
@@ -51,8 +51,11 @@ class GuitarPracticeTests(unittest.TestCase):
|
||||
voicing = find_playable_voicing('C7')
|
||||
self.assertEqual(len(voicing), 4)
|
||||
self.assertTrue(all(p.fret == 1 for p in voicing))
|
||||
def test_current_catalogue_all_keys_preserve_exact_voicings(self):
|
||||
source = practice_document(load_musicxml(Path('data/practice/chord/01_three_note_shell_voicings/01-cycles.xml')))
|
||||
def test_all_keys_preserve_exact_voicings_and_adjust_g_register(self):
|
||||
harmony = '<harmony><root><root-step>G</root-step></root><kind>dominant</kind></harmony>'
|
||||
content = xml_note('G', 2, 'half') + xml_note('B', 2, 'half', prefix='<chord/>') + xml_note('F', 2, 'half', prefix='<chord/>', octave=5)
|
||||
content += xml_note('A', 2, 'half', alter=-1, octave=3) + xml_note('G', 2, 'half', alter=-1, prefix='<chord/>') + xml_note('C', 2, 'half', prefix='<chord/>', octave=5)
|
||||
source = practice_document(parse(xml_score(harmony + content)))
|
||||
original = copy.deepcopy(source)
|
||||
for key in TARGET_KEYS:
|
||||
with self.subTest(key=key):
|
||||
@@ -209,11 +212,15 @@ class GuitarPracticeTests(unittest.TestCase):
|
||||
self.assertEqual(list(output.iterdir()), [output / 'last-good'])
|
||||
def test_all_practice_types_require_twelve_deterministic_variants(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
output = Path(temp)
|
||||
self.assertEqual(generate_practice_scores(Path('data/practice'), output), 48)
|
||||
source, output = Path(temp) / 'source', Path(temp) / 'output'
|
||||
for practice_type in ('chord', 'melody'):
|
||||
path = source / practice_type / '001_lesson' / '001-exercise.xml'
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(xml_score(xml_note()), encoding='utf-8')
|
||||
self.assertEqual(generate_practice_scores(source, output), 24)
|
||||
validate_practice_variants(output)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')}
|
||||
generate_practice_scores(Path('data/practice'), output)
|
||||
generate_practice_scores(source, output)
|
||||
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*.json')})
|
||||
next((output / 'chord').rglob('g.json')).unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'chord practice generator drift'):
|
||||
|
||||
@@ -45,7 +45,7 @@ class MusicXMLTests(unittest.TestCase):
|
||||
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
|
||||
def test_catalogue_imports(self):
|
||||
sources = sorted(Path('data').rglob('*.xml'))
|
||||
self.assertEqual(len(sources), 6)
|
||||
self.assertTrue(sources)
|
||||
for path in sources:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(load_musicxml(path)['schema_version'], 3)
|
||||
|
||||
@@ -40,6 +40,14 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
|
||||
generate_assets(args)
|
||||
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||
source.write_text(xml_score(xml_note()))
|
||||
renamed = lesson.with_name('002_renamed_lesson') / '127-renamed-exercise.xml'
|
||||
renamed.parent.mkdir()
|
||||
source.rename(renamed)
|
||||
source = renamed
|
||||
generate_assets(args)
|
||||
self.assertFalse((output / 'practice/chord/lesson').exists())
|
||||
self.assertEqual(len(list((output / 'practice/chord/002_renamed_lesson/127-renamed-exercise').glob('*.json'))), 12)
|
||||
source.unlink()
|
||||
generate_assets(args)
|
||||
self.assertFalse(list(output.rglob('*.png')))
|
||||
|
||||
Reference in New Issue
Block a user