Styles
ci / checks (push) Successful in 39m31s

This commit is contained in:
khodowany
2026-09-08 22:07:31 -05:00
parent 6e7db6fd97
commit fa7485d404
28 changed files with 522 additions and 292 deletions
+3 -3
View File
@@ -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 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. - 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: 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`. 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 ```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: 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
View File
@@ -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. - `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. - `data`: authored source score JSON for performance songs and practice exercises.
- `generator`: build-time Python preprocessing for performance arrangements and practice variants. - `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 ## 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. 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. 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 ## 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. 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.
-7
View File
@@ -129,7 +129,6 @@ install(TARGETS vamp-zero
) )
install(FILES package/config/vamp.example.json DESTINATION zero/config) install(FILES package/config/vamp.example.json DESTINATION zero/config)
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product-vamp) install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product-vamp)
install(DIRECTORY package/migrations/ DESTINATION zero/share/product-vamp/migrations)
install(PROGRAMS install(PROGRAMS
package/zero/install-zero.sh package/zero/install-zero.sh
package/zero/start-ap.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 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 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 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} 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} WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
DEPENDS DEPENDS
@@ -172,11 +170,6 @@ if(VAMP_BUILD_ZERO_IMAGE)
package/scripts/build_zero_image.sh package/scripts/build_zero_image.sh
package/zero/Dockerfile package/zero/Dockerfile
package/config/vamp.example.json 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} ${VAMP_GENERATED_ASSETS_STAMP}
VERBATIM VERBATIM
) )
-1
View File
@@ -38,7 +38,6 @@ class product_vamp_conan(conan.ConanFile):
"package/assets/**/*", "package/assets/**/*",
"package/config/*.json", "package/config/*.json",
"package/manifest.json.in", "package/manifest.json.in",
"package/migrations/*.sql",
"package/pico/*", "package/pico/*",
"package/scripts/*.py", "package/scripts/*.py",
"package/scripts/*.sh", "package/scripts/*.sh",
@@ -1,5 +1,4 @@
{ {
"version": 1,
"title": "01 Cycles", "title": "01 Cycles",
"composer": "", "composer": "",
"key": "C", "key": "C",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med. Swing", "style": "Medium Swing",
"measures": [ "measures": [
"[G7] (G4 B4 F5):4 (G4 B4 F5):4 [C7] (C4 Bb4 E5):4 (C4 Bb4 E5):4", "[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", "[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", "title": "01 The Diatonic Scales",
"composer": "", "composer": "",
"key": "C", "key": "C",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med.", "style": "Medium Swing",
"measures": [ "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 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}", "{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", "title": "02 The Diatonic Scales",
"composer": "", "composer": "",
"key": "C", "key": "C",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med.", "style": "Medium Swing",
"measures": [ "measures": [
"C4:8 D4:8 E4:8 D4:8 E4:8 F4:8 E4:8 F4:8", "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", "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", "title": "03 The Diatonic Scales",
"composer": "", "composer": "",
"key": "C", "key": "C",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med.", "style": "Medium Swing",
"measures": [ "measures": [
"B3:8 C4:8 D4:8 E4:8 F4:8 E4:8 F4:8 G4:8", "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", "F4:8 E4:8 F4:8 G4:8 A4:8 B4:8 C5:8 B4:8",
+1 -2
View File
@@ -1,5 +1,4 @@
{ {
"version": 1,
"title": "Autumn Leaves", "title": "Autumn Leaves",
"composer": "Joseph Kosma / Johnny Mercer / Jaques Prevert", "composer": "Joseph Kosma / Johnny Mercer / Jaques Prevert",
"key": "F", "key": "F",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med.", "style": "Medium Swing",
"measures": [ "measures": [
"r:4 D4:4 \"The\" E4:4 \"fall-\" F4:4 \"ing\"", "r:4 D4:4 \"The\" E4:4 \"fall-\" F4:4 \"ing\"",
"[G-7] Bb4:1~ \"leaves\"", "[G-7] Bb4:1~ \"leaves\"",
+1 -2
View File
@@ -1,5 +1,4 @@
{ {
"version": 1,
"title": "It Could Happen to You", "title": "It Could Happen to You",
"composer": "James Van Heusen / Johnny Burke", "composer": "James Van Heusen / Johnny Burke",
"key": "Eb", "key": "Eb",
@@ -7,7 +6,7 @@
"clef": "treble", "clef": "treble",
"part": "Vocal", "part": "Vocal",
"role": "vocal", "role": "vocal",
"style": "Med.", "style": "Medium Swing",
"measures": [ "measures": [
"[Ebmaj7] Bb3:2. \"Hide\" C4:4 \"your\"", "[Ebmaj7] Bb3:2. \"Hide\" C4:4 \"your\"",
"[G-7b5] Db4:4 \"heart\" C4:8 \"from\" G4:8~ \"sight,\" [C7b9] ~G4:2", "[G-7b5] Db4:4 \"heart\" C4:8 \"from\" G4:8~ \"sight,\" [C7b9] ~G4:2",
+81 -21
View File
@@ -6,6 +6,8 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from generator.styles import canonical_style
WHOLE_TICKS = 16384 WHOLE_TICKS = 16384
HALF_TICKS = WHOLE_TICKS // 2 HALF_TICKS = WHOLE_TICKS // 2
QUARTER_TICKS = WHOLE_TICKS // 4 QUARTER_TICKS = WHOLE_TICKS // 4
@@ -28,13 +30,6 @@ RENDERABLE_TICKS = (
REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4} REST_PITCH = {"letter": "b", "accidental": "n", "octave": 4}
FORBIDDEN_OUTPUT_FIELDS = { FORBIDDEN_OUTPUT_FIELDS = {
"tab_positions",
"tab_frets",
"finger",
"fingers",
"fingering",
"left_hand_finger",
"right_hand_finger",
"chord_optional", "chord_optional",
} }
@@ -91,6 +86,35 @@ def score_field(document: dict[str, Any], metadata: dict[str, Any], *names: str,
return fallback 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: def parse_duration_token(text: str, label: str) -> int:
dotted = text.endswith(".") dotted = text.endswith(".")
base_text = text[:-1] if dotted else text base_text = text[:-1] if dotted else text
@@ -352,9 +376,31 @@ class CompactMeasureParser:
return notes, self.tuplets 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): if isinstance(value, str):
return value, {} return value, {}, {}
if isinstance(value, dict): if isinstance(value, dict):
text = value.get("notes", value.get("notation", "")) text = value.get("notes", value.get("notation", ""))
if not isinstance(text, str): 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) field = value.get(source)
if isinstance(field, str): if isinstance(field, str):
overrides[target] = field 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") 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]: def normalize_score_document(document: dict[str, Any], path: Path | None = None) -> dict[str, Any]:
if isinstance(document.get("staves"), list): if isinstance(document.get("staves"), list):
return document return normalize_full_score_document(document, path)
measures_value = document.get("measures") measures_value = document.get("measures")
if not isinstance(measures_value, list): if not isinstance(measures_value, list):
return document return normalize_full_score_document(document, path)
metadata_value = document.get("metadata", {}) metadata_value = document.get("metadata", {})
metadata = metadata_value if isinstance(metadata_value, dict) else {} 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")) 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, "time_signature": time_signature,
}) })
for key, value in document.items(): 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 continue
output_metadata[key] = sanitize_json(value) output_metadata[key] = sanitize_json(value)
output_metadata = canonicalize_score_metadata(output_metadata, path)
measures: list[dict[str, Any]] = [] measures: list[dict[str, Any]] = []
for index, value in enumerate(measures_value): 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_time = overrides.get("time_signature", time_signature)
measure_key = overrides.get("key_signature", key_signature) measure_key = overrides.get("key_signature", key_signature)
parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time) parser = CompactMeasureParser(text, measure_number=index + 1, time_signature=measure_time)
notes, tuplets = parser.parse() notes, tuplets = parser.parse()
measures.append({ measure = {
"id": f"measure-{index + 1}", "id": f"measure-{index + 1}",
"section_label": overrides.get("section_label", ""), "section_label": overrides.get("section_label", ""),
"time_signature": measure_time, "time_signature": measure_time,
@@ -422,10 +474,11 @@ def normalize_score_document(document: dict[str, Any], path: Path | None = None)
"notes": notes, "notes": notes,
"tuplets": tuplets, "tuplets": tuplets,
}], }],
}) }
measure.update(extra_fields)
measures.append(measure)
return { return {
"version": int(document.get("version", 1)) if isinstance(document.get("version", 1), int) else 1,
"metadata": output_metadata, "metadata": output_metadata,
"staves": [{ "staves": [{
"id": "staff-source", "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]: 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] = { measure: dict[str, Any] = {
"id": measure_id, "id": measure_id,
@@ -719,15 +780,14 @@ def make_measure(measure_id: str, source: dict[str, Any], notes: list[dict[str,
} }
if section_label: if section_label:
measure["section_label"] = section_label measure["section_label"] = section_label
measure.update(measure_notation_metadata(source))
return measure 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]: 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", {})) source_metadata = source_document.get("metadata", {})
if not isinstance(metadata, dict): metadata = canonicalize_score_metadata(source_metadata if isinstance(source_metadata, dict) else {})
metadata = {}
output: dict[str, Any] = { output: dict[str, Any] = {
"version": int(source_document.get("version", 1)) if isinstance(source_document.get("version", 1), int) else 1,
"metadata": metadata, "metadata": metadata,
"staves": [{ "staves": [{
"id": staff_id, "id": staff_id,
+85
View File
@@ -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
+1 -1
View File
@@ -16,7 +16,7 @@ struct command_line {
std::bool_t health_check{}; std::bool_t health_check{};
std::bool_t print_version{}; std::bool_t print_version{};
std::bool_t exit_after_startup{}; 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>; auto parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected<vamp_zero::command_line, modulus::error>;
-1
View File
@@ -25,7 +25,6 @@ struct runtime_config {
struct database_config { struct database_config {
std::string path{"/var/lib/product-vamp/vamp.db"}; std::string path{"/var/lib/product-vamp/vamp.db"};
std::string migrations_path{"/usr/share/product-vamp/migrations"};
}; };
struct control_config { struct control_config {
+1 -3
View File
@@ -2,7 +2,6 @@
#include <expected> #include <expected>
#include <string> #include <string>
#include <vector>
#include <boost/asio/any_io_executor.hpp> #include <boost/asio/any_io_executor.hpp>
#include <boost/asio/awaitable.hpp> #include <boost/asio/awaitable.hpp>
@@ -14,10 +13,9 @@
namespace vamp_zero { 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_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_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 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>;
} }
+1 -2
View File
@@ -18,8 +18,7 @@
"heartbeat_path": "/run/product-vamp/heartbeat.json" "heartbeat_path": "/run/product-vamp/heartbeat.json"
}, },
"database": { "database": {
"path": "/var/lib/product-vamp/vamp.db", "path": "/var/lib/product-vamp/vamp.db"
"migrations_path": "/usr/share/product-vamp/migrations"
}, },
"control": { "control": {
"endpoint": { "endpoint": {
-18
View File
@@ -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);
+1 -7
View File
@@ -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 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 parsed = vamp_pico::remote_display_state{};
auto saw_version = std::bool_t{};
auto cursor = std::size_t{}; auto cursor = std::size_t{};
while (cursor <= body.size()) { while (cursor <= body.size()) {
auto line_end = body.find('\n', cursor); 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) { if (equals != std::string_view::npos) {
auto key = line.substr(0U, equals); auto key = line.substr(0U, equals);
auto value = line.substr(equals + 1U); auto value = line.substr(equals + 1U);
if (key == "version") { if (key == "active") {
saw_version = true;
} else if (key == "active") {
parsed.active = value == "1"; parsed.active = value == "1";
} else if (key == "title") { } else if (key == "title") {
copy_value(value, parsed.title, sizeof(parsed.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; cursor = line_end + 1U;
} }
if (!saw_version) {
return false;
}
state = parsed; state = parsed;
return true; return true;
} }
+11 -11
View File
@@ -41,7 +41,7 @@ auto invalid(std::string message) -> modulus::error {
} }
auto usage() -> std::string { 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 { 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)); 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 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(); io.run();
return future.get(); 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; result.print_version = true;
} else if (argument == "--once") { } else if (argument == "--once") {
result.exit_after_startup = true; result.exit_after_startup = true;
} else if (argument == "--migrate") { } else if (argument == "--ensure-database") {
result.migrate = true; result.ensure_database = true;
} else if (argument == "--help" || argument == "-h") { } else if (argument == "--help" || argument == "-h") {
return std::unexpected(invalid(usage())); return std::unexpected(invalid(usage()));
} else { } 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.health_check ? 1 : 0)
+ static_cast<std::uint32_t>(result.print_version ? 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.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) { if (command_count > 1U) {
return std::unexpected(invalid("only one command flag may be selected")); 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"; std::cout << "ok\n";
return {}; return {};
} }
if (command.migrate) { if (command.ensure_database) {
return migrate(*config); return ensure_database(*config);
} }
if (command.exit_after_startup || config->runtime.exit_after_startup) { if (command.exit_after_startup || config->runtime.exit_after_startup) {
std::cout << "ok\n"; std::cout << "ok\n";
return {}; return {};
} }
auto migrated = migrate(*config); auto ensured = ensure_database(*config);
if (!migrated) { if (!ensured) {
return migrated; return ensured;
} }
auto imported = import_library(*config); auto imported = import_library(*config);
if (!imported) { if (!imported) {
+2 -4
View File
@@ -116,13 +116,11 @@ auto parse_database(boost::json::object const& root, vamp_zero::database_config
return fallback; return fallback;
} }
auto path = optional_string(**object, "path", fallback.path); auto path = optional_string(**object, "path", fallback.path);
auto migrations_path = optional_string(**object, "migrations_path", fallback.migrations_path); if (!path) {
if (!path || !migrations_path) { return std::unexpected(path.error());
return std::unexpected(!path ? path.error() : migrations_path.error());
} }
return vamp_zero::database_config{ return vamp_zero::database_config{
.path = std::move(*path), .path = std::move(*path),
.migrations_path = std::move(*migrations_path),
}; };
} }
+164 -149
View File
@@ -5,9 +5,6 @@
#include <cstdint> #include <cstdint>
#include <expected> #include <expected>
#include <filesystem> #include <filesystem>
#include <fstream>
#include <iterator>
#include <sstream>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <utility> #include <utility>
@@ -26,38 +23,6 @@
namespace { namespace {
constexpr auto application_id = std::int64_t{0x56414D50}; 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 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"); 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; 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_value(noto::sqlite_value const& value) -> std::string {
auto text = std::get_if<std::string>(&value.value); auto text = std::get_if<std::string>(&value.value);
return text == nullptr ? std::string{} : *text; 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 path_parts(std::filesystem::path const& path) -> std::vector<std::string> {
auto parts = std::vector<std::string>{}; auto parts = std::vector<std::string>{};
for (auto const& part : path) { 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 vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto statements = std::vector<std::string>{}; if (config.path.empty()) {
auto current = std::string{}; co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "database path must not be empty"));
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"));
} }
if (config.path != ":memory:") { if (config.path != ":memory:") {
auto parent = std::filesystem::path{config.path}.parent_path(); 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"); auto ensured = co_await ensure_current_schema(connection);
if (!foundation_statements) { if (!ensured) {
co_return std::unexpected(foundation_statements.error()); auto close = co_await connection.async_close();
} if (!close) {
auto import_statements = read_migration(config, "002_song_import_metadata.sql"); modulus::log_error("database close failed after schema ensure error: " + close.error().describe());
if (!import_statements) { }
co_return std::unexpected(import_statements.error()); co_return std::unexpected(ensured.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 co_await connection.async_close(); co_return co_await connection.async_close();
} }
-1
View File
@@ -554,7 +554,6 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s
} }
auto output = std::ostringstream{}; auto output = std::ostringstream{};
output << "version=1\n";
output << "mode=" << vamp_zero::to_string(state.mode) << "\n"; output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "active=" << (active ? "1" : "0") << "\n"; output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n"; output << "title=" << sanitize_line_value(title) << "\n";
+100 -12
View File
@@ -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 { auto test_database_config(std::filesystem::path const& path) -> vamp_zero::database_config {
return { return {
.path = path.string(), .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 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(); io.run();
return future.get(); return future.get();
} }
@@ -264,9 +263,14 @@ auto integer_value(noto::sqlite_value const& value) -> std::int64_t {
return integer == nullptr ? 0 : *integer; 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 { auto score_asset_json(std::string title, std::string key = "C") -> std::string {
return std::string{R"json({ return std::string{R"json({
"version": 1,
"metadata": { "metadata": {
"title": ")json"} + title + R"json(", "title": ")json"} + title + R"json(",
"composer": "Composer", "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) { BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) {
auto parsed = vamp_zero::parse_score_document(R"json( auto parsed = vamp_zero::parse_score_document(R"json(
{ {
"version": 1,
"metadata": { "metadata": {
"title": "Standard Tune", "title": "Standard Tune",
"composer": "Composer", "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) { BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) {
auto state = vamp_pico::remote_display_state{}; 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(state.active);
BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0); 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_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{}; auto state = vamp_pico::remote_display_state{};
std::snprintf(state.title, sizeof(state.title), "Keep"); 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(!vamp_pico::parse_remote_display_body("page=1x\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_TEST(std::strcmp(state.title, "Keep") == 0);
} }
BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) { BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) {
auto state = vamp_pico::remote_display_state{}; 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_REQUIRE(vamp_pico::parse_remote_display_body(body, state));
BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U); 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_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) { BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) {
auto root = std::filesystem::temp_directory_path() auto root = std::filesystem::temp_directory_path()
/ ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); / ("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")); write_text(song_path, score_asset_json("Catalog Tune"));
auto config = test_database_config(database_path); auto config = test_database_config(database_path);
BOOST_REQUIRE(run_migrate(config)); BOOST_REQUIRE(run_ensure_database(config));
BOOST_REQUIRE(run_import(config, assets)); 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'"); auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'");
+62
View File
@@ -4,6 +4,7 @@ import unittest
from generator.arrange.generate import arrange_document from generator.arrange.generate import arrange_document
from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing 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.transposition import mod12
from generator.score import normalize_score_document from generator.score import normalize_score_document
@@ -48,7 +49,9 @@ class CompactScoreJsonTests(unittest.TestCase):
first_note = staff["measures"][0]["voices"][0]["notes"][0] first_note = staff["measures"][0]["voices"][0]["notes"][0]
second_measure_first_note = staff["measures"][1]["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"]["key_signature"], "F")
self.assertEqual(document["metadata"]["style"], "Medium Swing")
self.assertEqual(staff["name"], "Vocal") self.assertEqual(staff["name"], "Vocal")
self.assertEqual(first_note["duration_ticks"], 16384) self.assertEqual(first_note["duration_ticks"], 16384)
self.assertEqual(first_note["chord_symbol"], "G-7") 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(first_note["tie_to_next"])
self.assertTrue(second_measure_first_note["tie_from_previous"]) 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: def test_compact_json_marks_optional_chords(self) -> None:
document = normalize_score_document({ document = normalize_score_document({
"title": "Optional Turnaround", "title": "Optional Turnaround",
@@ -92,6 +153,7 @@ class CompactScoreJsonTests(unittest.TestCase):
non_final_turnaround = measures[7] non_final_turnaround = measures[7]
final_turnaround = measures[-1] final_turnaround = measures[-1]
self.assertNotIn("version", arranged)
self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"]) self.assertEqual(self.chord_symbols(non_final_turnaround), ["F-7", "Bb7"])
self.assertEqual(self.chord_symbols(final_turnaround), []) 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))) self.assertFalse(any("chord_optional" in note for measure in measures for note in self.measure_notes(measure)))