#include #include #include #include #include #include #include #include #include #include #include #include #include namespace { auto string_field(boost::json::object const &object, std::string_view name) -> std::expected { 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(expected))); } } auto vamp_zero::parse_notation_document(std::string_view json, std::string source_path) -> std::expected { auto invalid = [&](std::string message) -> std::expected { 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{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(), denominator(time).convert_to()}); } 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(); range.measure_count = value.at("measure_count").to_number(); 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(); 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(); 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{}; 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; }