Music XML
ci / checks (push) Successful in 42m24s

This commit is contained in:
khodowany
2026-09-10 22:01:18 -05:00
parent ea4e0bbba1
commit f84717990b
47 changed files with 16739 additions and 8391 deletions
+7 -2
View File
@@ -11,15 +11,19 @@ target_link_libraries(product-vamp-test PRIVATE
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
target_compile_definitions(product-vamp-test PRIVATE
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
)
add_test(NAME product-vamp COMMAND product-vamp-test)
add_executable(vamp-render-score EXCLUDE_FROM_ALL render_score.cpp)
target_link_libraries(vamp-render-score PRIVATE vamp-zero-core)
target_compile_features(vamp-render-score PRIVATE cxx_std_23)
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
add_test(
NAME product-vamp-generator
COMMAND ${Python3_EXECUTABLE} -m unittest tests.test_score_generator
COMMAND ${Python3_EXECUTABLE} -m unittest discover -s tests -p test_*.py
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
)
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 60)
set_tests_properties(product-vamp-generator PROPERTIES TIMEOUT 180)
add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND}
--test-dir ${CMAKE_BINARY_DIR}
@@ -28,3 +32,4 @@ add_custom_target(check
--timeout 60
USES_TERMINAL
)
add_dependencies(check product-vamp-test)
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<work><work-title>Pitch and Rhythm Regression</work-title></work>
<part-list><score-part id="P1"><part-name>Notation</part-name></score-part></part-list>
<part id="P1">
<measure number="1">
<attributes><divisions>16</divisions><key><fifths>0</fifths></key><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes>
<harmony><root><root-step>C</root-step></root><kind>diminished</kind></harmony>
<note><grace slash="yes"/><pitch><step>G</step><octave>5</octave></pitch><type>16th</type></note>
<note><pitch><step>C</step><alter>1</alter><octave>5</octave></pitch><duration>28</duration><type>quarter</type><dot/><dot/><accidental cautionary="yes">sharp</accidental></note>
<note><chord/><pitch><step>E</step><octave>5</octave></pitch><duration>8</duration><type>eighth</type></note>
<note><rest/><duration>4</duration><type>16th</type></note>
<note><pitch><step>B</step><octave>4</octave></pitch><duration>32</duration><tie type="start"/><type>half</type><notations><tied type="start"/></notations><lyric><syllabic>single</syllabic><text>held</text><extend type="start"/></lyric></note>
</measure>
<measure number="2">
<note><pitch><step>B</step><octave>4</octave></pitch><duration>16</duration><tie type="stop"/><type>quarter</type><notations><tied type="stop"/></notations></note>
<note><cue/><pitch><step>D</step><octave>5</octave></pitch><duration>16</duration><type>quarter</type></note>
<attributes><key><fifths>-1</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>
<note><pitch><step>A</step><octave>3</octave></pitch><duration>32</duration><type>half</type><notations><ornaments><tremolo type="single">2</tremolo></ornaments></notations></note>
</measure>
<measure number="3">
<attributes><divisions>24</divisions><time><beats>6</beats><beat-type>8</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes>
<direction><direction-type><octave-shift type="down" size="8"/></direction-type></direction>
<note><pitch><step>G</step><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">begin</beam></note>
<note><pitch><step>A</step><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">continue</beam></note>
<note><pitch><step>B</step><alter>-1</alter><octave>5</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">end</beam></note>
<note><pitch><step>C</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">begin</beam></note>
<note><pitch><step>D</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">continue</beam></note>
<note><pitch><step>E</step><octave>6</octave></pitch><duration>12</duration><type>eighth</type><beam number="1">end</beam></note>
<direction><direction-type><octave-shift type="stop" size="8"/></direction-type></direction>
</measure>
<measure number="4">
<attributes><divisions>3</divisions><time><beats>2</beats><beat-type>4</beat-type></time></attributes>
<note><pitch><step>C</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><tuplet type="start" bracket="yes"/></notations></note>
<note><pitch><step>D</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification></note>
<note><pitch><step>E</step><octave>5</octave></pitch><duration>2</duration><type>quarter</type><time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification><notations><tuplet type="stop"/></notations></note>
<barline location="right"><bar-style>light-heavy</bar-style></barline>
</measure>
</part>
</score-partwise>
+47
View File
@@ -0,0 +1,47 @@
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <iterator>
#include <memory>
#include <raylib.h>
#include <rlgl.h>
#include <luma.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/score.hpp>
namespace {
class score_view final : public luma::control {
public:
score_view(luma::application& app, vamp_zero::score_document score, std::string output, std::size_t page, bool& saved)
: app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {}
auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override { return {976,484}; }
auto draw(luma::draw_context& context, luma::theme const&) const -> void override {
vamp_zero::draw_score_page(context, bounds(), score_, page_);
rlDrawRenderBatchActive();
auto image = LoadImageFromScreen();
saved_ = ExportImage(image, output_.c_str());
UnloadImage(image);
app_.request_stop();
}
private:
luma::application& app_;
vamp_zero::score_document score_;
std::string output_;
std::size_t page_;
bool& saved_;
};
}
int main(int argc, char** argv) {
if (argc < 3) { std::cerr << "Usage: vamp-render-score input.json output.png [page]\n"; return 2; }
auto input = std::ifstream{argv[1]};
auto json = std::string{std::istreambuf_iterator<char>{input},std::istreambuf_iterator<char>{}};
auto score = vamp_zero::parse_score_document(json,argv[1]);
if (!score) { std::cerr << score.error().describe() << '\n'; return 1; }
auto app = luma::application{{.title="Notation Regression",.display={.width=976,.height=484,.target_fps=30,.fullscreen=false}}};
auto saved = false;
auto root = app.set_root(std::make_unique<score_view>(app,std::move(*score),argv[2],argc>3 ? std::stoul(argv[3]) : 0,saved));
if (!root) return 1;
auto ran = app.run();
return ran && saved ? 0 : 1;
}
+199 -104
View File
@@ -24,6 +24,8 @@
#include <boost/asio/use_future.hpp>
#include <boost/json/object.hpp>
#include <boost/json/parse.hpp>
#include <boost/json/serialize.hpp>
#include <boost/json/array.hpp>
#include <noto.hpp>
@@ -275,45 +277,24 @@ auto has_value(noto::query_result const& result, std::size_t column, std::string
}
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
return std::string{R"json({
"metadata": {
"title": ")json"} + title + R"json(",
"composer": "Composer",
"key_signature": ")json" + key + R"json(",
"time_signature": "4/4"
},
"staves": [
{
"id": "staff-lead",
"name": "Lead",
"clef": "treble",
"role": "lead",
"measures": [
{
"id": "measure-1",
"time_signature": "4/4",
"key_signature": ")json" + key + R"json(",
"voices": [
{
"id": "voice-1",
"notes": [
{
"id": "note-1",
"kind": "note",
"duration_ticks": 4096,
"pitches": [
{"letter": "c", "octave": 4, "accidental": "n"}
]
}
]
}
]
}
]
}
]
}
)json";
auto value = boost::json::parse(R"json({
"schema_version":2,
"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0},
"measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","time_signature":"4/4",
"key":{"alterations":[0,0,0,0,0,0,0]},"meter":{"groups":[{"beats":[4],"unit":4}]},
"clef":{"sign":"G","line":2,"octave_change":0},
"voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note",
"onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1},
"written":{"type":"whole","dots":0},
"pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0,
"duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}]
})json");
auto& root = value.as_object();
root["metadata"].as_object()["title"] = title;
root["metadata"].as_object()["key_signature"] = key;
root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key;
return boost::json::serialize(value);
}
auto navigation_state() -> vamp_zero::app_state {
@@ -504,75 +485,180 @@ BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) {
}
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
auto parsed = vamp_zero::parse_score_document(R"json(
{
"metadata": {
"title": "Standard Tune",
"composer": "Composer",
"key_signature": "Eb",
"time_signature": "3/4"
},
"staves": [
{
"id": "staff-lead",
"name": "Lead",
"clef": "treble",
"role": "lead",
"measures": [
{
"id": "measure-1",
"time_signature": "3/4",
"key_signature": "Eb",
"voices": [
{
"id": "voice-1",
"notes": [
{
"id": "note-1",
"kind": "note",
"duration_ticks": 4096,
"tuplet_id": "triplet-1",
"pitches": [
{
"letter": "b",
"octave": 4,
"accidental": "b"
}
]
}
],
"tuplets": [
{
"id": "triplet-1",
"note_count": 3,
"space_count": 2
}
]
}
]
}
]
}
]
}
)json", "standard-tune.json");
BOOST_REQUIRE(static_cast<std::bool_t>(parsed));
auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json");
BOOST_REQUIRE(static_cast<bool>(parsed));
BOOST_TEST(parsed->title == "Standard Tune");
BOOST_TEST(parsed->composer == "Composer");
BOOST_TEST(parsed->key_signature == "Eb");
BOOST_TEST(parsed->time_signature == "3/4");
BOOST_TEST(parsed->clef == "treble");
BOOST_TEST(parsed->time_signature == "4/4");
BOOST_TEST(parsed->clef == "G:2:0");
BOOST_TEST(parsed->part == "Lead");
BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U);
BOOST_TEST(parsed->measures.front().key_signature == "Eb");
BOOST_TEST(parsed->measures.front().time_signature == "3/4");
BOOST_REQUIRE_EQUAL(parsed->measures.front().notes.size(), 1U);
BOOST_TEST(parsed->measures.front().notes.front().tuplet_id == "triplet-1");
BOOST_REQUIRE_EQUAL(parsed->measures.front().tuplets.size(), 1U);
BOOST_TEST(parsed->measures.front().tuplets.front().id == "triplet-1");
BOOST_TEST(parsed->measures.front().tuplets.front().note_count == 3);
BOOST_TEST(parsed->measures.front().tuplets.front().space_count == 2);
BOOST_TEST(parsed->measures.front().duration.numerator == 4);
BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole");
}
BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) {
auto count = std::size_t{};
for (auto const& entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) {
if (entry.path().extension() != ".json") continue;
auto input = std::ifstream{entry.path()};
auto json = std::string{std::istreambuf_iterator<char>{input}, std::istreambuf_iterator<char>{}};
auto parsed = vamp_zero::parse_score_document(json, entry.path().string());
BOOST_TEST_CONTEXT(entry.path().string()) {
BOOST_REQUIRE(parsed.has_value());
BOOST_TEST(vamp_zero::score_page_count(*parsed) > 0U);
}
++count;
}
BOOST_TEST(count > 0U);
}
BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) {
BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value());
auto source = boost::json::parse(score_asset_json("Fixture"));
auto& voices = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array();
auto copy = voices[0];
voices.push_back(copy);
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
}
BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) {
auto source = boost::json::parse(score_asset_json("Fixture"));
auto& n = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array()[0].as_object();
n["duration"].as_object()["denominator"] = 0;
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
n["duration"].as_object()["denominator"] = 1;
n["pitches"].as_array()[0].as_object()["tie_to"] = "missing";
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
}
BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) {
auto names = std::vector<std::string>{"maxima","long","breve","whole","half","quarter","eighth","16th","32nd","64th","128th","256th","512th","1024th"};
for (auto const& name : names) {
auto n = note({pitch('c',"n",4)});
n.written = {.type=name,.dots=2};
n.pitches.front().written = n.written;
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0);
auto dots = layout_items(layout,vamp_zero::score_layout_item_role::DOT,0,0,0);
BOOST_TEST(dots.size()==2U);
for (auto const& item : layout.items) if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) {
BOOST_TEST(item.bounds.width > 0);
BOOST_TEST(item.bounds.height > 0);
}
n.kind = "rest";
n.pitches.clear();
layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0);
auto rests = layout_items(layout,vamp_zero::score_layout_item_role::REST,0,0,0);
BOOST_REQUIRE_EQUAL(rests.size(),1U);
BOOST_TEST(rests[0]->bounds.width > 0);
BOOST_TEST(rests[0]->bounds.height > 0);
}
}
BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) {
auto notes = std::vector<vamp_zero::score_note>{};
for (int i = 0; i < 128; ++i) {
auto n = note_with_ticks({pitch('c',"#",4)},128);
n.written = {.type="128th"};
n.duration = {1,32};
n.onset = {i,32};
notes.push_back(n);
}
auto score = score_with_notes(notes);
auto total = std::size_t{};
auto pages = vamp_zero::score_page_count(score);
BOOST_TEST(pages>1U);
for (auto page = std::size_t{}; page < pages; ++page) {
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score,page);
for (auto const& item : layout.items) if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) {
++total;
BOOST_TEST(item.bounds.x >= 0);
BOOST_TEST(item.bounds.x+item.bounds.width <= 976);
BOOST_TEST(item.bounds.y >= 0);
BOOST_TEST(item.bounds.y+item.bounds.height <= 484);
}
}
BOOST_TEST(total==notes.size());
}
BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) {
auto source = boost::json::parse(score_asset_json("Fixture"));
auto& m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object();
m["harmonies"] = boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])");
auto score = vamp_zero::parse_score_document(boost::json::serialize(source));
BOOST_REQUIRE(score.has_value());
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},*score,0);
auto heads = layout_items(layout,vamp_zero::score_layout_item_role::NOTEHEAD,0,0);
auto chords = layout_items(layout,vamp_zero::score_layout_item_role::CHORD_SYMBOL,0,0);
BOOST_REQUIRE_EQUAL(heads.size(),1U);
BOOST_REQUIRE_EQUAL(chords.size(),1U);
BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x);
}
BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) {
auto score = score_with_notes({note({pitch('e', "n", 5)})});
score.measures[0].section_label = "Melody";
score.measures[0].harmonies.push_back({"h1", {0,1}, "Cmaj7", "major-seventh"});
for (auto bounds : {luma::rect{.x=0,.y=0,.width=976,.height=484}, luma::rect{.x=0,.y=0,.width=480,.height=320}}) {
auto layout = vamp_zero::layout_score_page(bounds, score, 0);
auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL);
auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER);
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
BOOST_REQUIRE_EQUAL(sections.size(), 1U);
BOOST_REQUIRE_EQUAL(numbers.size(), 1U);
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
BOOST_TEST(sections[0]->bounds.y >= 0);
BOOST_TEST(bottom_edge(*sections[0]) < numbers[0]->bounds.y);
BOOST_TEST(bottom_edge(*numbers[0]) < chords[0]->bounds.y);
}
}
BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) {
auto source = boost::json::parse(score_asset_json("Partial tie"));
auto& ns = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array();
auto first = ns[0].as_object();
first["id"] = "a";
first["duration"].as_object()["numerator"] = 2;
first["written"].as_object()["type"] = "half";
auto& p = first["pitches"].as_array()[0].as_object();
p["id"] = "a-c";
p["duration"] = first["duration"];
p["written"] = first["written"];
auto other = p;
other["id"] = "a-e";
other["letter"] = "e";
first["pitches"].as_array().push_back(other);
auto second = first;
second["id"] = "b";
second["onset"].as_object()["numerator"] = 2;
second["pitches"].as_array()[0].as_object()["id"] = "b-c";
second["pitches"].as_array()[0].as_object()["tie_from"] = "a-c";
second["pitches"].as_array()[1].as_object()["id"] = "b-g";
second["pitches"].as_array()[1].as_object()["letter"] = "g";
first["pitches"].as_array()[0].as_object()["tie_to"] = "b-c";
ns = {first, second};
auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source));
BOOST_REQUIRE(parsed.has_value());
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, *parsed, 0);
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U);
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U);
ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0;
BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value());
}
BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) {
auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)});
n.written = {.type="quarter", .dots=2};
n.pitches[0].written = n.written;
n.pitches[1].written = {.type="eighth"};
auto score = score_with_notes({n});
score.measures[0].harmonies.push_back({"h",{0,1},"Cdim","diminished"});
auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, score, 0);
auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL);
BOOST_REQUIRE_EQUAL(chords.size(), 1U);
for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG})
for (auto item : layout_items(layout, role)) BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y);
}
BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) {
@@ -813,6 +899,15 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro
{noto::sqlite_value{score_asset_json("Custom Tune")}});
BOOST_REQUIRE(inserted_custom);
write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision"));
BOOST_REQUIRE(run_import(config, assets));
auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'");
BOOST_REQUIRE(custom);
BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U);
BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune");
BOOST_TEST(integer_value(custom->rows[0][1]) == 0);
std::filesystem::remove(assets / "songs" / "custom.json");
std::filesystem::remove(song_path);
BOOST_REQUIRE(run_import(config, assets));
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
@@ -919,7 +1014,7 @@ BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) {
.measures = std::move(measures),
};
BOOST_TEST(vamp_zero::score_page_count(score) == 2U);
BOOST_TEST(vamp_zero::score_page_count(score) == 3U);
}
BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) {
+34
View File
@@ -0,0 +1,34 @@
import hashlib
import importlib.util
from pathlib import Path
import subprocess
import sys
import types
import unittest
from unittest.mock import Mock, patch
class BuildBootstrapTests(unittest.TestCase):
def test_requirements_fingerprint_invalidates_cached_build_image(self):
root = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
bootstrap = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bootstrap)
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
self.assertEqual(bootstrap.build_requirements_digest(), digest)
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
package = types.ModuleType('modulus')
cli = types.ModuleType('modulus.cli')
package.cli = cli
available, docker_run = Mock(return_value=True), Mock()
cli.docker_image_available, cli.docker_run = available, docker_run
cli.main = lambda command: cli.docker_image_available(types.SimpleNamespace(docker_image='test-image'))
for label, expected in [('',False), ('outdated',False), (digest,True)]:
result = subprocess.CompletedProcess([], 0, label+'\n', '')
with self.subTest(label=label), patch.dict(sys.modules, {'modulus':package, 'modulus.cli':cli}), \
patch.object(bootstrap, 'ensure_runtime'), patch.object(bootstrap.subprocess, 'run', return_value=result), \
patch.object(sys, 'path', list(sys.path)):
self.assertEqual(bootstrap.delegate('configure', []), expected)
self.assertIs(cli.docker_image_available, available)
self.assertIs(cli.docker_run, docker_run)
+139
View File
@@ -0,0 +1,139 @@
import argparse
import copy
from fractions import Fraction
from pathlib import Path
import tempfile
import unittest
from generator import harmony
from generator.__main__ import generate_assets
from generator.arrange.generate import active_chord, arrange_document, generated_voice, propagated_chords
from generator.arrange.fretboard import pitches_for_voicing
from generator.model import beat_lengths, fraction, key_from_fifths, pitch_number, rational, validate_document
from generator.musicxml import load_musicxml
from generator.styles import BUILT_IN_STYLES, canonical_style
from generator.transposition import concert_document, shift_pitch, transpose_document
from tests.test_musicxml import parse, notes, xml_note, xml_score
class MusicModelTests(unittest.TestCase):
def test_chord_semantics_are_not_voicing_heuristics(self):
cases = {'C':{0,4,7},'Cdim':{0,3,6},'Cdim7':{0,3,6,9},'Csus2':{0,2,7},
'C7b5':{0,4,6,10},'G7b9':{7,11,2,5,8},'Fmaj7#11':{5,9,0,4,11}}
for label, expected in cases.items():
self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))),expected,label)
for label, essentials in [('G7b9',{11,5,8}),('C13',{4,10,9})]:
pitches = pitches_for_voicing(label)
self.assertTrue(essentials.issubset({pitch_number(p)%12 for p in pitches}),label)
with self.assertRaises(ValueError):
harmony.parse_symbol('Cunrecognised')
def test_transposition_preserves_spelling_octaves_modes(self):
for p in ({'letter':'b','alter':1,'octave':4},{'letter':'c','alter':-1,'octave':4},{'letter':'f','alter':2,'octave':4}):
self.assertEqual(shift_pitch(p,0,0),p)
self.assertEqual(pitch_number(shift_pitch(p,1,2)),pitch_number(p)+2)
source = parse(xml_score(xml_note(),attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
result = transpose_document(source,'D')
self.assertEqual(result['metadata']['key_signature'],'D minor')
self.assertEqual(result['metadata']['key']['alterations'],key_from_fifths(-1,'minor')['alterations'])
def test_additive_and_compound_grouping(self):
self.assertEqual(beat_lengths({'groups':[{'beats':[3,2],'unit':8}]}),[Fraction(3,2),Fraction(1)])
self.assertEqual(beat_lengths({'groups':[{'beats':[6],'unit':8}]}),[Fraction(3,2)]*2)
self.assertEqual(beat_lengths({'groups':[{'beats':[3],'unit':4}]}),[Fraction(1)]*3)
def test_generated_rhythm_follows_mid_measure_meter(self):
change = '<attributes><time><beats>6</beats><beat-type>8</beat-type></time></attributes>'
source = parse(xml_score(xml_note(duration=1,kind='quarter')+change+xml_note(duration=3,kind='half',extra='<dot/>')))
measure = source['staves'][0]['measures'][0]
events = generated_voice(measure,'slash',None)
self.assertEqual([fraction(n['onset']) for n in events],[0,1,Fraction(5,2)])
self.assertEqual([fraction(n['duration']) for n in events],[1,Fraction(3,2),Fraction(3,2)])
def test_harmony_carry_uses_last_event_and_never_future_event(self):
c = harmony.parse_symbol('C7') | {'onset':rational(0)}
f = harmony.parse_symbol('F7') | {'onset':rational(2)}
measures = [{'harmonies':[c,f]},{'harmonies':[]}]
self.assertEqual(propagated_chords(measures),[None,f])
self.assertIsNone(active_chord({'harmonies':[f]},Fraction(0),None))
self.assertEqual(active_chord(measures[0],Fraction(1),None),c)
def test_tuplet_ratios_and_tie_links_validated(self):
document = parse(xml_score(xml_note()))
notes(document)[0]['pitches'][0]['tie_to'] = 'missing'
with self.assertRaisesRegex(ValueError,'dangling tie'):
validate_document(document)
document = parse(xml_score(xml_note()))
notes(document)[0]['duration'] = {'numerator':8,'denominator':2}
with self.assertRaisesRegex(ValueError,'reduced'):
validate_document(document)
def test_optional_turnaround_and_form(self):
source = load_musicxml(Path('data/songs/it-could-happen-to-you.musicxml'))
for part in ('acoustic','electric','vocal'):
result = arrange_document(source,part)
validate_document(result)
measures = result['staves'][0]['measures']
self.assertEqual(sum(bool(m.get('section_label')) for m in measures),7)
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']))
def test_player_styles_preserved(self):
self.assertEqual(len(BUILT_IN_STYLES),50)
for text in ('Med.','Med. Swing','Medium','Medium Swing'):
self.assertEqual(canonical_style(text),'Medium Swing')
with self.assertRaises(ValueError):
canonical_style('Unrecognised style')
def test_failure_preserves_generated_assets(self):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
data = root/'data'
(data/'songs').mkdir(parents=True)
(data/'songs'/'bad.musicxml').write_text(xml_score(xml_note(duration=5)),encoding='utf-8')
output = root/'output'
output.mkdir()
(output/'last-good').write_text('preserved')
args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False)
with self.assertRaises(ValueError):
generate_assets(args)
self.assertEqual((output/'last-good').read_text(),'preserved')
(data/'songs'/'bad.musicxml').write_text(xml_score(xml_note()),encoding='utf-8')
args.check = True
with self.assertRaisesRegex(ValueError,'summary'):
generate_assets(args)
self.assertEqual((output/'last-good').read_text(),'preserved')
def test_instrument_transposition_is_converted_once(self):
source = parse(xml_score(xml_note(),attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
concert = concert_document(source)
p = notes(concert)[0]['pitches'][0]
self.assertEqual((p['letter'],p['alter'],p['octave']),('b',-1,3))
self.assertEqual(concert_document(concert),concert)
self.assertEqual(concert['metadata']['key_signature'],'Bb')
def test_deterministic_generation_and_removal(self):
with tempfile.TemporaryDirectory() as temp:
data,output = Path(temp)/'data',Path(temp)/'output'
(data/'songs').mkdir(parents=True)
source = data/'songs'/'fixture.musicxml'
source.write_text(xml_score(xml_note()),encoding='utf-8')
args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False)
generate_assets(args)
first = {p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()}
generate_assets(args)
self.assertEqual(first,{p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.unlink()
generate_assets(args)
self.assertFalse(list(output.rglob('*.json')))
def test_pickup_tie_survives_vocal_restoration(self):
source = xml_score(xml_note(duration=1,kind='quarter',extra='<tie type="start"/>'),implicit=True)
source = source.replace('</measure></part>','</measure><measure number="2">'+xml_note(extra='<tie type="stop"/>')+'</measure></part>')
result = arrange_document(parse(source),'vocal')
validate_document(result)
measures = result['staves'][0]['measures']
start = measures[0]['voices'][0]['notes'][-1]['pitches'][0]
end = measures[1]['voices'][0]['notes'][0]['pitches'][0]
self.assertEqual(start['tie_to'],end['id'])
+188
View File
@@ -0,0 +1,188 @@
from fractions import Fraction
from pathlib import Path
import tempfile
import unittest
from generator.musicxml import MusicXMLError, load_musicxml
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number
def xml_score(content, attributes='', *, divisions=1, implicit=False):
return ('<?xml version="1.0"?>'
'<score-partwise version="4.0"><work><work-title>Fixture</work-title></work>'
'<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>'
f'<part id="P1"><measure number="1" implicit="{"yes" if implicit else "no"}">'
f'<attributes><divisions>{divisions}</divisions><key><fifths>0</fifths></key>'
'<time><beats>4</beats><beat-type>4</beat-type></time>'
f'<clef><sign>G</sign><line>2</line></clef>{attributes}</attributes>'
f'{content}</measure></part></score-partwise>')
def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, octave=4):
return (f'<note>{prefix}<pitch><step>{step}</step><alter>{alter}</alter><octave>{octave}</octave></pitch>'
f'<duration>{duration}</duration><type>{kind}</type>{extra}</note>')
def parse(text):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'fixture.musicxml'
path.write_text(text, encoding='utf-8')
return load_musicxml(path)
def notes(document, measure=0):
return document['staves'][0]['measures'][measure]['voices'][0]['notes']
class MusicXMLTests(unittest.TestCase):
def test_regression_fixture(self):
score = load_musicxml(Path('tests/fixtures/notation.musicxml'))
self.assertEqual(len(score['staves'][0]['measures']),4)
self.assertEqual(len(score['staves'][0]['octave_shifts']),1)
def test_catalogue_imports(self):
sources = sorted(Path('data').rglob('*.musicxml'))
self.assertEqual(len(sources), 6)
for path in sources:
with self.subTest(path=path):
self.assertEqual(load_musicxml(path)['schema_version'], 2)
def test_all_written_values_and_dots(self):
for kind in NOTE_TYPES:
for dots in (0, 1, 2, 3):
value = TYPE_LENGTHS[kind] * (2-Fraction(1, 2**dots))
score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='<dot/>'*dots), divisions=value.denominator, implicit=True))
self.assertEqual(fraction(notes(score)[0]['duration']), value)
self.assertEqual(notes(score)[0]['written'], {'type':kind, 'dots':dots})
def test_divisions_do_not_change_music(self):
a = parse(xml_score(xml_note(duration=4)))
b = parse(xml_score(xml_note(duration=1920), divisions=480))
self.assertEqual(a, b)
def test_doctype_allowed_but_entities_rejected(self):
source = xml_score(xml_note())
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
self.assertEqual(parse(source)['schema_version'], 2)
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise',1)
with self.assertRaises(MusicXMLError):
parse(malicious)
with self.assertRaises(MusicXMLError):
parse('<score-partwise')
def test_harmony_inside_sustained_note_remains_independent(self):
h = '<harmony><root><root-step>F</root-step></root><kind>major</kind><offset>-2</offset></harmony>'
score = parse(xml_score(xml_note()+h))
self.assertEqual(len(notes(score)), 1)
self.assertEqual(fraction(score['staves'][0]['measures'][0]['harmonies'][0]['onset']), 2)
def test_partial_chord_ties_and_unequal_tones(self):
source = xml_note(duration=2,kind='half',extra='<tie type="start"/>')
source += xml_note('E',1,'quarter',prefix='<chord/>')
source += xml_note(duration=2,kind='half',extra='<tie type="stop"/>')
source += xml_note('G',2,'half',prefix='<chord/>')
result = notes(parse(xml_score(source)))
self.assertEqual(fraction(result[0]['pitches'][1]['duration']), 1)
self.assertEqual(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id'])
self.assertNotIn('tie_to', result[0]['pitches'][1])
def test_grace_cue_and_measured_tremolo(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
regular = xml_note(extra='<notations><ornaments><tremolo type="single">3</tremolo></ornaments></notations>', prefix='<cue/>')
result = notes(parse(xml_score(grace+regular)))
self.assertEqual(fraction(result[0]['duration']), 0)
self.assertTrue(result[0]['grace']['slash'])
self.assertTrue(result[1]['cue'])
self.assertEqual(result[1]['tremolo']['marks'], 3)
def test_exact_quintuplets(self):
content = ''
for i in range(5):
tuplet = '<tuplet type="start"/>' if i==0 else '<tuplet type="stop"/>' if i==4 else ''
extra = '<time-modification><actual-notes>5</actual-notes><normal-notes>4</normal-notes></time-modification>'
extra += '<notations>'+tuplet+'</notations>'
content += xml_note(duration=4, kind='quarter',extra=extra)
result = parse(xml_score(content,divisions=5))
self.assertEqual(sum(fraction(n['duration']) for n in notes(result)),4)
self.assertTrue(all(fraction(n['duration'])==Fraction(4,5) for n in notes(result)))
self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'],5)
def test_inconsistent_written_timing_is_rejected(self):
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'):
parse(xml_score(xml_note(extra=modulation)))
with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'):
parse(xml_score(xml_note(kind='half')))
def test_paired_tremolo_timing(self):
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
content = ''.join(xml_note(step,2,'whole',extra=modulation+f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>')
for step,action in [('C','start'),('G','stop')])
result = notes(parse(xml_score(content)))
self.assertEqual(result[0]['tremolo']['partner'],result[1]['id'])
self.assertEqual(fraction(result[0]['duration']),2)
def test_mid_measure_clef_and_key_changes(self):
change = '<attributes><key><fifths>-3</fifths><mode>minor</mode></key><clef><sign>C</sign><line>3</line></clef></attributes>'
result = parse(xml_score(xml_note(duration=2,kind='half')+change+xml_note(duration=2,kind='half')))
attr = result['staves'][0]['measures'][0]['attributes'][0]
self.assertEqual(fraction(attr['onset']),2)
self.assertEqual(attr['key']['mode'],'minor')
self.assertEqual(attr['clef']['sign'],'C')
def test_optional_marker_contract(self):
marker = '<direction><direction-type><words>vamp:optional-chord</words></direction-type></direction>'
h = '<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>'
score = parse(xml_score(marker+h+xml_note()))
self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional'])
for content in (marker+xml_note(),marker+marker+h+xml_note(),marker.replace('optional-chord','unknown')+xml_note()):
with self.assertRaisesRegex(MusicXMLError,'measure 1'):
parse(xml_score(content))
def test_rejects_musical_content_outside_contract(self):
extras = ['<notations><slur type="start"/></notations>', '<notations><articulations><accent/></articulations></notations>',
'<voice>2</voice><staff>2</staff>', '<pitch><unpitched/></pitch>',
'<notations><ornaments><trill-mark/></ornaments></notations>']
for extra in extras:
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(extra=extra)))
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>4</duration></backup>',
'<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
parse(xml_score(xml_note()+extra))
with self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(alter='0.5')))
with self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
with self.assertRaises(MusicXMLError):
parse(xml_score(xml_note(duration=5)))
def test_b_sharp_c_flat_preserve_octave(self):
b = notes(parse(xml_score(xml_note('B',alter=1))))[0]['pitches'][0]
c = notes(parse(xml_score(xml_note('C',alter=-1))))[0]['pitches'][0]
self.assertEqual(pitch_number(b),72)
self.assertEqual(pitch_number(c),59)
def test_musicxml_degree_alterations_are_relative(self):
h = '<harmony><root><root-step>C</root-step></root><kind>minor</kind><degree><degree-value>3</degree-value><degree-alter>1</degree-alter><degree-type>alter</degree-type></degree></harmony>'
from generator.harmony import degrees
score = parse(xml_score(h+xml_note()))
self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3],0)
h = h.replace('<kind>minor</kind>','<kind text="maj7">minor</kind>')
with self.assertRaisesRegex(MusicXMLError,'contradictory'):
parse(xml_score(h+xml_note()))
def test_nested_and_cross_bar_tuplets(self):
modulation = '<time-modification><actual-notes>9</actual-notes><normal-notes>4</normal-notes></time-modification>'
def tuplet(number,action):
details = '<tuplet-actual><tuplet-number>3</tuplet-number></tuplet-actual><tuplet-normal><tuplet-number>2</tuplet-number></tuplet-normal>' if action=='start' else ''
return f'<tuplet number="{number}" type="{action}">{details}</tuplet>'
first = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(1,'start')+tuplet(2,'start')+'</notations>')
last = xml_note(duration=4,kind='quarter',extra=modulation+'<notations>'+tuplet(2,'stop')+tuplet(1,'stop')+'</notations>')
text = xml_score(first,divisions=9,implicit=True)
text = text.replace('</measure></part>', '</measure><measure number="2" implicit="yes">'+last+'</measure></part>')
result = parse(text)
spans = result['staves'][0]['tuplets']
self.assertEqual([t['depth'] for t in spans],[0,1])
self.assertEqual(fraction(notes(result,1)[0]['duration']),Fraction(4,9))
self.assertTrue(all(t['end']==notes(result,1)[0]['id'] for t in spans))
-219
View File
@@ -8,11 +8,6 @@ import tempfile
from pathlib import Path
from generator.__main__ import copy_runtime_fonts
from generator.arrange.generate import arrange_document
from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing
from generator.styles import BUILT_IN_STYLES
from generator.transposition import mod12
from generator.score import normalize_score_document
from tools import generate_pico_font
@@ -42,217 +37,3 @@ class FontAssetsTests(unittest.TestCase):
self.assertEqual(before, output.stat().st_mtime_ns)
output.write_text("stale", encoding="utf-8")
self.assertNotEqual(subprocess.run([*command, "--check"], capture_output=True).returncode, 0)
class CompactScoreJsonTests(unittest.TestCase):
def pitch_classes(self, pitches: list[dict]) -> set[int]:
natural = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
alterations = {"bb": -2, "b": -1, "n": 0, "": 0, "#": 1, "x": 2}
result: set[int] = set()
for pitch in pitches:
letter = str(pitch.get("letter", "c")).lower()
accidental = str(pitch.get("accidental", "n"))
result.add(mod12(natural.get(letter, 0) + alterations.get(accidental, 0)))
return result
def chord_symbols(self, measure: dict) -> list[str]:
return [
note["chord_symbol"]
for note in measure["voices"][0]["notes"]
if isinstance(note.get("chord_symbol"), str) and note["chord_symbol"]
]
def measure_notes(self, measure: dict) -> list[dict]:
return measure["voices"][0]["notes"]
def test_compact_json_normalizes_notation_tokens(self) -> None:
document = normalize_score_document({
"title": "Compact Tune",
"composer": "Composer",
"key": "F",
"time": "4/4",
"clef": "treble",
"part": "Vocal",
"role": "vocal",
"measures": [
"[G-7] Bb4:1~ \"leaves\"",
"[C7] ~Bb4:4 C4:4 \"drift\" D4:4 \"by\" E4:4 \"my\"",
],
})
staff = document["staves"][0]
first_note = staff["measures"][0]["voices"][0]["notes"][0]
second_measure_first_note = staff["measures"][1]["voices"][0]["notes"][0]
self.assertNotIn("version", document)
self.assertEqual(document["metadata"]["key_signature"], "F")
self.assertEqual(document["metadata"]["style"], "Medium Swing")
self.assertEqual(staff["name"], "Vocal")
self.assertEqual(first_note["duration_ticks"], 16384)
self.assertEqual(first_note["chord_symbol"], "G-7")
self.assertEqual(first_note["lyric"], "leaves")
self.assertTrue(first_note["tie_to_next"])
self.assertTrue(second_measure_first_note["tie_from_previous"])
def test_compact_json_canonicalizes_player_styles(self) -> None:
self.assertEqual(len(BUILT_IN_STYLES), 50)
for style in ("Med.", "Med. Swing", "Medium", "Medium Swing"):
document = normalize_score_document({
"title": "Style Tune",
"style": style,
"measures": ["C4:1"],
})
self.assertEqual(document["metadata"]["style"], "Medium Swing")
with self.assertRaisesRegex(ValueError, "supported player style"):
normalize_score_document({
"title": "Bad Style Tune",
"style": "Medium Bossa That Does Not Exist",
"measures": ["C4:1"],
})
def test_compact_json_preserves_musicxml_like_measure_metadata(self) -> None:
document = normalize_score_document({
"title": "Metadata Tune",
"measures": [
{
"notes": "C4:1",
"attributes": {"divisions": 4},
"directions": [{"type": "dynamics", "value": "mf"}],
"harmony": [{"root": "C", "kind": "major-seventh"}],
"barlines": {"right": "light-heavy"},
},
],
})
measure = document["staves"][0]["measures"][0]
self.assertEqual(measure["attributes"], {"divisions": 4})
self.assertEqual(measure["directions"], [{"type": "dynamics", "value": "mf"}])
self.assertEqual(measure["harmony"], [{"root": "C", "kind": "major-seventh"}])
self.assertEqual(measure["barlines"], {"right": "light-heavy"})
def test_arrangements_preserve_measure_notation_metadata(self) -> None:
document = normalize_score_document({
"title": "Directions Tune",
"key": "C",
"time": "4/4",
"measures": [
{
"notes": "[Cmaj7] C4:1",
"directions": [{"type": "rehearsal", "value": "A"}],
"barlines": {"left": "heavy-light"},
},
],
})
arranged = arrange_document(document, "vocal")
measure = arranged["staves"][0]["measures"][0]
self.assertEqual(measure["directions"], [{"type": "rehearsal", "value": "A"}])
self.assertEqual(measure["barlines"], {"left": "heavy-light"})
def test_compact_json_marks_optional_chords(self) -> None:
document = normalize_score_document({
"title": "Optional Turnaround",
"key": "Eb",
"time": "4/4",
"measures": [
"[(F-7)] r:2 [(Bb7)] r:2",
],
})
notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
self.assertEqual(notes[0]["chord_symbol"], "F-7")
self.assertTrue(notes[0]["chord_optional"])
self.assertEqual(notes[1]["chord_symbol"], "Bb7")
self.assertTrue(notes[1]["chord_optional"])
def test_arrangements_omit_optional_turnaround_from_final_ending(self) -> None:
document = normalize_score_document({
"title": "Optional Turnaround",
"key": "Eb",
"time": "4/4",
"measures": [
"[Ebmaj7] C4:1",
"[Ab7] D4:1",
"[Ebmaj7] E4:1",
"[(F-7)] r:2 [(Bb7)] r:2",
],
})
for part_type in ("acoustic", "electric", "vocal"):
arranged = arrange_document(document, part_type)
measures = arranged["staves"][0]["measures"]
non_final_turnaround = measures[7]
final_turnaround = measures[-1]
self.assertNotIn("version", arranged)
self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"])
self.assertEqual(self.chord_symbols(final_turnaround), [])
self.assertFalse(any("chord_optional" in note for measure in measures for note in self.measure_notes(measure)))
if part_type in {"acoustic", "electric"}:
self.assertTrue(all(note["kind"] == "rest" for note in self.measure_notes(final_turnaround)))
def test_slash_arrangements_preserve_source_chord_markers(self) -> None:
document = normalize_score_document({
"title": "Slash Tune",
"key": "C",
"time": "4/4",
"measures": [
"[Cmaj7] C4:1",
"[F7#11] F4:1",
"[B-7b5] B4:1",
"[E7b9] E4:1",
],
})
arranged = arrange_document(document, "acoustic")
slash_measure = arranged["staves"][0]["measures"][12]
self.assertEqual(self.chord_symbols(slash_measure), ["Cmaj7"])
self.assertTrue(all(note.get("notehead") == "slash" for note in self.measure_notes(slash_measure)))
def test_jazz_core_chords_include_color_tones(self) -> None:
self.assertEqual(set(chord_pitch_classes("G7b9")), {7, 11, 5, 8})
self.assertEqual(set(chord_pitch_classes("C13")), {0, 4, 10, 9})
self.assertEqual(set(chord_pitch_classes("Fmaj7#11")), {5, 9, 4, 11})
self.assertTrue({11, 5, 8}.issubset(self.pitch_classes(pitches_for_voicing("G7b9"))))
self.assertTrue({4, 10, 9}.issubset(self.pitch_classes(pitches_for_voicing("C13"))))
def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None:
document = normalize_score_document({
"title": "Practice",
"key": "C",
"time": "4/4",
"measures": [
"[G7] (G4 B4 F5):4 r:4",
"{5:2 C4:4 D4:4 E4:4 F4:4 G4:4}",
],
})
first_measure_notes = document["staves"][0]["measures"][0]["voices"][0]["notes"]
second_voice = document["staves"][0]["measures"][1]["voices"][0]
tuplet_notes = second_voice["notes"]
self.assertEqual(len(first_measure_notes[0]["pitches"]), 3)
self.assertEqual(first_measure_notes[1]["kind"], "rest")
self.assertEqual(second_voice["tuplets"][0]["note_count"], 5)
self.assertEqual(second_voice["tuplets"][0]["space_count"], 2)
self.assertEqual(sum(note["duration_ticks"] for note in tuplet_notes), 8192)
self.assertTrue(all(note["display_ticks"] == 4096 for note in tuplet_notes))
def test_compact_json_rejects_overfull_measure(self) -> None:
with self.assertRaises(ValueError):
normalize_score_document({
"title": "Bad",
"time": "4/4",
"measures": ["C4:1 D4:4"],
})
if __name__ == "__main__":
unittest.main()