Build Fix
ci / checks (push) Failing after 1h18m29s

This commit is contained in:
khodowany
2026-09-25 09:35:09 -05:00
parent 07559ad74c
commit 17326dbf56
34 changed files with 283 additions and 3723 deletions
+5 -5
View File
@@ -109,16 +109,16 @@ jobs:
id: engraving-cache
uses: actions/cache/restore@v4
with:
path: build/build_common/engraving-cache/v2/${{ steps.renderer.outputs.fingerprint }}
key: product-vamp-pages-v2-${{ steps.renderer.outputs.fingerprint }}-${{ hashFiles('generator/**/*.py', 'generator/*.py', 'data/**/*.xml', 'data/*.xml') }}
restore-keys: product-vamp-pages-v2-${{ steps.renderer.outputs.fingerprint }}-
path: build/build_common/engraving-cache/${{ steps.renderer.outputs.fingerprint }}
key: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}-${{ hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml') }}
restore-keys: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}-
- name: Build native release
run: python3 modulus.py build --target x86_64_gcc12_release
- name: Save engraving cache
if: steps.engraving-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: build/build_common/engraving-cache/v2/${{ steps.renderer.outputs.fingerprint }}
path: build/build_common/engraving-cache/${{ steps.renderer.outputs.fingerprint }}
key: ${{ steps.engraving-cache.outputs.cache-primary-key }}
- name: Test native release
run: python3 modulus.py test --target x86_64_gcc12_release
@@ -142,7 +142,7 @@ jobs:
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py lsp --no-build-targets --database pico
- name: Create package
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py package --version latest --update
run: PICO_SDK_PATH="$PWD/build/build_common/pico-sdk" python3 modulus.py package --update
- name: Upload latest package
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env:
+1 -3
View File
@@ -1,7 +1,5 @@
cmake_minimum_required(VERSION 3.25)
set(VAMP_PROJECT_VERSION 0.1.0)
include(cmake/vamp_options.cmake)
if(VAMP_PICO_FIRMWARE_PROJECT)
@@ -9,6 +7,6 @@ if(VAMP_PICO_FIRMWARE_PROJECT)
return()
endif()
project(product-vamp VERSION ${VAMP_PROJECT_VERSION} LANGUAGES CXX)
project(product-vamp LANGUAGES CXX)
include(cmake/zero.cmake)
+13 -7
View File
@@ -35,6 +35,8 @@ python3 modulus.py build --target x86_64_gcc12_release
Configure/build read public packages anonymously from the khodo Conan registry, even when the project cache contains an expired login. `configure --update` refreshes both the exported Modulus tooling and Conan dependencies. `--no-remote` uses only locally cached packages. Publishing is the only operation that needs credentials. CMake checks the required Sonus and Luma APIs during configuration and reports stale dependencies early.
ARM builds use the versioned GCC 12 cross compilers. The selected compiler executables also identify internal Conan binaries, excluding old packages built with an unversioned compiler that could resolve to GCC 13. The first corrected build rebuilds missing internal packages; third-party caches remain reusable.
`modulus.py` is an unchanged copy of Modulus's canonical bootstrap. The project-specific build image lives in `package/Dockerfile.build`; `tooling.docker` declares its additional inputs and permits Docker socket access only for build/package commands that assemble the Zero image.
Refresh clangd compile databases, including the Pico firmware database, with:
@@ -56,17 +58,17 @@ Modulus also generates `.nvim.lua` for Neovim 0.11+ with `exrc` enabled. Review
Product packaging targets Raspberry Pi OS/Debian trixie on ARM64 and optionally builds Pico firmware. Set `PICO_SDK_PATH` when building the firmware artifact:
```sh
PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --version latest --update
PICO_SDK_PATH=/path/to/pico-sdk python3 modulus.py package --update
```
The package command creates:
```text
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz
build/packages/product-vamp-latest-arm_64_gcc12_release.tar.gz.sha256
build/packages/product-vamp-arm_64_gcc12_release.tar.gz
build/packages/product-vamp-arm_64_gcc12_release.tar.gz.sha256
```
The tarball contains the Zero Docker archive, Pi install scripts, generated catalog database, pages, audio, fonts, and Pico UF2 firmware. Complete score JSON is stored inside `catalog.db`; individual JSON files remain build intermediates and are not deployed.
The tarball contains the Zero Docker archive, Pi install scripts, generated catalog database, pages, audio, fonts, and Pico UF2 firmware. Complete score JSON is stored inside `catalog.db`; individual JSON files remain build intermediates and are not deployed. Builds replace the same target-specific archive without a release label. The manifest and `vamp-zero --build-info` identify the source commit.
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.
@@ -80,11 +82,15 @@ PACKAGE_REGISTRY_TOKEN=<token> \
python3 package/scripts/publish_latest_generic_package.py
```
Artifacts are published to the khodo generic package registry.
Artifacts are published to the khodo generic package registry's required `latest` slot. Publish the updated Modulus tooling before running Product Vamp CI so packaging and publishing agree on the stable filenames.
## Score Assets
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed. To inspect generated assets without building the full package, run:
Uncompressed MusicXML 3.1/4.0 scores exported from Soundslice or MuseScore live in `data/`. Generated score JSON is not committed.
Only `data/practice` and `data/songs` are active inputs. `data/rhythms` is reserved for future work and is ignored by generation, catalog tests, and packaging.
To inspect generated assets without building the full package, run:
```sh
python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated --static-assets-dir package/assets --check
@@ -95,7 +101,7 @@ The pinned build-only dependencies are in `generator/requirements.txt`. Configur
CMake builds engrave with two Python workers and share a disposable page cache under `build/build_common/engraving-cache`. Native and ARM packaging reuse unchanged pages; CI also retains this cache between runs. Music, playback, and catalog validation still run for every generation. Set `VAMP_ENGRAVING_JOBS` (1-16) to adjust concurrency, or set `VAMP_ENGRAVING_CACHE_DIR` to an empty CMake value to disable caching. Direct generator runs can opt in with `--jobs 2 --engraving-cache build/build_common/engraving-cache --progress`.
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> part generation -> schema-v3 JSON, engraved PNG pages, generated-tone audio, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Numbered sources in `data/practice/` (`000.xml` through `366.xml`, as authored) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises.
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model -> part generation -> JSON, engraved PNG pages, generated-tone audio, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Numbered sources in `data/practice/` (`000.xml` through `366.xml`, as authored) produce one pass each of **acoustic accompaniment**, **electric melody**, and **vocal melody**. Both categories use the authored musical key, with no key variants or key-selection menu. Duplicate titles never merge exercises.
Guitar parts share a theory-based voicing and proximity search over standard tuning, frets 0-20, and a maximum four-fret span excluding open strings. They render standard notation plus generated TAB. Practice sources provide a monophonic melody and optional chord symbols; the vocal part adds deterministic scat syllables without changing rhythm or written register. Acoustic, electric, and vocal parts all sound one octave below their notation, for both songs and practice. Explicit MusicXML instrument transposition is honored once; an unplayable passage fails rather than being shifted to fit.
+10 -11
View File
@@ -35,7 +35,7 @@ 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 validates the packaged `catalog.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. Performance navigation is Song -> Part -> Score; practice is Exercise -> Part -> Score. Both offer Acoustic, Electric, and Vocal, without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
At startup the Zero validates the packaged `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. Performance navigation is Song -> Part -> Score; practice is Exercise -> Part -> Score. Both offer Acoustic, Electric, and Vocal, without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
Runtime catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index; practice is a flat, numerically ordered exercise list. 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.
@@ -103,12 +103,11 @@ Ordinary ties still link adjacent matching tones across measures and systems. Tu
### Runtime Schema
A newly generated bundle has four top-level fields:
A newly generated bundle has three top-level fields:
- `schema_version: 3`.
- `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans.
- `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums.
- `playback`: versioned availability, exact performed beat duration/events, shared PCM sample descriptors, and a generated release envelope. Unavailable interpretations carry a reason instead of an approximate timeline.
- `presentation`: engraver name, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums.
- `playback`: availability, exact performed beat duration/events, shared PCM sample descriptors, and a generated release envelope. Unavailable interpretations carry a reason instead of an approximate timeline.
There are no authored-key/selected-key metadata pairs. The score's key metadata describes its actual music, and menus display that key alongside its part name. Instrument normalization transforms explicit key cancellations with the same interval as the notes.
@@ -140,7 +139,7 @@ Placement minimizes total movement of fret/string position centers, then fretted
All musical compilation and tone synthesis run in Python (`generator/playback.py`). Each transformed score receives a sorted timeline of independent tone events with rational quarter-note `onset` and `end` positions. Ties become one continuous attack. Rests and spacers remain silence, including trailing silence. Harmony labels are not auto-accompaniment: only pitches actually present in the selected arrangement or exercise sound. Cue notes are silent, hidden pitched notes sound, and pitchless rhythm slashes are silent.
The `soft-struck-1` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Instrument transposition is applied once from the generated musical model, without role- or filename-dependent octave heuristics. Samples are shared across scores and voices by content identity. There are no SoundFonts, bundled instrument samples, oscillators, or MusicXML parsing at runtime.
The `soft-struck` profile generates shared mono 48 kHz PCM16 WAVs with a short attack/decay and a seamless sustain loop, plus a shared 50 ms release envelope. Exact sounding pitches, including supported microtones, determine samples. Instrument transposition is applied once from the generated musical model, without role- or filename-dependent octave heuristics. Samples are shared across scores and voices by content identity. There are no SoundFonts, bundled instrument samples, oscillators, or MusicXML parsing at runtime.
Grace groups honor explicit steal-time-previous/following or make-time; unspecified groups share 25 percent of the following event. Conflicting grace modes, trailing grace groups, unsupported unpitched sound, tied measured tremolo, and incompatible paired tremolo make playback explicitly unavailable while retaining the notation. Measured tremolo uses written subdivision and tuplet ratios. The tone range is 20-18,000 Hz. Asset validation checks containment, SHA-256, PCM format/length, loop endpoints, envelope shape, event ordering, references, and rational timing. Generation remains transactional if validation fails.
@@ -166,15 +165,15 @@ Generated guitar tones carry `tablature: {string, fret}`; their staff is marked
`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. The musical bundle version is separate from the catalog database version; runtime accepts only the supported packaged catalog schema.
Bundles preserve the complete `music` object. Presentation identifies the `Vamp Python` engraver; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Scores, playback manifests, and the catalog have no numbered schema labels. Required fields, types, relationships, dimensions, and checksums determine whether generated data is usable. Incompatible data is rejected, never migrated or guessed.
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
### Validation And Publication
Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; 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 catalog. Runtime packages contain `catalog.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 catalog score.
The page cache uses `v2/<renderer fingerprint>/<score fingerprint>/` namespaces. `python3 -m generator --renderer-fingerprint` reports the same identity used by rendering and CI: source bytes, fonts, style, Python implementation/version, and rendering-library versions. Python compiler/build timestamps are excluded. CI keys append all generator and XML inputs and restore older snapshots only within the same renderer namespace; only that namespace is saved after a successful build. A renderer or environment change intentionally starts cold. Progress reports the resolved namespace and reused, missing, and rejected counts. Old flat cache entries are not migrated and can be removed when builds are idle.
The page cache uses `<renderer fingerprint>/<score fingerprint>/` directories, without a manual cache revision. `python3 -m generator --renderer-fingerprint` reports the same identity used by rendering and CI: source bytes, fonts, style, Python implementation/version, and rendering-library versions. Python compiler/build timestamps are excluded. CI keys append all generator and active XML inputs and restore earlier snapshots only within the same renderer directory; only that directory is saved after a successful build. A renderer or environment change intentionally starts cold. Progress reports the resolved directory and reused, missing, and rejected counts. Obsolete cache directories are not migrated and can be removed when builds are idle.
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`.
@@ -220,7 +219,7 @@ 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/catalog.py` uses Python's standard-library SQLite module to construct `catalog.db` after musical, playback, and engraving validation. The database carries application ID `0x56414D50` and catalog schema version 3, 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.
`generator/catalog.py` uses Python's standard-library SQLite module to construct `catalog.db` after musical, playback, and engraving validation. The database carries application ID `0x56414D50`, complete score bundles, and menu metadata. Runtime validates both required strict tables, their column names/types, non-null requirements, and primary keys directly. 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/catalog.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 catalog compatibility; `--once` additionally runs SQLite's integrity probe and checks all page/audio files. Missing, corrupt, or incompatible catalogs fail rather than creating an empty library. `--ensure-database` is no longer a supported command.
@@ -228,7 +227,7 @@ Every run of `zero/install-zero.sh` is a clean installation. It stages configura
Configuration, AP credentials, and all prior catalog 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.
CI builds stable target-specific archives with `python3 modulus.py package`, then `package/scripts/publish_latest_generic_package.py` uploads exactly the configured targets and their checksums. Gitea requires a registry slot, so publishing replaces `latest` and removes obsolete remote entries. There is no application release number, artifact version argument, or versioned install directory. The manifest, heartbeat, status endpoint, and `--build-info` retain the Git commit for diagnostics. Conan references, compiler/dependency pins, and MusicXML protocol identifiers remain external tool requirements.
## Shared Protocol
+1 -1
View File
@@ -29,7 +29,7 @@ endif()
include(${PICO_SDK_PATH}/pico_sdk_init.cmake)
project(product-vamp-pico VERSION ${VAMP_PROJECT_VERSION} LANGUAGES C CXX ASM)
project(product-vamp-pico LANGUAGES C CXX ASM)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
+1 -1
View File
@@ -103,7 +103,6 @@ target_include_directories(vamp-zero-core PUBLIC
)
target_compile_features(vamp-zero-core PUBLIC cxx_std_23)
target_compile_definitions(vamp-zero-core PUBLIC
VAMP_VERSION="${PROJECT_VERSION}"
VAMP_GIT_SHA="${VAMP_GIT_SHA}"
)
target_link_libraries(vamp-zero-core PUBLIC
@@ -141,6 +140,7 @@ file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
)
list(FILTER VAMP_GENERATOR_INPUTS EXCLUDE REGEX "/data/rhythms/")
set(VAMP_GENERATED_ASSETS_DIR ${CMAKE_CURRENT_BINARY_DIR}/generated-assets)
set(VAMP_GENERATED_ASSETS_STAMP ${CMAKE_CURRENT_BINARY_DIR}/generated-assets.stamp)
set(VAMP_ENGRAVING_JOBS 2 CACHE STRING "Maximum parallel Python engraving workers (1-16)")
+7 -1
View File
@@ -2,4 +2,10 @@ include(../../build/build_tools/conan/profiles/arm_64_gcc12_release)
[conf]
tools.build:jobs=2
tools.build:compiler_executables={"c": "/usr/bin/aarch64-linux-gnu-gcc-12", "cpp": "/usr/bin/aarch64-linux-gnu-g++-12"}
tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}
# Exclude older internal binaries published with an unversioned cross compiler.
modulus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
ligo/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
noto/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
luma/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
sonus/*:tools.info.package_id:confs=["tools.build:compiler_executables"]
+2
View File
@@ -57,6 +57,8 @@ def generate_assets(args: argparse.Namespace) -> None:
report('checking source paths')
for source in sorted(data_dir.rglob('*.xml')):
if source.is_relative_to(data_dir / 'rhythms'):
continue
if source.parent not in (data_dir / 'practice', data_dir / 'songs'):
raise ValueError(f'{source}: MusicXML sources belong directly under data/practice or data/songs')
source_paths(data_dir / 'songs')
+2 -4
View File
@@ -7,7 +7,6 @@ import tempfile
from pathlib import Path
APPLICATION_ID = 0x56414D50
SCHEMA_VERSION = 3
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),
@@ -24,8 +23,8 @@ def metadata(path: Path, root: Path) -> dict:
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')
if not isinstance(document, dict) or not isinstance(document.get('music'), dict) or not isinstance(document.get('presentation'), dict):
raise ValueError('score bundle requires music and presentation objects')
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')
@@ -46,7 +45,6 @@ def populate(connection: sqlite3.Connection, root: Path) -> None:
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, part TEXT NOT NULL) STRICT;
CREATE INDEX songs_menu ON songs(title COLLATE NOCASE, composer COLLATE NOCASE, part COLLATE NOCASE);
+2 -3
View File
@@ -17,7 +17,6 @@ from generator.engraving.types import EngravingError, Style
from generator.model import validate_document
ENGINE_NAME = 'Vamp Python'
ENGINE_VERSION = '1.0.0'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
@@ -70,8 +69,8 @@ def engrave_scores(paths: list[Path], root: Path, fonts: Path, cache_dir: Path |
else:
reused += 1
entries = [{**page, 'path': destination.name + '/' + page['path']} for page in pages]
music = {k: v for k, v in document.items() if k not in ('schema_version', 'playback')}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
music = {k: v for k, v in document.items() if k != 'playback'}
bundle = {'music': music, 'presentation': {'engine': ENGINE_NAME, 'style_sha256': fingerprint, 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
if 'playback' in document:
bundle['playback'] = document['playback']
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
+1 -1
View File
@@ -22,7 +22,7 @@ def renderer_fingerprint(fonts, style) -> str:
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
def renderer_namespace(cache: Path, renderer: str) -> Path:
namespace = cache / 'v2' / renderer
namespace = cache / renderer
if namespace.parent.is_symlink() or namespace.is_symlink():
raise ValueError('engraving cache namespaces must not be symlinks')
return namespace
+7 -4
View File
@@ -4,7 +4,6 @@ import copy
from fractions import Fraction
from typing import Any
SCHEMA_VERSION = 3
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
@@ -141,11 +140,15 @@ def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str,
return event
def validate_document(document: dict) -> None:
if document.get('schema_version') != SCHEMA_VERSION:
raise ValueError('unsupported score schema; regenerate score assets with the current generator')
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
raise ValueError('score requires a metadata object')
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
staves = document.get('staves', [])
if len(staves) != 1:
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
raise ValueError('exactly one staff is currently supported')
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
raise ValueError('score requires measures')
instruments = staves[0].get('instruments', [])
instrument_ids = {i['id'] for i in instruments}
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
+2 -2
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from defusedxml import ElementTree
from generator import harmony
from generator.model import (ACCIDENTALS, SCHEMA_VERSION, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.model import (ACCIDENTALS, TYPE_LENGTHS, alteration, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, )
from generator.styles import canonical_style
class MusicXMLError(ValueError):
@@ -625,7 +625,7 @@ class Importer:
for p in n['pitches']:
p.pop('_absolute', None)
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
result = {'schema_version': SCHEMA_VERSION, 'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
result = {'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]}
result['staves'][0]['instruments'] = instruments
validate_document(result)
return result
+5 -5
View File
@@ -13,7 +13,7 @@ from generator.model import TYPE_LENGTHS, fraction, rational
from generator import pitch
SAMPLE_RATE = 48000
PROFILE = 'soft-struck-1'
PROFILE = 'soft-struck'
MAX_EVENTS = 1000000
class PlaybackUnavailable(ValueError):
@@ -145,7 +145,7 @@ def compile_timeline(document: dict) -> dict:
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
return {'version': 1, 'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
return {'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
def write_pcm(path: Path, values) -> str:
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
@@ -193,13 +193,13 @@ def compile_tree(root: Path) -> None:
used[tone['id']] = tone
playback.update({'samples': list(used.values()), 'release': release, 'profile': PROFILE})
except PlaybackUnavailable as error:
playback = {'version': 1, 'available': False, 'reason': str(error)}
playback = {'available': False, 'reason': str(error)}
document['playback'] = playback
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> None:
if playback.get('version') != 1 or type(playback.get('available')) is not bool:
raise ValueError('invalid playback manifest version/availability')
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
raise ValueError('playback manifest requires boolean availability')
if not playback['available']:
if not isinstance(playback.get('reason'), str) or not playback['reason']:
raise ValueError('unavailable playback must explain why')
+8 -6
View File
@@ -6,19 +6,21 @@ import json
import re
from pathlib import Path
from generator.model import SCHEMA_VERSION, validate_document
from generator.model import validate_document
def load_json(path: Path) -> dict:
document = json.loads(path.read_text(encoding='utf-8'))
return validate_json(document, path)
def validate_json(document: dict, path: Path) -> dict:
if not isinstance(document, dict):
raise ValueError('expected a score object')
if 'music' in document:
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
from generator.engraving import ENGINE_NAME, PAGE_HEIGHT, PAGE_WIDTH, check_page
presentation = document.get('presentation', {})
if document.get('schema_version') != SCHEMA_VERSION or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
if not isinstance(document['music'], dict) or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
raise ValueError('invalid score bundle; regenerate score assets')
if (presentation.get('engine'), presentation.get('engine_version'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, PAGE_WIDTH, PAGE_HEIGHT):
raise ValueError('unsupported score presentation; regenerate score assets')
for page in presentation['pages']:
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
@@ -33,7 +35,7 @@ def validate_json(document: dict, path: Path) -> dict:
if not re.fullmatch('[0-9a-f]{64}', page['sha256']) or hashlib.sha256(resolved.read_bytes()).hexdigest() != page['sha256']:
raise ValueError('score page checksum mismatch')
check_page(resolved)
document = {'schema_version': SCHEMA_VERSION, **document['music']}
document = document['music']
validate_document(document)
return document
@@ -117,7 +119,7 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
return tuplets
def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict:
return {'schema_version': SCHEMA_VERSION, 'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
return {'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': copy.deepcopy(octave_shifts or []), 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
def count_json_files(root: Path) -> int:
return sum(1 for p in root.rglob('*.json') if p.is_file())
+1 -1
View File
@@ -15,7 +15,7 @@ struct command_line {
std::string config_path{"/etc/product-vamp/vamp.json"};
std::bool_t check_config{};
std::bool_t health_check{};
std::bool_t print_version{};
std::bool_t print_build_info{};
std::bool_t exit_after_startup{};
};
-3601
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1,6 +1,5 @@
{
"package": "product-vamp",
"version": "@PROJECT_VERSION@",
"git_sha": "@VAMP_GIT_SHA@",
"zero": {
"device": "raspberry-pi-zero-2w",
+1 -1
View File
@@ -23,5 +23,5 @@ else
docker build --platform linux/arm64 --tag "$image_tag" "$context_dir"
fi
docker run --rm --platform linux/arm64 "$image_tag" /usr/bin/vamp-zero --version
docker run --rm --platform linux/arm64 "$image_tag" /usr/bin/vamp-zero --build-info
docker save "$image_tag" --output "$output_tar"
@@ -10,7 +10,7 @@ import urllib.parse
import urllib.request
DEFAULT_REGISTRY_URL = "https://git.thyme-labs.com/api/packages/khodo/generic"
DEFAULT_VERSION = "latest"
REGISTRY_SLOT = "latest"
PAGE_LIMIT = 50
def read_config(path):
@@ -30,7 +30,10 @@ def read_config(path):
registry_url = artifact.get("registry_url")
if not isinstance(registry_url, str) or not registry_url:
registry_url = DEFAULT_REGISTRY_URL
return name, registry_url
targets = artifact.get("targets")
if not isinstance(targets, list) or not targets or any(not isinstance(target, str) or target not in data.get("tooling", {}).get("targets", {}) for target in targets):
raise SystemExit(f"{path}: package.artifact.targets must name configured build targets")
return name, registry_url, targets
def parse_registry_url(registry_url):
parsed = urllib.parse.urlparse(registry_url.rstrip("/"))
@@ -66,11 +69,11 @@ def version_delete_url(parsed, prefix, owner, package_name, version):
path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}"
return build_url(parsed, path)
def file_upload_url(registry_url, package_name, version, path):
def file_upload_url(registry_url, package_name, path):
return "/".join((
registry_url.rstrip("/"),
urllib.parse.quote(package_name, safe=""),
urllib.parse.quote(version, safe=""),
REGISTRY_SLOT,
urllib.parse.quote(path.name, safe=""),
))
@@ -129,23 +132,18 @@ def delete_version(parsed, prefix, owner, package_name, version, auth):
raise SystemExit(f"DELETE {url} returned HTTP {response.status}")
print(f"Deleted package version: {package_name}/{version}")
def discover_artifacts(dist_dir, package_name, version):
archives = sorted(dist_dir.glob(f"{package_name}-{version}-*.tar.gz"))
def discover_artifacts(dist_dir, package_name, targets):
artifacts = []
for archive in archives:
artifacts.append(archive)
checksum = archive.with_name(archive.name + ".sha256")
if checksum.is_file():
artifacts.append(checksum)
if not artifacts:
raise SystemExit(f"no artifacts found matching {dist_dir}/{package_name}-{version}-*.tar.gz")
for target in sorted(set(targets)):
archive = dist_dir / f"{package_name}-{target}.tar.gz"
artifacts.extend((archive, archive.with_name(archive.name + ".sha256")))
missing = [path for path in artifacts if not path.is_file()]
if missing:
raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing))
return artifacts
def upload_file(registry_url, package_name, version, path, auth):
url = file_upload_url(registry_url, package_name, version, path)
def upload_file(registry_url, package_name, path, auth):
url = file_upload_url(registry_url, package_name, path)
headers = {
"Content-Length": str(path.stat().st_size),
"Content-Type": "application/octet-stream",
@@ -155,34 +153,33 @@ def upload_file(registry_url, package_name, version, path, auth):
with response:
if response.status != 201:
raise SystemExit(f"PUT {url} returned HTTP {response.status}")
print(f"Uploaded package file: {package_name}/{version}/{path.name}")
print(f"Uploaded package file: {package_name}/{path.name}")
def parse_args(argv):
parser = argparse.ArgumentParser(description="Replace Product Vamp's rolling Gitea generic package.")
parser.add_argument("--config", type=pathlib.Path, default=pathlib.Path("modulus.json"))
parser.add_argument("--dist-dir", type=pathlib.Path, default=pathlib.Path("build/packages"))
parser.add_argument("--version", default=DEFAULT_VERSION)
parser.add_argument("--user", default=None)
parser.add_argument("--token", default=None)
return parser.parse_args(argv)
def main(argv):
args = parse_args(argv)
package_name, registry_url = read_config(args.config)
package_name, registry_url, targets = read_config(args.config)
user = args.user or os_environ("PACKAGE_REGISTRY_USER")
token = args.token or os_environ("PACKAGE_REGISTRY_TOKEN")
auth = authorization(user, token)
parsed, prefix, owner = parse_registry_url(registry_url)
artifacts = discover_artifacts(args.dist_dir, package_name, args.version)
artifacts = discover_artifacts(args.dist_dir, package_name, targets)
existing_versions = list_versions(parsed, prefix, owner, package_name, auth)
# Gitea rejects duplicate file names within a generic package version, so
# replacing the rolling release requires deleting the old version first.
if args.version in existing_versions:
delete_version(parsed, prefix, owner, package_name, args.version, auth)
if REGISTRY_SLOT in existing_versions:
delete_version(parsed, prefix, owner, package_name, REGISTRY_SLOT, auth)
for artifact in artifacts:
upload_file(registry_url, package_name, args.version, artifact, auth)
upload_file(registry_url, package_name, artifact, auth)
for version in existing_versions:
if version != args.version:
if version != REGISTRY_SLOT:
delete_version(parsed, prefix, owner, package_name, version, auth)
return 0
+6 -7
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]";
return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--build-info] [--once]";
}
auto bind_port(vamp_zero::config const &config) -> std::uint16_t {
@@ -127,7 +127,6 @@ auto write_text_atomic(std::filesystem::path const &destination, std::string con
auto write_heartbeat(vamp_zero::config const &config) -> std::expected<void, modulus::error> {
auto heartbeat = boost::json::object{};
heartbeat["version"] = VAMP_VERSION;
heartbeat["git_sha"] = VAMP_GIT_SHA;
heartbeat["status"] = "ok";
heartbeat["endpoint"] = "http://" + config.control.zero_ip + ":" + std::to_string(bind_port(config));
@@ -457,8 +456,8 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std
result.check_config = true;
} else if (argument == "--health-check") {
result.health_check = true;
} else if (argument == "--version") {
result.print_version = true;
} else if (argument == "--build-info") {
result.print_build_info = true;
} else if (argument == "--once") {
result.exit_after_startup = true;
} else if (argument == "--help" || argument == "-h") {
@@ -469,7 +468,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.print_build_info ? 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"));
@@ -478,8 +477,8 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std
}
auto vamp_zero::run(vamp_zero::command_line command) -> std::expected<void, modulus::error> {
if (command.print_version) {
std::cout << VAMP_VERSION << " " << VAMP_GIT_SHA << '\n';
if (command.print_build_info) {
std::cout << "product-vamp " << VAMP_GIT_SHA << '\n';
return {};
}
auto config = vamp_zero::load_config(command.config_path);
+34 -3
View File
@@ -25,7 +25,6 @@
namespace {
constexpr auto application_id = std::int64_t{0x56414D50};
constexpr auto catalog_schema_version = std::int64_t{3};
auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config {
auto result = noto::file_connection_config(config.path, "product-vamp");
@@ -89,6 +88,34 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t {
return count == nullptr || *count <= 0 ? 0U : static_cast<std::size_t>(*count);
}
auto compatible_table(noto::connection &connection, std::string const &table) -> boost::asio::awaitable<std::bool_t> {
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages"};
if (table == "practice_exercises") {
columns.push_back("exercise_id");
}
columns.push_back("part");
auto parameters = std::vector<noto::sqlite_value>{noto::sqlite_value{table}};
auto selected = co_await connection.async_execute("SELECT name, type, \"notnull\", pk FROM pragma_table_info(?1) "
"WHERE EXISTS (SELECT 1 FROM pragma_table_list WHERE schema = 'main' AND name = ?1 AND type = 'table' AND strict = 1) ORDER BY cid",
std::move(parameters));
if (!selected || selected->rows.size() != columns.size()) {
co_return false;
}
for (auto index = std::size_t{}; index < columns.size(); ++index) {
auto const &row = selected->rows[index];
if (row.size() != 4U) {
co_return false;
}
auto required = std::get_if<std::int64_t>(&row[2].value);
auto primary = std::get_if<std::int64_t>(&row[3].value);
auto type = index == 6U || index == 7U ? "INTEGER" : "TEXT";
if (text_value(row[0]) != columns[index] || text_value(row[1]) != type || required == nullptr || *required != 1 || primary == nullptr || *primary != (index == 0U ? 1 : 0)) {
co_return false;
}
}
co_return true;
}
}
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>> {
@@ -102,11 +129,15 @@ auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, v
co_return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "cannot open catalog: " + config.path + "; rebuild or reinstall Product Vamp: " + connected.error().describe()));
}
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 != catalog_schema_version) {
if (!application || *application != application_id) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid or incompatible catalog: " + config.path + "; rebuild or reinstall Product Vamp"));
}
for (auto table : {"songs", "practice_exercises"}) {
if (status && !(co_await compatible_table(connection, table))) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "incompatible catalog table " + std::string{table} + ": " + 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") {
-3
View File
@@ -50,9 +50,6 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
return invalid("expected a JSON object");
}
auto const &root = value.as_object();
if (!integer_equals(root, "schema_version", 3)) {
return invalid("unsupported schema; regenerate this score and its page assets");
}
auto music = object_field(root, "music");
auto presentation = object_field(root, "presentation");
if (music == nullptr || presentation == nullptr) {
+2 -4
View File
@@ -132,9 +132,8 @@ auto vamp_zero::prepare_playback(vamp_zero::score_document const &score, std::fi
return std::unexpected(invalid("regenerate this score to enable playback"));
}
auto const &object = playback->as_object();
auto version = number(object, "version");
auto available = object.if_contains("available");
if (!version || *version != 1U || !available || !available->is_bool()) {
if (!available || !available->is_bool()) {
return std::unexpected(invalid("unsupported playback manifest"));
}
if (!available->as_bool()) {
@@ -205,9 +204,8 @@ auto vamp_zero::validate_playback_assets(vamp_zero::score_document const &score,
if (playback->is_object()) {
auto available = playback->as_object().if_contains("available");
if (available && available->is_bool() && !available->as_bool()) {
auto version = number(playback->as_object(), "version");
auto reason = text(playback->as_object(), "reason");
if (version && *version == 1U && reason && !reason->empty()) {
if (reason && !reason->empty()) {
return {};
}
}
-1
View File
@@ -155,7 +155,6 @@ auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::a
control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind);
auto root = boost::json::object{};
root["device"] = "zero";
root["version"] = VAMP_VERSION;
root["git_sha"] = VAMP_GIT_SHA;
root["control"] = std::move(control);
root["state"] = vamp_zero::app_status_json(state);
+20 -3
View File
@@ -38,6 +38,7 @@
#include <vamp_zero/parse.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/app.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/types.hpp>
@@ -94,7 +95,6 @@ auto has_value(noto::query_result const &result, std::size_t column, std::string
auto score_asset_json(std::string title, std::string key = "C", std::string part = "Lead") -> std::string {
auto value = boost::json::parse(R"json({
"schema_version":3,
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"id":"staff-lead","name":"Lead","measures":[{"id":"measure-1","voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[{"path":"fixture.pages/0001.png",
@@ -152,6 +152,23 @@ void write_text(std::filesystem::path const &path, std::string const &text) {
}
BOOST_AUTO_TEST_CASE(build_identification_uses_git_without_an_application_release_label) {
auto program = std::string{"vamp-zero"};
auto option = std::string{"--build-info"};
auto args = std::vector<std::char_t *>{program.data(), option.data()};
auto command = vamp_zero::parse_command_line(2, args.data());
BOOST_REQUIRE(command);
BOOST_TEST(command->print_build_info);
command->config_path = "/nonexistent/product-vamp.json";
BOOST_TEST(vamp_zero::run(*command).has_value());
auto once = std::string{"--once"};
args.push_back(once.data());
BOOST_TEST(!vamp_zero::parse_command_line(3, args.data()));
auto status = vamp_zero::portal_status_json({}, {}).as_object();
BOOST_TEST(status.at("git_sha").as_string() == VAMP_GIT_SHA);
BOOST_TEST(!status.contains("version"));
}
BOOST_AUTO_TEST_CASE(runtime_config_uses_headless_and_ignores_unknown_fields) {
auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({"runtime":{"headless":true,"obsolete_setting":"ignored"},"audio":{"enabled":false}})json"));
BOOST_REQUIRE(parsed);
@@ -521,8 +538,8 @@ BOOST_AUTO_TEST_CASE(score_bundle_preserves_complete_music_without_runtime_flatt
BOOST_TEST(vamp_zero::score_page_count(*parsed) == 1U);
}
BOOST_AUTO_TEST_CASE(score_bundle_rejects_legacy_schema_malformed_manifest_and_unsafe_paths) {
for (auto text : {"", "null", "[]", "{}", "{", R"({"schema_version":2})"}) {
BOOST_AUTO_TEST_CASE(score_bundle_rejects_malformed_structure_manifest_and_unsafe_paths) {
for (auto text : {"", "null", "[]", "{}", "{", R"({"music":{}})", R"({"presentation":{}})"}) {
BOOST_CHECK_NO_THROW(BOOST_TEST(!vamp_zero::parse_score_document(text)));
}
for (auto path : {"../page.png", "/page.png", "pages/../../page.png", "pages/./page.png", "page.jpg", ""}) {
+38
View File
@@ -1,12 +1,50 @@
import json
import importlib.util
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import types
import unittest
from unittest.mock import Mock, patch
class BuildBootstrapTests(unittest.TestCase):
def test_arm_profile_selects_versioned_compilers_available_in_ci_and_docker(self):
root = Path(__file__).resolve().parents[1]
profile = (root / 'conan/profiles/arm_64_gcc12_release').read_text()
self.assertIn('tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-12", "cpp": "aarch64-linux-gnu-g++-12"}', profile)
for path in ('.gitea/workflows/ci.yml', 'package/Dockerfile.build'):
source = (root / path).read_text()
for compiler in ('gcc', 'g++'):
self.assertIn(f'{compiler}-12-aarch64-linux-gnu', source)
self.assertNotIn(f'{compiler}-aarch64-linux-gnu', source)
@unittest.skipUnless(shutil.which('conan'), 'Conan is required for package identity checks')
def test_arm_package_ids_exclude_unverified_internal_binaries_only(self):
root = Path(__file__).resolve().parents[1]
if not (root / 'build/build_tools/conan/profiles/arm_64_gcc12_release').is_file():
self.skipTest('Configure first to export the shared Conan profiles')
profile = root / 'conan/profiles/arm_64_gcc12_release'
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
environment = dict(os.environ, CONAN_HOME=str(directory / 'conan'), CONAN_NON_INTERACTIVE='1')
command = ['conan', 'graph', 'info', str(directory), f'--profile:host={profile}', f'--profile:build={profile}', '--no-remote', '--format=json']
def package_id(*extra):
result = subprocess.run([*command, *extra], env=environment, text=True, capture_output=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)['graph']['nodes']['0']['package_id']
for name in ('modulus', 'ligo', 'noto', 'luma', 'sonus', 'raylib'):
with self.subTest(package=name):
(directory / 'conanfile.py').write_text(f'from conan import ConanFile\nclass Fixture(ConanFile):\n name = "{name}"\n version = "latest"\n settings = "os", "arch", "compiler", "build_type"\n')
current = package_id()
legacy = package_id('-c:h', f'{name}/*:tools.info.package_id:confs=[]')
different_compiler = package_id('-c:h', 'tools.build:compiler_executables={"c": "aarch64-linux-gnu-gcc-13", "cpp": "aarch64-linux-gnu-g++-13"}')
if name == 'raylib':
self.assertEqual(current, different_compiler)
else:
self.assertNotEqual(current, legacy)
self.assertNotEqual(current, different_compiler)
def test_build_recipe_is_declared_and_editor_commands_do_not_bootstrap(self):
root = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
+1 -3
View File
@@ -5,11 +5,10 @@ import tempfile
import unittest
from pathlib import Path
from generator.catalog import APPLICATION_ID, SCHEMA_VERSION, build_catalog
from generator.catalog import APPLICATION_ID, build_catalog
def bundle(title='Fixture', key='C', part='Lead'):
return {
'schema_version': 3,
'music': {'metadata': {'title': title, 'composer': 'Composer', 'key_signature': key, 'time_signature': '4/4'},
'staves': [{'name': part, 'arrangement_part_type': part.lower(), 'measures': [{'voices': []}]}]},
'presentation': {'width': 976, 'height': 484, 'pages': [{'path': 'fixture.pages/1.png', 'sha256': '0' * 64}]},
@@ -51,7 +50,6 @@ class CatalogTests(unittest.TestCase):
self.assertNotIn('authored_key_signature', practice[0])
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())
+28 -6
View File
@@ -81,7 +81,6 @@ auto catalog_query(boost::asio::any_io_executor executor, vamp_zero::database_co
auto catalog_bundle(std::string_view title, std::string_view key = "C", std::string_view part = "Lead") -> std::string {
auto value = boost::json::parse(R"json({
"schema_version":3,
"music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"},
"staves":[{"name":"Lead","measures":[{"voices":[]},{"voices":[]},{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[
@@ -388,7 +387,7 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
BOOST_TEST(disagreement.error().message().find("disagree on key") != std::string_view::npos);
}
BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_legacy_and_corrupt_files_without_repair) {
BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_incompatible_and_corrupt_files_without_repair) {
auto db = catalog_database{};
auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, true); };
BOOST_TEST(!validate());
@@ -401,14 +400,16 @@ BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_legacy_and_corrupt_files
BOOST_REQUIRE(practice);
BOOST_TEST(songs->parts.empty());
BOOST_TEST(practice->exercises.empty());
for (auto version : {0, 1, 2}) {
db.query("PRAGMA user_version = " + std::to_string(version));
for (auto sql : {"ALTER TABLE songs RENAME COLUMN part TO obsolete_part", "ALTER TABLE practice_exercises RENAME COLUMN exercise_id TO obsolete_id", "DROP TABLE songs", "DROP TABLE practice_exercises"}) {
db.query(sql);
auto snapshot_sql = "SELECT json_group_array(sql) FROM (SELECT sql FROM sqlite_schema ORDER BY name)";
auto before = std::get<std::string>(db.query(snapshot_sql).rows[0][0].value);
auto result = validate();
BOOST_REQUIRE(!result);
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);
BOOST_TEST(std::get<std::string>(db.query(snapshot_sql).rows[0][0].value) == before);
BOOST_REQUIRE(db.build());
}
db.query("PRAGMA user_version = 3");
db.query("PRAGMA application_id = 0");
BOOST_TEST(!validate());
auto output = std::ofstream{db.config.path, std::ios::trunc};
@@ -419,6 +420,27 @@ BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_legacy_and_corrupt_files
BOOST_TEST(!std::filesystem::exists(db.config.path + "-journal"));
}
BOOST_AUTO_TEST_CASE(catalog_validation_checks_column_types_constraints_and_table_kind) {
auto db = catalog_database{};
auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, false); };
for (auto change : {std::pair{"title TEXT NOT NULL", "title BLOB NOT NULL"}, {"title TEXT NOT NULL", "title TEXT"}, {"PRIMARY KEY", ""}, {" STRICT", ""}}) {
BOOST_REQUIRE(db.build());
auto sql = std::get<std::string>(db.query("SELECT sql FROM sqlite_schema WHERE name = 'songs'").rows[0][0].value);
auto position = sql.find(change.first);
BOOST_REQUIRE(position != std::string::npos);
sql.replace(position, std::string_view{change.first}.size(), change.second);
db.query("DROP TABLE songs");
db.query(sql);
auto timestamp = std::filesystem::last_write_time(db.config.path);
BOOST_TEST(!validate());
BOOST_TEST((std::filesystem::last_write_time(db.config.path) == timestamp));
}
BOOST_REQUIRE(db.build());
db.query("ALTER TABLE songs RENAME TO underlying_songs");
db.query("CREATE VIEW songs AS SELECT * FROM underlying_songs");
BOOST_TEST(!validate());
}
BOOST_AUTO_TEST_CASE(catalog_reads_readonly_database_without_json_and_resolves_relocated_assets) {
auto db = catalog_database{};
db.write("songs/tune.json", catalog_bundle("Relocated"));
+5 -4
View File
@@ -18,9 +18,10 @@ from tests.test_musicxml import parse, xml_note, xml_score
class EngravingCacheTests(unittest.TestCase):
def test_ci_snapshot_tracks_all_generator_inputs_and_keeps_fallback(self):
workflow = Path('.gitea/workflows/ci.yml').read_text()
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/**/*.xml', 'data/*.xml')", workflow)
self.assertIn("hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml')", workflow)
self.assertIn('python3 -m generator --renderer-fingerprint', workflow)
self.assertIn('restore-keys: product-vamp-pages-v2-${{ steps.renderer.outputs.fingerprint }}-', workflow)
self.assertIn('restore-keys: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}-', workflow)
self.assertIn('path: build/build_common/engraving-cache/${{ steps.renderer.outputs.fingerprint }}', workflow)
self.assertIn("if: steps.engraving-cache.outputs.cache-hit != 'true'", workflow)
self.assertIn('key: ${{ steps.engraving-cache.outputs.cache-primary-key }}', workflow)
def setUp(self):
@@ -147,7 +148,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertIn('cold namespace', progress.getvalue())
self.assertIn('0 reused, 1 missing, 0 rejected', progress.getvalue())
self.assertTrue((old / 'pages.json').is_file())
self.assertEqual(len(list((self.cache / 'v2').iterdir())), 2)
self.assertEqual(len(list(self.cache.iterdir())), 2)
self.write_scores(root, [self.document])
progress = io.StringIO()
with patch('generator.engraving.cache.platform.python_version', return_value='0.0.0'), contextlib.redirect_stdout(progress), patch('generator.engraving.layout_score', side_effect=AssertionError('cache miss')):
@@ -155,7 +156,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertIn('1 reused, 0 missing, 0 rejected', progress.getvalue())
def test_namespace_symlink_is_rejected(self):
self.cache.mkdir()
(self.cache / 'v2').symlink_to(self.root, target_is_directory=True)
(self.cache / 'renderer').symlink_to(self.root, target_is_directory=True)
with self.assertRaisesRegex(ValueError, 'must not be symlinks'):
renderer_namespace(self.cache, 'renderer')
def test_only_changed_score_is_rendered_and_validation_reads_json_once(self):
+1 -1
View File
@@ -166,7 +166,7 @@ auto checksum(std::string const &bytes) -> std::string {
}
auto write_score(std::filesystem::path const &path, std::string part = "Lead") -> void {
auto score = boost::json::parse(R"({"schema_version":3,"music":{"metadata":{"title":"Fixture","composer":"Composer",
auto score = boost::json::parse(R"({"music":{"metadata":{"title":"Fixture","composer":"Composer",
"key_signature":"C","time_signature":"4/4"},"staves":[{"name":"Lead","measures":[{"voices":[]}]}]},
"presentation":{"width":976,"height":484,"pages":[]}})");
score.at("music").at("staves").as_array().front().as_object()["name"] = part;
+3 -3
View File
@@ -44,11 +44,11 @@ class MusicXMLTests(unittest.TestCase):
self.assertEqual(len(score['staves'][0]['measures']), 4)
self.assertEqual(len(score['staves'][0]['octave_shifts']), 1)
def test_catalog_imports(self):
sources = sorted(Path('data').rglob('*.xml'))
sources = sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml'))
self.assertTrue(sources)
for path in sources:
with self.subTest(path=path):
self.assertEqual(load_musicxml(path)['schema_version'], 3)
self.assertEqual(set(load_musicxml(path)), {'metadata', 'staves'})
def test_all_written_values_and_dots(self):
for kind in NOTE_TYPES:
for dots in (0, 1, 2, 3):
@@ -71,7 +71,7 @@ class MusicXMLTests(unittest.TestCase):
def test_doctype_allowed_but_entities_rejected(self):
source = xml_score(xml_note())
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
self.assertEqual(parse(source)['schema_version'], 3)
self.assertEqual(parse(source), parse(xml_score(xml_note())))
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
with self.assertRaises(MusicXMLError):
parse(malicious)
+38
View File
@@ -0,0 +1,38 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from package.scripts import publish_latest_generic_package
class PackagePublishingTests(unittest.TestCase):
def test_only_configured_target_archives_and_checksums_are_uploaded(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
archive = root / 'product-vamp-arm_64_gcc12_release.tar.gz'
checksum = archive.with_name(archive.name + '.sha256')
for path in (archive, checksum, root / 'product-vamp-obsolete-arm_64_gcc12_release.tar.gz'):
path.write_bytes(b'fixture')
self.assertEqual(publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release']), [archive, checksum])
checksum.unlink()
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
archive.unlink()
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.discover_artifacts(root, 'product-vamp', ['arm_64_gcc12_release'])
def test_missing_artifacts_do_not_touch_the_registry(self):
with tempfile.TemporaryDirectory() as temporary, patch.object(publish_latest_generic_package, 'request') as request:
with self.assertRaisesRegex(SystemExit, 'missing artifact files'):
publish_latest_generic_package.main(['--dist-dir', temporary, '--user', 'user', '--token', 'test-token'])
request.assert_not_called()
def test_publication_replaces_the_single_registry_slot(self):
archive = Path('product-vamp-arm_64_gcc12_release.tar.gz')
with patch.object(publish_latest_generic_package, 'discover_artifacts', return_value=[archive]), \
patch.object(publish_latest_generic_package, 'list_versions', return_value=['latest', 'obsolete']), \
patch.object(publish_latest_generic_package, 'delete_version') as delete, \
patch.object(publish_latest_generic_package, 'upload_file') as upload:
self.assertEqual(publish_latest_generic_package.main(['--user', 'user', '--token', 'test-token']), 0)
self.assertEqual([call.args[-2] for call in delete.call_args_list], ['latest', 'obsolete'])
self.assertEqual(upload.call_args.args[2], archive)
url = publish_latest_generic_package.file_upload_url('https://example.test/generic', 'product-vamp', archive)
self.assertEqual(url, 'https://example.test/generic/product-vamp/latest/product-vamp-arm_64_gcc12_release.tar.gz')
+18 -4
View File
@@ -11,7 +11,7 @@ from xml.etree import ElementTree
from generator.arrange.generate import arrange_document
from generator.__main__ import generate_assets
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
from generator.engraving import ENGINE_NAME, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
from tests.musicxml_roundtrip import audit_roundtrip
from generator.model import fraction, pitch_number, rational, validate_document
from generator.musicxml import load_musicxml
@@ -34,8 +34,16 @@ class ScoreBundleTests(unittest.TestCase):
generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
(data / 'rhythms' / 'drafts').mkdir(parents=True)
for path in ('rhythms/001.xml', 'rhythms/drafts/002.xml'):
(data / path).write_text('<unfinished')
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
misplaced = data / '001.xml'
misplaced.write_text(xml_score(xml_note()))
with self.assertRaisesRegex(ValueError, 'belong directly under'):
generate_assets(args)
misplaced.unlink()
with patch('generator.__main__.compile_tree', side_effect=ValueError('invalid generated audio')):
with self.assertRaisesRegex(ValueError, 'audio'):
generate_assets(args)
@@ -64,6 +72,12 @@ class ScoreBundleTests(unittest.TestCase):
path = Path(temporary) / 'roundtrip.xml'
write_musicxml(path, document)
return load_musicxml(path)
def test_score_structure_is_validated_without_schema_labels(self):
document = parse(xml_score(xml_note()))
self.assertEqual(set(document), {'metadata', 'staves'})
for malformed in (None, [], {}, {'metadata': {}, 'staves': []}, {**document, 'staves': None}, {**document, 'staves': [{}]}):
with self.subTest(document=malformed), self.assertRaises(ValueError):
validate_document(malformed)
def test_multiple_voices_keep_independent_ties_and_transformations(self):
def half(action):
return xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
@@ -159,7 +173,7 @@ class ScoreBundleTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
validate_document(source)
def test_catalog_roundtrip_preserves_written_pitch_and_rhythm(self):
for path in Path('data').rglob('*.xml'):
for path in sorted(path for category in ('practice', 'songs') for path in (Path('data') / category).glob('*.xml')):
with self.subTest(path=path):
source = load_musicxml(path)
restored = self.round_trip(source)
@@ -231,7 +245,7 @@ class ScoreBundleTests(unittest.TestCase):
page = assets / 'page.png'
page.write_bytes(data)
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
bundle = {'music': document, 'presentation': {'engine': ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
path = assets / 'score.json'
path.write_text(json.dumps(bundle))
self.assertEqual(load_json(path), document)
@@ -255,7 +269,7 @@ class ScoreBundleTests(unittest.TestCase):
write_json(root / 'fixture.json', document)
engrave_tree(root, Path('package/assets/fonts/petaluma'))
bundle = json.loads((root / 'fixture.json').read_text())
self.assertEqual(bundle['music'], {k: v for k, v in document.items() if k != 'schema_version'})
self.assertEqual(bundle['music'], document)
self.assertEqual(len(bundle['presentation']['pages']), 1)
page = root / bundle['presentation']['pages'][0]['path']
with Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected: