Files
product-vamp/src/vamp_zero/notation.cpp
T
khodowany d2f493c17b
ci / checks (push) Successful in 50m29s
Playback Mode Pages
2026-10-06 23:11:25 -05:00

266 lines
12 KiB
C++

#include <algorithm>
#include <cstdint>
#include <cmath>
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <utility>
#include <boost/json.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/performance.hpp>
#include <boost/multiprecision/cpp_int.hpp>
namespace {
auto string_field(boost::json::object const &object, std::string_view name) -> std::expected<std::string, modulus::error> {
auto value = object.if_contains(name);
if (value == nullptr || !value->is_string()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::string{name} + " must be a string"));
}
return std::string{value->as_string()};
}
auto object_field(boost::json::object const &object, std::string_view name) -> boost::json::object const * {
auto value = object.if_contains(name);
return value == nullptr ? nullptr : value->if_object();
}
auto array_field(boost::json::object const &object, std::string_view name) -> boost::json::array const * {
auto value = object.if_contains(name);
return value == nullptr ? nullptr : value->if_array();
}
auto integer_equals(boost::json::object const &object, std::string_view name, std::int64_t expected) -> std::bool_t {
auto value = object.if_contains(name);
return value != nullptr && ((value->is_int64() && value->as_int64() == expected) ||
(value->is_uint64() && value->as_uint64() == static_cast<std::uint64_t>(expected)));
}
}
auto vamp_zero::parse_notation_document(std::string_view json, std::string source_path) -> std::expected<score_document, modulus::error> {
auto invalid = [&](std::string message) -> std::expected<score_document, modulus::error> {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, source_path + ": score bundle: " + std::move(message)));
};
auto error = boost::system::error_code{};
auto value = boost::json::parse(json, error);
if (error || !value.is_object()) {
return invalid("expected a JSON object");
}
auto const &root = value.as_object();
auto music = object_field(root, "music");
auto presentation = object_field(root, "presentation");
if (music == nullptr || presentation == nullptr) {
return invalid("music and presentation objects are required; regenerate score assets");
}
auto metadata = object_field(*music, "metadata");
auto staves = array_field(*music, "staves");
auto pages = array_field(*presentation, "pages");
if (metadata == nullptr || staves == nullptr || staves->size() != 1 || !staves->front().is_object()) {
return invalid("music requires metadata and exactly one staff");
}
if (!integer_equals(*presentation, "width", 976) || !integer_equals(*presentation, "height", 484) ||
pages == nullptr || pages->empty() || pages->size() > 10000) {
return invalid("presentation requires 976x484 pages");
}
auto const &staff = staves->front().as_object();
auto measures = array_field(staff, "measures");
if (measures == nullptr || measures->empty()) {
return invalid("music requires measures");
}
auto document = score_document{};
for (auto field : {std::pair{"title", &document.title}, {"composer", &document.composer}, {"key_signature", &document.key_signature}, {"time_signature", &document.time_signature}}) {
auto text = string_field(*metadata, field.first);
if (!text) {
return invalid(std::string{text.error().message()});
}
*field.second = std::move(*text);
}
auto part = string_field(staff, "name");
if (!part) {
return invalid(std::string{part.error().message()});
}
document.part = std::move(*part);
if (staff.if_contains("arrangement_part_type")) {
auto type = string_field(staff, "arrangement_part_type");
if (!type) {
return invalid(std::string{type.error().message()});
}
document.arrangement_part_type = std::move(*type);
}
auto times = std::vector<boost::multiprecision::cpp_rational>{0};
auto rational = [](vamp_zero::beat_position beat) -> boost::multiprecision::cpp_rational { return boost::multiprecision::cpp_rational{beat.numerator} / beat.denominator; };
try {
for (auto const &measure : *measures) {
auto duration = vamp_zero::parse_beat(measure.at("duration"));
if (!duration || duration->numerator == 0) {
return invalid("invalid measure duration");
}
times.push_back(times.back() + rational(*duration));
}
auto const &performance = root.at("performance").as_object();
document.song_id = std::string{performance.at("song_id").as_string()};
auto duration = vamp_zero::parse_beat(performance.at("duration"));
if (!duration || rational(*duration) != times.back() || !integer_equals(performance, "count_in_beats", 4)) {
return invalid("invalid performance duration or count-in");
}
document.duration = *duration;
auto sustain = performance.if_contains("sustain_start") ? vamp_zero::parse_beat(performance.at("sustain_start")) : duration;
if (!sustain || std::find(times.begin(), times.end(), rational(*sustain)) == times.end()) {
return invalid("invalid held-ending boundary");
}
document.sustain_start = *sustain;
for (auto i = std::size_t{}; i < measures->size(); ++i) {
auto marker = (*measures)[i].as_object().if_contains("ending_sustain");
auto ending = marker && marker->is_bool() && marker->as_bool();
if (ending != (times[i] >= rational(*sustain))) {
return invalid("held-ending measures disagree with performance timing");
}
}
auto const &clicks = performance.at("clicks").as_array();
auto click = std::size_t{};
for (auto measure = std::size_t{}; measure + 1 < times.size() && times[measure] < rational(*sustain); ++measure) {
for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) {
if (click >= clicks.size()) {
return invalid("missing metronome beat");
}
auto position = vamp_zero::parse_beat(clicks[click].at("beat"));
if (!position || rational(*position) != beat || clicks[click].at("downbeat").as_bool() != (beat == times[measure])) {
return invalid("invalid metronome accent or beat");
}
++click;
}
}
if (click != clicks.size()) {
return invalid("unexpected metronome beats");
}
} catch (...) {
return invalid("missing performance timing; regenerate score assets");
}
try {
for (auto const &time : times) {
document.measure_boundaries.push_back({numerator(time).convert_to<std::uint64_t>(), denominator(time).convert_to<std::uint64_t>()});
}
auto const *playback = array_field(*presentation, "playback_pages");
if (!playback || playback->empty() || playback->size() > 10000) {
return invalid("missing playback pages; regenerate score assets");
}
auto parse_page = [&](boost::json::value const &item) -> score_page {
if (!item.is_object()) {
throw std::invalid_argument("page must be an object");
}
auto path = string_field(item.as_object(), "path");
auto hash = string_field(item.as_object(), "sha256");
if (!path || !hash) {
throw std::invalid_argument("page requires path and sha256 strings");
}
auto relative = std::filesystem::path{*path};
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
relative.is_absolute() || relative.extension() != ".png" ||
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
throw std::invalid_argument("page paths must be safe relative PNG paths");
}
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
})) {
throw std::invalid_argument("page sha256 must be 64 lowercase hexadecimal digits");
}
auto parse_range = [&](boost::json::value const &value, auto &range) {
range.first_measure = value.at("first_measure").to_number<std::size_t>();
range.measure_count = value.at("measure_count").to_number<std::size_t>();
auto a = range.first_measure, count = range.measure_count;
auto start = parse_beat(value.at("start_beat")), end = parse_beat(value.at("end_beat"));
if (!a || a > measures->size() || !count || count > measures->size() - (a - 1) || !start || !end || rational(*start) != times[a - 1] || rational(*end) != times[a + count - 1]) {
throw std::invalid_argument("invalid page/system timing or coverage");
}
range.start_beat = *start;
range.end_beat = *end;
};
auto result = score_page{};
result.path = std::move(*path);
result.sha256 = std::move(*hash);
parse_range(item, result);
auto const &systems = item.at("systems").as_array();
if (systems.empty() || systems.size() > 2) {
throw std::invalid_argument("invalid page systems");
}
auto cursor = result.first_measure;
for (auto const &value : systems) {
auto system = score_page::system{};
parse_range(value, system);
if (system.first_measure != cursor) {
throw std::invalid_argument("system gap or overlap");
}
auto const &labels = value.at("labels").as_array();
if (labels.size() != system.measure_count) {
throw std::invalid_argument("invalid measure label coverage");
}
for (auto const &label : labels) {
auto parsed = score_page::measure_label{};
parsed.measure = label.at("measure").to_number<std::size_t>();
auto const &bounds = label.at("bounds").as_array();
if (parsed.measure != cursor++ || bounds.size() != 4) {
throw std::invalid_argument("invalid measure label");
}
for (std::size_t k = 0; k < 4; ++k) {
parsed.bounds[k] = bounds[k].to_number<double>();
if (!std::isfinite(parsed.bounds[k])) {
throw std::invalid_argument("non-finite label bounds");
}
}
auto const &r = parsed.bounds;
if (!(4 <= r[0] && r[0] < r[2] && r[2] <= 972 && 4 <= r[1] && r[1] < r[3] && r[3] <= 480)) {
throw std::invalid_argument("invalid label bounds");
}
system.labels.push_back(parsed);
}
result.systems.push_back(std::move(system));
}
if (cursor != result.first_measure + result.measure_count) {
throw std::invalid_argument("systems disagree with page coverage");
}
return result;
};
auto cursor = std::size_t{1};
auto canonical = std::vector<score_page::system>{};
for (auto const &item : *pages) {
auto page = parse_page(item);
if (page.first_measure != cursor || (page.systems.size() != 2 && &item != &pages->back())) {
return invalid("invalid page measure coverage");
}
cursor += page.measure_count;
canonical.insert(canonical.end(), page.systems.begin(), page.systems.end());
document.pages.push_back(std::move(page));
}
if (cursor != measures->size() + 1 || playback->size() != canonical.size()) {
return invalid("incomplete normal or playback coverage");
}
for (std::size_t i = 0; i < playback->size(); ++i) {
auto page = parse_page((*playback)[i]);
if (page.systems.size() != std::min(std::size_t{2}, canonical.size() - i)) {
return invalid("invalid playback system pairing");
}
for (std::size_t j = 0; j < page.systems.size(); ++j) {
auto const &a = canonical[i + j];
auto const &b = page.systems[j];
if (a.first_measure != b.first_measure || a.measure_count != b.measure_count) {
return invalid("playback changes system boundaries");
}
}
document.playback_pages.push_back(std::move(page));
}
} catch (std::exception const &error) {
return invalid(std::string{"invalid playback presentation: "} + error.what() + "; regenerate score assets");
}
document.id = std::filesystem::path{source_path}.stem().string();
document.source_path = std::move(source_path);
document.source_json = std::string{json};
document.measure_count = measures->size();
return document;
}