@@ -5,7 +5,7 @@ Product Vamp is a two-device sheet-music appliance.
|
||||
- Raspberry Pi Zero 2 W hosts the control Wi-Fi AP, runs the display/control service in Docker, and drives a Waveshare Zero-DISP-7A 1024x600 touch display.
|
||||
- Raspberry Pi Pico 2 W runs UF2 firmware for a Waveshare 2-inch capacitive touch LCD and sends menu/page commands over the Zero-hosted AP.
|
||||
|
||||
The Zero imports generated score assets into SQLite through Noto at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities.
|
||||
The Zero ensures the current SQLite schema through Noto, then imports generated score assets at startup. Runtime navigation and rendering use Ligo for the HTTP API, Luma for touch UI rendering, and Modulus for shared build/runtime utilities.
|
||||
|
||||
Pico controls:
|
||||
|
||||
@@ -76,7 +76,7 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --
|
||||
|
||||
The `--check` path compares generation invariants against `data/expected/performance_summary.json`.
|
||||
|
||||
Authored score files use compact JSON. Metadata stays as normal JSON fields, while each measure is a readable notation string:
|
||||
Authored score files use compact JSON. Metadata stays as normal JSON fields, while each measure is a readable notation string. The optional `style` field must be a supported built-in player style; compact aliases `Med.`, `Med. Swing`, and `Medium` normalize to `Medium Swing`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -95,7 +95,7 @@ Authored score files use compact JSON. Metadata stays as normal JSON fields, whi
|
||||
}
|
||||
```
|
||||
|
||||
Notation tokens are `PITCH:DURATION`, `r:DURATION`, and `(PITCH PITCH ...):DURATION` for written chord tones. Durations use `1`, `2`, `4`, `8`, `16`, `32`, plus `.` for dotted values. `[Chord]` applies to the next token, `[(Chord)]` marks an optional turnaround chord that is used between generated performance iterations but omitted from the final ending, quoted text applies as a lyric to the previous note, `~` starts or ends ties, and `{N:M ...}` writes tuplets such as five quarter notes in the space of two quarter notes.
|
||||
Notation tokens are `PITCH:DURATION`, `r:DURATION`, and `(PITCH PITCH ...):DURATION` for written chord tones. Durations use `1`, `2`, `4`, `8`, `16`, `32`, plus `.` for dotted values. `[Chord]` applies to the next token, `[(Chord)]` marks an optional turnaround chord that is used between generated performance iterations but omitted from the final ending, quoted text applies as a lyric to the previous note, `~` starts or ends ties, and `{N:M ...}` writes tuplets such as five quarter notes in the space of two quarter notes. A measure may also be an object with `notes` plus MusicXML-style metadata fields such as `attributes`, `directions`, `harmony`, or `barlines`.
|
||||
|
||||
Bundled third-party font assets live in `package/assets/fonts/petaluma`. Petaluma is from Steinberg and is distributed under the SIL Open Font License; the package keeps `OFL.txt` next to the font files. If the Pico bitmap font needs to be refreshed after changing the bundled UI font, run:
|
||||
|
||||
|
||||
+4
-4
@@ -11,7 +11,7 @@ Product Vamp follows the Modulus product layout used by the khodo repos:
|
||||
- `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style`: shared color palette, Petaluma font paths, and Pico bitmap font data.
|
||||
- `data`: authored source score JSON for performance songs and practice exercises.
|
||||
- `generator`: build-time Python preprocessing for performance arrangements and practice variants.
|
||||
- `package`: generic bundle inputs, scripts, config, migrations, static font assets, and device install files.
|
||||
- `package`: generic bundle inputs, scripts, config, static font assets, and device install files.
|
||||
|
||||
## Zero Runtime
|
||||
|
||||
@@ -30,14 +30,14 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip
|
||||
|
||||
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
|
||||
|
||||
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, parses generated score JSON, imports both catalogs into SQLite with Noto, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer draws title/composer/part metadata, systems, measures, clef/key/time preambles, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON.
|
||||
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer draws title/composer/part metadata, systems, measures, clef/key/time preambles, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON.
|
||||
Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps clefs, noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates. Printed accidentals are derived from the active key signature and per-measure accidental state, so natural signs are shown only when they are musically required.
|
||||
|
||||
## Score Generation
|
||||
|
||||
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON is sanitized so packaged scores do not contain tab positions or fingering fields; guitar playability logic is used only inside the arrangement generator.
|
||||
The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON keeps notation metadata while removing generator-only optional chord markers; guitar playability logic is used only inside the arrangement generator.
|
||||
|
||||
Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; legacy staff/voice JSON with `staves` is still accepted. Measure notation supports `PITCH:DURATION`, `r:DURATION`, parenthesized chord tones like `(G4 B4 F5):4`, `[Chord]` markers, optional turnaround markers as `[(Chord)]`, quoted lyrics, `~` tie starts/stops, and tuplets as `{N:M ...}`. Tuplet normalization keeps the written duration in `display_ticks` and distributes rounded timeline ticks across the tuplet group so the measure total remains stable on the existing 16,384 ticks-per-whole-note grid.
|
||||
Authored source files use compact JSON and are normalized by the generator before arrangement. A root-level `measures` array means compact source; staff/voice JSON with `staves` is still accepted. Measure notation supports `PITCH:DURATION`, `r:DURATION`, parenthesized chord tones like `(G4 B4 F5):4`, `[Chord]` markers, optional turnaround markers as `[(Chord)]`, quoted lyrics, `~` tie starts/stops, and tuplets as `{N:M ...}`. Measure objects may also carry notation metadata such as attributes, directions, harmony, and barlines so the data shape can grow toward MusicXML-style concepts without changing the compact note syntax. Tuplet normalization keeps the written duration in `display_ticks` and distributes rounded timeline ticks across the tuplet group so the measure total remains stable on the existing 16,384 ticks-per-whole-note grid.
|
||||
|
||||
Generator checks compare the generated performance summary against `data/expected/performance_summary.json`. The summary stores compatibility invariants such as section layout, chord-label placement, slash-section placement, and vocal timing without committing the generated score files themselves. Refresh that file only when an intentional arrangement-logic change should become the new baseline.
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ install(TARGETS vamp-zero
|
||||
)
|
||||
install(FILES package/config/vamp.example.json DESTINATION zero/config)
|
||||
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product-vamp)
|
||||
install(DIRECTORY package/migrations/ DESTINATION zero/share/product-vamp/migrations)
|
||||
install(PROGRAMS
|
||||
package/zero/install-zero.sh
|
||||
package/zero/start-ap.sh
|
||||
@@ -164,7 +163,6 @@ if(VAMP_BUILD_ZERO_IMAGE)
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory ${VAMP_GENERATED_ASSETS_DIR} ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config
|
||||
COMMAND ${CMAKE_COMMAND} -E copy package/config/vamp.example.json ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/config/vamp.example.json
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory package/migrations ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp/migrations
|
||||
COMMAND ${BASH_EXECUTABLE} package/scripts/build_zero_image.sh ${VAMP_ZERO_IMAGE_CONTEXT} ${VAMP_ZERO_IMAGE_TAR} ${VAMP_ZERO_IMAGE_TAG}
|
||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
DEPENDS
|
||||
@@ -172,11 +170,6 @@ if(VAMP_BUILD_ZERO_IMAGE)
|
||||
package/scripts/build_zero_image.sh
|
||||
package/zero/Dockerfile
|
||||
package/config/vamp.example.json
|
||||
package/migrations/001_foundation.sql
|
||||
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
|
||||
)
|
||||
|
||||
@@ -38,7 +38,6 @@ class product_vamp_conan(conan.ConanFile):
|
||||
"package/assets/**/*",
|
||||
"package/config/*.json",
|
||||
"package/manifest.json.in",
|
||||
"package/migrations/*.sql",
|
||||
"package/pico/*",
|
||||
"package/scripts/*.py",
|
||||
"package/scripts/*.sh",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "01 Cycles",
|
||||
"composer": "",
|
||||
"key": "C",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med. Swing",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"[G7] (G4 B4 F5):4 (G4 B4 F5):4 [C7] (C4 Bb4 E5):4 (C4 Bb4 E5):4",
|
||||
"[F7] (F4 A4 Eb5):4 (F4 A4 Eb5):4 [Bb7] (Bb3 Ab4 D5):4 (Bb3 Ab4 D5):4",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "01 The Diatonic Scales",
|
||||
"composer": "",
|
||||
"key": "C",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med.",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"{3:2 C4:8 D4:8 E4:8} {3:2 D4:8 E4:8 F4:8} {3:2 E4:8 F4:8 G4:8} {3:2 F4:8 G4:8 A4:8}",
|
||||
"{3:2 G4:8 A4:8 B4:8} {3:2 A4:8 B4:8 C5:8} {3:2 B4:8 C5:8 D5:8} {3:2 C5:8 D5:8 E5:8}",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "02 The Diatonic Scales",
|
||||
"composer": "",
|
||||
"key": "C",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med.",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"C4:8 D4:8 E4:8 D4:8 E4:8 F4:8 E4:8 F4:8",
|
||||
"G4:8 F4:8 G4:8 A4:8 G4:8 A4:8 B4:8 A4:8",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "03 The Diatonic Scales",
|
||||
"composer": "",
|
||||
"key": "C",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med.",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"B3:8 C4:8 D4:8 E4:8 F4:8 E4:8 F4:8 G4:8",
|
||||
"F4:8 E4:8 F4:8 G4:8 A4:8 B4:8 C5:8 B4:8",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Autumn Leaves",
|
||||
"composer": "Joseph Kosma / Johnny Mercer / Jaques Prevert",
|
||||
"key": "F",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med.",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"r:4 D4:4 \"The\" E4:4 \"fall-\" F4:4 \"ing\"",
|
||||
"[G-7] Bb4:1~ \"leaves\"",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"version": 1,
|
||||
"title": "It Could Happen to You",
|
||||
"composer": "James Van Heusen / Johnny Burke",
|
||||
"key": "Eb",
|
||||
@@ -7,7 +6,7 @@
|
||||
"clef": "treble",
|
||||
"part": "Vocal",
|
||||
"role": "vocal",
|
||||
"style": "Med.",
|
||||
"style": "Medium Swing",
|
||||
"measures": [
|
||||
"[Ebmaj7] Bb3:2. \"Hide\" C4:4 \"your\"",
|
||||
"[G-7b5] Db4:4 \"heart\" C4:8 \"from\" G4:8~ \"sight,\" [C7b9] ~G4:2",
|
||||
|
||||
+81
-21
@@ -6,6 +6,8 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from generator.styles import canonical_style
|
||||
|
||||
WHOLE_TICKS = 16384
|
||||
HALF_TICKS = WHOLE_TICKS // 2
|
||||
QUARTER_TICKS = WHOLE_TICKS // 4
|
||||
@@ -28,13 +30,6 @@ RENDERABLE_TICKS = (
|
||||
|
||||
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
|
||||
FORBIDDEN_OUTPUT_FIELDS = {
|
||||
"tab_positions",
|
||||
"tab_frets",
|
||||
"finger",
|
||||
"fingers",
|
||||
"fingering",
|
||||
"left_hand_finger",
|
||||
"right_hand_finger",
|
||||
"chord_optional",
|
||||
}
|
||||
|
||||
@@ -91,6 +86,35 @@ def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str,
|
||||
return fallback
|
||||
|
||||
|
||||
def canonicalize_score_metadata(metadata: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
||||
output = sanitize_json(metadata)
|
||||
if not isinstance(output, dict):
|
||||
output = {}
|
||||
style_source = output.get("style")
|
||||
try:
|
||||
output["style"] = canonical_style(style_source)
|
||||
except ValueError as error:
|
||||
location = f"{path}: " if path is not None else ""
|
||||
raise CompactScoreError(f"{location}{error}") from error
|
||||
return output
|
||||
|
||||
|
||||
def normalize_full_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
||||
output = copy.deepcopy(document)
|
||||
output.pop("version", None)
|
||||
metadata_value = output.get("metadata", {})
|
||||
if metadata_value is not None and not isinstance(metadata_value, dict):
|
||||
location = f"{path}: " if path is not None else ""
|
||||
raise CompactScoreError(f"{location}metadata must be an object")
|
||||
metadata = copy.deepcopy(metadata_value) if isinstance(metadata_value, dict) else {}
|
||||
for key in ("style",):
|
||||
if key in output and key not in metadata:
|
||||
metadata[key] = output[key]
|
||||
output.pop(key, None)
|
||||
output["metadata"] = canonicalize_score_metadata(metadata, path)
|
||||
return output
|
||||
|
||||
|
||||
def parse_duration_token(text: str, label: str) -> int:
|
||||
dotted = text.endswith(".")
|
||||
base_text = text[:-1] if dotted else text
|
||||
@@ -352,9 +376,31 @@ class CompactMeasureParser:
|
||||
return notes, self.tuplets
|
||||
|
||||
|
||||
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
|
||||
COMPACT_MEASURE_CONTROL_FIELDS = {
|
||||
"notes",
|
||||
"notation",
|
||||
"section",
|
||||
"section_label",
|
||||
"key",
|
||||
"key_signature",
|
||||
"time",
|
||||
"time_signature",
|
||||
"id",
|
||||
"voices",
|
||||
}
|
||||
|
||||
GENERATED_MEASURE_FIELDS = {
|
||||
"id",
|
||||
"section_label",
|
||||
"time_signature",
|
||||
"key_signature",
|
||||
"voices",
|
||||
}
|
||||
|
||||
|
||||
def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str], dict[str, Any]]:
|
||||
if isinstance(value, str):
|
||||
return value, {}
|
||||
return value, {}, {}
|
||||
if isinstance(value, dict):
|
||||
text = value.get("notes", value.get("notation", ""))
|
||||
if not isinstance(text, str):
|
||||
@@ -371,16 +417,21 @@ def compact_measure_text(value: Any, index: int) -> tuple[str, dict[str, str]]:
|
||||
field = value.get(source)
|
||||
if isinstance(field, str):
|
||||
overrides[target] = field
|
||||
return text, overrides
|
||||
extra_fields = {
|
||||
key: sanitize_json(extra_value)
|
||||
for key, extra_value in value.items()
|
||||
if key not in COMPACT_MEASURE_CONTROL_FIELDS
|
||||
}
|
||||
return text, overrides, extra_fields
|
||||
raise CompactScoreError(f"measure {index + 1}: compact measure must be a string or object")
|
||||
|
||||
|
||||
def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
|
||||
if isinstance(document.get("staves"), list):
|
||||
return document
|
||||
return normalize_full_score_document(document, path)
|
||||
measures_value = document.get("measures")
|
||||
if not isinstance(measures_value, list):
|
||||
return document
|
||||
return normalize_full_score_document(document, path)
|
||||
metadata_value = document.get("metadata", {})
|
||||
metadata = metadata_value if isinstance(metadata_value, dict) else {}
|
||||
title = score_field(document, metadata, "title", fallback=human_label(path.stem if path is not None else "score"))
|
||||
@@ -401,18 +452,19 @@ def normalize_score_document(document: dict[str, Any], path: Path | None = None)
|
||||
"time_signature": time_signature,
|
||||
})
|
||||
for key, value in document.items():
|
||||
if key in {"title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
|
||||
if key in {"version", "title", "composer", "key", "key_signature", "time", "time_signature", "clef", "part", "name", "role", "measures", "metadata"}:
|
||||
continue
|
||||
output_metadata[key] = sanitize_json(value)
|
||||
output_metadata = canonicalize_score_metadata(output_metadata, path)
|
||||
|
||||
measures: list[dict[str, Any]] = []
|
||||
for index, value in enumerate(measures_value):
|
||||
text, overrides = compact_measure_text(value, index)
|
||||
text, overrides, extra_fields = compact_measure_text(value, index)
|
||||
measure_time = overrides.get("time_signature", time_signature)
|
||||
measure_key = overrides.get("key_signature", key_signature)
|
||||
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
|
||||
notes, tuplets = parser.parse()
|
||||
measures.append({
|
||||
measure = {
|
||||
"id": f"measure-{index + 1}",
|
||||
"section_label": overrides.get("section_label", ""),
|
||||
"time_signature": measure_time,
|
||||
@@ -422,10 +474,11 @@ def normalize_score_document(document: dict[str, Any], path: Path | None = None)
|
||||
"notes": notes,
|
||||
"tuplets": tuplets,
|
||||
}],
|
||||
})
|
||||
}
|
||||
measure.update(extra_fields)
|
||||
measures.append(measure)
|
||||
|
||||
return {
|
||||
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
|
||||
"metadata": output_metadata,
|
||||
"staves": [{
|
||||
"id": "staff-source",
|
||||
@@ -706,6 +759,14 @@ def copy_source_voice(measure: dict[str, Any], *, strip_lyrics: bool, include_op
|
||||
}
|
||||
|
||||
|
||||
def measure_notation_metadata(source: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: sanitize_json(value)
|
||||
for key, value in source.items()
|
||||
if key not in GENERATED_MEASURE_FIELDS
|
||||
}
|
||||
|
||||
|
||||
def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str, Any]], *, section_label: str = "", tuplets: list[Any] | None = None) -> dict[str, Any]:
|
||||
measure: dict[str, Any] = {
|
||||
"id": measure_id,
|
||||
@@ -719,15 +780,14 @@ def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str,
|
||||
}
|
||||
if section_label:
|
||||
measure["section_label"] = section_label
|
||||
measure.update(measure_notation_metadata(source))
|
||||
return measure
|
||||
|
||||
|
||||
def make_arranged_document(source_document: dict[str, Any], staff: dict[str, Any], *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
metadata = sanitize_json(source_document.get("metadata", {}))
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
source_metadata = source_document.get("metadata", {})
|
||||
metadata = canonicalize_score_metadata(source_metadata if isinstance(source_metadata, dict) else {})
|
||||
output: dict[str, Any] = {
|
||||
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
|
||||
"metadata": metadata,
|
||||
"staves": [{
|
||||
"id": staff_id,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_STYLE = "Medium Swing"
|
||||
|
||||
BUILT_IN_STYLES = (
|
||||
# Jazz
|
||||
"Afro 12/8",
|
||||
"Ballad Double Time Feel",
|
||||
"Ballad Even",
|
||||
"Ballad Melodic",
|
||||
"Ballad Swing",
|
||||
"Blue Note",
|
||||
"Bossa Nova",
|
||||
"Doo Doo Cats",
|
||||
"Double Time Swing",
|
||||
"Even 8ths",
|
||||
"Even 8ths Open",
|
||||
"Even 16ths",
|
||||
"Guitar Trio",
|
||||
"Gypsy Jazz",
|
||||
"Latin",
|
||||
"Latin/Swing",
|
||||
"Long Notes",
|
||||
"Medium Swing",
|
||||
"Medium Up Swing",
|
||||
"Medium Up Swing 2",
|
||||
"New Orleans Swing",
|
||||
"Second Line",
|
||||
"Slow Swing",
|
||||
"Swing Two/Four",
|
||||
"Trad Jazz",
|
||||
"Up Tempo Swing",
|
||||
"Up Tempo Swing 2",
|
||||
# Latin
|
||||
"Argentina: Tango",
|
||||
"Brazil: Bossa Acoustic",
|
||||
"Brazil: Bossa Electric",
|
||||
"Brazil: Samba",
|
||||
"Cuba: Bolero",
|
||||
"Cuba: Cha Cha Cha",
|
||||
"Cuba: Son Montuno 2-3",
|
||||
"Cuba: Son Montuno 3-2",
|
||||
# Pop
|
||||
"Bluegrass",
|
||||
"Country",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Glam Funk",
|
||||
"House",
|
||||
"Reggae",
|
||||
"RnB",
|
||||
"Rock",
|
||||
"Rock 12/8",
|
||||
"Shuffle",
|
||||
"Slow Rock",
|
||||
"Smooth",
|
||||
"Soul",
|
||||
"Virtual Funk",
|
||||
)
|
||||
|
||||
BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES)
|
||||
|
||||
STYLE_ALIASES = {
|
||||
"Med.": DEFAULT_STYLE,
|
||||
"Med. Swing": DEFAULT_STYLE,
|
||||
"Medium": DEFAULT_STYLE,
|
||||
}
|
||||
|
||||
|
||||
def canonical_style(value: object) -> str:
|
||||
if value is None:
|
||||
return DEFAULT_STYLE
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("style must be a string")
|
||||
|
||||
style = value.strip()
|
||||
if not style:
|
||||
return DEFAULT_STYLE
|
||||
|
||||
style = STYLE_ALIASES.get(style, style)
|
||||
if style not in BUILT_IN_STYLE_SET:
|
||||
raise ValueError(f"style {value!r} is not a supported player style")
|
||||
|
||||
return style
|
||||
@@ -16,7 +16,7 @@ struct command_line {
|
||||
std::bool_t health_check{};
|
||||
std::bool_t print_version{};
|
||||
std::bool_t exit_after_startup{};
|
||||
std::bool_t migrate{};
|
||||
std::bool_t ensure_database{};
|
||||
};
|
||||
|
||||
auto parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected<vamp_zero::command_line, modulus::error>;
|
||||
|
||||
@@ -25,7 +25,6 @@ struct runtime_config {
|
||||
|
||||
struct database_config {
|
||||
std::string path{"/var/lib/product-vamp/vamp.db"};
|
||||
std::string migrations_path{"/usr/share/product-vamp/migrations"};
|
||||
};
|
||||
|
||||
struct control_config {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include <expected>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
#include <boost/asio/awaitable.hpp>
|
||||
@@ -14,10 +13,9 @@
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
auto async_migrate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_import_catalogs(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
|
||||
auto async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>>;
|
||||
auto async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>>;
|
||||
auto split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error>;
|
||||
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"heartbeat_path": "/run/product-vamp/heartbeat.json"
|
||||
},
|
||||
"database": {
|
||||
"path": "/var/lib/product-vamp/vamp.db",
|
||||
"migrations_path": "/usr/share/product-vamp/migrations"
|
||||
"path": "/var/lib/product-vamp/vamp.db"
|
||||
},
|
||||
"control": {
|
||||
"endpoint": {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
CREATE TABLE songs (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||
) STRICT;
|
||||
-- vamp-statement
|
||||
CREATE TABLE page_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
song_id TEXT NOT NULL CHECK(length(trim(song_id)) BETWEEN 1 AND 120),
|
||||
page_index INTEGER NOT NULL CHECK(page_index >= 0),
|
||||
next_count INTEGER NOT NULL DEFAULT 0 CHECK(next_count >= 0),
|
||||
previous_count INTEGER NOT NULL DEFAULT 0 CHECK(previous_count >= 0),
|
||||
last_command TEXT NOT NULL DEFAULT '',
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= 0)
|
||||
) STRICT;
|
||||
@@ -1,5 +0,0 @@
|
||||
ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT '';
|
||||
-- vamp-statement
|
||||
ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT '';
|
||||
-- vamp-statement
|
||||
CREATE INDEX idx_songs_title_part ON songs(title, part);
|
||||
@@ -1,14 +0,0 @@
|
||||
ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT '';
|
||||
-- vamp-statement
|
||||
CREATE TABLE practice_exercises (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT 'C',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||
) STRICT;
|
||||
-- vamp-statement
|
||||
CREATE INDEX idx_practice_exercises_title ON practice_exercises(title);
|
||||
@@ -1,9 +0,0 @@
|
||||
ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general';
|
||||
-- vamp-statement
|
||||
ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice';
|
||||
-- vamp-statement
|
||||
ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general';
|
||||
-- vamp-statement
|
||||
ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General';
|
||||
-- vamp-statement
|
||||
CREATE INDEX idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id, lesson_group_id, source_path, title);
|
||||
@@ -1,7 +0,0 @@
|
||||
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);
|
||||
@@ -43,7 +43,6 @@ auto strip_cr(std::string_view line) -> std::string_view {
|
||||
|
||||
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);
|
||||
@@ -53,9 +52,7 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
|
||||
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") {
|
||||
if (key == "active") {
|
||||
parsed.active = value == "1";
|
||||
} else if (key == "title") {
|
||||
copy_value(value, parsed.title, sizeof(parsed.title));
|
||||
@@ -78,9 +75,6 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
|
||||
}
|
||||
cursor = line_end + 1U;
|
||||
}
|
||||
if (!saw_version) {
|
||||
return false;
|
||||
}
|
||||
state = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
+11
-11
@@ -41,7 +41,7 @@ auto invalid(std::string message) -> modulus::error {
|
||||
}
|
||||
|
||||
auto usage() -> std::string {
|
||||
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--migrate]";
|
||||
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--ensure-database]";
|
||||
}
|
||||
|
||||
auto bind_port(vamp_zero::config const& config) -> std::uint16_t {
|
||||
@@ -88,9 +88,9 @@ auto write_heartbeat(vamp_zero::config const& config) -> std::expected<void, mod
|
||||
return write_text_atomic(config.runtime.heartbeat_path, boost::json::serialize(heartbeat));
|
||||
}
|
||||
|
||||
auto migrate(vamp_zero::config const& config) -> std::expected<void, modulus::error> {
|
||||
auto ensure_database(vamp_zero::config const& 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(), config.database), boost::asio::use_future);
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), config.database), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
@@ -258,8 +258,8 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std
|
||||
result.print_version = true;
|
||||
} else if (argument == "--once") {
|
||||
result.exit_after_startup = true;
|
||||
} else if (argument == "--migrate") {
|
||||
result.migrate = true;
|
||||
} else if (argument == "--ensure-database") {
|
||||
result.ensure_database = true;
|
||||
} else if (argument == "--help" || argument == "-h") {
|
||||
return std::unexpected(invalid(usage()));
|
||||
} else {
|
||||
@@ -270,7 +270,7 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std
|
||||
+ static_cast<std::uint32_t>(result.health_check ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.print_version ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0)
|
||||
+ static_cast<std::uint32_t>(result.migrate ? 1 : 0);
|
||||
+ static_cast<std::uint32_t>(result.ensure_database ? 1 : 0);
|
||||
if (command_count > 1U) {
|
||||
return std::unexpected(invalid("only one command flag may be selected"));
|
||||
}
|
||||
@@ -295,17 +295,17 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
if (command.migrate) {
|
||||
return migrate(*config);
|
||||
if (command.ensure_database) {
|
||||
return ensure_database(*config);
|
||||
}
|
||||
if (command.exit_after_startup || config->runtime.exit_after_startup) {
|
||||
std::cout << "ok\n";
|
||||
return {};
|
||||
}
|
||||
|
||||
auto migrated = migrate(*config);
|
||||
if (!migrated) {
|
||||
return migrated;
|
||||
auto ensured = ensure_database(*config);
|
||||
if (!ensured) {
|
||||
return ensured;
|
||||
}
|
||||
auto imported = import_library(*config);
|
||||
if (!imported) {
|
||||
|
||||
@@ -116,13 +116,11 @@ auto parse_database(boost::json::object const& root, vamp_zero::database_config
|
||||
return fallback;
|
||||
}
|
||||
auto path = optional_string(**object, "path", fallback.path);
|
||||
auto migrations_path = optional_string(**object, "migrations_path", fallback.migrations_path);
|
||||
if (!path || !migrations_path) {
|
||||
return std::unexpected(!path ? path.error() : migrations_path.error());
|
||||
if (!path) {
|
||||
return std::unexpected(path.error());
|
||||
}
|
||||
return vamp_zero::database_config{
|
||||
.path = std::move(*path),
|
||||
.migrations_path = std::move(*migrations_path),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+163
-148
@@ -5,9 +5,6 @@
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
@@ -26,38 +23,6 @@
|
||||
namespace {
|
||||
|
||||
constexpr auto application_id = std::int64_t{0x56414D50};
|
||||
constexpr auto statement_delimiter = std::string_view{"-- vamp-statement"};
|
||||
|
||||
auto trim(std::string value) -> std::string {
|
||||
auto first = value.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos) {
|
||||
return {};
|
||||
}
|
||||
auto last = value.find_last_not_of(" \t\r\n");
|
||||
return value.substr(first, last - first + 1U);
|
||||
}
|
||||
|
||||
auto read_text(std::string const& path) -> std::expected<std::string, modulus::error> {
|
||||
auto input = std::ifstream{path, std::ios::binary};
|
||||
if (!input) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open migration: " + path));
|
||||
}
|
||||
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
||||
if (!input.good() && !input.eof()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read migration: " + path));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
auto execute_statements(noto::connection& connection, std::vector<std::string> statements) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
for (auto& statement : statements) {
|
||||
auto result = co_await connection.async_execute(std::move(statement));
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto connection_config(vamp_zero::database_config const& config) -> noto::connection_config {
|
||||
auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp") : noto::file_connection_config(config.path, "product-vamp");
|
||||
@@ -70,19 +35,165 @@ auto connection_config(vamp_zero::database_config const& config) -> noto::connec
|
||||
return result;
|
||||
}
|
||||
|
||||
auto read_migration(vamp_zero::database_config const& config, std::string filename) -> std::expected<std::vector<std::string>, modulus::error> {
|
||||
auto migration_text = read_text(config.migrations_path + "/" + filename);
|
||||
if (!migration_text) {
|
||||
return std::unexpected(migration_text.error());
|
||||
}
|
||||
return vamp_zero::split_migration_statements(*migration_text);
|
||||
}
|
||||
|
||||
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 execute_schema_statement(noto::connection& connection, std::string statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto result = co_await connection.async_execute(std::move(statement));
|
||||
if (!result) {
|
||||
co_return std::unexpected(result.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto column_exists(noto::connection& connection, std::string table, std::string column) -> boost::asio::awaitable<std::expected<std::bool_t, modulus::error>> {
|
||||
auto selected = co_await connection.async_execute("PRAGMA table_info(" + table + ")");
|
||||
if (!selected) {
|
||||
co_return std::unexpected(selected.error());
|
||||
}
|
||||
for (auto const& row : selected->rows) {
|
||||
if (row.size() >= 2U && text_value(row[1]) == column) {
|
||||
co_return true;
|
||||
}
|
||||
}
|
||||
co_return false;
|
||||
}
|
||||
|
||||
auto ensure_column(noto::connection& connection, std::string table, std::string column, std::string alter_statement) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto exists = co_await column_exists(connection, std::move(table), std::move(column));
|
||||
if (!exists) {
|
||||
co_return std::unexpected(exists.error());
|
||||
}
|
||||
if (*exists) {
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
co_return co_await execute_schema_statement(connection, std::move(alter_statement));
|
||||
}
|
||||
|
||||
auto ensure_current_schema(noto::connection& connection) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
|
||||
if (!begin) {
|
||||
co_return std::unexpected(begin.error());
|
||||
}
|
||||
|
||||
auto ensured = std::expected<void, modulus::error>{};
|
||||
auto schema_statements = std::vector<std::string>{
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
|
||||
part TEXT NOT NULL DEFAULT '',
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT '',
|
||||
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
|
||||
) STRICT
|
||||
)sql",
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS page_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
active_song_id TEXT NOT NULL DEFAULT '',
|
||||
page_index INTEGER NOT NULL DEFAULT 0 CHECK(page_index >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= 0)
|
||||
) STRICT
|
||||
)sql",
|
||||
R"sql(
|
||||
CREATE TABLE IF NOT EXISTS practice_exercises (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120),
|
||||
practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200),
|
||||
lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120),
|
||||
lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT 'C',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms),
|
||||
catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))
|
||||
) STRICT
|
||||
)sql",
|
||||
"DROP TABLE IF EXISTS _noto_schema_migrations",
|
||||
};
|
||||
|
||||
for (auto& statement : schema_statements) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
}
|
||||
ensured = co_await execute_schema_statement(connection, std::move(statement));
|
||||
}
|
||||
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "source_path", "ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "key_signature", "ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "songs", "catalog_managed", "ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_id", "ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_title", "ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_id", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_title", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200)");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "source_path", "ALTER TABLE practice_exercises ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
|
||||
}
|
||||
if (ensured) {
|
||||
ensured = co_await ensure_column(connection, "practice_exercises", "catalog_managed", "ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))");
|
||||
}
|
||||
|
||||
auto index_statements = std::vector<std::string>{
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_title_part ON songs(title COLLATE NOCASE, part COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_songs_catalog_managed ON songs(catalog_managed)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_title ON practice_exercises(title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)",
|
||||
};
|
||||
for (auto& statement : index_statements) {
|
||||
if (!ensured) {
|
||||
break;
|
||||
}
|
||||
ensured = co_await execute_schema_statement(connection, std::move(statement));
|
||||
}
|
||||
|
||||
if (ensured) {
|
||||
auto committed = co_await connection.async_execute("COMMIT");
|
||||
if (!committed) {
|
||||
ensured = std::unexpected(committed.error());
|
||||
}
|
||||
} else {
|
||||
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
|
||||
}
|
||||
|
||||
if (!ensured) {
|
||||
co_return std::unexpected(ensured.error());
|
||||
}
|
||||
auto version = co_await connection.async_set_user_version(0);
|
||||
if (!version) {
|
||||
co_return std::unexpected(version.error());
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
auto path_parts(std::filesystem::path const& path) -> std::vector<std::string> {
|
||||
auto parts = std::vector<std::string>{};
|
||||
for (auto const& part : path) {
|
||||
@@ -136,42 +247,9 @@ auto execute_delete_stale_catalog_rows(noto::connection& connection, std::string
|
||||
|
||||
}
|
||||
|
||||
auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected<std::vector<std::string>, modulus::error> {
|
||||
auto statements = std::vector<std::string>{};
|
||||
auto current = std::string{};
|
||||
auto stream = std::istringstream{input};
|
||||
auto line = std::string{};
|
||||
while (std::getline(stream, line)) {
|
||||
if (trim(line) == statement_delimiter) {
|
||||
current = trim(std::move(current));
|
||||
if (current.empty()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration contains an empty statement"));
|
||||
}
|
||||
statements.push_back(std::move(current));
|
||||
current.clear();
|
||||
continue;
|
||||
}
|
||||
current += line;
|
||||
current.push_back('\n');
|
||||
}
|
||||
current = trim(std::move(current));
|
||||
if (!current.empty()) {
|
||||
statements.push_back(std::move(current));
|
||||
}
|
||||
if (statements.empty()) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "migration must contain at least one statement"));
|
||||
}
|
||||
for (auto const& statement : statements) {
|
||||
if (statement.empty() || statement.back() != ';') {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "every migration statement must end with a semicolon"));
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (config.path.empty() || config.migrations_path.empty()) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database paths must not be empty"));
|
||||
auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
|
||||
if (config.path.empty()) {
|
||||
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database path must not be empty"));
|
||||
}
|
||||
if (config.path != ":memory:") {
|
||||
auto parent = std::filesystem::path{config.path}.parent_path();
|
||||
@@ -213,76 +291,13 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va
|
||||
}
|
||||
}
|
||||
|
||||
auto foundation_statements = read_migration(config, "001_foundation.sql");
|
||||
if (!foundation_statements) {
|
||||
co_return std::unexpected(foundation_statements.error());
|
||||
auto ensured = co_await ensure_current_schema(connection);
|
||||
if (!ensured) {
|
||||
auto close = co_await connection.async_close();
|
||||
if (!close) {
|
||||
modulus::log_error("database close failed after schema ensure error: " + close.error().describe());
|
||||
}
|
||||
auto import_statements = read_migration(config, "002_song_import_metadata.sql");
|
||||
if (!import_statements) {
|
||||
co_return std::unexpected(import_statements.error());
|
||||
}
|
||||
auto practice_statements = read_migration(config, "003_practice_catalog.sql");
|
||||
if (!practice_statements) {
|
||||
co_return std::unexpected(practice_statements.error());
|
||||
}
|
||||
auto practice_hierarchy_statements = read_migration(config, "004_practice_hierarchy.sql");
|
||||
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{};
|
||||
foundation.id = "001_foundation";
|
||||
foundation.description = "initial Product Vamp appliance tables";
|
||||
foundation.checksum = "001_foundation_v1";
|
||||
foundation.up = [statements = std::move(*foundation_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(foundation));
|
||||
auto import = noto::migration{};
|
||||
import.id = "002_song_import_metadata";
|
||||
import.description = "song import metadata";
|
||||
import.checksum = "002_song_import_metadata_v1";
|
||||
import.up = [statements = std::move(*import_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(import));
|
||||
auto practice = noto::migration{};
|
||||
practice.id = "003_practice_catalog";
|
||||
practice.description = "practice exercise catalog";
|
||||
practice.checksum = "003_practice_catalog_v1";
|
||||
practice.up = [statements = std::move(*practice_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(practice));
|
||||
auto practice_hierarchy = noto::migration{};
|
||||
practice_hierarchy.id = "004_practice_hierarchy";
|
||||
practice_hierarchy.description = "practice type and lesson hierarchy";
|
||||
practice_hierarchy.checksum = "004_practice_hierarchy_v1";
|
||||
practice_hierarchy.up = [statements = std::move(*practice_hierarchy_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(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(5);
|
||||
if (!version) {
|
||||
co_return std::unexpected(version.error());
|
||||
co_return std::unexpected(ensured.error());
|
||||
}
|
||||
co_return co_await connection.async_close();
|
||||
}
|
||||
|
||||
@@ -554,7 +554,6 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s
|
||||
}
|
||||
|
||||
auto output = std::ostringstream{};
|
||||
output << "version=1\n";
|
||||
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
|
||||
output << "active=" << (active ? "1" : "0") << "\n";
|
||||
output << "title=" << sanitize_line_value(title) << "\n";
|
||||
|
||||
+100
-12
@@ -209,13 +209,12 @@ void expect_down_stem_connected(vamp_zero::score_layout_item const& head, vamp_z
|
||||
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 run_ensure_database(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);
|
||||
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), std::move(config)), boost::asio::use_future);
|
||||
io.run();
|
||||
return future.get();
|
||||
}
|
||||
@@ -264,9 +263,14 @@ auto integer_value(noto::sqlite_value const& value) -> std::int64_t {
|
||||
return integer == nullptr ? 0 : *integer;
|
||||
}
|
||||
|
||||
auto has_value(noto::query_result const& result, std::size_t column, std::string_view expected) -> std::bool_t {
|
||||
return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const& row) {
|
||||
return row.size() > column && text_value(row[column]) == expected;
|
||||
});
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -318,7 +322,6 @@ void write_text(std::filesystem::path const& path, std::string const& text) {
|
||||
BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
|
||||
auto parsed = vamp_zero::parse_score_document(R"json(
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"title": "Standard Tune",
|
||||
"composer": "Composer",
|
||||
@@ -467,7 +470,7 @@ BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals)
|
||||
|
||||
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_REQUIRE(vamp_pico::parse_remote_display_body("active=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);
|
||||
@@ -477,19 +480,17 @@ BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) {
|
||||
BOOST_TEST(state.pages == 11U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_missing_version_and_bad_numbers) {
|
||||
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_bad_numbers_and_preserves_state) {
|
||||
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(!vamp_pico::parse_remote_display_body("page=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";
|
||||
auto body = std::string{"title="} + 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);
|
||||
@@ -497,6 +498,93 @@ BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
|
||||
BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(database_ensure_creates_current_schema_without_version_bookkeeping) {
|
||||
auto root = std::filesystem::temp_directory_path()
|
||||
/ ("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto database_path = root / "vamp.db";
|
||||
std::filesystem::remove_all(root);
|
||||
|
||||
auto config = test_database_config(database_path);
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
|
||||
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name");
|
||||
BOOST_REQUIRE(tables);
|
||||
BOOST_TEST(has_value(*tables, 0U, "songs"));
|
||||
BOOST_TEST(has_value(*tables, 0U, "practice_exercises"));
|
||||
BOOST_TEST(has_value(*tables, 0U, "page_state"));
|
||||
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
||||
|
||||
auto version = query_database(database_path, "PRAGMA user_version");
|
||||
BOOST_REQUIRE(version);
|
||||
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
||||
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(database_ensure_updates_legacy_schema_in_place) {
|
||||
auto root = std::filesystem::temp_directory_path()
|
||||
/ ("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
|
||||
auto database_path = root / "vamp.db";
|
||||
std::filesystem::remove_all(root);
|
||||
std::filesystem::create_directories(root);
|
||||
|
||||
BOOST_REQUIRE(query_database(database_path, R"sql(
|
||||
CREATE TABLE songs (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||
) STRICT
|
||||
)sql"));
|
||||
BOOST_REQUIRE(query_database(database_path, R"sql(
|
||||
CREATE TABLE practice_exercises (
|
||||
id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160),
|
||||
title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200),
|
||||
composer TEXT NOT NULL DEFAULT '',
|
||||
key_signature TEXT NOT NULL DEFAULT 'C',
|
||||
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
|
||||
source_path TEXT NOT NULL DEFAULT '',
|
||||
created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0),
|
||||
updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms)
|
||||
) STRICT
|
||||
)sql"));
|
||||
BOOST_REQUIRE(query_database(database_path, "CREATE TABLE _noto_schema_migrations(id TEXT PRIMARY KEY NOT NULL) STRICT"));
|
||||
BOOST_REQUIRE(query_database(database_path, "PRAGMA user_version = 5"));
|
||||
|
||||
auto config = test_database_config(database_path);
|
||||
BOOST_REQUIRE(run_ensure_database(config));
|
||||
|
||||
auto song_columns = query_database(database_path, "PRAGMA table_info(songs)");
|
||||
BOOST_REQUIRE(song_columns);
|
||||
BOOST_TEST(has_value(*song_columns, 1U, "part"));
|
||||
BOOST_TEST(has_value(*song_columns, 1U, "source_path"));
|
||||
BOOST_TEST(has_value(*song_columns, 1U, "key_signature"));
|
||||
BOOST_TEST(has_value(*song_columns, 1U, "catalog_managed"));
|
||||
|
||||
auto practice_columns = query_database(database_path, "PRAGMA table_info(practice_exercises)");
|
||||
BOOST_REQUIRE(practice_columns);
|
||||
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_id"));
|
||||
BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_title"));
|
||||
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_id"));
|
||||
BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_title"));
|
||||
BOOST_TEST(has_value(*practice_columns, 1U, "catalog_managed"));
|
||||
|
||||
auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table'");
|
||||
BOOST_REQUIRE(tables);
|
||||
BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations"));
|
||||
|
||||
auto version = query_database(database_path, "PRAGMA user_version");
|
||||
BOOST_REQUIRE(version);
|
||||
BOOST_REQUIRE_EQUAL(version->rows.size(), 1U);
|
||||
BOOST_TEST(integer_value(version->rows[0][0]) == 0);
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
|
||||
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()));
|
||||
@@ -507,7 +595,7 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro
|
||||
write_text(song_path, score_asset_json("Catalog Tune"));
|
||||
|
||||
auto config = test_database_config(database_path);
|
||||
BOOST_REQUIRE(run_migrate(config));
|
||||
BOOST_REQUIRE(run_ensure_database(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'");
|
||||
|
||||
@@ -4,6 +4,7 @@ import unittest
|
||||
|
||||
from generator.arrange.generate import arrange_document
|
||||
from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing
|
||||
from generator.styles import BUILT_IN_STYLES
|
||||
from generator.transposition import mod12
|
||||
from generator.score import normalize_score_document
|
||||
|
||||
@@ -48,7 +49,9 @@ class CompactScoreJsonTests(unittest.TestCase):
|
||||
first_note = staff["measures"][0]["voices"][0]["notes"][0]
|
||||
second_measure_first_note = staff["measures"][1]["voices"][0]["notes"][0]
|
||||
|
||||
self.assertNotIn("version", document)
|
||||
self.assertEqual(document["metadata"]["key_signature"], "F")
|
||||
self.assertEqual(document["metadata"]["style"], "Medium Swing")
|
||||
self.assertEqual(staff["name"], "Vocal")
|
||||
self.assertEqual(first_note["duration_ticks"], 16384)
|
||||
self.assertEqual(first_note["chord_symbol"], "G-7")
|
||||
@@ -56,6 +59,64 @@ class CompactScoreJsonTests(unittest.TestCase):
|
||||
self.assertTrue(first_note["tie_to_next"])
|
||||
self.assertTrue(second_measure_first_note["tie_from_previous"])
|
||||
|
||||
def test_compact_json_canonicalizes_player_styles(self) -> None:
|
||||
self.assertEqual(len(BUILT_IN_STYLES), 50)
|
||||
|
||||
for style in ("Med.", "Med. Swing", "Medium", "Medium Swing"):
|
||||
document = normalize_score_document({
|
||||
"title": "Style Tune",
|
||||
"style": style,
|
||||
"measures": ["C4:1"],
|
||||
})
|
||||
self.assertEqual(document["metadata"]["style"], "Medium Swing")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "supported player style"):
|
||||
normalize_score_document({
|
||||
"title": "Bad Style Tune",
|
||||
"style": "Medium Bossa That Does Not Exist",
|
||||
"measures": ["C4:1"],
|
||||
})
|
||||
|
||||
def test_compact_json_preserves_musicxml_like_measure_metadata(self) -> None:
|
||||
document = normalize_score_document({
|
||||
"title": "Metadata Tune",
|
||||
"measures": [
|
||||
{
|
||||
"notes": "C4:1",
|
||||
"attributes": {"divisions": 4},
|
||||
"directions": [{"type": "dynamics", "value": "mf"}],
|
||||
"harmony": [{"root": "C", "kind": "major-seventh"}],
|
||||
"barlines": {"right": "light-heavy"},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
measure = document["staves"][0]["measures"][0]
|
||||
self.assertEqual(measure["attributes"], {"divisions": 4})
|
||||
self.assertEqual(measure["directions"], [{"type": "dynamics", "value": "mf"}])
|
||||
self.assertEqual(measure["harmony"], [{"root": "C", "kind": "major-seventh"}])
|
||||
self.assertEqual(measure["barlines"], {"right": "light-heavy"})
|
||||
|
||||
def test_arrangements_preserve_measure_notation_metadata(self) -> None:
|
||||
document = normalize_score_document({
|
||||
"title": "Directions Tune",
|
||||
"key": "C",
|
||||
"time": "4/4",
|
||||
"measures": [
|
||||
{
|
||||
"notes": "[Cmaj7] C4:1",
|
||||
"directions": [{"type": "rehearsal", "value": "A"}],
|
||||
"barlines": {"left": "heavy-light"},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
arranged = arrange_document(document, "vocal")
|
||||
measure = arranged["staves"][0]["measures"][0]
|
||||
|
||||
self.assertEqual(measure["directions"], [{"type": "rehearsal", "value": "A"}])
|
||||
self.assertEqual(measure["barlines"], {"left": "heavy-light"})
|
||||
|
||||
def test_compact_json_marks_optional_chords(self) -> None:
|
||||
document = normalize_score_document({
|
||||
"title": "Optional Turnaround",
|
||||
@@ -92,6 +153,7 @@ class CompactScoreJsonTests(unittest.TestCase):
|
||||
non_final_turnaround = measures[7]
|
||||
final_turnaround = measures[-1]
|
||||
|
||||
self.assertNotIn("version", arranged)
|
||||
self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"])
|
||||
self.assertEqual(self.chord_symbols(final_turnaround), [])
|
||||
self.assertFalse(any("chord_optional" in note for measure in measures for note in self.measure_notes(measure)))
|
||||
|
||||
Reference in New Issue
Block a user