@@ -79,6 +79,7 @@ add_executable(product-vamp-pico2w
|
||||
src/vamp_pico/display.cpp
|
||||
src/vamp_pico/http_client.cpp
|
||||
src/vamp_pico/main.cpp
|
||||
src/vamp_pico/remote_display.cpp
|
||||
src/vamp_pico/touch.cpp
|
||||
src/vamp_protocol/page_control.cpp
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ set(VAMP_CORE_SOURCES
|
||||
src/vamp_zero/database.cpp
|
||||
src/vamp_zero/errors.cpp
|
||||
src/vamp_zero/library.cpp
|
||||
src/vamp_zero/navigation.cpp
|
||||
src/vamp_zero/page_control.cpp
|
||||
src/vamp_zero/portal.cpp
|
||||
src/vamp_zero/score.cpp
|
||||
@@ -175,6 +176,7 @@ if(VAMP_BUILD_ZERO_IMAGE)
|
||||
package/migrations/002_song_import_metadata.sql
|
||||
package/migrations/003_practice_catalog.sql
|
||||
package/migrations/004_practice_hierarchy.sql
|
||||
package/migrations/005_catalog_managed_rows.sql
|
||||
${VAMP_GENERATED_ASSETS_STAMP}
|
||||
VERBATIM
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,12 @@ class TabPosition(NamedTuple):
|
||||
midi: int
|
||||
|
||||
|
||||
class ChordSymbol(NamedTuple):
|
||||
root: str
|
||||
quality: str
|
||||
bass: str
|
||||
|
||||
|
||||
ROOTS = {
|
||||
"C": 0,
|
||||
"C#": 1,
|
||||
@@ -37,6 +43,7 @@ ROOTS = {
|
||||
|
||||
|
||||
def parse_root(symbol: str) -> tuple[str, str] | None:
|
||||
symbol = symbol.strip()
|
||||
if not symbol or symbol[0].upper() not in "ABCDEFG":
|
||||
return None
|
||||
length = 1
|
||||
@@ -45,36 +52,97 @@ def parse_root(symbol: str) -> tuple[str, str] | None:
|
||||
return symbol[:length], symbol[length:]
|
||||
|
||||
|
||||
def parse_chord_symbol(symbol: str) -> ChordSymbol | None:
|
||||
clean = symbol.strip().replace(" ", "")
|
||||
base, _, slash = clean.partition("/")
|
||||
parsed = parse_root(base)
|
||||
if parsed is None:
|
||||
return None
|
||||
root, quality = parsed
|
||||
bass = ""
|
||||
if slash:
|
||||
bass_parsed = parse_root(slash)
|
||||
if bass_parsed is not None:
|
||||
bass = bass_parsed[0]
|
||||
return ChordSymbol(root, quality, bass)
|
||||
|
||||
|
||||
def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]:
|
||||
result: list[int] = []
|
||||
seen: set[int] = set()
|
||||
for value in values:
|
||||
interval = mod12(value)
|
||||
if interval in seen:
|
||||
continue
|
||||
seen.add(interval)
|
||||
result.append(interval)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def color_intervals(value: str) -> tuple[int, ...]:
|
||||
colors: list[int] = []
|
||||
if "alt" in value:
|
||||
colors.append(1)
|
||||
if "b9" in value:
|
||||
colors.append(1)
|
||||
if "#9" in value:
|
||||
colors.append(3)
|
||||
if "9" in value and "b9" not in value and "#9" not in value:
|
||||
colors.append(2)
|
||||
if "#11" in value:
|
||||
colors.append(6)
|
||||
elif "11" in value:
|
||||
colors.append(5)
|
||||
if "b13" in value:
|
||||
colors.append(8)
|
||||
elif "13" in value:
|
||||
colors.append(9)
|
||||
return tuple(colors)
|
||||
|
||||
|
||||
def chord_intervals(quality: str) -> tuple[int, ...]:
|
||||
value = quality.lower()
|
||||
value = quality.lower().replace(" ", "")
|
||||
if "7b5" in value or "ø" in value or "half" in value:
|
||||
return (0, 3, 6, 10)
|
||||
if "dim" in value or "o" in value:
|
||||
return (0, 3, 6, 9)
|
||||
colors = color_intervals(value)
|
||||
if "aug" in value or "+" in value:
|
||||
return (0, 4, 8, 10)
|
||||
return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8)
|
||||
if "sus" in value:
|
||||
return (0, 5, 7, 10)
|
||||
if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")):
|
||||
return (0, 3, 7, 10 if "7" in value else 10)
|
||||
if "maj7" in value or "ma7" in value or "Δ" in value:
|
||||
return (0, 4, 7, 11)
|
||||
return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7)
|
||||
minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma")))
|
||||
if minor:
|
||||
if "6" in value and "7" not in value:
|
||||
return unique_intervals((0, 3, 7, 9) + colors)
|
||||
if "7" in value or colors:
|
||||
return unique_intervals((0, 3, 10) + colors)
|
||||
return (0, 3, 7)
|
||||
if "maj" in value or value.startswith("ma") or "Δ" in value:
|
||||
return unique_intervals((0, 4, 11) + colors)
|
||||
if "6" in value and "7" not in value:
|
||||
return (0, 4, 7, 9)
|
||||
if "7" in value:
|
||||
return (0, 4, 7, 10)
|
||||
return unique_intervals((0, 4, 7, 9) + colors)
|
||||
if "7" in value or colors:
|
||||
return unique_intervals((0, 4, 10) + colors)
|
||||
return (0, 4, 7)
|
||||
|
||||
|
||||
def pitch_class_for_root(root: str) -> int | None:
|
||||
return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
|
||||
|
||||
|
||||
def chord_pitch_classes(symbol: str) -> tuple[int, ...]:
|
||||
parsed = parse_root(symbol)
|
||||
parsed = parse_chord_symbol(symbol)
|
||||
if parsed is None:
|
||||
return ()
|
||||
root, quality = parsed
|
||||
root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper()))
|
||||
root_pc = pitch_class_for_root(parsed.root)
|
||||
if root_pc is None:
|
||||
return ()
|
||||
return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality))
|
||||
pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)]
|
||||
bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None
|
||||
if bass_pc is not None and bass_pc not in pitch_classes:
|
||||
pitch_classes.append(bass_pc)
|
||||
return tuple(pitch_classes)
|
||||
|
||||
|
||||
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
|
||||
@@ -148,4 +216,3 @@ def pitch_from_midi(midi: int) -> dict[str, int | str]:
|
||||
|
||||
def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]:
|
||||
return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, incl
|
||||
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
|
||||
|
||||
|
||||
def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]:
|
||||
def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
|
||||
capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4")))
|
||||
notes: list[dict] = []
|
||||
cursor = 0
|
||||
@@ -130,7 +130,7 @@ def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[d
|
||||
duration = min(QUARTER_TICKS, capacity - cursor)
|
||||
notes.append(slash_note(f"arr-slash-{sequence}", duration))
|
||||
cursor += duration
|
||||
return notes, sequence
|
||||
return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence
|
||||
|
||||
|
||||
def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]:
|
||||
@@ -211,7 +211,7 @@ def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol:
|
||||
elif section.role == "accompaniment":
|
||||
notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional)
|
||||
elif section.role == "slash":
|
||||
notes, sequence = slash_notes(measure, chord_symbol, sequence)
|
||||
notes, sequence = slash_notes(measure, chord_symbol, sequence, include_optional=include_optional)
|
||||
else:
|
||||
notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional)
|
||||
return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
#include <vamp_protocol/page_control.hpp>
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
@@ -14,6 +17,7 @@ struct remote_display_state {
|
||||
std::uint32_t pages{};
|
||||
};
|
||||
|
||||
auto parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t;
|
||||
auto post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t;
|
||||
auto heartbeat() -> std::bool_t;
|
||||
auto fetch_remote_display(vamp_pico::remote_display_state& state) -> std::bool_t;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
#include <vamp_zero/types.hpp>
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const*;
|
||||
auto selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const*;
|
||||
auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const*;
|
||||
auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const*;
|
||||
auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const*;
|
||||
auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const*;
|
||||
auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const*;
|
||||
auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t;
|
||||
auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const*;
|
||||
auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view>;
|
||||
auto practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t;
|
||||
auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string;
|
||||
auto active_pages(vamp_zero::app_state const& state) -> std::size_t;
|
||||
auto practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode;
|
||||
|
||||
}
|
||||
@@ -83,11 +83,17 @@ struct score_page_layout {
|
||||
std::vector<vamp_zero::score_layout_item> items{};
|
||||
};
|
||||
|
||||
struct score_system_range {
|
||||
std::size_t first_measure{};
|
||||
std::size_t measure_count{};
|
||||
};
|
||||
|
||||
constexpr auto score_measures_per_system = std::size_t{4};
|
||||
constexpr auto score_systems_per_page = std::size_t{3};
|
||||
constexpr auto score_measures_per_page = score_measures_per_system * score_systems_per_page;
|
||||
|
||||
auto notated_accidentals_for_measure(vamp_zero::score_measure const& measure, std::string_view inherited_key_signature) -> std::vector<vamp_zero::score_accidental_mark>;
|
||||
auto score_system_ranges(vamp_zero::score_document const& score) -> std::vector<vamp_zero::score_system_range>;
|
||||
auto score_page_count(vamp_zero::score_document const& score) -> std::size_t;
|
||||
auto layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout;
|
||||
auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void;
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
struct score_draw_cache;
|
||||
|
||||
struct ui_state {
|
||||
vamp_zero::app_state app{};
|
||||
std::string ap_ssid{};
|
||||
@@ -47,6 +49,7 @@ private:
|
||||
boost::asio::steady_timer tap_timer_;
|
||||
luma::application application_;
|
||||
command_handler command_handler_{};
|
||||
std::shared_ptr<vamp_zero::score_draw_cache> score_cache_{};
|
||||
mutable std::mutex mutex_{};
|
||||
vamp_zero::ui_state state_{};
|
||||
mutable std::mutex touch_mutex_{};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1));
|
||||
-- vamp-statement
|
||||
ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1));
|
||||
-- vamp-statement
|
||||
CREATE INDEX idx_songs_catalog_managed ON songs(catalog_managed);
|
||||
-- vamp-statement
|
||||
CREATE INDEX idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed);
|
||||
@@ -3,7 +3,6 @@
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
@@ -198,60 +197,6 @@ auto body_start(std::char_t const* response) -> std::char_t const* {
|
||||
return marker == nullptr ? response : marker + 4;
|
||||
}
|
||||
|
||||
auto copy_value(std::char_t const* begin, std::size_t size, std::char_t* output, std::size_t capacity) -> void {
|
||||
if (capacity == 0U) {
|
||||
return;
|
||||
}
|
||||
auto count = std::min(capacity - 1U, size);
|
||||
std::memcpy(output, begin, count);
|
||||
output[count] = '\0';
|
||||
}
|
||||
|
||||
auto parse_uint(std::char_t const* begin, std::size_t size) -> std::uint32_t {
|
||||
auto buffer = std::array<std::char_t, 16>{};
|
||||
copy_value(begin, std::min<std::size_t>(size, buffer.size() - 1U), buffer.data(), buffer.size());
|
||||
return static_cast<std::uint32_t>(std::strtoul(buffer.data(), nullptr, 10));
|
||||
}
|
||||
|
||||
auto parse_display_body(std::char_t const* body, vamp_pico::remote_display_state& state) -> std::bool_t {
|
||||
auto parsed = vamp_pico::remote_display_state{};
|
||||
auto saw_version = std::bool_t{};
|
||||
auto* cursor = body;
|
||||
while (cursor != nullptr && *cursor != '\0') {
|
||||
auto* line_end = std::strchr(cursor, '\n');
|
||||
auto size = line_end == nullptr ? std::strlen(cursor) : static_cast<std::size_t>(line_end - cursor);
|
||||
if (size != 0U && cursor[size - 1U] == '\r') {
|
||||
--size;
|
||||
}
|
||||
auto* equals = static_cast<std::char_t const*>(std::memchr(cursor, '=', size));
|
||||
if (equals != nullptr) {
|
||||
auto key_size = static_cast<std::size_t>(equals - cursor);
|
||||
auto* value = equals + 1;
|
||||
auto value_size = size - key_size - 1U;
|
||||
if (key_size == 7U && std::memcmp(cursor, "version", 7U) == 0) {
|
||||
saw_version = true;
|
||||
} else if (key_size == 6U && std::memcmp(cursor, "active", 6U) == 0) {
|
||||
parsed.active = value_size != 0U && value[0] == '1';
|
||||
} else if (key_size == 5U && std::memcmp(cursor, "title", 5U) == 0) {
|
||||
copy_value(value, value_size, parsed.title, sizeof(parsed.title));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "part", 4U) == 0) {
|
||||
copy_value(value, value_size, parsed.part, sizeof(parsed.part));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "mode", 4U) == 0) {
|
||||
copy_value(value, value_size, parsed.mode, sizeof(parsed.mode));
|
||||
} else if (key_size == 4U && std::memcmp(cursor, "page", 4U) == 0) {
|
||||
parsed.page = parse_uint(value, value_size);
|
||||
} else if (key_size == 5U && std::memcmp(cursor, "pages", 5U) == 0) {
|
||||
parsed.pages = parse_uint(value, value_size);
|
||||
}
|
||||
}
|
||||
cursor = line_end == nullptr ? nullptr : line_end + 1;
|
||||
}
|
||||
if (saw_version) {
|
||||
state = parsed;
|
||||
}
|
||||
return saw_version;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t {
|
||||
@@ -259,7 +204,7 @@ auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pi
|
||||
if (!run_request("POST", vamp_protocol::page_command_path(command), "{}", response.data(), response.size(), 700)) {
|
||||
return false;
|
||||
}
|
||||
if (parse_display_body(body_start(response.data()), state)) {
|
||||
if (vamp_pico::parse_remote_display_body(body_start(response.data()), state)) {
|
||||
return true;
|
||||
}
|
||||
return vamp_pico::fetch_remote_display(state);
|
||||
@@ -274,5 +219,5 @@ auto vamp_pico::fetch_remote_display(vamp_pico::remote_display_state& state) ->
|
||||
if (!run_request("GET", vamp_protocol::remote_display_path, nullptr, response.data(), response.size(), 1000)) {
|
||||
return false;
|
||||
}
|
||||
return parse_display_body(body_start(response.data()), state);
|
||||
return vamp_pico::parse_remote_display_body(body_start(response.data()), state);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <system_error>
|
||||
|
||||
namespace {
|
||||
|
||||
auto copy_value(std::string_view value, std::char_t* output, std::size_t capacity) -> void {
|
||||
if (capacity == 0U) {
|
||||
return;
|
||||
}
|
||||
auto count = std::min(capacity - 1U, value.size());
|
||||
std::memcpy(output, value.data(), count);
|
||||
output[count] = '\0';
|
||||
}
|
||||
|
||||
auto parse_uint(std::string_view value, std::uint32_t& output) -> std::bool_t {
|
||||
if (value.empty()) {
|
||||
return false;
|
||||
}
|
||||
auto parsed = std::uint64_t{};
|
||||
auto const* begin = value.data();
|
||||
auto const* end = value.data() + value.size();
|
||||
auto result = std::from_chars(begin, end, parsed, 10);
|
||||
if (result.ec != std::errc{} || result.ptr != end || parsed > std::numeric_limits<std::uint32_t>::max()) {
|
||||
return false;
|
||||
}
|
||||
output = static_cast<std::uint32_t>(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
auto strip_cr(std::string_view line) -> std::string_view {
|
||||
if (!line.empty() && line.back() == '\r') {
|
||||
line.remove_suffix(1U);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t {
|
||||
auto parsed = vamp_pico::remote_display_state{};
|
||||
auto saw_version = std::bool_t{};
|
||||
auto cursor = std::size_t{};
|
||||
while (cursor <= body.size()) {
|
||||
auto line_end = body.find('\n', cursor);
|
||||
auto line = line_end == std::string_view::npos ? body.substr(cursor) : body.substr(cursor, line_end - cursor);
|
||||
line = strip_cr(line);
|
||||
auto equals = line.find('=');
|
||||
if (equals != std::string_view::npos) {
|
||||
auto key = line.substr(0U, equals);
|
||||
auto value = line.substr(equals + 1U);
|
||||
if (key == "version") {
|
||||
saw_version = true;
|
||||
} else if (key == "active") {
|
||||
parsed.active = value == "1";
|
||||
} else if (key == "title") {
|
||||
copy_value(value, parsed.title, sizeof(parsed.title));
|
||||
} else if (key == "part") {
|
||||
copy_value(value, parsed.part, sizeof(parsed.part));
|
||||
} else if (key == "mode") {
|
||||
copy_value(value, parsed.mode, sizeof(parsed.mode));
|
||||
} else if (key == "page") {
|
||||
if (!parse_uint(value, parsed.page)) {
|
||||
return false;
|
||||
}
|
||||
} else if (key == "pages") {
|
||||
if (!parse_uint(value, parsed.pages)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (line_end == std::string_view::npos) {
|
||||
break;
|
||||
}
|
||||
cursor = line_end + 1U;
|
||||
}
|
||||
if (!saw_version) {
|
||||
return false;
|
||||
}
|
||||
state = parsed;
|
||||
return true;
|
||||
}
|
||||
+112
-15
@@ -21,6 +21,7 @@
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -108,6 +109,31 @@ auto now_ms() -> std::int64_t {
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
auto execute_delete_stale_catalog_rows(noto::connection& connection, std::string_view table, std::vector<std::string> const& ids) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto sql = std::string{"DELETE FROM "};
|
||||
sql += table;
|
||||
sql += " WHERE catalog_managed = 1";
|
||||
auto params = std::vector<noto::sqlite_value>{};
|
||||
params.reserve(ids.size());
|
||||
if (!ids.empty()) {
|
||||
sql += " AND id NOT IN (";
|
||||
for (auto index = std::size_t{}; index < ids.size(); ++index) {
|
||||
if (index != 0U) {
|
||||
sql += ", ";
|
||||
}
|
||||
sql += "?";
|
||||
sql += std::to_string(index + 1U);
|
||||
params.emplace_back(ids[index]);
|
||||
}
|
||||
sql += ")";
|
||||
}
|
||||
auto deleted = co_await connection.async_execute(std::move(sql), std::move(params));
|
||||
if (!deleted) {
|
||||
co_return std::unexpected(deleted.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error> {
|
||||
@@ -203,6 +229,10 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va
|
||||
if (!practice_hierarchy_statements) {
|
||||
co_return std::unexpected(practice_hierarchy_statements.error());
|
||||
}
|
||||
auto catalog_managed_statements = read_migration(config, "005_catalog_managed_rows.sql");
|
||||
if (!catalog_managed_statements) {
|
||||
co_return std::unexpected(catalog_managed_statements.error());
|
||||
}
|
||||
auto migrator = noto::migrator{connection};
|
||||
auto migrations = std::vector<noto::migration>{};
|
||||
auto foundation = noto::migration{};
|
||||
@@ -237,12 +267,20 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(practice_hierarchy));
|
||||
auto catalog_managed = noto::migration{};
|
||||
catalog_managed.id = "005_catalog_managed_rows";
|
||||
catalog_managed.description = "mark rows managed by bundled catalog import";
|
||||
catalog_managed.checksum = "005_catalog_managed_rows_v1";
|
||||
catalog_managed.up = [statements = std::move(*catalog_managed_statements)](noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
co_return co_await execute_statements(connection, statements);
|
||||
};
|
||||
migrations.push_back(std::move(catalog_managed));
|
||||
|
||||
auto migrated = co_await migrator.async_migrate(std::move(migrations));
|
||||
if (!migrated) {
|
||||
co_return std::unexpected(migrated.error());
|
||||
}
|
||||
auto version = co_await connection.async_set_user_version(4);
|
||||
auto version = co_await connection.async_set_user_version(5);
|
||||
if (!version) {
|
||||
co_return std::unexpected(version.error());
|
||||
}
|
||||
@@ -268,21 +306,14 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
|
||||
co_return std::unexpected(begin.error());
|
||||
}
|
||||
auto imported = std::expected<void, modulus::error>{};
|
||||
auto cleared = co_await connection.async_execute("DELETE FROM songs");
|
||||
if (!cleared) {
|
||||
imported = std::unexpected(cleared.error());
|
||||
}
|
||||
if (imported) {
|
||||
auto cleared_practice = co_await connection.async_execute("DELETE FROM practice_exercises");
|
||||
if (!cleared_practice) {
|
||||
imported = std::unexpected(cleared_practice.error());
|
||||
}
|
||||
}
|
||||
auto timestamp = now_ms();
|
||||
auto song_ids = std::vector<std::string>{};
|
||||
song_ids.reserve(performance->parts.size());
|
||||
for (auto const& part : performance->parts) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
song_ids.push_back(part.id);
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{part.id},
|
||||
noto::sqlite_value{part.title},
|
||||
@@ -294,16 +325,41 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
|
||||
noto::sqlite_value{part.source_path},
|
||||
noto::sqlite_value{part.key_signature},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", std::move(params));
|
||||
auto inserted = co_await connection.async_execute(
|
||||
R"sql(
|
||||
INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
composer = excluded.composer,
|
||||
source_json = excluded.source_json,
|
||||
part = excluded.part,
|
||||
source_path = excluded.source_path,
|
||||
key_signature = excluded.key_signature,
|
||||
catalog_managed = 1,
|
||||
updated_at_ms = CASE
|
||||
WHEN songs.title != excluded.title
|
||||
OR songs.composer != excluded.composer
|
||||
OR songs.source_json != excluded.source_json
|
||||
OR songs.part != excluded.part
|
||||
OR songs.source_path != excluded.source_path
|
||||
OR songs.key_signature != excluded.key_signature
|
||||
THEN excluded.updated_at_ms
|
||||
ELSE songs.updated_at_ms
|
||||
END
|
||||
)sql",
|
||||
std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
}
|
||||
}
|
||||
auto practice_ids = std::vector<std::string>{};
|
||||
for (auto const& exercise : practice->exercises) {
|
||||
if (!imported) {
|
||||
break;
|
||||
}
|
||||
for (auto const& variant : exercise.variants) {
|
||||
practice_ids.push_back(variant.id);
|
||||
auto params = std::vector<noto::sqlite_value>{
|
||||
noto::sqlite_value{variant.id},
|
||||
noto::sqlite_value{exercise.practice_type_id},
|
||||
@@ -318,13 +374,54 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam
|
||||
noto::sqlite_value{timestamp},
|
||||
noto::sqlite_value{timestamp},
|
||||
};
|
||||
auto inserted = co_await connection.async_execute("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", std::move(params));
|
||||
auto inserted = co_await connection.async_execute(
|
||||
R"sql(
|
||||
INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
practice_type_id = excluded.practice_type_id,
|
||||
practice_type_title = excluded.practice_type_title,
|
||||
lesson_group_id = excluded.lesson_group_id,
|
||||
lesson_group_title = excluded.lesson_group_title,
|
||||
title = excluded.title,
|
||||
composer = excluded.composer,
|
||||
key_signature = excluded.key_signature,
|
||||
source_json = excluded.source_json,
|
||||
source_path = excluded.source_path,
|
||||
catalog_managed = 1,
|
||||
updated_at_ms = CASE
|
||||
WHEN practice_exercises.practice_type_id != excluded.practice_type_id
|
||||
OR practice_exercises.practice_type_title != excluded.practice_type_title
|
||||
OR practice_exercises.lesson_group_id != excluded.lesson_group_id
|
||||
OR practice_exercises.lesson_group_title != excluded.lesson_group_title
|
||||
OR practice_exercises.title != excluded.title
|
||||
OR practice_exercises.composer != excluded.composer
|
||||
OR practice_exercises.key_signature != excluded.key_signature
|
||||
OR practice_exercises.source_json != excluded.source_json
|
||||
OR practice_exercises.source_path != excluded.source_path
|
||||
THEN excluded.updated_at_ms
|
||||
ELSE practice_exercises.updated_at_ms
|
||||
END
|
||||
)sql",
|
||||
std::move(params));
|
||||
if (!inserted) {
|
||||
imported = std::unexpected(inserted.error());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "songs", song_ids);
|
||||
if (!deleted) {
|
||||
imported = std::unexpected(deleted.error());
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "practice_exercises", practice_ids);
|
||||
if (!deleted) {
|
||||
imported = std::unexpected(deleted.error());
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
auto committed = co_await connection.async_execute("COMMIT");
|
||||
if (!committed) {
|
||||
@@ -380,7 +477,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
||||
.source_path = text_value(row[4]),
|
||||
.source_json = std::move(source_json),
|
||||
.measure_count = parsed->measures.size(),
|
||||
.total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U,
|
||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||
});
|
||||
}
|
||||
auto closed = co_await connection.async_close();
|
||||
@@ -428,7 +525,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
||||
.source_path = source_path,
|
||||
.source_json = source_json,
|
||||
.measure_count = parsed->measures.size(),
|
||||
.total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U,
|
||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])),
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -273,11 +274,6 @@ auto parse_measure(boost::json::value const& value) -> std::expected<vamp_zero::
|
||||
return measure;
|
||||
}
|
||||
|
||||
auto score_page_count(std::size_t measure_count) -> std::size_t {
|
||||
constexpr auto measures_per_page = std::size_t{12};
|
||||
return measure_count == 0U ? 1U : (measure_count + measures_per_page - 1U) / measures_per_page;
|
||||
}
|
||||
|
||||
auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected<std::vector<vamp_zero::score_document>, modulus::error> {
|
||||
auto error = std::error_code{};
|
||||
if (!std::filesystem::exists(directory, error)) {
|
||||
@@ -559,7 +555,7 @@ auto vamp_zero::load_performance_library(std::string const& assets_path) -> std:
|
||||
.source_path = document.source_path,
|
||||
.source_json = std::move(document.source_json),
|
||||
.measure_count = document.measures.size(),
|
||||
.total_pages = score_page_count(document.measures.size()),
|
||||
.total_pages = vamp_zero::score_page_count(document),
|
||||
});
|
||||
}
|
||||
return vamp_zero::group_library_parts(std::move(parts));
|
||||
@@ -616,7 +612,7 @@ auto vamp_zero::load_practice_library(std::string const& assets_path) -> std::ex
|
||||
.source_path = document->source_path,
|
||||
.source_json = document->source_json,
|
||||
.measure_count = document->measures.size(),
|
||||
.total_pages = score_page_count(document->measures.size()),
|
||||
.total_pages = vamp_zero::score_page_count(*document),
|
||||
};
|
||||
exercises.push_back({
|
||||
.id = std::move(exercise_id),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
auto menu_row_count(std::size_t item_count) -> std::size_t {
|
||||
return item_count + 1U;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.performance.groups[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
auto const* group = vamp_zero::selected_group(state);
|
||||
if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_part_index - 1U;
|
||||
if (index >= group->parts.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &group->parts[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
if (state.active_part_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) {
|
||||
return part.id == state.active_part_id;
|
||||
});
|
||||
return found == state.performance.parts.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* {
|
||||
if (state.selected_practice_type_index == 0U || state.practice.types.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.practice.types[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* {
|
||||
auto const* type = vamp_zero::selected_practice_type(state);
|
||||
if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_lesson_index - 1U;
|
||||
if (index >= type->lesson_groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &type->lesson_groups[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto const* lesson = vamp_zero::selected_practice_lesson(state);
|
||||
if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_exercise_index - 1U;
|
||||
if (index >= lesson->exercises.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &lesson->exercises[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
if (state.active_practice_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) {
|
||||
return exercise.id == state.active_practice_id;
|
||||
});
|
||||
return found == state.practice.exercises.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto vamp_zero::supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t {
|
||||
return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id);
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* {
|
||||
if (exercise.variants.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!vamp_zero::supports_key_selection(exercise)) {
|
||||
return &exercise.variants.front();
|
||||
}
|
||||
if (state.selected_practice_key_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_key_index - 1U;
|
||||
if (index >= exercise.variants.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &exercise.variants[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view> {
|
||||
auto const* variant = vamp_zero::selected_practice_variant(state, exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return variant->key_signature;
|
||||
}
|
||||
|
||||
auto vamp_zero::practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t {
|
||||
auto const* exercise = vamp_zero::selected_practice_exercise(state);
|
||||
if (exercise == nullptr || !vamp_zero::supports_key_selection(*exercise)) {
|
||||
return 1U;
|
||||
}
|
||||
return menu_row_count(exercise->variants.size());
|
||||
}
|
||||
|
||||
auto vamp_zero::effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string {
|
||||
if (auto const* variant = vamp_zero::selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) {
|
||||
return variant->key_signature;
|
||||
}
|
||||
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature;
|
||||
}
|
||||
|
||||
auto vamp_zero::active_pages(vamp_zero::app_state const& state) -> std::size_t {
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const* part = vamp_zero::active_part(state);
|
||||
return part == nullptr ? 0U : part->total_pages;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = vamp_zero::active_practice(state);
|
||||
auto const* variant = exercise == nullptr ? nullptr : vamp_zero::selected_practice_variant(state, *exercise);
|
||||
return variant == nullptr ? 0U : variant->total_pages;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
auto vamp_zero::practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode {
|
||||
if (vamp_zero::selected_practice_lesson(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
}
|
||||
if (vamp_zero::selected_practice_type(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
}
|
||||
return vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <boost/json/value.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -37,151 +38,6 @@ auto menu_row_count(std::size_t item_count) -> std::size_t {
|
||||
return item_count + 1U;
|
||||
}
|
||||
|
||||
auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.performance.groups[index];
|
||||
}
|
||||
|
||||
auto selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
auto const* group = selected_group(state);
|
||||
if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_part_index - 1U;
|
||||
if (index >= group->parts.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &group->parts[index];
|
||||
}
|
||||
|
||||
auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
if (state.active_part_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) {
|
||||
return part.id == state.active_part_id;
|
||||
});
|
||||
return found == state.performance.parts.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* {
|
||||
if (state.selected_practice_type_index == 0U || state.practice.types.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.practice.types[index];
|
||||
}
|
||||
|
||||
auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* {
|
||||
auto const* type = selected_practice_type(state);
|
||||
if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_lesson_index - 1U;
|
||||
if (index >= type->lesson_groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &type->lesson_groups[index];
|
||||
}
|
||||
|
||||
auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto const* lesson = selected_practice_lesson(state);
|
||||
if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_exercise_index - 1U;
|
||||
if (index >= lesson->exercises.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &lesson->exercises[index];
|
||||
}
|
||||
|
||||
auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
if (state.active_practice_id.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) {
|
||||
return exercise.id == state.active_practice_id;
|
||||
});
|
||||
return found == state.practice.exercises.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t {
|
||||
return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id);
|
||||
}
|
||||
|
||||
auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* {
|
||||
if (exercise.variants.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!supports_key_selection(exercise)) {
|
||||
return &exercise.variants.front();
|
||||
}
|
||||
if (state.selected_practice_key_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_key_index - 1U;
|
||||
if (index >= exercise.variants.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &exercise.variants[index];
|
||||
}
|
||||
|
||||
auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view> {
|
||||
auto const* variant = selected_practice_variant(state, exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return variant->key_signature;
|
||||
}
|
||||
|
||||
auto practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t {
|
||||
auto const* exercise = selected_practice_exercise(state);
|
||||
if (exercise == nullptr || !supports_key_selection(*exercise)) {
|
||||
return 1U;
|
||||
}
|
||||
return menu_row_count(exercise->variants.size());
|
||||
}
|
||||
|
||||
auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string {
|
||||
if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) {
|
||||
return variant->key_signature;
|
||||
}
|
||||
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature;
|
||||
}
|
||||
|
||||
auto active_pages(vamp_zero::app_state const& state) -> std::size_t {
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const* part = active_part(state);
|
||||
return part == nullptr ? 0U : part->total_pages;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
auto const* variant = exercise == nullptr ? nullptr : selected_practice_variant(state, *exercise);
|
||||
return variant == nullptr ? 0U : variant->total_pages;
|
||||
}
|
||||
return 0U;
|
||||
}
|
||||
|
||||
auto practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode {
|
||||
if (selected_practice_lesson(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_EXERCISE_MENU;
|
||||
}
|
||||
if (selected_practice_type(state) != nullptr) {
|
||||
return vamp_zero::app_mode::PRACTICE_LESSON_MENU;
|
||||
}
|
||||
return vamp_zero::app_mode::PRACTICE_TYPE_MENU;
|
||||
}
|
||||
|
||||
auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
|
||||
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U);
|
||||
|
||||
+64
-8
@@ -777,6 +777,45 @@ auto effective_time_signature(vamp_zero::score_document const& score, vamp_zero:
|
||||
return "4/4";
|
||||
}
|
||||
|
||||
auto notation_state_changes_before(vamp_zero::score_document const& score, std::size_t measure_index) -> std::bool_t {
|
||||
if (measure_index == 0U || measure_index >= score.measures.size()) {
|
||||
return false;
|
||||
}
|
||||
auto const& previous = score.measures[measure_index - 1U];
|
||||
auto const& current = score.measures[measure_index];
|
||||
return effective_key_signature(score, previous) != effective_key_signature(score, current)
|
||||
|| effective_time_signature(score, &previous) != effective_time_signature(score, ¤t);
|
||||
}
|
||||
|
||||
auto make_score_system_ranges(vamp_zero::score_document const& score) -> std::vector<vamp_zero::score_system_range> {
|
||||
auto ranges = std::vector<vamp_zero::score_system_range>{};
|
||||
if (score.measures.empty()) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
auto current = vamp_zero::score_system_range{.first_measure = 0U, .measure_count = 0U};
|
||||
for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) {
|
||||
if (current.measure_count != 0U
|
||||
&& (current.measure_count >= vamp_zero::score_measures_per_system || notation_state_changes_before(score, measure_index))) {
|
||||
ranges.push_back(current);
|
||||
current = vamp_zero::score_system_range{.first_measure = measure_index, .measure_count = 0U};
|
||||
}
|
||||
++current.measure_count;
|
||||
}
|
||||
if (current.measure_count != 0U) {
|
||||
ranges.push_back(current);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
auto show_time_signature_for_system(vamp_zero::score_document const& score, vamp_zero::score_system_range const& range) -> std::bool_t {
|
||||
if (range.first_measure == 0U) {
|
||||
return true;
|
||||
}
|
||||
return notation_state_changes_before(score, range.first_measure)
|
||||
&& effective_time_signature(score, &score.measures[range.first_measure - 1U]) != effective_time_signature(score, &score.measures[range.first_measure]);
|
||||
}
|
||||
|
||||
auto draw_curve(luma::draw_context& context, luma::point start, luma::point control, luma::point end, std::float32_t thickness, luma::color color) -> void {
|
||||
constexpr auto segments = std::int32_t{10};
|
||||
auto previous = start;
|
||||
@@ -2221,6 +2260,18 @@ auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const&
|
||||
return accidentals;
|
||||
}
|
||||
|
||||
auto vamp_zero::score_system_ranges(vamp_zero::score_document const& score) -> std::vector<vamp_zero::score_system_range> {
|
||||
return make_score_system_ranges(score);
|
||||
}
|
||||
|
||||
auto vamp_zero::score_page_count(vamp_zero::score_document const& score) -> std::size_t {
|
||||
auto ranges = make_score_system_ranges(score);
|
||||
if (ranges.empty()) {
|
||||
return 1U;
|
||||
}
|
||||
return (ranges.size() + vamp_zero::score_systems_per_page - 1U) / vamp_zero::score_systems_per_page;
|
||||
}
|
||||
|
||||
auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout {
|
||||
auto layout = vamp_zero::score_page_layout{
|
||||
.bounds = bounds,
|
||||
@@ -2240,23 +2291,28 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c
|
||||
auto const content_y = bounds.y + 84.0F;
|
||||
auto const content_width = std::max<std::float32_t>(spacing * 18.0F, bounds.width - margin * 2.0F);
|
||||
auto const system_height = (bounds.height - 108.0F) / static_cast<std::float32_t>(vamp_zero::score_systems_per_page);
|
||||
auto const first_measure = page_index * vamp_zero::score_measures_per_page;
|
||||
auto const system_ranges = make_score_system_ranges(score);
|
||||
auto const first_system = page_index * vamp_zero::score_systems_per_page;
|
||||
auto note_refs = std::vector<laid_out_note_ref>{};
|
||||
auto measure_refs = std::vector<laid_out_measure_ref>{};
|
||||
|
||||
for (auto system = std::size_t{}; system < vamp_zero::score_systems_per_page; ++system) {
|
||||
auto const system_y = content_y + static_cast<std::float32_t>(system) * system_height;
|
||||
auto const staff_top = system_y + spacing * 4.25F;
|
||||
auto const system_first_measure = first_measure + system * vamp_zero::score_measures_per_system;
|
||||
auto const* first_system_measure = system_first_measure < score.measures.size() ? &score.measures[system_first_measure] : nullptr;
|
||||
auto const range_index = first_system + system;
|
||||
auto const* range = range_index < system_ranges.size() ? &system_ranges[range_index] : nullptr;
|
||||
auto const* first_system_measure = range != nullptr && range->first_measure < score.measures.size() ? &score.measures[range->first_measure] : nullptr;
|
||||
layout_staff(layout, content_x, staff_top, content_width, spacing, system);
|
||||
auto const show_time = page_index == 0U && system == 0U;
|
||||
auto const show_time = range == nullptr ? page_index == 0U && system == 0U : show_time_signature_for_system(score, *range);
|
||||
auto const measure_start_x = layout_system_preamble(layout, score, first_system_measure, content_x, staff_top, spacing, show_time, system);
|
||||
auto const measure_area_width = std::max<std::float32_t>(spacing * 12.0F, content_x + content_width - measure_start_x);
|
||||
auto const measure_width = measure_area_width / static_cast<std::float32_t>(vamp_zero::score_measures_per_system);
|
||||
if (range == nullptr || range->measure_count == 0U) {
|
||||
continue;
|
||||
}
|
||||
auto const measure_width = measure_area_width / static_cast<std::float32_t>(range->measure_count);
|
||||
|
||||
for (auto measure = std::size_t{}; measure < vamp_zero::score_measures_per_system; ++measure) {
|
||||
auto const measure_index = system_first_measure + measure;
|
||||
for (auto measure = std::size_t{}; measure < range->measure_count; ++measure) {
|
||||
auto const measure_index = range->first_measure + measure;
|
||||
auto const x = measure_start_x + static_cast<std::float32_t>(measure) * measure_width;
|
||||
if (measure_index < score.measures.size()) {
|
||||
layout_measure(
|
||||
@@ -2274,7 +2330,7 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c
|
||||
}
|
||||
|
||||
auto const bar_x = x + measure_width;
|
||||
auto const final_bar = measure + 1U == vamp_zero::score_measures_per_system || measure_index + 1U >= score.measures.size();
|
||||
auto const final_bar = measure + 1U == range->measure_count || measure_index + 1U >= score.measures.size();
|
||||
add_stroke_line(
|
||||
layout,
|
||||
final_bar ? vamp_zero::score_layout_item_role::FINAL_BARLINE : vamp_zero::score_layout_item_role::BARLINE,
|
||||
|
||||
+59
-110
@@ -3,11 +3,13 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
@@ -19,8 +21,47 @@
|
||||
#include <vamp_style/font.hpp>
|
||||
#include <vamp_zero/color.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
struct vamp_zero::score_draw_cache {
|
||||
struct entry {
|
||||
std::string source_json{};
|
||||
std::shared_ptr<vamp_zero::score_document const> document{};
|
||||
};
|
||||
|
||||
auto get(std::string_view source_json, std::string source_path, std::string part_override) -> std::expected<std::shared_ptr<vamp_zero::score_document const>, modulus::error> {
|
||||
auto key = source_path;
|
||||
key.push_back('\n');
|
||||
key += part_override;
|
||||
key.push_back('\n');
|
||||
key += std::to_string(source_json.size());
|
||||
key.push_back('\n');
|
||||
key += std::to_string(std::hash<std::string_view>{}(source_json));
|
||||
|
||||
auto found = entries.find(key);
|
||||
if (found != entries.end() && found->second.source_json == source_json && found->second.document) {
|
||||
return found->second.document;
|
||||
}
|
||||
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, source_path);
|
||||
if (!parsed) {
|
||||
return std::unexpected(parsed.error());
|
||||
}
|
||||
if (!part_override.empty()) {
|
||||
parsed->part = std::move(part_override);
|
||||
}
|
||||
auto document = std::make_shared<vamp_zero::score_document const>(std::move(*parsed));
|
||||
entries[key] = {
|
||||
.source_json = std::string{source_json},
|
||||
.document = document,
|
||||
};
|
||||
return document;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, entry> entries{};
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto touch_hold_duration = std::chrono::milliseconds{700};
|
||||
@@ -41,102 +82,6 @@ auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
|
||||
}
|
||||
}
|
||||
|
||||
auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* {
|
||||
auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) {
|
||||
return part.id == state.active_part_id;
|
||||
});
|
||||
return found == state.performance.parts.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* {
|
||||
if (state.selected_song_index == 0U || state.performance.groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_song_index - 1U;
|
||||
if (index >= state.performance.groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.performance.groups[index];
|
||||
}
|
||||
|
||||
auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) {
|
||||
return exercise.id == state.active_practice_id;
|
||||
});
|
||||
return found == state.practice.exercises.end() ? nullptr : &*found;
|
||||
}
|
||||
|
||||
auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* {
|
||||
if (state.selected_practice_type_index == 0U || state.practice.types.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_type_index - 1U;
|
||||
if (index >= state.practice.types.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &state.practice.types[index];
|
||||
}
|
||||
|
||||
auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* {
|
||||
auto const* type = selected_practice_type(state);
|
||||
if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_lesson_index - 1U;
|
||||
if (index >= type->lesson_groups.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &type->lesson_groups[index];
|
||||
}
|
||||
|
||||
auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* {
|
||||
auto const* lesson = selected_practice_lesson(state);
|
||||
if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_exercise_index - 1U;
|
||||
if (index >= lesson->exercises.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &lesson->exercises[index];
|
||||
}
|
||||
|
||||
auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t {
|
||||
return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id);
|
||||
}
|
||||
|
||||
auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* {
|
||||
if (exercise.variants.empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!supports_key_selection(exercise)) {
|
||||
return &exercise.variants.front();
|
||||
}
|
||||
if (state.selected_practice_key_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_practice_key_index - 1U;
|
||||
if (index >= exercise.variants.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &exercise.variants[index];
|
||||
}
|
||||
|
||||
auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional<std::string_view> {
|
||||
auto const* variant = selected_practice_variant(state, exercise);
|
||||
if (variant == nullptr) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return variant->key_signature;
|
||||
}
|
||||
|
||||
auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string {
|
||||
if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) {
|
||||
return variant->key_signature;
|
||||
}
|
||||
return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature;
|
||||
}
|
||||
|
||||
auto status_color(vamp_zero::app_state const& state) -> vamp_style::color {
|
||||
return state.pico_connected ? vamp_style::connected : vamp_style::retrying;
|
||||
}
|
||||
@@ -361,9 +306,10 @@ auto draw_practice_key_menu(luma::draw_context& context, luma::rect bounds, vamp
|
||||
}
|
||||
}
|
||||
|
||||
auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void {
|
||||
auto source_json = std::string{};
|
||||
auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state, vamp_zero::score_draw_cache& score_cache) -> void {
|
||||
auto source_json = std::string_view{};
|
||||
auto source_path = std::string{};
|
||||
auto part_override = std::string{};
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
auto const* part = active_part(state);
|
||||
if (part == nullptr) {
|
||||
@@ -385,32 +331,28 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s
|
||||
}
|
||||
source_json = variant->source_json;
|
||||
source_path = variant->source_path;
|
||||
part_override = "Key: " + effective_practice_key(state, *exercise);
|
||||
}
|
||||
|
||||
auto parsed = vamp_zero::parse_score_document(source_json, source_path);
|
||||
auto parsed = score_cache.get(source_json, source_path, std::move(part_override));
|
||||
if (!parsed) {
|
||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
|
||||
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
||||
return;
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
auto const* exercise = active_practice(state);
|
||||
if (exercise != nullptr) {
|
||||
parsed->part = "Key: " + effective_practice_key(state, *exercise);
|
||||
}
|
||||
}
|
||||
auto paper_bounds = luma::rect{
|
||||
.x = bounds.x + 24.0F,
|
||||
.y = bounds.y + 18.0F,
|
||||
.width = bounds.width - 48.0F,
|
||||
.height = bounds.height - 36.0F,
|
||||
};
|
||||
vamp_zero::draw_score_page(context, paper_bounds, *parsed, state.page_index);
|
||||
vamp_zero::draw_score_page(context, paper_bounds, **parsed, state.page_index);
|
||||
}
|
||||
|
||||
class vamp_root_control final : public luma::control {
|
||||
public:
|
||||
explicit vamp_root_control(vamp_zero::ui_state state, touch_handler on_touch) : state_{std::move(state)}, on_touch_{std::move(on_touch)} {}
|
||||
explicit vamp_root_control(vamp_zero::ui_state state, std::shared_ptr<vamp_zero::score_draw_cache> score_cache, touch_handler on_touch)
|
||||
: state_{std::move(state)}, score_cache_{std::move(score_cache)}, on_touch_{std::move(on_touch)} {}
|
||||
auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override {
|
||||
return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits);
|
||||
}
|
||||
@@ -439,7 +381,9 @@ public:
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) {
|
||||
draw_practice_key_menu(context, content, state_.app);
|
||||
} else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || state_.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) {
|
||||
draw_score(context, content, state_.app);
|
||||
if (score_cache_) {
|
||||
draw_score(context, content, state_.app, *score_cache_);
|
||||
}
|
||||
} else {
|
||||
draw_main_menu(context, content, state_.app);
|
||||
}
|
||||
@@ -449,13 +393,18 @@ public:
|
||||
}
|
||||
private:
|
||||
vamp_zero::ui_state state_{};
|
||||
std::shared_ptr<vamp_zero::score_draw_cache> score_cache_{};
|
||||
touch_handler on_touch_{};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::zero_ui::command_handler command_handler)
|
||||
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)}, command_handler_{std::move(command_handler)} {}
|
||||
: executor_{std::move(executor)},
|
||||
tap_timer_{executor_},
|
||||
application_{std::move(config)},
|
||||
command_handler_{std::move(command_handler)},
|
||||
score_cache_{std::make_shared<vamp_zero::score_draw_cache>()} {}
|
||||
|
||||
auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void {
|
||||
if (!command_handler_) {
|
||||
@@ -593,7 +542,7 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
|
||||
}
|
||||
|
||||
auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const& state) -> std::expected<std::unique_ptr<luma::control>, modulus::error> {
|
||||
return std::make_unique<vamp_root_control>(state, [this](luma::touch_event const& event) {
|
||||
return std::make_unique<vamp_root_control>(state, score_cache_, [this](luma::touch_event const& event) {
|
||||
return handle_touch(event);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
add_executable(product-vamp-test
|
||||
../src/vamp_pico/remote_display.cpp
|
||||
test.cpp
|
||||
)
|
||||
target_include_directories(product-vamp-test PRIVATE
|
||||
@@ -8,6 +9,9 @@ target_link_libraries(product-vamp-test PRIVATE
|
||||
vamp-zero-core
|
||||
)
|
||||
target_compile_features(product-vamp-test PRIVATE cxx_std_23)
|
||||
target_compile_definitions(product-vamp-test PRIVATE
|
||||
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
|
||||
)
|
||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||
add_test(
|
||||
|
||||
+286
@@ -1,16 +1,32 @@
|
||||
#include <test.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <future>
|
||||
#include <initializer_list>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <variant>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
|
||||
#include <noto.hpp>
|
||||
|
||||
#include <vamp_pico/http_client.hpp>
|
||||
#include <vamp_zero/database.hpp>
|
||||
#include <vamp_zero/library.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/types.hpp>
|
||||
@@ -166,6 +182,113 @@ auto near(std::float32_t left, std::float32_t right) -> std::bool_t {
|
||||
return std::abs(left - right) < 0.02F;
|
||||
}
|
||||
|
||||
auto test_database_config(std::filesystem::path const& path) -> vamp_zero::database_config {
|
||||
return {
|
||||
.path = path.string(),
|
||||
.migrations_path = (std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package" / "migrations").string(),
|
||||
};
|
||||
}
|
||||
|
||||
auto run_migrate(vamp_zero::database_config config) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_migrate_database(io.get_executor(), std::move(config)), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto run_import(vamp_zero::database_config config, std::filesystem::path const& assets_path) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto query_database(std::filesystem::path const& path, std::string sql, std::vector<noto::sqlite_value> params = {}) -> std::expected<noto::query_result, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto future = boost::asio::co_spawn(
|
||||
io,
|
||||
[executor = io.get_executor(), path = path.string(), sql = std::move(sql), params = std::move(params)]() mutable -> boost::asio::awaitable<std::expected<noto::query_result, modulus::error>> {
|
||||
auto config = noto::file_connection_config(path, "product-vamp-test");
|
||||
config.foreign_keys = true;
|
||||
auto connection = noto::connection{executor, std::move(config)};
|
||||
auto connected = co_await connection.async_connect();
|
||||
if (!connected) {
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected = co_await connection.async_execute(std::move(sql), std::move(params));
|
||||
auto closed = co_await connection.async_close();
|
||||
if (!selected) {
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
if (!closed) {
|
||||
co_return std::unexpected(closed.error());
|
||||
}
|
||||
co_return selected;
|
||||
},
|
||||
boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
|
||||
auto text_value(noto::sqlite_value const& value) -> std::string {
|
||||
auto text = std::get_if<std::string>(&value.value);
|
||||
return text == nullptr ? std::string{} : *text;
|
||||
}
|
||||
|
||||
auto integer_value(noto::sqlite_value const& value) -> std::int64_t {
|
||||
auto integer = std::get_if<std::int64_t>(&value.value);
|
||||
return integer == nullptr ? 0 : *integer;
|
||||
}
|
||||
|
||||
auto score_asset_json(std::string title, std::string key = "C") -> std::string {
|
||||
return std::string{R"json({
|
||||
"version": 1,
|
||||
"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";
|
||||
}
|
||||
|
||||
void write_text(std::filesystem::path const& path, std::string const& text) {
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
||||
output << text;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
|
||||
@@ -318,6 +441,93 @@ BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals)
|
||||
expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "flat"});
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) {
|
||||
auto state = vamp_pico::remote_display_state{};
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body("version=1\r\nactive=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state));
|
||||
|
||||
BOOST_TEST(state.active);
|
||||
BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0);
|
||||
BOOST_TEST(std::strcmp(state.part, "Vocal") == 0);
|
||||
BOOST_TEST(std::strcmp(state.mode, "score") == 0);
|
||||
BOOST_TEST(state.page == 2U);
|
||||
BOOST_TEST(state.pages == 11U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_missing_version_and_bad_numbers) {
|
||||
auto state = vamp_pico::remote_display_state{};
|
||||
std::snprintf(state.title, sizeof(state.title), "Keep");
|
||||
|
||||
BOOST_TEST(!vamp_pico::parse_remote_display_body("title=Autumn Leaves\npage=1\npages=2\n", state));
|
||||
BOOST_TEST(std::strcmp(state.title, "Keep") == 0);
|
||||
BOOST_TEST(!vamp_pico::parse_remote_display_body("version=1\npage=1x\npages=2\n", state));
|
||||
BOOST_TEST(std::strcmp(state.title, "Keep") == 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
|
||||
auto state = vamp_pico::remote_display_state{};
|
||||
auto body = std::string{"version=1\ntitle="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score";
|
||||
|
||||
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state));
|
||||
BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U);
|
||||
BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U);
|
||||
BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) {
|
||||
auto root = std::filesystem::temp_directory_path()
|
||||
/ ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto assets = root / "assets";
|
||||
auto database_path = root / "vamp.db";
|
||||
auto song_path = assets / "songs" / "tune.json";
|
||||
std::filesystem::remove_all(root);
|
||||
write_text(song_path, score_asset_json("Catalog Tune"));
|
||||
|
||||
auto config = test_database_config(database_path);
|
||||
BOOST_REQUIRE(run_migrate(config));
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
|
||||
auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'");
|
||||
BOOST_REQUIRE(first);
|
||||
BOOST_REQUIRE_EQUAL(first->rows.size(), 1U);
|
||||
auto created = integer_value(first->rows[0][1]);
|
||||
auto updated = integer_value(first->rows[0][2]);
|
||||
BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune");
|
||||
BOOST_TEST(integer_value(first->rows[0][3]) == 1);
|
||||
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
||||
BOOST_REQUIRE(unchanged);
|
||||
BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U);
|
||||
BOOST_TEST(integer_value(unchanged->rows[0][0]) == created);
|
||||
BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated);
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds{2});
|
||||
write_text(song_path, score_asset_json("Catalog Tune Updated"));
|
||||
BOOST_REQUIRE(run_import(config, assets));
|
||||
auto changed = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
|
||||
BOOST_REQUIRE(changed);
|
||||
BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U);
|
||||
BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated");
|
||||
BOOST_TEST(integer_value(changed->rows[0][1]) == created);
|
||||
BOOST_TEST(integer_value(changed->rows[0][2]) >= updated);
|
||||
|
||||
auto inserted_custom = query_database(
|
||||
database_path,
|
||||
"INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', 0)",
|
||||
{noto::sqlite_value{score_asset_json("Custom Tune")}});
|
||||
BOOST_REQUIRE(inserted_custom);
|
||||
|
||||
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");
|
||||
BOOST_REQUIRE(counts);
|
||||
BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U);
|
||||
BOOST_TEST(integer_value(counts->rows[0][0]) == 0);
|
||||
BOOST_TEST(integer_value(counts->rows[0][1]) == 1);
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) {
|
||||
auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble");
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
@@ -340,6 +550,82 @@ BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) {
|
||||
BOOST_TEST(near(times[1]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 3.5F));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) {
|
||||
auto score = vamp_zero::score_document{
|
||||
.title = "Key Change",
|
||||
.composer = "Composer",
|
||||
.key_signature = "C",
|
||||
.time_signature = "4/4",
|
||||
.clef = "treble",
|
||||
.part = "Lead",
|
||||
.measures = {
|
||||
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}},
|
||||
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}},
|
||||
{.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('f', "#")})}},
|
||||
{.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('g', "n")})}},
|
||||
},
|
||||
};
|
||||
|
||||
auto ranges = vamp_zero::score_system_ranges(score);
|
||||
BOOST_REQUIRE_EQUAL(ranges.size(), 2U);
|
||||
BOOST_TEST(ranges[0].first_measure == 0U);
|
||||
BOOST_TEST(ranges[0].measure_count == 2U);
|
||||
BOOST_TEST(ranges[1].first_measure == 2U);
|
||||
BOOST_TEST(ranges[1].measure_count == 2U);
|
||||
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty());
|
||||
BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 1U).empty());
|
||||
BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) {
|
||||
auto score = vamp_zero::score_document{
|
||||
.title = "Time Change",
|
||||
.composer = "Composer",
|
||||
.key_signature = "C",
|
||||
.time_signature = "4/4",
|
||||
.clef = "treble",
|
||||
.part = "Lead",
|
||||
.measures = {
|
||||
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}},
|
||||
{.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}},
|
||||
{.time_signature = "3/4", .key_signature = "C", .notes = {note({pitch('e', "n")})}},
|
||||
},
|
||||
};
|
||||
|
||||
auto ranges = vamp_zero::score_system_ranges(score);
|
||||
BOOST_REQUIRE_EQUAL(ranges.size(), 2U);
|
||||
BOOST_TEST(ranges[1].first_measure == 2U);
|
||||
BOOST_TEST(ranges[1].measure_count == 1U);
|
||||
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty());
|
||||
BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) {
|
||||
auto measures = std::vector<vamp_zero::score_measure>{};
|
||||
for (auto index = std::size_t{}; index < 10U; ++index) {
|
||||
measures.push_back({
|
||||
.time_signature = "4/4",
|
||||
.key_signature = (index / 2U) % 2U == 0U ? "C" : "G",
|
||||
.notes = {note({pitch('c', "n")})},
|
||||
});
|
||||
}
|
||||
auto score = vamp_zero::score_document{
|
||||
.title = "Many Breaks",
|
||||
.composer = "Composer",
|
||||
.key_signature = "C",
|
||||
.time_signature = "4/4",
|
||||
.clef = "treble",
|
||||
.part = "Lead",
|
||||
.measures = std::move(measures),
|
||||
};
|
||||
|
||||
BOOST_TEST(vamp_zero::score_page_count(score) == 2U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) {
|
||||
auto score = score_with_notes({note({pitch('d', "n")})});
|
||||
auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U);
|
||||
|
||||
@@ -3,10 +3,22 @@ from __future__ import annotations
|
||||
import unittest
|
||||
|
||||
from generator.arrange.generate import arrange_document
|
||||
from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing
|
||||
from generator.transposition import mod12
|
||||
from generator.score import normalize_score_document
|
||||
|
||||
|
||||
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"]
|
||||
@@ -87,6 +99,33 @@ class CompactScoreJsonTests(unittest.TestCase):
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user