Fix
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-19 08:17:25 -05:00
parent e1410b0fdc
commit cee9f45b0c
28 changed files with 860 additions and 1234 deletions
+7 -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 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 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.
Python builds the complete SQLite catalogue with the score assets. The Zero opens that packaged catalogue read-only through Noto, without creating a database, migrating schemas, or importing JSON 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 catalogue stores shared, immutable menu metadata. Complete score bundles stay in SQLite and are loaded individually when selected; snapshots and Pico heartbeats do not copy the catalogue's musical JSON.
@@ -31,7 +31,7 @@ python3 modulus.py configure --target x86_64_gcc12_release
python3 modulus.py build --target x86_64_gcc12_release
```
`python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp-zero --once` initializes/imports the database, validates page assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files.
`python3 modulus.py run` uses `package/config/vamp.development.json` for a headless readiness check against native build assets. `vamp-zero --once` checks the packaged database and referenced page/audio assets, checks runtime writes and the server bind, then exits without opening audio or display hardware. Missing or incompatible catalogues require rebuilding or reinstalling; they are never repaired at runtime. `--check-config` only validates configuration; `--health-check` probes the running service's `/api/health` endpoint without modifying files.
Configure/build can read public packages anonymously from the khodo Conan registry.
@@ -64,7 +64,11 @@ build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256
```
The tarball contains the Zero Docker archive, Pi install scripts, generated score assets, and Pico UF2 firmware.
The tarball contains the Zero Docker archive, Pi install scripts, generated catalogue database, pages, audio, fonts, and Pico UF2 firmware. Complete score JSON is stored inside `catalogue.db`; individual JSON files remain build intermediates and are not deployed.
Run `sudo zero/install-zero.sh` from an extracted bundle on the Zero. Every installation resets all Product Vamp data and settings, including the control Wi-Fi profile, to the new bundle defaults. No previous database rows or configuration are migrated. Unrelated OS data and networks are untouched. Installing through the control Wi-Fi can disconnect your session; use a local console or an independent connection.
The installer probes the incoming image before removing existing application data. A failed replacement leaves Product Vamp stopped and reports an error; rerun the installer after fixing the cause. Normal service restarts do not perform installation cleanup. No automatic backup of the previous installation is retained.
Publishing the rolling generic package requires credentials only for upload:
+17 -10
View File
@@ -31,13 +31,13 @@ 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`, 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 and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures.
At startup the Zero validates the packaged `catalogue.db` application identifier and schema version, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. 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 and chord practice both offer key selection by choosing a pre-generated score variant. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures.
Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups and practice lessons reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Imports process one file at a time in a transaction; failed imports roll back, and unmanaged rows remain untouched. Legacy metadata is backfilled one score at a time from an indexed pending set. External score-JSON changes invalidate cached metadata; imports restore their already-validated metadata within the transaction. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
Runtime catalogues contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups and practice lessons reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database before deployment; metadata and embedded JSON are published together. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
Practice IDs retain their existing hyphen-joined format. Generation and runtime scanning reject distinct paths that produce the same exercise or variant ID, reporting both paths before replacing assets or importing rows. For example, `chord/one-two/three/c.json` and `chord/one/two-three/c.json` cannot coexist; rename one lesson or exercise to give it a distinct ID.
Practice IDs retain their existing hyphen-joined format. Generation and catalogue compilation reject distinct paths that produce the same exercise or variant ID, reporting both paths before publishing the generated bundle. For example, `chord/one-two/three/c.json` and `chord/one/two-three/c.json` cannot coexist; rename one lesson or exercise to give it a distinct ID.
`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it imports and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only.
`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it checks database integrity and validates the catalogue and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only.
Pico networking progresses without blocking touch sampling. Navigation commands use a bounded 16-entry FIFO ahead of background status polls; a full queue reports busy. Commands are not automatically retried because a timed-out request may already have executed. Background polls wait one second after completion before trying again.
@@ -47,7 +47,7 @@ Zero and Pico share one gesture state machine: a second release within 220 ms of
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 and chord practice sources are emitted in all 12 keys at build time. Generated JSON keeps notation metadata while removing generator-only optional chord markers. The catalogue produces three bundles per song and twelve per practice exercise; source counts are discovered, not fixed in code.
Practice sources live at `data/practice/<chord|melody>/<lesson_group>/<exercise>.xml`. For example, `chord/001_three_note_shell_voicings/001-cycles.xml` generates `practice/chord/001_three_note_shell_voicings/001-cycles/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Keep numeric prefixes zero-padded to the same width for menu ordering. Lesson folders determine lesson labels; MusicXML work/movement titles determine exercise labels, falling back to the filename stem when absent. Repeated titles do not merge exercises: identity includes the practice type, lesson folder, and normalized filename stem. Invalid nesting, unknown practice types, and colliding normalized filenames fail generation. Adding or renaming a source needs no registry edit; the next build discovers it, replaces obsolete generated paths, and runtime catalogue import removes obsolete managed database entries.
Practice sources live at `data/practice/<chord|melody>/<lesson_group>/<exercise>.xml`. For example, `chord/001_three_note_shell_voicings/001-cycles.xml` generates `practice/chord/001_three_note_shell_voicings/001-cycles/{c,db,d,eb,e,f,gb,g,ab,a,bb,b}.json` and corresponding page directories. Keep numeric prefixes zero-padded to the same width for menu ordering. Lesson folders determine lesson labels; MusicXML work/movement titles determine exercise labels, falling back to the filename stem when absent. Repeated titles do not merge exercises: identity includes the practice type, lesson folder, and normalized filename stem. Invalid nesting, unknown practice types, and colliding normalized filenames fail generation. Adding or renaming a source needs no registry edit; the next build discovers it, replaces obsolete generated paths, and builds a fresh database containing only the current sources.
The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML.
@@ -128,7 +128,7 @@ At runtime the shared on-demand score loader serves both display and audio. A ba
`audio.playback_enabled` defaults to false and replaces the old inert `track_enabled` setting. The old config name is accepted for migration, but conflicting values fail. Playback suppresses the metronome without changing its stored toggle. Authored tempo marks remain notation; the menu BPM is constant throughout a session. Selecting a practice variant or performance part starts a new session. Manual page changes never restart or seek. Natural completion, including trailing silence/release, returns to the corresponding key/part menu with its selection preserved. Leaving the score cancels playback; stale load/completion results cannot close a newer score. Errors stay in the score view and `/api/status` rather than being mistaken for completion. Existing bundles without `playback` still display but require regeneration to play.
`--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages/JSON; the source XML and Python dependencies remain build-only.
`--once` additionally validates referenced audio without opening display/audio hardware. Runtime packages contain WAVs alongside pages and the SQLite catalogue; the source XML and Python dependencies remain build-only.
### Engraving Boundary
@@ -140,17 +140,17 @@ The former backend-specific four-voice, duration, unequal-tone and tuplet restri
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Runtime remains engine-agnostic, so valid existing schema-v3 bundles need no database migration.
Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. The musical bundle version is separate from the catalogue database version; runtime accepts only the supported packaged catalogue schema.
Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
### Validation And Migration
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete, including when the compatibility `--clean` flag is omitted; deleted sources cannot leave stale JSON, pages, or audio. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain semantic JSON, PNG pages, generated PCM WAVs, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score.
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete, including when the compatibility `--clean` flag is omitted; deleted sources cannot leave stale JSON, pages, or audio. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain `catalogue.db` with embedded semantic JSON, PNG pages, generated PCM WAVs, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score.
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
The six compact source files were replaced, not retained as a second authoring format. Existing catalogue rows refresh from regenerated JSON at startup. Custom database rows (`catalog_managed = 0`) are retained, including ID conflicts, and are never automatically deleted to resolve a schema mismatch. Custom legacy JSON must be regenerated as a schema-v3 bundle with page assets; the loader reports a regeneration error instead of interpreting old ticks heuristically.
The six compact source files were replaced, not retained as a second authoring format. Installation replaces the entire catalogue, rather than retaining or migrating rows from older releases. Legacy data must be authored as supported MusicXML and regenerated; the runtime never upgrades old databases.
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
@@ -192,6 +192,14 @@ Building the full package requires Docker/buildx, the ARM embedded GCC toolchain
Generated score assets are created during CMake build/package and copied into the Zero image context. Static font assets remain tracked under `package/assets/fonts`.
`generator/catalogue.py` uses Python's standard-library SQLite module to construct `catalogue.db` after musical, playback, and engraving validation. The database carries application ID `0x56414D50` and catalogue schema version 1, with complete score bundles and menu metadata. Sorted insertion and the absence of wall-clock timestamps make repeated builds reproducible with the same toolchain. It is closed and integrity-checked before publication, without journal/WAL sidecars. Individual JSON files remain available in the build tree for inspection, but both CMake installation and Docker image assembly exclude them.
The default `database.path` is `/usr/share/product-vamp/catalogue.db`; development configuration points at the native generated-assets database. Stored source paths are logical, asset-relative locations, not build-host filenames. Runtime resolves them against `runtime.assets_path` and rejects traversal and escaping symlinks. The JSON file itself need not exist: its parent identifies the score's page directory. Normal startup checks catalogue compatibility; `--once` additionally runs SQLite's integrity probe and checks all page/audio files. Missing, corrupt, or incompatible catalogues fail rather than creating an empty library. `--ensure-database` is no longer a supported command.
Every run of `zero/install-zero.sh` is a clean installation. It stages configuration/scripts, verifies the manifest's Docker tag against the incoming archive's image digest, and probes that image before stopping Product Vamp. It then clears the owned `/etc/product-vamp`, `/var/lib/product-vamp`, `/run/product-vamp`, and obsolete host `/usr/share/product-vamp` trees, including old SQLite sidecars. The installed container uses assets from its image and no longer mounts a writable state database. Startup reads the verified image ID from `/etc/product-vamp/image-id`, rather than depending on a mutable Docker tag. The installer replaces its scripts and systemd units, removes their old overrides, recreates only the `product-vamp-control` NetworkManager profile, and requires a successful service health check before announcing success.
Configuration, AP credentials, and all prior catalogue data are intentionally reset to bundle defaults. There is no automatic backup or migration. Installation through the control AP can disconnect the session; use the local console or independent networking. Preflight failure leaves existing application files untouched and restores the previous image tag; replacement failure stops Product Vamp and reports the error. Rerunning the installer is the recovery path. Ordinary boots/restarts do not run cleanup. Unrelated networks, OS data, containers, and system logs remain untouched. Cleanup rejects symlink/mount targets, never follows paths from old configuration, and removes only identified obsolete Product Vamp images not used by another container.
CI builds the generic artifact with `python3 modulus.py package --version latest`, then `package/scripts/publish_latest_generic_package.py` replaces the Gitea generic package version `latest` and deletes older Product Vamp generic package versions.
## Shared Protocol
@@ -209,4 +217,3 @@ python3 modulus.py lsp
```
The `tooling.lsp` section in `modulus.json` owns the Pico CMake configure. It unsets host compiler variables before configuring the Pico SDK project so clangd sees the embedded ARM toolchain. When running directly outside the generated Modulus Docker image, set `PICO_SDK_PATH` before refreshing the Pico database.
+4
View File
@@ -0,0 +1,4 @@
if(NOT EXISTS "${SOURCE}/catalogue.db")
message(FATAL_ERROR "Missing generated catalogue: ${SOURCE}/catalogue.db")
endif()
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE)
+5 -2
View File
@@ -127,6 +127,7 @@ file(CONFIGURE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.inputs
CONTENT "@VAMP_GENERATOR_INPUT_MANIFEST@\n" @ONLY)
add_custom_command(
OUTPUT ${VAMP_GENERATED_ASSETS_STAMP}
BYPRODUCTS ${VAMP_GENERATED_ASSETS_DIR}/catalogue.db
COMMAND ${Python3_EXECUTABLE}
-m generator
--data-dir ${CMAKE_CURRENT_SOURCE_DIR}/data
@@ -148,7 +149,8 @@ install(TARGETS vamp-zero
RUNTIME DESTINATION zero/bin
)
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
PATTERN "*.json" EXCLUDE)
install(PROGRAMS
package/zero/install-zero.sh
package/zero/start-ap.sh
@@ -180,7 +182,7 @@ if(VAMP_BUILD_ZERO_IMAGE)
COMMAND ${CMAKE_COMMAND} -E make_directory ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp
COMMAND ${CMAKE_COMMAND} -E copy $<TARGET_FILE:vamp-zero> ${VAMP_ZERO_IMAGE_CONTEXT}/usr/bin/vamp-zero
COMMAND ${CMAKE_COMMAND} -E copy package/zero/Dockerfile ${VAMP_ZERO_IMAGE_CONTEXT}/Dockerfile
COMMAND ${CMAKE_COMMAND} -E copy_directory ${VAMP_GENERATED_ASSETS_DIR} ${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp
COMMAND ${CMAKE_COMMAND} -DSOURCE=${VAMP_GENERATED_ASSETS_DIR} -DDESTINATION=${VAMP_ZERO_IMAGE_CONTEXT}/usr/share/product-vamp -P cmake/runtime_assets.cmake
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 ${BASH_EXECUTABLE} package/scripts/build_zero_image.sh ${VAMP_ZERO_IMAGE_CONTEXT} ${VAMP_ZERO_IMAGE_TAR} ${VAMP_ZERO_IMAGE_TAG}
@@ -190,6 +192,7 @@ if(VAMP_BUILD_ZERO_IMAGE)
package/scripts/build_zero_image.sh
package/zero/Dockerfile
package/config/vamp.example.json
cmake/runtime_assets.cmake
${VAMP_GENERATED_ASSETS_STAMP}
VERBATIM
)
+2
View File
@@ -12,6 +12,7 @@ from generator.summary import validate_expected_performance_summary, validate_pr
from generator.musicxml import load_musicxml
from generator.engraving import engrave_tree
from generator.playback import compile_tree
from generator.catalogue import build_catalogue
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
@@ -51,6 +52,7 @@ def generate_assets(args: argparse.Namespace) -> None:
compile_tree(staged)
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma')
validate_generated_tree(staged)
build_catalogue(staged)
backup = Path(temporary) / 'previous'
if output_dir.exists():
output_dir.rename(backup)
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import argparse
import json
import sqlite3
import tempfile
from pathlib import Path
APPLICATION_ID = 0x56414D50
SCHEMA_VERSION = 1
COMMON_COLUMNS = """
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,
key_signature TEXT NOT NULL,
authored_key_signature TEXT NOT NULL,
source_path TEXT NOT NULL UNIQUE CHECK(length(source_path) > 0),
source_json TEXT NOT NULL CHECK(json_valid(source_json)),
measure_count INTEGER NOT NULL CHECK(measure_count > 0),
total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000)
"""
def human_label(value: str) -> str:
result, capitalize = '', True
for character in value:
if character in '-_':
result += ' '
capitalize = True
else:
result += character.upper() if capitalize and 'a' <= character <= 'z' else character
capitalize = False
return result or 'Score'
def metadata(path: Path, root: Path) -> dict:
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
raise ValueError('score escapes the asset root')
text = path.read_text(encoding='utf-8')
document = json.loads(text)
if document.get('schema_version') != 3:
raise ValueError('expected a schema-v3 score bundle')
music, presentation = document['music'], document['presentation']
if len(music['staves']) != 1 or (presentation['width'], presentation['height']) != (976, 484):
raise ValueError('expected one staff and 976x484 pages')
staff, meta = music['staves'][0], music['metadata']
for name in ('title', 'composer', 'key_signature', 'time_signature'):
if not isinstance(meta[name], str):
raise ValueError(f'{name} must be a string')
authored = meta.get('authored_key_signature', '')
if not isinstance(authored, str) or ('authored_key_signature' in meta and not authored):
raise ValueError('authored_key_signature must be a nonempty string')
key = meta['key_signature']
if staff.get('arrangement_part_type') == 'practice' and key == 'No key' and 'variant_tonic' in meta:
if not isinstance(meta['variant_tonic'], str):
raise ValueError('variant_tonic must be a string')
key = meta['variant_tonic'] + ' (no key)'
if not isinstance(staff['name'], str):
raise ValueError('staff name must be a string')
relative = path.relative_to(root)
return dict(id=path.stem, title=meta['title'], composer=meta['composer'], key_signature=key,
authored_key_signature=authored, source_path=relative.as_posix(), source_json=text,
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), part=staff['name'])
def populate(connection: sqlite3.Connection, root: Path) -> None:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
connection.executescript(f'''
PRAGMA application_id = {APPLICATION_ID};
PRAGMA user_version = {SCHEMA_VERSION};
CREATE TABLE songs ({COMMON_COLUMNS}, part TEXT NOT NULL) STRICT;
CREATE TABLE practice_exercises ({COMMON_COLUMNS},
exercise_id TEXT NOT NULL,
practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL) STRICT;
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
CREATE INDEX practice_menu ON practice_exercises(practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE);
''')
identities, authored_keys = {}, {}
for category, table, depth in (('songs', 'songs', 1), ('practice', 'practice_exercises', 4)):
folder = root / category
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
raise ValueError(f'{folder}: score asset directory not found')
for path in sorted((root / category).rglob('*.json')):
try:
parts = path.relative_to(root / category).parts
if len(parts) != depth:
raise ValueError(f'unexpected {category} score path')
row = metadata(path, root)
if category == 'practice':
identity = '-'.join(parts[:3])
previous = identities.setdefault(identity, path.parent)
if previous != path.parent:
raise ValueError(f'practice exercise ID collision {identity!r}: {previous} and {path.parent}')
if authored_keys.setdefault(identity, row['authored_key_signature']) != row['authored_key_signature']:
raise ValueError(f'practice variants disagree on authored key: {identity}')
row.pop('part')
row.update(id=identity + '-' + path.stem, exercise_id=identity, practice_type_id=parts[0], practice_type_title=human_label(parts[0]) + ' Practice',
lesson_group_id=parts[1], lesson_group_title=human_label(parts[1]))
connection.execute(f'INSERT INTO {table} ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
raise ValueError(f'{path}: {error}') from error
connection.commit()
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise ValueError('catalogue integrity check failed')
def build_catalogue(root: Path, destination: Path | None = None) -> Path:
if not root.is_dir():
raise ValueError(f'{root}: score asset directory not found')
destination = destination or root / 'catalogue.db'
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix='.vamp-catalogue-', dir=destination.parent) as temporary:
staged = Path(temporary) / 'catalogue.db'
connection = sqlite3.connect(staged)
try:
populate(connection, root)
finally:
connection.close()
staged.replace(destination)
return destination
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalogue.')
parser.add_argument('assets', type=Path)
parser.add_argument('destination', type=Path, nargs='?')
arguments = parser.parse_args()
build_catalogue(arguments.assets, arguments.destination)
-1
View File
@@ -17,7 +17,6 @@ struct command_line {
std::bool_t health_check{};
std::bool_t print_version{};
std::bool_t exit_after_startup{};
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>;
+1 -1
View File
@@ -26,7 +26,7 @@ struct runtime_config {
};
struct database_config {
std::string path{"/var/lib/product-vamp/vamp.db"};
std::string path{"/usr/share/product-vamp/catalogue.db"};
};
struct control_config {
+4 -5
View File
@@ -14,10 +14,9 @@
namespace vamp_zero {
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 async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>;
auto async_validate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::bool_t integrity = false) -> boost::asio::awaitable<std::expected<void, modulus::error>>;
auto async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path) -> 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, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>>;
auto async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>;
}
-7
View File
@@ -1,7 +1,6 @@
#pragma once
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
@@ -13,12 +12,6 @@
namespace vamp_zero {
auto parse_score_document(std::string_view json, std::string source_path = {}) -> std::expected<vamp_zero::score_document, modulus::error>;
auto read_score_document(std::filesystem::path const &path) -> std::expected<vamp_zero::score_document, modulus::error>;
auto score_paths(std::string const &assets_path, vamp_zero::score_category category) -> std::expected<std::vector<std::filesystem::path>, modulus::error>;
auto performance_metadata(vamp_zero::score_document const &document) -> vamp_zero::library_part;
auto practice_metadata(vamp_zero::score_document const &document, std::string const &assets_path) -> std::expected<vamp_zero::practice_exercise, modulus::error>;
auto load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error>;
auto load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error>;
auto group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state;
auto group_practice_exercises(std::vector<vamp_zero::practice_exercise> exercises) -> std::expected<vamp_zero::practice_library, modulus::error>;
+1 -1
View File
@@ -30,7 +30,7 @@ struct ui_state {
class zero_ui {
public:
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::function<void(vamp_protocol::page_command)> command_handler = {});
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {});
~zero_ui();
zero_ui(zero_ui const &) = delete;
auto operator=(zero_ui const &) -> zero_ui & = delete;
+1 -1
View File
@@ -5,7 +5,7 @@
"heartbeat_path": "build/build_x86_debug/runtime/heartbeat.json"
},
"database": {
"path": "build/build_x86_debug/runtime/vamp.db"
"path": "build/build_x86_debug/generated-assets/catalogue.db"
},
"control": {
"endpoint": {
+1 -1
View File
@@ -18,7 +18,7 @@
"heartbeat_path": "/run/product-vamp/heartbeat.json"
},
"database": {
"path": "/var/lib/product-vamp/vamp.db"
"path": "/usr/share/product-vamp/catalogue.db"
},
"control": {
"endpoint": {
+2
View File
@@ -1,5 +1,7 @@
FROM debian:trixie-slim
LABEL org.khodo.product=product-vamp-zero
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
+141 -10
View File
@@ -8,6 +8,49 @@ config_dir=/etc/product-vamp
state_dir=/var/lib/product-vamp
run_dir=/run/product-vamp
share_dir=/usr/share/product-vamp
image_tag=
connection=product-vamp-control
replacement_started=false
image_loaded=false
previous_image=
stage_dir=
cleanup() {
status=$?
trap - EXIT
if [ "$status" -ne 0 ] && [ "$replacement_started" = true ]; then
systemctl stop product-vamp-zero.service product-vamp-ap.service >/dev/null 2>&1 || true
docker rm -f product-vamp-zero >/dev/null 2>&1 || true
nmcli connection down "$connection" >/dev/null 2>&1 || true
echo "Installation failed; Product Vamp services are stopped. Rerun the installer." >&2
elif [ "$status" -ne 0 ] && [ "$image_loaded" = true ] && [ -n "$previous_image" ]; then
docker tag "$previous_image" "$image_tag" || echo "Failed to restore the previous image tag." >&2
fi
if [ -n "$stage_dir" ]; then
rm -rf -- "$stage_dir"
fi
exit "$status"
}
trap cleanup EXIT
validate_target() {
target=$1
if [ "$(realpath -m -- "$target")" != "$target" ] || [ -L "$target" ]; then
echo "Refusing unsafe installation target: $target" >&2
exit 1
fi
if [ -e "$target" ] && [ -d "$target" ]; then
if [ -n "$(find "$target" -type l -print -quit)" ]; then
echo "Refusing installation target containing symlinks: $target" >&2
exit 1
fi
mounts=$(find "$target" -xdev -type d -exec mountpoint -q {} ';' -print)
if [ -n "$mounts" ]; then
echo "Refusing mounted installation target: $target" >&2
exit 1
fi
fi
}
required_packages=(
docker.io
docker-cli
@@ -51,25 +94,113 @@ else
fi
systemctl enable --now docker NetworkManager
install -d -m 0755 "$config_dir" "$state_dir" "$run_dir" "$share_dir"
cp -R "$zero_dir/share/product-vamp/." "$share_dir/"
image_details=$(python3 - "$bundle_dir/manifest.json" "$image_tar" <<'PY'
import hashlib
import json
import re
import sys
import tarfile
with open(sys.argv[1], encoding='utf-8') as source:
tag = json.load(source)['zero']['image_tag']
if not isinstance(tag, str) or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:/-]*', tag):
raise ValueError('invalid bundle image tag')
with tarfile.open(sys.argv[2]) as archive:
with archive.extractfile('manifest.json') as source:
images = [image for image in json.load(source) if tag in (image.get('RepoTags') or [])]
if len(images) != 1:
raise ValueError('Docker archive does not contain exactly one matching bundle image')
with archive.extractfile(images[0]['Config']) as source:
digest = hashlib.file_digest(source, 'sha256').hexdigest()
print(tag)
print('sha256:' + digest)
PY
)
mapfile -t image_fields <<< "$image_details"
image_tag=${image_fields[0]}
expected_image=${image_fields[1]}
if [ ! -f "$config_dir/vamp.json" ]; then
cp "$zero_dir/config/vamp.example.json" "$config_dir/vamp.json"
fi
for target in "$config_dir" "$state_dir" "$run_dir" "$share_dir" \
/usr/local/bin/product-vamp-start-ap /usr/local/bin/product-vamp-start-zero \
/etc/systemd/system/product-vamp-ap.service.d /etc/systemd/system/product-vamp-zero.service.d \
/run/systemd/system/product-vamp-ap.service.d /run/systemd/system/product-vamp-zero.service.d \
/etc/systemd/system/product-vamp-ap.service /etc/systemd/system/product-vamp-zero.service; do
validate_target "$target"
done
stage_dir=$(mktemp -d /tmp/product-vamp-install-XXXXXX)
install -m 0644 "$zero_dir/config/vamp.example.json" "$stage_dir/vamp.json"
install -m 0755 "$zero_dir/start-ap.sh" "$stage_dir/start-ap.sh"
install -m 0755 "$zero_dir/start-zero.sh" "$stage_dir/start-zero.sh"
install -m 0644 "$zero_dir/systemd/product-vamp-ap.service" "$stage_dir/product-vamp-ap.service"
install -m 0644 "$zero_dir/systemd/product-vamp-zero.service" "$stage_dir/product-vamp-zero.service"
bash -n "$stage_dir/start-ap.sh"
bash -n "$stage_dir/start-zero.sh"
previous_image=$(docker image inspect --format '{{.Id}}' "$image_tag" 2>/dev/null || true)
previous_images=$(docker image ls --quiet --no-trunc --filter label=org.khodo.product=product-vamp-zero)
image_loaded=true
docker load -i "$image_tar"
new_image=$(docker image inspect --format '{{.Id}}' "$image_tag")
if [ "$new_image" != "$expected_image" ]; then
echo "Loaded Docker image does not match the incoming archive." >&2
exit 1
fi
# Probe the actual image and staged configuration without touching host state or hardware.
docker run --rm --network none --mount "type=bind,src=$stage_dir,dst=/install,readonly" \
"$new_image" /usr/bin/vamp-zero --config /install/vamp.json --once
install -m 0755 "$zero_dir/start-ap.sh" /usr/local/bin/product-vamp-start-ap
install -m 0755 "$zero_dir/start-zero.sh" /usr/local/bin/product-vamp-start-zero
install -m 0644 "$zero_dir/systemd/product-vamp-ap.service" /etc/systemd/system/product-vamp-ap.service
install -m 0644 "$zero_dir/systemd/product-vamp-zero.service" /etc/systemd/system/product-vamp-zero.service
echo "Resetting all Product Vamp data and settings, including control Wi-Fi credentials."
echo "A connection through the control Wi-Fi may disconnect."
replacement_started=true
for service in product-vamp-zero.service product-vamp-ap.service; do
if systemctl cat "$service" >/dev/null 2>&1; then
systemctl stop "$service"
fi
done
if docker container inspect product-vamp-zero >/dev/null 2>&1; then
docker rm -f product-vamp-zero
fi
for target in "$config_dir" "$state_dir" "$run_dir" "$share_dir" \
/etc/systemd/system/product-vamp-ap.service.d /etc/systemd/system/product-vamp-zero.service.d \
/run/systemd/system/product-vamp-ap.service.d /run/systemd/system/product-vamp-zero.service.d; do
validate_target "$target"
rm -rf --one-file-system -- "$target"
done
install -d -m 0755 "$config_dir" "$run_dir"
install -m 0644 "$stage_dir/vamp.json" "$config_dir/vamp.json"
printf '%s\n' "$new_image" > "$config_dir/image-id"
install -m 0755 "$stage_dir/start-ap.sh" /usr/local/bin/product-vamp-start-ap
install -m 0755 "$stage_dir/start-zero.sh" /usr/local/bin/product-vamp-start-zero
install -m 0644 "$stage_dir/product-vamp-ap.service" /etc/systemd/system/product-vamp-ap.service
install -m 0644 "$stage_dir/product-vamp-zero.service" /etc/systemd/system/product-vamp-zero.service
if nmcli -t -f NAME connection show | grep -Fxq "$connection"; then
nmcli connection delete "$connection"
fi
systemctl daemon-reload
systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
systemctl enable --now getty@tty2.service >/dev/null 2>&1 || true
systemctl enable --now product-vamp-ap.service
systemctl enable product-vamp-zero.service
systemctl restart product-vamp-zero.service
healthy=false
for attempt in $(seq 1 60); do
if docker exec product-vamp-zero /usr/bin/vamp-zero --config /etc/product-vamp/vamp.json --health-check; then
healthy=true
break
fi
sleep 1
done
if [ "$healthy" != true ]; then
echo "Product Vamp did not become healthy after installation." >&2
exit 1
fi
for image in $(printf '%s\n' "$previous_image" "$previous_images" | sort -u); do
if [ "$image" != "$new_image" ]; then
if [ -z "$(docker ps -aq --filter "ancestor=$image")" ]; then
docker image rm "$image" || echo "Warning: could not remove old Product Vamp image $image" >&2
else
echo "Keeping old Product Vamp image $image: another container still uses it." >&2
fi
fi
done
echo "Product Vamp Zero installed. Control AP defaults to SSID 'Vamp Control' at http://192.168.42.1:8080."
+4 -3
View File
@@ -2,9 +2,11 @@
set -euo pipefail
container_name=${PRODUCT_VAMP_ZERO_CONTAINER:-product-vamp-zero}
image_tag=${PRODUCT_VAMP_ZERO_IMAGE:-product-vamp-zero:dev}
config_dir=${PRODUCT_VAMP_CONFIG_DIR:-/etc/product-vamp}
state_dir=${PRODUCT_VAMP_STATE_DIR:-/var/lib/product-vamp}
image_tag=${PRODUCT_VAMP_ZERO_IMAGE:-}
if [ -z "$image_tag" ]; then
read -r image_tag < "$config_dir/image-id"
fi
run_dir=${PRODUCT_VAMP_RUN_DIR:-/run/product-vamp}
display_tty=${PRODUCT_VAMP_DISPLAY_TTY:-/dev/tty1}
@@ -36,7 +38,6 @@ echo "Starting Product Vamp Zero container '$container_name' from '$image_tag' o
exec docker run --rm --name "$container_name" --network host --privileged \
--env PRODUCT_VAMP_DISPLAY_TTY="$display_tty" \
-v "$config_dir:/etc/product-vamp:ro" \
-v "$state_dir:/var/lib/product-vamp" \
-v "$run_dir:/run/product-vamp" \
"$image_tag" \
sh -lc 'display_tty=${PRODUCT_VAMP_DISPLAY_TTY:-/dev/tty1}; exec setsid --wait --ctty /usr/bin/vamp-zero --config /etc/product-vamp/vamp.json < "$display_tty"'
+10 -27
View File
@@ -50,7 +50,7 @@ auto invalid(std::string message) -> modulus::error {
}
auto usage() -> std::string {
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--ensure-database]";
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once]";
}
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
@@ -134,9 +134,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 ensure_database(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
auto validate_database(vamp_zero::config const &config, std::bool_t integrity) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), config.database), boost::asio::use_future);
auto future = boost::asio::co_spawn(io, vamp_zero::async_validate_database(io.get_executor(), config.database, integrity), boost::asio::use_future);
io.run();
return future.get();
}
@@ -152,17 +152,10 @@ auto make_server_endpoint(vamp_zero::config const &config) -> std::expected<ligo
return endpoint;
}
auto import_library(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_import_catalogs(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
io.run();
return future.get();
}
auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_zero::app_state, modulus::error> {
auto io = boost::asio::io_context{};
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
io.run();
auto performance = performance_future.get();
if (!performance) {
@@ -194,7 +187,7 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
}
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference));
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference), config.runtime.assets_path);
if (!score) {
co_return std::unexpected(score.error());
}
@@ -348,7 +341,7 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
if (audio != nullptr) {
audio->sync(state.snapshot());
}
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); });
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); });
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
auto heartbeat = write_heartbeat(config);
if (!heartbeat) {
@@ -371,7 +364,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
if (!endpoint) {
return std::unexpected(endpoint.error());
}
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, [&state](vamp_protocol::page_command command) {
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) {
static_cast<void>(state.apply(command));
}};
state.set_observer([&ui, &config](vamp_zero::app_state app) {
@@ -468,8 +461,6 @@ 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 == "--ensure-database") {
result.ensure_database = true;
} else if (argument == "--help" || argument == "-h") {
return std::unexpected(invalid(usage()));
} else {
@@ -479,8 +470,7 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std
auto command_count = static_cast<std::uint32_t>(result.check_config ? 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.exit_after_startup ? 1 : 0) +
static_cast<std::uint32_t>(result.ensure_database ? 1 : 0);
static_cast<std::uint32_t>(result.exit_after_startup ? 1 : 0);
if (command_count > 1U) {
return std::unexpected(invalid("only one command flag may be selected"));
}
@@ -509,17 +499,10 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modu
std::cout << "ok\n";
return {};
}
if (command.ensure_database) {
return ensure_database(*config);
}
auto ensured = ensure_database(*config);
auto ensured = validate_database(*config, command.exit_after_startup || config->runtime.exit_after_startup);
if (!ensured) {
return ensured;
}
auto imported = import_library(*config);
if (!imported) {
return imported;
}
auto initial = load_initial_state(*config);
if (!initial) {
return std::unexpected(initial.error());
+63 -525
View File
@@ -1,10 +1,10 @@
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <string>
#include <string_view>
#include <map>
#include <utility>
#include <variant>
#include <vector>
@@ -25,16 +25,13 @@
namespace {
constexpr auto application_id = std::int64_t{0x56414D50};
constexpr auto catalogue_schema_version = std::int64_t{1};
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 = noto::file_connection_config(config.path, "product-vamp");
result.mode = noto::open_mode::READ_ONLY;
result.busy_timeout = std::chrono::seconds{5};
result.foreign_keys = true;
result.pragmas = {
{.name = "synchronous", .value = std::string{"NORMAL"}},
{.name = "temp_store", .value = std::string{"MEMORY"}},
};
result.pragmas = {{.name = "query_only", .value = true}};
return result;
}
@@ -61,243 +58,30 @@ auto close_catalogue_query(noto::connection &connection, noto::statement &statem
co_return status;
}
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",
auto resolve_source_path(std::string const &assets_path, std::string const &source) -> std::expected<std::string, modulus::error> {
auto fail = [] {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "unsafe catalogue asset path; rebuild or reinstall Product Vamp"));
};
for (auto &statement : schema_statements) {
if (!ensured) {
break;
}
ensured = co_await execute_schema_statement(connection, std::move(statement));
auto relative = std::filesystem::path{source};
if (source.empty() || source.find('\0') != std::string::npos || source.find('\\') != std::string::npos ||
relative.is_absolute() || relative.extension() != ".json" ||
std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part.empty() || part == "." || part == ".."; })) {
return fail();
}
if (ensured) {
ensured =
co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''");
auto error = std::error_code{};
auto root = std::filesystem::weakly_canonical(assets_path, error);
if (error) {
return fail();
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "source_path", "ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT ''");
auto path = std::filesystem::weakly_canonical(root / relative, error);
if (error) {
return fail();
}
if (ensured) {
ensured = co_await ensure_column(connection, "songs", "key_signature", "ALTER TABLE songs ADD COLUMN key_signature TEXT NOT NULL DEFAULT ''");
auto resolved = path.lexically_relative(root);
if (resolved.empty() || *resolved.begin() == "..") {
return fail();
}
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 table : {"songs", "practice_exercises"}) {
for (auto column : {"measure_count", "total_pages", "authored_key_signature"}) {
if (!ensured) {
break;
}
auto type = std::string_view{column} == "authored_key_signature" ? " TEXT" : " INTEGER CHECK(" + std::string{column} + " > 0)";
ensured = co_await ensure_column(connection, table, column, "ALTER TABLE " + std::string{table} + " ADD COLUMN " + column + type);
}
if (ensured) {
ensured = co_await execute_schema_statement(connection, "CREATE TRIGGER IF NOT EXISTS invalidate_" + std::string{table} + "_metadata AFTER UPDATE OF source_json ON " + table + " WHEN NEW.source_json != OLD.source_json BEGIN UPDATE " + table + " SET measure_count = NULL, total_pages = NULL, authored_key_signature = NULL WHERE id = NEW.id; END");
}
index_statements.push_back("CREATE INDEX IF NOT EXISTS idx_" + std::string{table} + "_pending_metadata ON " + table + "(id) WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL");
}
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) {
parts.push_back(part.string());
}
return parts;
}
auto logical_practice_exercise_id(std::string const &type_id, std::string const &lesson_id, std::string const &source_path, std::string const &fallback) -> std::string {
auto parts = path_parts(std::filesystem::path{source_path});
for (auto index = std::size_t{}; index + 3U < parts.size(); ++index) {
if (parts[index] == "practice" && parts[index + 1U] == type_id && parts[index + 2U] == lesson_id) {
return type_id + "-" + lesson_id + "-" + parts[index + 3U];
}
}
auto stem = std::filesystem::path{source_path}.parent_path().filename().string();
if (stem.empty()) {
stem = fallback;
}
return type_id + "-" + lesson_id + "-" + stem;
}
auto now_ms() -> std::int64_t {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
.count();
}
auto backfill_metadata(boost::asio::any_io_executor executor, noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (;;) {
// Bound immediate coroutine completion chains even for very large catalogues.
co_await boost::asio::post(executor, boost::asio::use_awaitable);
auto row = co_await connection.async_execute("SELECT id, source_path, source_json FROM " + table + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
if (!row) {
co_return std::unexpected(row.error());
}
if (row->rows.empty()) {
co_return std::expected<void, modulus::error>{};
}
auto const &values = row->rows.front();
auto document = vamp_zero::parse_score_document(text_value(values[2]), text_value(values[1]));
if (!document) {
co_return std::unexpected(document.error());
}
auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{static_cast<std::int64_t>(document->measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(document->pages.size())},
noto::sqlite_value{document->authored_key_signature},
noto::sqlite_value{text_value(values[0])},
noto::sqlite_value{text_value(values[2])},
};
auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND source_json = ?5", std::move(params));
if (!updated) {
co_return std::unexpected(updated.error());
}
}
}
auto restore_imported_metadata(noto::connection &connection, std::string const &table, std::string const &id, vamp_zero::score_document const &document) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{static_cast<std::int64_t>(document.measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(document.pages.size())},
noto::sqlite_value{document.authored_key_signature},
noto::sqlite_value{id},
};
auto updated = co_await connection.async_execute("UPDATE " + table + " SET measure_count = ?1, total_pages = ?2, authored_key_signature = ?3 WHERE id = ?4 AND catalog_managed = 1 AND (measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL)", std::move(params));
if (!updated) {
co_return std::unexpected(updated.error());
}
co_return std::expected<void, modulus::error>{};
return path.string();
}
auto count_value(noto::sqlite_value const &value) -> std::size_t {
@@ -305,296 +89,44 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
}
auto execute_delete_stale_catalog_rows(noto::connection &connection, std::string_view table, std::vector<std::string> const &ids) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto sql = std::string{"DELETE FROM "};
sql += table;
sql += " WHERE catalog_managed = 1";
auto params = std::vector<noto::sqlite_value>{};
params.reserve(ids.size());
if (!ids.empty()) {
sql += " AND id NOT IN (";
for (auto index = std::size_t{}; index < ids.size(); ++index) {
if (index != 0U) {
sql += ", ";
}
sql += "?";
sql += std::to_string(index + 1U);
params.emplace_back(ids[index]);
}
sql += ")";
}
auto deleted = co_await connection.async_execute(std::move(sql), std::move(params));
if (!deleted) {
co_return std::unexpected(deleted.error());
}
co_return std::expected<void, modulus::error>{};
}
}
auto vamp_zero::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();
if (!parent.empty()) {
auto error = std::error_code{};
std::filesystem::create_directories(parent, error);
if (error) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to create database directory: " + error.message()));
}
}
auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::bool_t integrity) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto error = std::error_code{};
if (!std::filesystem::is_regular_file(config.path, error) || error) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "catalogue is missing or unreadable: " + config.path + "; rebuild or reinstall Product Vamp"));
}
auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalogue: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe()));
}
if (config.path != ":memory:") {
auto journal = co_await connection.async_journal_mode("WAL");
if (!journal || *journal != "wal") {
auto close = co_await connection.async_close();
if (!close) {
modulus::log_error("database close failed after WAL error: " + close.error().describe());
}
co_return std::unexpected(journal ? vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "SQLite did not enter WAL mode") : journal.error());
auto application = co_await connection.async_application_id();
auto version = co_await connection.async_user_version();
auto status = std::expected<void, modulus::error>{};
if (!application || !version || *application != application_id || *version != catalogue_schema_version) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid or incompatible catalogue: " + config.path + "; rebuild or reinstall Product Vamp"));
}
if (status && integrity) {
auto checked = co_await connection.async_execute("PRAGMA quick_check(1)");
if (!checked || checked->rows.size() != 1U || checked->rows[0].size() != 1U || text_value(checked->rows[0][0]) != "ok") {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "corrupt catalogue: " + config.path + "; rebuild or reinstall Product Vamp"));
}
}
auto current_application_id = co_await connection.async_application_id();
if (!current_application_id) {
co_return std::unexpected(current_application_id.error());
auto closed = co_await connection.async_close();
if (!status) {
co_return std::unexpected(status.error());
}
if (*current_application_id != 0 && *current_application_id != application_id) {
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "database application id does not belong to Product Vamp"));
}
if (*current_application_id == 0) {
auto set = co_await connection.async_set_application_id(application_id);
if (!set) {
co_return std::unexpected(set.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());
}
co_return std::unexpected(ensured.error());
}
co_return co_await connection.async_close();
co_return closed;
}
auto vamp_zero::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 performance = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
if (!performance) {
co_return std::unexpected(performance.error());
}
auto practice = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
if (!practice) {
co_return std::unexpected(practice.error());
}
auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto begin = co_await connection.async_execute("BEGIN IMMEDIATE");
if (!begin) {
co_return std::unexpected(begin.error());
}
auto imported = std::expected<void, modulus::error>{};
auto timestamp = now_ms();
auto song_ids = std::vector<std::string>{};
song_ids.reserve(performance->size());
for (auto const &path : *performance) {
if (!imported) {
break;
}
co_await boost::asio::post(executor, boost::asio::use_awaitable);
auto document = vamp_zero::read_score_document(path);
if (!document) {
imported = std::unexpected(document.error());
break;
}
auto const &part = *document;
song_ids.push_back(part.id);
auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{part.id},
noto::sqlite_value{part.title},
noto::sqlite_value{part.composer},
noto::sqlite_value{part.source_json},
noto::sqlite_value{timestamp},
noto::sqlite_value{timestamp},
noto::sqlite_value{part.part},
noto::sqlite_value{part.source_path},
noto::sqlite_value{part.key_signature},
noto::sqlite_value{static_cast<std::int64_t>(part.measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(part.pages.size())},
noto::sqlite_value{part.authored_key_signature},
};
auto inserted = co_await connection.async_execute(
R"sql(
INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed, measure_count, total_pages, authored_key_signature)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1, ?10, ?11, ?12)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
composer = excluded.composer,
source_json = excluded.source_json,
part = excluded.part,
source_path = excluded.source_path,
key_signature = excluded.key_signature,
catalog_managed = 1,
measure_count = excluded.measure_count,
total_pages = excluded.total_pages,
authored_key_signature = excluded.authored_key_signature,
updated_at_ms = CASE
WHEN songs.title != excluded.title
OR songs.composer != excluded.composer
OR songs.source_json != excluded.source_json
OR songs.part != excluded.part
OR songs.source_path != excluded.source_path
OR songs.key_signature != excluded.key_signature
THEN excluded.updated_at_ms
ELSE songs.updated_at_ms
END
WHERE songs.catalog_managed = 1
)sql",
std::move(params));
if (!inserted) {
imported = std::unexpected(inserted.error());
} else {
imported = co_await restore_imported_metadata(connection, "songs", part.id, *document);
}
}
auto practice_ids = std::vector<std::string>{};
auto authored_keys = std::map<std::string, std::string>{};
for (auto const &path : *practice) {
if (!imported) {
break;
}
co_await boost::asio::post(executor, boost::asio::use_awaitable);
auto document = vamp_zero::read_score_document(path);
if (!document) {
imported = std::unexpected(document.error());
break;
}
auto metadata = vamp_zero::practice_metadata(*document, assets_path);
if (!metadata) {
imported = std::unexpected(metadata.error());
break;
}
auto const &exercise = *metadata;
auto [origin, added] = authored_keys.emplace(exercise.id, exercise.authored_key_signature);
if (!added && origin->second != exercise.authored_key_signature) {
imported = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice variants disagree on authored key: " + exercise.id));
break;
}
for (auto const &variant : exercise.variants) {
practice_ids.push_back(variant.id);
auto params = std::vector<noto::sqlite_value>{
noto::sqlite_value{variant.id},
noto::sqlite_value{exercise.practice_type_id},
noto::sqlite_value{exercise.practice_type_title},
noto::sqlite_value{exercise.lesson_group_id},
noto::sqlite_value{exercise.lesson_group_title},
noto::sqlite_value{exercise.title},
noto::sqlite_value{exercise.composer},
noto::sqlite_value{variant.key_signature},
noto::sqlite_value{document->source_json},
noto::sqlite_value{variant.source_path},
noto::sqlite_value{timestamp},
noto::sqlite_value{timestamp},
noto::sqlite_value{static_cast<std::int64_t>(variant.measure_count)},
noto::sqlite_value{static_cast<std::int64_t>(variant.total_pages)},
noto::sqlite_value{exercise.authored_key_signature},
};
auto inserted = co_await connection.async_execute(
R"sql(
INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature)
VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, ?13, ?14, ?15)
ON CONFLICT(id) DO UPDATE SET
practice_type_id = excluded.practice_type_id,
practice_type_title = excluded.practice_type_title,
lesson_group_id = excluded.lesson_group_id,
lesson_group_title = excluded.lesson_group_title,
title = excluded.title,
composer = excluded.composer,
key_signature = excluded.key_signature,
source_json = excluded.source_json,
source_path = excluded.source_path,
catalog_managed = 1,
measure_count = excluded.measure_count,
total_pages = excluded.total_pages,
authored_key_signature = excluded.authored_key_signature,
updated_at_ms = CASE
WHEN practice_exercises.practice_type_id != excluded.practice_type_id
OR practice_exercises.practice_type_title != excluded.practice_type_title
OR practice_exercises.lesson_group_id != excluded.lesson_group_id
OR practice_exercises.lesson_group_title != excluded.lesson_group_title
OR practice_exercises.title != excluded.title
OR practice_exercises.composer != excluded.composer
OR practice_exercises.key_signature != excluded.key_signature
OR practice_exercises.source_json != excluded.source_json
OR practice_exercises.source_path != excluded.source_path
THEN excluded.updated_at_ms
ELSE practice_exercises.updated_at_ms
END
WHERE practice_exercises.catalog_managed = 1
)sql",
std::move(params));
if (!inserted) {
imported = std::unexpected(inserted.error());
break;
}
imported = co_await restore_imported_metadata(connection, "practice_exercises", variant.id, *document);
if (!imported) {
break;
}
}
}
if (imported) {
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "songs", song_ids);
if (!deleted) {
imported = std::unexpected(deleted.error());
}
}
if (imported) {
auto deleted = co_await execute_delete_stale_catalog_rows(connection, "practice_exercises", practice_ids);
if (!deleted) {
imported = std::unexpected(deleted.error());
}
}
if (imported) {
auto committed = co_await connection.async_execute("COMMIT");
if (!committed) {
imported = std::unexpected(committed.error());
}
} else {
static_cast<void>(co_await connection.async_execute("ROLLBACK"));
}
if (!imported) {
auto close = co_await connection.async_close();
if (!close) {
modulus::log_error("database close failed after import error: " + close.error().describe());
}
co_return std::unexpected(imported.error());
}
co_return co_await connection.async_close();
}
auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config)
auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path)
-> boost::asio::awaitable<std::expected<vamp_zero::library_state, modulus::error>> {
auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto filled = co_await backfill_metadata(executor, connection, "songs");
if (!filled) {
static_cast<void>(co_await connection.async_close());
co_return std::unexpected(filled.error());
}
auto selected =
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM "
"songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE");
@@ -621,13 +153,18 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
break;
}
auto source = resolve_source_path(assets_path, text_value(row[4]));
if (!source) {
status = std::unexpected(source.error());
break;
}
parts.push_back({
.id = text_value(row[0]),
.title = text_value(row[1]),
.composer = text_value(row[2]),
.key_signature = text_value(row[6]),
.part = text_value(row[3]),
.source_path = text_value(row[4]),
.source_path = std::move(*source),
.measure_count = count_value(row[5]),
.total_pages = count_value(row[7]),
});
@@ -639,20 +176,15 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
co_return vamp_zero::group_library_parts(std::move(parts));
}
auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config)
auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets_path)
-> boost::asio::awaitable<std::expected<vamp_zero::practice_library, modulus::error>> {
auto connection = noto::connection{executor, connection_config(config)};
auto connected = co_await connection.async_connect();
if (!connected) {
co_return std::unexpected(connected.error());
}
auto filled = co_await backfill_metadata(executor, connection, "practice_exercises");
if (!filled) {
static_cast<void>(co_await connection.async_close());
co_return std::unexpected(filled.error());
}
auto selected = co_await connection.async_prepare("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, "
"key_signature, source_path, measure_count, total_pages, authored_key_signature FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
"key_signature, source_path, measure_count, total_pages, authored_key_signature, exercise_id FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, "
"lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE");
if (!selected) {
auto close = co_await connection.async_close();
@@ -673,13 +205,18 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
break;
}
auto const &row = **next;
if (row.size() != 12U || count_value(row[9]) == 0U || count_value(row[10]) == 0U) {
if (row.size() != 13U || count_value(row[9]) == 0U || count_value(row[10]) == 0U || text_value(row[12]).empty()) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata"));
break;
}
auto type_id = text_value(row[1]);
auto lesson_id = text_value(row[3]);
auto source_path = text_value(row[8]);
auto source = resolve_source_path(assets_path, text_value(row[8]));
if (!source) {
status = std::unexpected(source.error());
break;
}
auto source_path = std::move(*source);
auto key_signature = text_value(row[7]).empty() ? std::string{"C"} : text_value(row[7]);
auto variant = vamp_zero::practice_exercise::score_variant{
.id = text_value(row[0]),
@@ -689,7 +226,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
.total_pages = count_value(row[10]),
};
exercises.push_back({
.id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])),
.id = text_value(row[12]),
.practice_type_id = std::move(type_id),
.practice_type_title = text_value(row[2]),
.lesson_group_id = std::move(lesson_id),
@@ -707,7 +244,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
co_return vamp_zero::group_practice_exercises(std::move(exercises));
}
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
// Unwind inline database completions between sequential loads of many scores.
co_await boost::asio::post(executor, boost::asio::use_awaitable);
auto connection = noto::connection{executor, connection_config(config)};
@@ -731,7 +268,8 @@ auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zer
if (!row) {
result = std::unexpected(row.error());
} else if (*row) {
result = vamp_zero::parse_score_document(text_value((**row)[0]), text_value((**row)[1]));
auto source = resolve_source_path(assets_path, text_value((**row)[1]));
result = source ? vamp_zero::parse_score_document(text_value((**row)[0]), std::move(*source)) : std::expected<vamp_zero::score_document, modulus::error>{std::unexpected(source.error())};
if (result) {
result->id = reference.id;
}
-168
View File
@@ -1,12 +1,8 @@
#include <algorithm>
#include <expected>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <map>
#include <string>
#include <string_view>
#include <system_error>
#include <tuple>
#include <utility>
#include <vector>
@@ -23,22 +19,6 @@ auto invalid(std::string message) -> modulus::error {
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message));
}
auto human_label(std::string value) -> std::string {
auto capitalize_next = std::bool_t{true};
for (auto &ch : value) {
if (ch == '-' || ch == '_') {
ch = ' ';
capitalize_next = true;
continue;
}
if (capitalize_next && ch >= 'a' && ch <= 'z') {
ch = static_cast<std::char_t>(ch - ('a' - 'A'));
}
capitalize_next = false;
}
return value.empty() ? "Score" : value;
}
auto key_rank(std::string const &key) -> std::size_t {
auto const &keys = vamp_zero::practice_key_names();
auto tonic = key.substr(0, key.find(' '));
@@ -51,118 +31,6 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p
return vamp_zero::parse_notation_document(json, std::move(source_path));
}
auto vamp_zero::read_score_document(std::filesystem::path const &path) -> std::expected<vamp_zero::score_document, 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 score JSON: " + path.string()));
}
auto text = std::string{std::istreambuf_iterator<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
if (input.bad()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to read score JSON: " + path.string()));
}
return vamp_zero::parse_score_document(text, path.string());
}
auto vamp_zero::score_paths(std::string const &assets_path, vamp_zero::score_category category) -> std::expected<std::vector<std::filesystem::path>, modulus::error> {
auto practice = category == vamp_zero::score_category::PRACTICE;
auto root = std::filesystem::path{assets_path} / (practice ? "practice" : "songs");
auto error = std::error_code{};
auto exists = std::filesystem::exists(root, error);
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, root.string() + ": " + error.message()));
}
if (!exists) {
if (practice) {
return std::vector<std::filesystem::path>{};
}
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + root.string()));
}
auto paths = std::vector<std::filesystem::path>{};
auto entries = std::filesystem::recursive_directory_iterator{root, error};
auto end = std::filesystem::recursive_directory_iterator{};
while (!error && entries != end) {
auto const &entry = *entries;
if (entry.is_regular_file(error) && entry.path().extension() == ".json") {
auto relative = entry.path().lexically_relative(root);
if (std::distance(relative.begin(), relative.end()) != (practice ? 4 : 1)) {
return std::unexpected(invalid("unexpected score asset path: " + entry.path().string()));
}
paths.push_back(entry.path());
}
if (!error) {
entries.increment(error);
}
}
if (error) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message()));
}
std::sort(paths.begin(), paths.end());
if (practice) {
auto exercises = std::map<std::string, std::filesystem::path>{};
auto variants = std::map<std::string, std::filesystem::path>{};
for (auto const &path : paths) {
auto relative = path.lexically_relative(root);
auto exercise_path = relative.parent_path();
auto id = std::string{};
for (auto const &part : exercise_path) {
id += (id.empty() ? "" : "-") + part.string();
}
auto [exercise, added] = exercises.emplace(id, relative);
if (!added && exercise->second.parent_path() != exercise_path) {
return std::unexpected(invalid("practice exercise ID collision '" + id + "': " + (root / exercise->second).string() + " and " + path.string()));
}
auto variant_id = id + "-" + path.stem().string();
auto [variant, unique] = variants.emplace(variant_id, relative);
if (!unique) {
return std::unexpected(invalid("practice variant ID collision '" + variant_id + "': " + (root / variant->second).string() + " and " + path.string()));
}
}
}
return paths;
}
auto vamp_zero::performance_metadata(vamp_zero::score_document const &document) -> vamp_zero::library_part {
return {
.id = document.id,
.title = document.title,
.composer = document.composer,
.key_signature = document.key_signature,
.part = document.part,
.source_path = document.source_path,
.measure_count = document.measure_count,
.total_pages = vamp_zero::score_page_count(document),
};
}
auto vamp_zero::practice_metadata(vamp_zero::score_document const &document, std::string const &assets_path) -> std::expected<vamp_zero::practice_exercise, modulus::error> {
auto relative = std::filesystem::path{document.source_path}.lexically_relative(std::filesystem::path{assets_path} / "practice");
auto parts = std::vector<std::string>{};
for (auto const &part : relative) {
parts.push_back(part.string());
}
if (parts.size() != 4U) {
return std::unexpected(invalid("practice JSON must be stored as practice/<type>/<lesson_group>/<exercise>/<key>.json: " + document.source_path));
}
auto id = parts[0] + "-" + parts[1] + "-" + parts[2];
return vamp_zero::practice_exercise{
.id = id,
.practice_type_id = parts[0],
.practice_type_title = human_label(parts[0]) + " Practice",
.lesson_group_id = parts[1],
.lesson_group_title = human_label(parts[1]),
.title = document.title,
.composer = document.composer,
.authored_key_signature = document.authored_key_signature,
.variants = {{
.id = id + "-" + std::filesystem::path{parts[3]}.stem().string(),
.key_signature = document.key_signature,
.source_path = document.source_path,
.measure_count = document.measure_count,
.total_pages = vamp_zero::score_page_count(document),
}},
};
}
auto vamp_zero::group_library_parts(std::vector<vamp_zero::library_part> parts) -> vamp_zero::library_state {
std::sort(parts.begin(), parts.end(), [](auto const &left, auto const &right) {
return std::tie(left.title, left.composer, left.part, left.id) < std::tie(right.title, right.composer, right.part, right.id);
@@ -210,39 +78,3 @@ auto vamp_zero::group_practice_exercises(std::vector<vamp_zero::practice_exercis
}
return vamp_zero::practice_library{.exercises = std::move(merged), .types = std::move(types)};
}
auto vamp_zero::load_performance_library(std::string const &assets_path) -> std::expected<vamp_zero::library_state, modulus::error> {
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PERFORMANCE);
if (!paths) {
return std::unexpected(paths.error());
}
auto parts = std::vector<vamp_zero::library_part>{};
for (auto const &path : *paths) {
auto document = vamp_zero::read_score_document(path);
if (!document) {
return std::unexpected(document.error());
}
parts.push_back(vamp_zero::performance_metadata(*document));
}
return vamp_zero::group_library_parts(std::move(parts));
}
auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::expected<vamp_zero::practice_library, modulus::error> {
auto paths = vamp_zero::score_paths(assets_path, vamp_zero::score_category::PRACTICE);
if (!paths) {
return std::unexpected(paths.error());
}
auto exercises = std::vector<vamp_zero::practice_exercise>{};
for (auto const &path : *paths) {
auto document = vamp_zero::read_score_document(path);
if (!document) {
return std::unexpected(document.error());
}
auto exercise = vamp_zero::practice_metadata(*document, assets_path);
if (!exercise) {
return std::unexpected(exercise.error());
}
exercises.push_back(std::move(*exercise));
}
return vamp_zero::group_practice_exercises(std::move(exercises));
}
+2 -2
View File
@@ -557,10 +557,10 @@ private:
}
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::function<void(vamp_protocol::page_command)> command_handler)
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler)
: application_{std::make_shared<luma::application>(std::move(config))},
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler))},
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); }, [weak = std::weak_ptr{application_}] {
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
if (auto application = weak.lock()) {
static_cast<void>(application->request_layout());
} })} {}
+1
View File
@@ -23,6 +23,7 @@ target_compile_definitions(product-vamp-test PRIVATE
VAMP_ZERO_HOST="192.168.42.1"
VAMP_ZERO_PORT=8080
VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}"
VAMP_TEST_PYTHON="${Python3_EXECUTABLE}"
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
VAMP_TEST_PLAYBACK_ASSETS_DIR="${CMAKE_CURRENT_BINARY_DIR}/playback-assets"
)
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <cerrno>
#include <filesystem>
#include <sys/wait.h>
#include <unistd.h>
#include <boost/test/unit_test.hpp>
#include <modulus/types.hpp>
namespace vamp_test {
inline auto build_catalogue(std::filesystem::path const &assets, std::filesystem::path const &database) -> std::bool_t {
auto source = assets.string();
auto destination = database.string();
auto child = ::fork();
BOOST_REQUIRE(child >= 0);
if (child == 0) {
::execl(VAMP_TEST_PYTHON, VAMP_TEST_PYTHON, VAMP_TEST_SOURCE_DIR "/generator/catalogue.py", source.c_str(), destination.c_str(), nullptr);
::_exit(127);
}
auto status = std::int32_t{};
auto waited = ::waitpid(child, &status, 0);
while (waited < 0 && errno == EINTR) {
waited = ::waitpid(child, &status, 0);
}
BOOST_REQUIRE(waited == child);
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
}
+26 -224
View File
@@ -41,6 +41,8 @@
#include <vamp_zero/score.hpp>
#include <vamp_zero/types.hpp>
#include "catalogue_fixture.hpp"
namespace {
auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config {
@@ -49,20 +51,6 @@ auto test_database_config(std::filesystem::path const &path) -> vamp_zero::datab
};
}
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_ensure_database(io.get_executor(), std::move(config)), boost::asio::use_future);
io.run();
return future.get();
}
auto run_import(vamp_zero::database_config config, std::filesystem::path const &assets_path) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future);
io.run();
return future.get();
}
auto query_database(std::filesystem::path const &path,
std::string sql,
std::vector<noto::sqlite_value> params = {}) -> std::expected<noto::query_result, modulus::error> {
@@ -98,11 +86,6 @@ auto text_value(noto::sqlite_value const &value) -> std::string {
return text == nullptr ? std::string{} : *text;
}
auto integer_value(noto::sqlite_value const &value) -> std::int64_t {
auto integer = std::get_if<std::int64_t>(&value.value);
return integer == nullptr ? 0 : *integer;
}
auto 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;
@@ -368,11 +351,10 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
}
}
BOOST_REQUIRE(expected_exercises > 0U);
BOOST_REQUIRE(run_ensure_database(config));
for (auto iteration = std::uint32_t{}; iteration < 2U; ++iteration) {
BOOST_REQUIRE(run_import(config, VAMP_TEST_ASSETS_DIR));
config.path = (std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "catalogue.db").string();
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
io.run();
auto library = future.get();
BOOST_REQUIRE_MESSAGE(library.has_value(), (library ? "" : library.error().describe()));
@@ -386,6 +368,21 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_practice_keys) {
BOOST_TEST(key.substr(0, key.find(' ')) == keys[index]);
BOOST_TEST(exercise.variants[index].total_pages > 0U);
BOOST_TEST(exercise.variants[index].measure_count > 0U);
if (iteration == 0U) {
auto const &variant = exercise.variants[index];
io.restart();
auto loaded = boost::asio::co_spawn(io, vamp_zero::async_load_score(io.get_executor(), config, {vamp_zero::score_category::PRACTICE, variant.id}, VAMP_TEST_ASSETS_DIR), boost::asio::use_future);
io.run();
auto score = loaded.get();
BOOST_REQUIRE_MESSAGE(score, (score ? "" : score.error().describe()));
BOOST_TEST(score->title == exercise.title);
BOOST_TEST(score->composer == exercise.composer);
BOOST_TEST(score->key_signature == variant.key_signature);
BOOST_TEST(score->authored_key_signature == exercise.authored_key_signature);
BOOST_TEST(score->measure_count == variant.measure_count);
BOOST_TEST(score->pages.size() == variant.total_pages);
BOOST_TEST(score->source_path == variant.source_path);
}
}
}
}
@@ -400,7 +397,11 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
for (auto name : {"chord/001_lesson/010-cycles", "chord/001_lesson/002-cycles", "chord/002_lesson/001-cycles", "melody/001_lesson/127-cycles"}) {
write_text(assets / "practice" / name / "c.json", score_asset_json("Shared Title"));
}
auto library = vamp_zero::load_practice_library(assets.string());
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config, assets.string()), boost::asio::use_future);
io.run();
auto library = future.get();
BOOST_REQUIRE(library);
BOOST_REQUIRE_EQUAL(library->exercises.size(), 4U);
BOOST_REQUIRE_EQUAL(library->types.size(), 2U);
@@ -414,11 +415,10 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
for (auto const &exercise : library->exercises) {
BOOST_TEST(exercise.title == "Shared Title");
}
BOOST_REQUIRE(run_ensure_database(config));
BOOST_REQUIRE(run_import(config, assets));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
std::filesystem::rename(assets / "practice/chord/001_lesson", assets / "practice/chord/003_renamed_lesson");
std::filesystem::rename(assets / "practice/chord/003_renamed_lesson/002-cycles", assets / "practice/chord/003_renamed_lesson/099-renamed");
BOOST_REQUIRE(run_import(config, assets));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.path));
auto rows = query_database(root / "vamp.db", "SELECT id FROM practice_exercises ORDER BY id");
BOOST_REQUIRE(rows);
BOOST_REQUIRE_EQUAL(rows->rows.size(), 4U);
@@ -431,39 +431,6 @@ BOOST_AUTO_TEST_CASE(numbered_practice_paths_keep_duplicate_titles_separate_and_
std::filesystem::remove_all(root);
}
BOOST_AUTO_TEST_CASE(catalogue_queries_handle_empty_results_and_invalid_stored_scores) {
auto root = std::filesystem::temp_directory_path() / ("product-vamp-query-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
auto config = test_database_config(root / "vamp.db");
BOOST_REQUIRE(run_ensure_database(config));
for (auto invalid_rows : {false, true}) {
if (invalid_rows) {
for (auto table : {"songs", "practice_exercises"}) {
BOOST_REQUIRE(query_database(root / "vamp.db", std::string{"INSERT INTO "} + table + "(id, title, source_json, created_at_ms, updated_at_ms) VALUES('invalid', 'Invalid', '{}', 0, 0)"));
}
}
auto io = boost::asio::io_context{};
auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config), boost::asio::use_future);
auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config), boost::asio::use_future);
io.run();
auto performance = performance_future.get();
auto practice = practice_future.get();
if (invalid_rows) {
BOOST_REQUIRE(!performance);
BOOST_REQUIRE(!practice);
auto invalid_score = vamp_zero::parse_score_document("{}", "");
BOOST_REQUIRE(!invalid_score);
BOOST_TEST(performance.error().message() == invalid_score.error().message());
BOOST_TEST(practice.error().message() == invalid_score.error().message());
} else {
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_TEST(performance->parts.empty());
BOOST_TEST(practice->exercises.empty());
}
}
std::filesystem::remove_all(root);
}
BOOST_AUTO_TEST_CASE(keyless_practice_variants_display_their_transposition_reference) {
auto source = boost::json::parse(score_asset_json("Keyless", "No key"));
auto &music = source.as_object()["music"].as_object();
@@ -488,35 +455,6 @@ BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) {
}
}
BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_success) {
auto root = std::filesystem::temp_directory_path() /
("product-vamp-scan-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
write_text(root / "songs", "not a directory");
write_text(root / "practice", "not a directory");
auto performance = vamp_zero::load_performance_library(root.string());
auto practice = vamp_zero::load_practice_library(root.string());
BOOST_REQUIRE(!performance.has_value());
BOOST_REQUIRE(!practice.has_value());
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
std::filesystem::remove(root / "songs");
std::filesystem::remove(root / "practice");
std::filesystem::create_directories(root / "songs");
std::filesystem::create_directories(root / "practice");
std::filesystem::create_symlink("loop.json", root / "songs" / "loop.json");
std::filesystem::create_symlink("loop.json", root / "practice" / "loop.json");
BOOST_CHECK_NO_THROW(performance = vamp_zero::load_performance_library(root.string()));
BOOST_CHECK_NO_THROW(practice = vamp_zero::load_practice_library(root.string()));
BOOST_REQUIRE(!performance.has_value());
BOOST_REQUIRE(!practice.has_value());
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::UNAVAILABLE));
std::filesystem::remove_all(root);
practice = vamp_zero::load_practice_library(root.string());
BOOST_REQUIRE(practice.has_value());
BOOST_TEST(practice->exercises.empty());
}
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("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state));
@@ -545,142 +483,6 @@ 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()));
auto assets = root / "assets";
auto database_path = root / "vamp.db";
auto song_path = assets / "songs" / "tune.json";
std::filesystem::remove_all(root);
write_text(song_path, score_asset_json("Catalog Tune"));
auto config = test_database_config(database_path);
BOOST_REQUIRE(run_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'");
BOOST_REQUIRE(first);
BOOST_REQUIRE_EQUAL(first->rows.size(), 1U);
auto created = integer_value(first->rows[0][1]);
auto updated = integer_value(first->rows[0][2]);
BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune");
BOOST_TEST(integer_value(first->rows[0][3]) == 1);
BOOST_REQUIRE(run_import(config, assets));
auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
BOOST_REQUIRE(unchanged);
BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U);
BOOST_TEST(integer_value(unchanged->rows[0][0]) == created);
BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated);
std::this_thread::sleep_for(std::chrono::milliseconds{2});
write_text(song_path, score_asset_json("Catalog Tune Updated"));
BOOST_REQUIRE(run_import(config, assets));
auto changed =
query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'");
BOOST_REQUIRE(changed);
BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U);
BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated");
BOOST_TEST(integer_value(changed->rows[0][1]) == created);
BOOST_TEST(integer_value(changed->rows[0][2]) >= updated);
auto inserted_custom = query_database(database_path,
"INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
"key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', "
"0)",
{noto::sqlite_value{score_asset_json("Custom Tune")}});
BOOST_REQUIRE(inserted_custom);
write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision"));
BOOST_REQUIRE(run_import(config, assets));
auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'");
BOOST_REQUIRE(custom);
BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U);
BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune");
BOOST_TEST(integer_value(custom->rows[0][1]) == 0);
std::filesystem::remove(assets / "songs" / "custom.json");
std::filesystem::remove(song_path);
BOOST_REQUIRE(run_import(config, assets));
auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs");
BOOST_REQUIRE(counts);
BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U);
BOOST_TEST(integer_value(counts->rows[0][0]) == 0);
BOOST_TEST(integer_value(counts->rows[0][1]) == 1);
std::filesystem::remove_all(root);
}
BOOST_AUTO_TEST_CASE(score_bundle_preserves_complete_music_without_runtime_flattening) {
auto source = score_asset_json("Fixture");
auto parsed = vamp_zero::parse_score_document(source, "fixture.json");
+120
View File
@@ -0,0 +1,120 @@
from contextlib import closing
import json
import sqlite3
import tempfile
import unittest
from pathlib import Path
from generator.catalogue import APPLICATION_ID, SCHEMA_VERSION, build_catalogue
def bundle(title='Fixture', key='C', authored='Eb'):
return {
'schema_version': 3,
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4', 'authored_key_signature': authored},
'staves': [{'name': 'Lead', 'arrangement_part_type': 'practice', 'measures': [{'voices': []}]}]},
'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
}
def write_score(root, relative, document=None):
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(document or bundle()), encoding='utf-8')
return path
class CatalogueTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
(self.root / 'songs').mkdir()
def rows(self, table):
with closing(sqlite3.connect(self.root / 'catalogue.db')) as connection:
connection.row_factory = sqlite3.Row
return [dict(row) for row in connection.execute(f'SELECT * FROM {table} ORDER BY id')]
def test_metadata_relative_paths_and_complete_documents(self):
document = bundle('Song', 'F')
write_score(self.root, 'songs/tune.json', document)
for key in ('c', 'd'):
write_score(self.root, f'practice/chord/001_lesson/exercise/{key}.json', bundle('Exercise', key.upper()))
database = build_catalogue(self.root)
song = self.rows('songs')[0]
self.assertEqual(json.loads(song['source_json']), document)
self.assertEqual((song['id'], song['part'], song['source_path']), ('tune', 'Lead', 'songs/tune.json'))
self.assertEqual((song['measure_count'], song['total_pages']), (1, 1))
practice = self.rows('practice_exercises')
self.assertEqual([row['id'] for row in practice], ['chord-001_lesson-exercise-c', 'chord-001_lesson-exercise-d'])
self.assertEqual(practice[0]['practice_type_title'], 'Chord Practice')
self.assertEqual(practice[0]['lesson_group_title'], '001 Lesson')
self.assertEqual(practice[0]['authored_key_signature'], 'Eb')
with closing(sqlite3.connect(database)) as connection:
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], APPLICATION_ID)
self.assertEqual(connection.execute('PRAGMA user_version').fetchone()[0], SCHEMA_VERSION)
self.assertEqual(connection.execute('PRAGMA integrity_check').fetchall(), [('ok',)])
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name").fetchall(), [('practice_exercises',), ('songs',)])
self.assertFalse(Path(str(database) + '-wal').exists())
self.assertFalse(Path(str(database) + '-journal').exists())
def test_rebuild_is_deterministic_relocatable_and_replaces_deleted_rows(self):
score = write_score(self.root, 'songs/tune.json')
database = build_catalogue(self.root)
previous = database.read_bytes()
build_catalogue(self.root)
self.assertEqual(database.read_bytes(), previous)
with tempfile.TemporaryDirectory() as other:
root = Path(other)
write_score(root, 'songs/tune.json')
self.assertEqual(build_catalogue(root).read_bytes(), previous)
score.rename(score.with_name('renamed.json'))
build_catalogue(self.root)
self.assertEqual([row['id'] for row in self.rows('songs')], ['renamed'])
score.with_name('renamed.json').unlink()
build_catalogue(self.root)
self.assertEqual(self.rows('songs'), [])
def test_invalid_input_preserves_existing_database(self):
score = write_score(self.root, 'songs/tune.json')
database = build_catalogue(self.root)
previous = database.read_bytes()
for document in ({}, bundle(title=''), bundle(authored='')):
score.write_text(json.dumps(document))
with self.assertRaises(ValueError):
build_catalogue(self.root)
self.assertEqual(database.read_bytes(), previous)
self.assertFalse(list(self.root.glob('.vamp-catalogue-*')))
def test_path_collisions_and_authored_key_disagreement_fail(self):
write_score(self.root, 'practice/chord/one-two/three/c.json')
conflicting = write_score(self.root, 'practice/chord/one/two-three/c.json')
with self.assertRaisesRegex(ValueError, 'ID collision'):
build_catalogue(self.root)
conflicting.unlink()
write_score(self.root, 'practice/chord/one-two/three/d.json', bundle(key='D', authored='F'))
with self.assertRaisesRegex(ValueError, 'authored key'):
build_catalogue(self.root)
def test_keyless_display_metadata_keeps_original_music(self):
document = bundle(key='No key', authored='No key')
document['music']['metadata']['variant_tonic'] = 'D'
write_score(self.root, 'practice/chord/lesson/exercise/d.json', document)
build_catalogue(self.root)
row = self.rows('practice_exercises')[0]
self.assertEqual(row['key_signature'], 'D (no key)')
self.assertEqual(json.loads(row['source_json']), document)
def test_invalid_layout_and_escaping_source_fail(self):
with self.assertRaisesRegex(ValueError, 'directory not found'):
build_catalogue(self.root / 'missing')
self.assertFalse((self.root / 'missing').exists())
score = write_score(self.root, 'songs/nested/tune.json')
with self.assertRaisesRegex(ValueError, 'unexpected songs'):
build_catalogue(self.root)
score.unlink()
(self.root / 'songs/escape.json').symlink_to('/etc/passwd')
with self.assertRaises(ValueError):
build_catalogue(self.root)
def test_runtime_asset_packaging_excludes_score_json(self):
import subprocess
write_score(self.root, 'songs/tune.json')
build_catalogue(self.root)
(self.root / 'songs/tune.png').write_bytes(b'page')
with tempfile.TemporaryDirectory() as temporary:
destination = Path(temporary)
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
self.assertTrue((destination / 'catalogue.db').exists())
self.assertTrue((destination / 'songs/tune.png').exists())
self.assertFalse(list(destination.rglob('*.json')))
+85 -239
View File
@@ -29,6 +29,8 @@
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/portal.hpp>
#include "catalogue_fixture.hpp"
namespace {
template <typename T>
@@ -108,8 +110,8 @@ struct catalogue_database {
return std::move(*result);
}
auto rows(std::string const &table, std::string const &where = "") const -> std::vector<std::string> {
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, catalog_managed, "
"measure_count, total_pages, authored_key_signature, created_at_ms, updated_at_ms) FROM " +
auto result = query("SELECT json_array(id, title, composer, key_signature, source_path, source_json, "
"measure_count, total_pages, authored_key_signature) FROM " +
table + where + " ORDER BY id");
auto rows = std::vector<std::string>{};
for (auto const &row : result.rows) {
@@ -125,20 +127,17 @@ struct catalogue_database {
output.close();
BOOST_REQUIRE(output.good());
}
auto ensure() const -> std::expected<void, modulus::error> {
return run_catalogue_operation(vamp_zero::async_ensure_database, config);
}
auto import_assets() const -> std::expected<void, modulus::error> {
return run_catalogue_operation(vamp_zero::async_import_catalogs, config, assets.string());
auto build() const -> std::bool_t {
return vamp_test::build_catalogue(assets, config.path);
}
auto performance() const -> std::expected<vamp_zero::library_state, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_performance_library, config);
return run_catalogue_operation(vamp_zero::async_load_performance_library, config, assets.string());
}
auto practice() const -> std::expected<vamp_zero::practice_library, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_practice_library, config);
return run_catalogue_operation(vamp_zero::async_load_practice_library, config, assets.string());
}
auto score(vamp_zero::score_category category, std::string id) const -> std::expected<vamp_zero::score_document, modulus::error> {
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)});
return run_catalogue_operation(vamp_zero::async_load_score, config, vamp_zero::score_reference{category, std::move(id)}, assets.string());
}
};
@@ -285,10 +284,6 @@ BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_n
auto document = vamp_zero::parse_score_document(catalogue_bundle("Exercise", "D", authored), "/assets/practice/melody/lesson/exercise/d.json");
BOOST_REQUIRE(document);
BOOST_TEST(document->authored_key_signature == authored);
auto metadata = vamp_zero::practice_metadata(*document, "/assets");
BOOST_REQUIRE(metadata);
BOOST_TEST(metadata->authored_key_signature == authored);
BOOST_TEST(metadata->variants[0].key_signature == "D");
}
for (auto invalid : {boost::json::value{}, boost::json::value{false}, boost::json::value{42}, boost::json::value{""}, boost::json::value{boost::json::array{}}}) {
auto value = boost::json::parse(catalogue_bundle("Invalid"));
@@ -300,123 +295,13 @@ BOOST_AUTO_TEST_CASE(catalogue_authored_key_metadata_is_optional_but_must_be_a_n
}
}
BOOST_AUTO_TEST_CASE(catalogue_streamed_import_rolls_back_and_preserves_unmanaged_rows) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/a-managed.json", catalogue_bundle("Original"));
db.write("songs/custom.json", catalogue_bundle("Custom Song"));
db.write("songs/stale.json", catalogue_bundle("Stale"));
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Exercise", "D", "Eb"));
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Custom Exercise", "C", "Eb"));
BOOST_REQUIRE(db.import_assets());
db.query("UPDATE songs SET catalog_managed = 0 WHERE id = 'custom'");
db.query("UPDATE practice_exercises SET catalog_managed = 0 WHERE id = 'melody-lesson-custom-c'");
auto songs = db.rows("songs");
auto practice = db.rows("practice_exercises");
auto custom_songs = db.rows("songs", " WHERE catalog_managed = 0");
auto custom_practice = db.rows("practice_exercises", " WHERE catalog_managed = 0");
BOOST_REQUIRE_EQUAL(custom_songs.size(), 1U);
BOOST_REQUIRE_EQUAL(custom_practice.size(), 1U);
db.write("songs/a-managed.json", catalogue_bundle("Updated"));
db.write("songs/b-new.json", catalogue_bundle("New"));
db.write("songs/custom.json", catalogue_bundle("Collision"));
db.write("practice/melody/lesson/custom/c.json", catalogue_bundle("Collision", "C", "Eb"));
db.write("practice/melody/lesson/exercise/c.json", catalogue_bundle("Updated Exercise", "C", "Eb"));
std::filesystem::remove(db.assets / "songs/stale.json");
for (auto invalid : {std::string{"{}"}, catalogue_bundle("Exercise", "D", "F")}) {
db.write("practice/melody/lesson/exercise/d.json", invalid);
auto imported = db.import_assets();
BOOST_REQUIRE(!imported);
BOOST_TEST(imported.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
}
db.write("practice/melody/lesson/exercise/d.json", catalogue_bundle("Updated Exercise", "D", "Eb"));
BOOST_REQUIRE(db.import_assets());
auto updated = db.query("SELECT title FROM songs WHERE id = 'a-managed'");
BOOST_REQUIRE_EQUAL(updated.rows.size(), 1U);
BOOST_TEST(std::get<std::string>(updated.rows[0][0].value) == "Updated");
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'b-new'").rows.size() == 1U);
BOOST_TEST(db.query("SELECT id FROM songs WHERE id = 'stale'").rows.empty());
BOOST_TEST(db.rows("songs", " WHERE catalog_managed = 0") == custom_songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises", " WHERE catalog_managed = 0") == custom_practice, boost::test_tools::per_element());
std::filesystem::remove_all(db.assets);
std::filesystem::create_directories(db.assets / "songs");
BOOST_REQUIRE(db.import_assets());
BOOST_TEST(db.rows("songs") == custom_songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == custom_practice, boost::test_tools::per_element());
}
BOOST_AUTO_TEST_CASE(catalogue_legacy_metadata_backfill_retains_custom_rows_and_score_json) {
auto db = catalogue_database{};
db.query(R"sql(CREATE TABLE songs (
id TEXT PRIMARY KEY, title TEXT NOT NULL, composer TEXT NOT NULL, source_json TEXT NOT NULL,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, part TEXT NOT NULL,
source_path TEXT NOT NULL, key_signature TEXT NOT NULL, catalog_managed INTEGER NOT NULL
) STRICT)sql");
db.query(R"sql(CREATE TABLE practice_exercises (
id TEXT PRIMARY KEY, practice_type_id TEXT NOT NULL, practice_type_title TEXT NOT NULL,
lesson_group_id TEXT NOT NULL, lesson_group_title TEXT NOT NULL, title TEXT NOT NULL, composer TEXT NOT NULL,
key_signature TEXT NOT NULL, source_json TEXT NOT NULL, source_path TEXT NOT NULL,
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, catalog_managed INTEGER NOT NULL
) STRICT)sql");
auto song = catalogue_bundle("Embedded Song", "D");
auto exercise = catalogue_bundle("Embedded Exercise", "D", "Eb");
db.query("INSERT INTO songs VALUES('legacy-song', 'Custom Song', 'Composer', ?1, 11, 17, 'Lead', 'legacy.json', 'D', 0)", {noto::sqlite_value{song}});
db.query("INSERT INTO practice_exercises VALUES('legacy-practice', 'melody', 'Melody Practice', 'lesson', 'Lesson', "
"'Custom Exercise', 'Composer', 'D', ?1, 'practice/melody/lesson/exercise/d.json', 11, 17, 0)",
{noto::sqlite_value{exercise}});
BOOST_REQUIRE(db.ensure());
for (auto table : {"songs", "practice_exercises"}) {
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
auto performance = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].id == "legacy-song");
BOOST_TEST(performance->parts[0].title == "Custom Song");
BOOST_TEST(performance->parts[0].measure_count == 3U);
BOOST_TEST(performance->parts[0].total_pages == 2U);
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
BOOST_TEST(practice->exercises[0].title == "Custom Exercise");
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
BOOST_TEST(practice->exercises[0].variants[0].id == "legacy-practice");
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 3U);
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 2U);
for (auto table : {"songs", "practice_exercises"}) {
auto row = db.query("SELECT source_json, catalog_managed, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature FROM " + std::string{table});
BOOST_REQUIRE_EQUAL(row.rows.size(), 1U);
auto const &values = row.rows[0];
auto is_song = std::string_view{table} == "songs";
BOOST_TEST(std::get<std::string>(values[0].value) == (is_song ? song : exercise));
BOOST_TEST(std::get<std::int64_t>(values[1].value) == 0);
BOOST_TEST(std::get<std::int64_t>(values[2].value) == 11);
BOOST_TEST(std::get<std::int64_t>(values[3].value) == 17);
BOOST_TEST(std::get<std::int64_t>(values[4].value) == 3);
BOOST_TEST(std::get<std::int64_t>(values[5].value) == 2);
BOOST_TEST(std::get<std::string>(values[6].value) == (is_song ? "" : "Eb"));
}
auto songs = db.rows("songs");
auto exercises = db.rows("practice_exercises");
BOOST_REQUIRE(db.ensure());
BOOST_REQUIRE(db.performance());
BOOST_REQUIRE(db.practice());
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == exercises, boost::test_tools::per_element());
}
BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports_missing_or_invalid) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
auto song = catalogue_bundle("Song", "F");
auto exercise = catalogue_bundle("Exercise", "D", "Eb");
db.write("songs/shared.json", song);
db.write("practice/melody/lesson/exercise/d.json", exercise);
BOOST_REQUIRE(db.import_assets());
BOOST_REQUIRE(db.build());
db.query("UPDATE practice_exercises SET id = 'shared'");
std::filesystem::remove_all(db.assets);
for (auto category : {vamp_zero::score_category::PERFORMANCE, vamp_zero::score_category::PRACTICE}) {
@@ -449,36 +334,35 @@ BOOST_AUTO_TEST_CASE(catalogue_individual_score_lookup_uses_category_and_reports
BOOST_AUTO_TEST_CASE(catalogue_sequential_score_loads_keep_executor_stack_bounded) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/repeated.json", catalogue_bundle("Repeated"));
BOOST_REQUIRE(db.import_assets());
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config)
BOOST_REQUIRE(db.build());
auto repeated_loads = [](boost::asio::any_io_executor executor, vamp_zero::database_config config, std::string assets)
-> boost::asio::awaitable<std::expected<std::uint32_t, modulus::error>> {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = "repeated"};
for (auto count = std::uint32_t{}; count < 4096U; ++count) {
auto score = co_await vamp_zero::async_load_score(executor, config, reference);
auto score = co_await vamp_zero::async_load_score(executor, config, reference, assets);
if (!score) {
co_return std::unexpected(score.error());
}
}
co_return 4096U;
};
auto result = run_catalogue_operation(repeated_loads, db.config);
auto result = run_catalogue_operation(repeated_loads, db.config, db.assets.string());
BOOST_REQUIRE_MESSAGE(result, (result ? "" : result.error().describe()));
BOOST_TEST(*result == 4096U);
}
BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score_json) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
BOOST_REQUIRE(db.build());
// Complete cached metadata must not require parsing or retaining the score body.
db.query("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, "
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
"key_signature, measure_count, total_pages, authored_key_signature) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 1, 1, 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '')");
for (auto key : {"C", "D"}) {
db.query("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
"title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, measure_count, total_pages, authored_key_signature) "
"VALUES(?1, 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 1, 1, 3, 2, 'Eb')",
db.query("INSERT INTO practice_exercises(id, exercise_id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, "
"title, composer, key_signature, source_json, source_path, measure_count, total_pages, authored_key_signature) "
"VALUES(?1, 'melody-lesson-exercise', 'melody', 'Melody Practice', 'lesson', 'Lesson', 'Metadata Exercise', 'Composer', ?2, '{}', ?3, 3, 2, 'Eb')",
{noto::sqlite_value{std::string{"variant-"} + key}, noto::sqlite_value{std::string{key}},
noto::sqlite_value{std::string{"practice/melody/lesson/exercise/"} + key + ".json"}});
}
@@ -519,117 +403,79 @@ BOOST_AUTO_TEST_CASE(catalogue_reload_uses_cached_metadata_without_parsing_score
BOOST_TEST(disagreement.error().message().find("authored key") != std::string_view::npos);
}
BOOST_AUTO_TEST_CASE(catalogue_changed_score_json_invalidates_metadata_and_requires_successful_backfill) {
BOOST_AUTO_TEST_CASE(catalogue_validation_rejects_missing_legacy_and_corrupt_files_without_repair) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
auto original = catalogue_bundle("Original", "D", "Eb");
db.write("songs/tune.json", original);
db.write("practice/melody/lesson/exercise/d.json", original);
BOOST_REQUIRE(db.import_assets());
auto changed = boost::json::parse(catalogue_bundle("Changed", "D", "F"));
changed.at("music").at("staves").as_array()[0].at("measures").as_array().pop_back();
changed.at("presentation").at("pages").as_array().pop_back();
for (auto table : {"songs", "practice_exercises"}) {
auto name = std::string{table};
auto before = db.rows(name);
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{original}});
BOOST_TEST(db.rows(name) == before, boost::test_tools::per_element());
db.query("UPDATE " + name + " SET source_json = ?1", {noto::sqlite_value{boost::json::serialize(changed)}});
BOOST_TEST(db.query("SELECT id FROM " + name + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
auto performance = db.performance();
auto validate = [&] { return run_catalogue_operation(vamp_zero::async_validate_database, db.config, true); };
BOOST_TEST(!validate());
BOOST_TEST(!std::filesystem::exists(db.config.path));
BOOST_REQUIRE(db.build());
BOOST_REQUIRE(validate());
auto songs = db.performance();
auto practice = db.practice();
BOOST_REQUIRE(performance);
BOOST_REQUIRE(songs);
BOOST_REQUIRE(practice);
BOOST_REQUIRE_EQUAL(performance->parts.size(), 1U);
BOOST_TEST(performance->parts[0].measure_count == 2U);
BOOST_TEST(performance->parts[0].total_pages == 1U);
BOOST_REQUIRE_EQUAL(practice->exercises.size(), 1U);
BOOST_TEST(practice->exercises[0].authored_key_signature == "F");
BOOST_REQUIRE_EQUAL(practice->exercises[0].variants.size(), 1U);
BOOST_TEST(practice->exercises[0].variants[0].measure_count == 2U);
BOOST_TEST(practice->exercises[0].variants[0].total_pages == 1U);
for (auto table : {"songs", "practice_exercises"}) {
db.query("UPDATE " + std::string{table} + " SET source_json = '{}'");
}
performance = db.performance();
practice = db.practice();
BOOST_REQUIRE(!performance);
BOOST_REQUIRE(!practice);
BOOST_TEST(performance.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
BOOST_TEST(practice.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
for (auto table : {"songs", "practice_exercises"}) {
BOOST_TEST(db.query("SELECT id FROM " + std::string{table} + " WHERE measure_count IS NULL AND total_pages IS NULL AND authored_key_signature IS NULL").rows.size() == 1U);
}
}
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_paths_before_import_or_direct_load) {
for (auto key : {"c", "d"}) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("songs/tune.json", catalogue_bundle("Original"));
db.write("practice/chord/one-two/three/c.json", catalogue_bundle("Original"));
BOOST_REQUIRE(db.import_assets());
auto songs = db.rows("songs");
auto practice = db.rows("practice_exercises");
db.write("songs/tune.json", catalogue_bundle("Changed"));
auto second = std::string{"practice/chord/one/two-three/"} + key + ".json";
db.write(second, catalogue_bundle("Conflicting", key));
auto result = db.import_assets();
BOOST_TEST(songs->parts.empty());
BOOST_TEST(practice->exercises.empty());
for (auto version : {0, 2}) {
db.query("PRAGMA user_version = " + std::to_string(version));
auto result = validate();
BOOST_REQUIRE(!result);
auto message = result.error().describe();
BOOST_TEST(message.find("chord-one-two-three") != std::string::npos);
BOOST_TEST(message.find("practice/chord/one-two/three/c.json") != std::string::npos);
BOOST_TEST(message.find(second) != std::string::npos);
BOOST_TEST(!vamp_zero::load_practice_library(db.assets.string()));
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
BOOST_TEST(db.rows("practice_exercises") == practice, boost::test_tools::per_element());
BOOST_TEST(result.error().message().find("reinstall") != std::string_view::npos);
BOOST_TEST(std::get<std::int64_t>(db.query("PRAGMA user_version").rows[0][0].value) == version);
}
db.query("PRAGMA user_version = 1");
db.query("PRAGMA application_id = 0");
BOOST_TEST(!validate());
auto output = std::ofstream{db.config.path, std::ios::trunc};
output << "not a database";
output.close();
BOOST_TEST(!validate());
BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal"));
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
}
BOOST_AUTO_TEST_CASE(catalogue_changed_import_keeps_complete_cached_metadata) {
BOOST_AUTO_TEST_CASE(catalogue_reads_readonly_database_without_json_and_resolves_relocated_assets) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
for (auto title : {"Original", "Changed"}) {
db.write("songs/tune.json", catalogue_bundle(title, "D", "Eb"));
db.write("practice/chord/lesson/exercise/d.json", catalogue_bundle(title, "D", "Eb"));
BOOST_REQUIRE(db.import_assets());
for (auto table : {"songs", "practice_exercises"}) {
auto result = db.query("SELECT measure_count, total_pages, authored_key_signature FROM " + std::string{table});
BOOST_REQUIRE_EQUAL(result.rows.size(), 1U);
BOOST_TEST(std::get<std::int64_t>(result.rows[0][0].value) == 3);
BOOST_TEST(std::get<std::int64_t>(result.rows[0][1].value) == 2);
BOOST_TEST(std::get<std::string>(result.rows[0][2].value) == "Eb");
db.write("songs/tune.json", catalogue_bundle("Relocated"));
db.write("practice/chord/lesson/exercise/c.json", catalogue_bundle("Exercise", "C", "Eb"));
BOOST_REQUIRE(db.build());
std::filesystem::remove(db.assets / "songs/tune.json");
std::filesystem::remove(db.assets / "practice/chord/lesson/exercise/c.json");
auto moved = db.directory.path / "relocated";
std::filesystem::rename(db.assets, moved);
db.assets = moved;
auto timestamp = std::filesystem::last_write_time(db.config.path);
std::filesystem::permissions(db.config.path, std::filesystem::perms::owner_read);
BOOST_REQUIRE(run_catalogue_operation(vamp_zero::async_validate_database, db.config, true));
auto songs = db.performance();
auto practice = db.practice();
auto score = db.score(vamp_zero::score_category::PERFORMANCE, "tune");
BOOST_REQUIRE(songs);
BOOST_REQUIRE(practice);
BOOST_REQUIRE(score);
BOOST_TEST(score->source_path == (moved / "songs/tune.json").string());
BOOST_TEST(songs->parts[0].source_path == score->source_path);
BOOST_TEST(practice->exercises[0].authored_key_signature == "Eb");
BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp));
BOOST_TEST(!std::filesystem::exists(db.config.path + "-wal"));
BOOST_TEST(!std::filesystem::exists(db.config.path + "-shm"));
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
}
BOOST_AUTO_TEST_CASE(catalogue_rejects_paths_outside_the_asset_root) {
auto db = catalogue_database{};
db.write("songs/tune.json", catalogue_bundle("Fixture"));
BOOST_REQUIRE(db.build());
for (auto path : {"/tmp/tune.json", "../tune.json", "songs/../../tune.json", "songs/./tune.json", "songs\\tune.json", ""}) {
if (std::string_view{path}.empty()) {
continue;
}
db.query("UPDATE songs SET source_path = ?1", {noto::sqlite_value{std::string{path}}});
BOOST_TEST(!db.performance());
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
}
}
BOOST_AUTO_TEST_CASE(catalogue_rejects_ambiguous_variant_ids_with_distinct_exercise_ids) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
db.write("practice/chord/lesson/exercise/c-d.json", catalogue_bundle("First"));
db.write("practice/chord/lesson/exercise-c/d.json", catalogue_bundle("Second"));
auto result = db.import_assets();
BOOST_REQUIRE(!result);
BOOST_TEST(result.error().describe().find("practice variant ID collision 'chord-lesson-exercise-c-d'") != std::string::npos);
BOOST_TEST(db.rows("practice_exercises").empty());
}
BOOST_AUTO_TEST_CASE(catalogue_backfill_uses_pending_indexes_and_preserves_large_mixed_catalogues) {
auto db = catalogue_database{};
BOOST_REQUIRE(db.ensure());
BOOST_REQUIRE(db.ensure());
for (auto table : {"songs", "practice_exercises"}) {
auto name = std::string{table};
db.query("WITH RECURSIVE sequence(n) AS (VALUES(1) UNION ALL SELECT n + 1 FROM sequence WHERE n < 4000) INSERT INTO " + name + "(id, title, source_json, created_at_ms, updated_at_ms, catalog_managed, measure_count, total_pages, authored_key_signature) SELECT 'score-' || n, 'Fixture', ?1, 1, 2, 0, CASE WHEN n % 2 = 0 THEN 3 END, 2, 'Eb' FROM sequence", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
auto plan = db.query("EXPLAIN QUERY PLAN SELECT id, source_path, source_json FROM " + name + " WHERE measure_count IS NULL OR total_pages IS NULL OR authored_key_signature IS NULL LIMIT 1");
BOOST_REQUIRE_EQUAL(plan.rows.size(), 1U);
BOOST_TEST(std::get<std::string>(plan.rows[0][3].value).find("idx_" + name + "_pending_metadata") != std::string::npos);
}
BOOST_REQUIRE(db.performance());
BOOST_REQUIRE(db.practice());
for (auto table : {"songs", "practice_exercises"}) {
auto rows = db.query("SELECT count(*) FROM " + std::string{table} + " WHERE measure_count = 3 AND total_pages = 2 AND authored_key_signature = 'Eb' AND created_at_ms = 1 AND updated_at_ms = 2 AND catalog_managed = 0 AND source_json = ?1", {noto::sqlite_value{catalogue_bundle("Fixture", "D", "Eb")}});
BOOST_TEST(std::get<std::int64_t>(rows.rows[0][0].value) == 4000);
}
std::filesystem::create_directory_symlink(db.directory.path, db.assets / "escape");
db.query("UPDATE songs SET source_path = 'escape/tune.json'");
BOOST_TEST(!db.performance());
BOOST_TEST(!db.score(vamp_zero::score_category::PERFORMANCE, "tune"));
}
+18 -4
View File
@@ -29,6 +29,8 @@
#include <vamp_zero/health.hpp>
#include <vamp_zero/portal.hpp>
#include "catalogue_fixture.hpp"
namespace {
@@ -184,6 +186,7 @@ auto make_assets(vamp_zero::config const &config) -> void {
write_score(root / "songs" / "second.json");
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "c.json");
write_score(root / "practice" / "melody" / "lesson" / "exercise" / "d.json", "D");
BOOST_REQUIRE(vamp_test::build_catalogue(root, config.database.path));
}
auto run_once(std::filesystem::path const &root, vamp_zero::config const &config) -> std::expected<void, modulus::error> {
@@ -330,18 +333,26 @@ BOOST_AUTO_TEST_CASE(portal_remote_commands_refresh_connection_without_backgroun
}
}
BOOST_AUTO_TEST_CASE(readiness_imports_catalogue_without_hardware_or_heartbeat) {
BOOST_AUTO_TEST_CASE(readiness_reads_packaged_catalogue_without_hardware_or_heartbeat) {
auto root = temporary_directory{};
auto config = fixture_config(root.path);
make_assets(config);
auto database_time = std::filesystem::last_write_time(config.database.path);
for (auto const &entry : std::filesystem::recursive_directory_iterator{config.runtime.assets_path}) {
if (entry.path().extension() == ".json") {
std::filesystem::remove(entry.path());
}
}
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
BOOST_TEST(std::filesystem::exists(config.database.path));
BOOST_TEST((std::filesystem::last_write_time(config.database.path) == database_time));
BOOST_TEST(!std::filesystem::exists(config.database.path + "-wal"));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
require_no_probe(config);
auto io = boost::asio::io_context{};
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future);
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future);
auto performance = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
auto practice = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future);
io.run();
auto songs = performance.get();
auto exercises = practice.get();
@@ -385,6 +396,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
auto score = boost::json::parse(read_file(path));
score.as_object()["playback"] = boost::json::parse(read_file(fixture / "score.json")).at("playback");
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(ready, (ready ? "" : ready.error().describe()));
auto &sample = score.at("playback").at("samples").as_array().front().as_object();
@@ -394,6 +406,7 @@ BOOST_AUTO_TEST_CASE(readiness_validates_generated_audio_using_configured_asset_
sample["sha256"] = checksum(corrupt);
write_file(audio, corrupt);
write_file(path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(assets, config.database.path));
ready = run_once(root.path, config);
BOOST_REQUIRE(!ready);
BOOST_TEST(std::string{ready.error().message()}.find("PCM16 WAV") != std::string::npos);
@@ -426,7 +439,7 @@ BOOST_AUTO_TEST_CASE(readiness_rejects_missing_or_malformed_catalogues) {
auto config = fixture_config(root.path);
BOOST_TEST(!run_once(root.path, config));
make_assets(config);
write_file(std::filesystem::path{config.runtime.assets_path} / "songs" / "first.json", "not JSON");
write_file(config.database.path, "not a database");
BOOST_TEST(!run_once(root.path, config));
BOOST_TEST(!std::filesystem::exists(config.runtime.heartbeat_path));
}
@@ -463,6 +476,7 @@ BOOST_AUTO_TEST_CASE(readiness_decodes_pages_even_when_checksums_match) {
write_file(score_path.parent_path() / std::string{page.at("path").as_string()}, bytes);
page["sha256"] = checksum(bytes);
write_file(score_path, boost::json::serialize(score));
BOOST_REQUIRE(vamp_test::build_catalogue(config.runtime.assets_path, config.database.path));
auto ready = run_once(root.path, config);
BOOST_REQUIRE_MESSAGE(!ready, kind);
BOOST_TEST(ready.error().describe().find("first.pages/1.png") != std::string::npos);
+183
View File
@@ -0,0 +1,183 @@
import json
import hashlib
import io
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import tarfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
MOCK = '''#!/usr/bin/env python3
import json, os, sys
from pathlib import Path
root = Path(os.environ['INSTALL_FIXTURE'])
command, args = Path(sys.argv[0]).name, sys.argv[1:]
with (root / 'commands').open('a') as output:
output.write(json.dumps([command, *args]) + '\\n')
if command == 'id':
print('0')
elif command == 'dpkg-query':
print('install ok installed')
elif command == 'docker':
if args[:2] == ['image', 'inspect']:
if os.environ.get('FIRST_INSTALL') and not (root / 'loaded').exists():
sys.exit(1)
print(os.environ['FIXTURE_IMAGE'] if (root / 'loaded').exists() else 'sha256:old')
elif args[:2] == ['image', 'ls']:
if not os.environ.get('FIRST_INSTALL'):
print('sha256:older')
elif args[:2] == ['container', 'inspect'] and os.environ.get('FIRST_INSTALL'):
sys.exit(1)
elif args[0] == 'load':
(root / 'loaded').touch()
elif args[0] == 'run' and os.environ.get('FAIL_PREFLIGHT'):
sys.exit(1)
elif args[0] == 'exec' and os.environ.get('FAIL_HEALTH'):
sys.exit(1)
elif args[0] == 'ps' and os.environ.get('SHARED_IMAGE') in args:
print('another-container')
elif command == 'systemctl' and args[0] == 'cat' and os.environ.get('FIRST_INSTALL'):
sys.exit(1)
elif command == 'nmcli' and args[:4] == ['-t', '-f', 'NAME', 'connection']:
print('product-vamp-control')
print('unrelated-network')
'''
class InstallZeroTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.bundle = self.root / 'bundle'
self.zero = self.bundle / 'zero'
self.zero.mkdir(parents=True)
(self.zero / 'config').mkdir()
(self.zero / 'systemd').mkdir()
self.image_tag = 'product-vamp-zero:dev'
self.write_archive(self.image_tag)
shutil.copyfile(ROOT / 'package/config/vamp.example.json', self.zero / 'config/vamp.example.json')
for name in ('start-zero.sh', 'start-ap.sh'):
shutil.copyfile(ROOT / 'package/zero' / name, self.zero / name)
for name in ('product-vamp-zero.service', 'product-vamp-ap.service'):
shutil.copyfile(ROOT / 'package/zero' / name, self.zero / 'systemd' / name)
script = (ROOT / 'package/zero/install-zero.sh').read_text()
for prefix in ('/tmp/', '/etc/', '/var/', '/run/', '/usr/local/', '/usr/share/'):
script = script.replace(prefix, str(self.root) + prefix)
(self.zero / 'install-zero.sh').write_text(script)
for path in ('etc/systemd/system', 'usr/local/bin', 'tmp', 'bin'):
(self.root / path).mkdir(parents=True, exist_ok=True)
for command in ('id', 'dpkg-query', 'apt-get', 'systemctl', 'docker', 'nmcli', 'sleep', 'chvt', 'setterm'):
path = self.root / 'bin' / command
path.write_text(MOCK)
path.chmod(0o755)
self.environment = {**os.environ, 'INSTALL_FIXTURE': str(self.root), 'FIXTURE_IMAGE': self.image_id, 'PATH': str(self.root / 'bin') + os.pathsep + os.environ['PATH']}
def write_archive(self, tag):
configuration = b'{"architecture":"arm64","os":"linux"}'
self.image_id = 'sha256:' + hashlib.sha256(configuration).hexdigest()
manifest = json.dumps([{'Config': 'image.json', 'RepoTags': [tag]}]).encode()
with tarfile.open(self.zero / 'product-vamp-zero-linux-arm64.docker.tar', 'w') as archive:
for name, data in (('manifest.json', manifest), ('image.json', configuration)):
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
(self.bundle / 'manifest.json').write_text(json.dumps({'zero': {'image_tag': tag}}))
def install(self, **environment):
return subprocess.run(['bash', str(self.zero / 'install-zero.sh')], env={**self.environment, **environment}, text=True, capture_output=True)
def commands(self):
return [json.loads(line) for line in (self.root / 'commands').read_text().splitlines()]
def stale_state(self):
for name in ('etc/product-vamp/vamp.json', 'etc/product-vamp/obsolete', 'var/lib/product-vamp/vamp.db',
'var/lib/product-vamp/vamp.db-wal', 'var/lib/product-vamp/vamp.db-shm', 'run/product-vamp/heartbeat.json',
'usr/share/product-vamp/songs/removed.json', 'etc/systemd/system/product-vamp-zero.service.d/override.conf',
'run/systemd/system/product-vamp-ap.service.d/override.conf'):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('previous installation')
unrelated = self.root / 'var/lib/unrelated/keep'
unrelated.parent.mkdir(parents=True)
unrelated.write_text('keep')
def test_first_install_and_reinstall_reset_only_owned_state(self):
first = self.install(FIRST_INSTALL='1')
self.assertEqual(first.returncode, 0, first.stderr)
self.stale_state()
second = self.install()
self.assertEqual(second.returncode, 0, second.stderr)
self.assertEqual((self.root / 'etc/product-vamp/vamp.json').read_bytes(), (self.zero / 'config/vamp.example.json').read_bytes())
self.assertEqual((self.root / 'etc/product-vamp/image-id').read_text().strip(), self.image_id)
self.assertFalse((self.root / 'etc/product-vamp/obsolete').exists())
self.assertFalse((self.root / 'var/lib/product-vamp').exists())
self.assertFalse((self.root / 'usr/share/product-vamp').exists())
self.assertFalse((self.root / 'etc/systemd/system/product-vamp-zero.service.d').exists())
self.assertFalse((self.root / 'run/systemd/system/product-vamp-ap.service.d').exists())
self.assertEqual(list((self.root / 'run/product-vamp').iterdir()), [])
self.assertEqual((self.root / 'var/lib/unrelated/keep').read_text(), 'keep')
self.assertEqual(list((self.root / 'tmp').iterdir()), [])
commands = self.commands()
self.assertIn(['nmcli', 'connection', 'delete', 'product-vamp-control'], commands)
self.assertNotIn(['nmcli', 'connection', 'delete', 'unrelated-network'], commands)
self.assertFalse(any('prune' in command for command in commands))
probe = next(index for index, command in enumerate(commands) if command[:2] == ['docker', 'run'])
stop = commands.index(['systemctl', 'stop', 'product-vamp-zero.service'])
self.assertLess(probe, stop)
def test_failed_preflight_keeps_old_state_and_restores_image_tag(self):
self.stale_state()
result = self.install(FAIL_PREFLIGHT='1')
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
self.assertEqual((self.root / 'etc/product-vamp/vamp.json').read_text(), 'previous installation')
self.assertNotIn(['systemctl', 'stop', 'product-vamp-zero.service'], self.commands())
self.assertIn(['docker', 'tag', 'sha256:old', 'product-vamp-zero:dev'], self.commands())
def test_failed_health_leaves_services_stopped_without_success(self):
result = self.install(FAIL_HEALTH='1')
self.assertNotEqual(result.returncode, 0)
self.assertNotIn('Zero installed.', result.stdout)
self.assertIn(['systemctl', 'stop', 'product-vamp-zero.service', 'product-vamp-ap.service'], self.commands())
self.assertIn(['docker', 'rm', '-f', 'product-vamp-zero'], self.commands())
def test_symlink_target_aborts_without_deleting_external_data(self):
external = self.root / 'external'
external.mkdir()
(external / 'keep').write_text('keep')
(self.root / 'etc/product-vamp').symlink_to(external, target_is_directory=True)
result = self.install()
self.assertNotEqual(result.returncode, 0)
self.assertIn('unsafe installation target', result.stderr)
self.assertEqual((external / 'keep').read_text(), 'keep')
self.assertNotIn(['systemctl', 'stop', 'product-vamp-zero.service'], self.commands())
def test_image_in_use_by_another_container_is_not_removed(self):
result = self.install(SHARED_IMAGE='ancestor=sha256:old')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn(['docker', 'image', 'rm', 'sha256:old'], self.commands())
self.assertIn(['docker', 'image', 'rm', 'sha256:older'], self.commands())
def test_custom_bundle_tag_selects_and_pins_the_incoming_image(self):
self.write_archive('product-vamp-zero:release-test')
result = self.install()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn(['docker', 'image', 'inspect', '--format', '{{.Id}}', 'product-vamp-zero:release-test'], self.commands())
self.assertEqual((self.root / 'etc/product-vamp/image-id').read_text().strip(), self.image_id)
def test_wrong_archive_never_resets_the_old_installation(self):
self.stale_state()
(self.bundle / 'manifest.json').write_text(json.dumps({'zero': {'image_tag': 'product-vamp-zero:missing'}}))
result = self.install()
self.assertNotEqual(result.returncode, 0)
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
self.assertFalse(any(command[:2] == ['docker', 'load'] for command in self.commands()))
def test_loaded_image_must_match_archive_digest(self):
self.stale_state()
result = self.install(FIXTURE_IMAGE='sha256:unexpected')
self.assertNotEqual(result.returncode, 0)
self.assertIn('does not match', result.stderr)
self.assertEqual((self.root / 'var/lib/product-vamp/vamp.db').read_text(), 'previous installation')
self.assertIn(['docker', 'tag', 'sha256:old', 'product-vamp-zero:dev'], self.commands())
def test_startup_uses_pinned_image_without_mounting_legacy_database(self):
result = self.install()
self.assertEqual(result.returncode, 0, result.stderr)
environment = {**self.environment, 'PRODUCT_VAMP_CONFIG_DIR': str(self.root / 'etc/product-vamp'),
'PRODUCT_VAMP_RUN_DIR': str(self.root / 'run/product-vamp'), 'PRODUCT_VAMP_DISPLAY_TTY': str(self.root / 'tty')}
started = subprocess.run(['bash', str(ROOT / 'package/zero/start-zero.sh')], env=environment, text=True, capture_output=True)
self.assertEqual(started.returncode, 0, started.stderr)
command = [item for item in self.commands() if item[:2] == ['docker', 'run']][-1]
self.assertIn(self.image_id, command)
self.assertFalse(any('/var/lib/product-vamp' in argument for argument in command))
+3
View File
@@ -21,6 +21,9 @@ class MusicModelTests(unittest.TestCase):
engraving = patch('generator.__main__.engrave_tree')
engraving.start()
self.addCleanup(engraving.stop)
catalogue = patch('generator.__main__.build_catalogue')
catalogue.start()
self.addCleanup(catalogue.stop)
def test_chord_semantics_are_not_voicing_heuristics(self):
cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7},
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}