diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index a88d688..b822fa1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -112,7 +112,7 @@ jobs: uses: actions/cache/restore@v4 with: 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') }} + key: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}-${{ hashFiles('generator/**/*.py', 'generator/*.py', 'data/practice/*.xml', 'data/songs/*.xml', 'data/rhythms/**/*.xml') }} restore-keys: product-vamp-pages-${{ steps.renderer.outputs.fingerprint }}- - name: Build native release run: python3 modulus.py build --target x86_64_gcc12_release diff --git a/README.md b/README.md index 395c4c7..6a15191 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ 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. Builds replace the same target-specific archive without a release label. The manifest and `vamp-zero --build-info` identify the source commit. +The tarball contains the Zero Docker archive, Pi install scripts, generated catalog database, pages, audio, fonts, Pico title artwork, 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. @@ -88,7 +88,7 @@ Artifacts are published to the khodo generic package registry's required `latest 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. +`data/practice` and `data/songs` supply the authored music. `data/rhythms///` supplies the accompaniment rhythm library for 2/4, 3/4, 4/4, and 5/4. Rhythm snippets are build inputs, not separate catalog entries or runtime files. Generation loads the library once and shares it across parts. To inspect generated assets without building the full package, run: @@ -101,7 +101,13 @@ 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 -> JSON, engraved PNG pages, generated-tone audio, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) 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. +Identical notation bodies share layout and body rasterization even when titles or exercise identities differ; each score retains its own header, pages, music, and catalog identity. A `cold namespace` means no cache directory exists for the current renderer fingerprint. It does not mean that every score must be rendered independently: repeated bodies can still be reused within that build, including with persistent caching disabled. Progress separates full-score hits, body hits, newly rasterized bodies, and composed pages. + +The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Four-digit numbered sources in `data/practice/` (currently `0001.xml` through `0366.xml`; `0000.xml` is also valid) 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. + +Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic provides the foundation; electric accompaniment responds to the melody and acoustic attacks. Selection considers harmonic coverage, density, collisions, and available space, and restarts at section or meter changes. Rhythm snippets supply attacks, rests, and written durations; structured harmony supplies chord tones. N.C. and absent harmony remain silent. Authored melody, solo slashes, and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly. + +There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order. 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. @@ -109,7 +115,7 @@ Write all music in reading order: repeat bars, numbered endings, navigation jump The main menu's **Playback** toggle plays the selected part once at the menu BPM, suppresses the metronome, and returns to the part menu after natural completion. Manual page turns do not seek or restart audio. Playback is off by default. Python generates shared PCM tones and exact beat timelines; C++/Sonus only loads, schedules, and mixes them. Audio sample sustain loops hold individual notes for their written duration; they never repeat musical passages. No SoundFonts or runtime synthesizer are required. Practice navigation is Exercise -> Part -> Score, with the filename number retained alongside an authored title. -Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. +Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. Use American English in project-owned identifiers, filenames, messages, and documentation, such as `catalog`, `color`, and `canceled`. Preserve authored music and external API names. @@ -117,17 +123,17 @@ See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicX Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r generator/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets. -Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include PNG score pages, `PetalumaScript.otf` for UI text, and `OFL.txt`; the music font and engraver stay build-only. +Petaluma is from Steinberg under the SIL Open Font License. Petaluma supplies music glyphs, and Petaluma Script supplies all visible text on score pages and both device interfaces. The Zero uses packaged `PetalumaScript.otf` for dynamic UI text; the Pico displays prerendered Petaluma Script artwork. Runtime packages retain `OFL.txt`; the music font and engraver stay build-only. -Font generation requires Pillow's Raqm layout support to preserve kerning. On Debian/Ubuntu, install `libfribidi0` alongside the pinned Python requirements. The build image and CI install both. See [Pillow's installation requirements](https://pillow.readthedocs.io/en/stable/installation/basic-installation.html). +Pico artwork generation requires Pillow's Raqm layout support to preserve kerning. On Debian/Ubuntu, install `libfribidi0` alongside the pinned Python requirements. The build image and CI install both. See [Pillow's installation requirements](https://pillow.readthedocs.io/en/stable/installation/basic-installation.html). -Native and Pico builds generate their bitmap font implementation under `generated/vamp_style/font.cpp` in the build directory. The generated implementation is not checked in. The build image and CI install the pinned generator dependency automatically. For a direct build, first run: +Pico firmware embeds a small set of fixed labels, part names, digits, and the page separator, generated as four-bit coverage sprites under `generated/pico/static.p4`. Score generation prerenders titles into content-addressed `pico/titles/*.mask` assets referenced by the catalog and served by the Zero when the selection changes. Pico blends those pixels and composes page counts from digit sprites; it does not load fonts, shape text, or rasterize glyphs. All Pico text shaping and artwork generation run on the build host. The build image and CI install the pinned dependencies automatically. For a direct build, first run: ```sh python3 -m pip install -r generator/requirements.txt ``` -To check a generated atlas, run `python3 generator/generate_pico_font.py --output build/build_x86_debug/generated/vamp_style/font.cpp --check`. Changing the source OTF or generator rebuilds the atlas automatically. +To inspect the fixed artwork independently, run `python3 -m generator.pico_artwork --static-output /tmp/product-vamp-pico/static.p4`. Changing the source OTF or artwork generator rebuilds the firmware sprites and generated title assets. These generated files are not authored music and are not checked in. ## License diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index a663221..0d4820c 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -8,9 +8,9 @@ Product Vamp follows the Modulus product layout used by the khodo repos: - `include/vamp_pico` and `src/vamp_pico`: Pi Pico 2 W C++17 firmware. - `include/vamp_protocol` and `src/vamp_protocol`: shared route and page-command contracts used by both devices. - `cmake/pico.cmake` and `cmake/zero.cmake`: device-specific build definitions selected by the root `CMakeLists.txt`. -- `include/vamp_style.hpp`, `include/vamp_style/font.hpp`, and `src/vamp_style.cpp`: shared color palette and Petaluma font interfaces. Bitmap font data is generated in the build directory. -- `data`: authored MusicXML for performance songs and practice exercises, plus the generator regression summary. -- `generator`: build-time Python part generation, guitar placement, scat lyrics, engraving, audio compilation, and catalog construction. +- `include/vamp_style.hpp` and `src/vamp_style.cpp`: shared visual palette. `include/vamp_pico/artwork.hpp` defines the Pico coverage-image and sprite interfaces; fixed artwork is generated in the build directory. +- `data`: authored MusicXML for performance songs, practice exercises, and accompaniment rhythms, plus the generator regression summary. +- `generator`: build-time Python rhythm planning, part generation, guitar placement, scat lyrics, engraving, audio compilation, Pico artwork, and catalog construction. - `package`: generic bundle inputs, scripts, config, static font assets, and device install files. ## Zero Runtime @@ -24,6 +24,7 @@ The API is plain HTTP on the private AP: - `GET /api/status` - `GET /api/health` - `GET /api/remote/display` +- `GET /api/remote/title` - `POST /api/remote/heartbeat` - `POST /api/remote/next` - `POST /api/remote/previous` @@ -53,16 +54,22 @@ The build runs `python3 -m generator` against `data/` and writes build-only asse Practice sources live directly in `data/practice/` with four-digit ASCII filenames, currently `0001.xml` through `0366.xml`; `0000.xml` is also valid. For example, `data/practice/0001.xml` generates `practice/0001/{acoustic,electric,vocal}.json` and corresponding page directories. Songs stay in `data/songs/`. The filename number determines exercise identity and ordering; MusicXML titles determine labels, falling back to the number. Root-level XML, other practice filenames, and nested practice directories are rejected. Adding, renaming, or removing a source requires no registry edit. Regeneration replaces the output tree and catalog, removing old three-digit paths and IDs without aliases or migration. The current 366 exercises produce 1,098 practice parts, plus six performance parts. -The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> part generation -> semantic JSON, audio, and engraved pages -> packaged SQLite catalog. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. The musical model retains independent voices and per-tone durations/ties; automatic practice generation additionally requires a single monophonic melody and optional structured harmonies. Packaged music describes each generated part, not an archival copy of the XML. +The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and rhythm plans -> part generation -> semantic JSON, audio, engraved pages, and Pico title artwork -> packaged SQLite catalog and assets. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. The musical model retains independent voices and per-tone durations/ties; automatic practice generation additionally requires a single monophonic melody and optional structured harmonies. Packaged music describes each generated part, not an archival copy of the XML. ### Rhythm Sources -`data/rhythms///` is an independent source collection, excluded from application generation and packaging. Each series contains `4^N` four-digit numbered XML files for an N/4 meter: 16 in 2/4, 64 in 3/4, 256 in 4/4, and 1,024 in 5/4, totaling 2,720 files across both series. Numbering starts at `0001.xml` in every directory. +`data/rhythms///` is an active build input for accompaniment, using meter directories `2-4`, `3-4`, `4-4`, and `5-4`. Each series contains `4^N` four-digit numbered XML files for an N/4 meter: 16 in 2/4, 64 in 3/4, 256 in 4/4, and 1,024 in 5/4, totaling 2,720 files across both series. Numbering starts at `0001.xml` in every directory. Snippets do not create menu entries and their XML is not deployed. - Series `1` uses two measures. Read the file number minus one as `2*N` binary digits, most significant first: `0` is a quarter rest and `1` is a C5 quarter note. - Series `2` uses one measure. Read the file number minus one as `N` base-4 digits, most significant first: `0` is a quarter note, `1` a quarter rest, `2` two eighth notes, and `3` an eighth rest followed by an eighth note. - All pitched notes are C5, with a Vocal part, treble clef, C major, and tempo 120. Quarter-only measures use divisions 4; measures containing eighths use divisions 8. Consecutive pitched eighths beam together, interrupted by rests, quarter notes, and barlines. In 4/4, beams also break at the half-bar; the other meters use whole-measure groups. Isolated eighth notes have no beam. +`generator.rhythm.load_library()` parses the library once per asset generation. Immutable snippets retain exact event onsets, durations, written values, rests, beams, and continuations. Their placeholder pitches and tempo do not transpose chords or select playback BPM. A snippet must contain one or two complete sequential bars in one supported meter and voice, without grace notes, tuplets, time modifications, tremolo, or meter changes. + +`generator.accompaniment` selects a two-bar motif, either a complete two-bar snippet or a pair of one-bar snippets, and realizes a four-bar `A, B, A, B'` cycle. The fourth bar varies one beat when a suitable candidate exists. The cycle repeats through the section, restarting at section or meter boundaries; a final partial cycle uses only the required bars. Acoustic establishes the foundation, while electric accompaniment is planned against both the authored melody and that acoustic plan. Selection ranks harmonic coverage and density, then attack collisions, occupied melody time, and pulse, with source identity breaking ties deterministically. It does not enumerate every pair of one-bar snippets or introduce randomness. + +Pickups use the corresponding tail of a full-meter rhythm bar. Harmonic state carries across bars until changed or canceled by N.C.; a snippet attack sounds only when a chord is active. A held generated chord stops before a different harmony or N.C., leaving silence until the next selected attack. Melody attacks influence selection but do not directly delete acoustic attacks. Missing rhythm material, unsupported accompaniment meters, and mid-measure meter changes produce errors. The broader import and engraving model still accepts the meters described below. + ### MusicXML Contract | Area | Accepted notation | @@ -74,7 +81,7 @@ The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model -> | Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. | | Staff state | G/F/C, percussion, and no-clef staves; octave clefs and octave-shift spans; major/minor/modal keys or custom seven-letter alterations; timed key, clef, and meter changes. Authored TAB and technical fingering instructions are rejected; TAB is generated from pitched guitar parts. | | Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. Leading rests are retained. | -| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten notes. | +| Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten authored melody notes. | | Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. | | Tempo | Metronome directions and positive quarter-note BPM from ``, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. | | Reading order | All measures are written out. Repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. Ordinary barlines and contiguous ties remain supported. | @@ -87,17 +94,9 @@ Chord labels are generated from structured harmony using `M`, `m`, `aug`, `dim`, Standard MusicXML kinds and explicit degrees remain authoritative. For Soundslice compatibility, a text-only `other` (also `m7b5`, `7b9`, and other recognized chord spellings) is normalized into a standard kind and degrees. Complete symbols such as `(Fm7)` or `(Bb7)` supply their own root; an exporter placeholder C is accepted only for text-only `other` harmonies. A conflicting non-placeholder root, conflicting bass, contradictory structured chord, or unknown spelling is an error with source/measure context. Raw exporter text is not retained as an override that could become stale during transposition. This compatibility path does not require editing the XML or weakening structured-harmony validation. -### Vamp Metadata +### Vamp Directives -In MuseScore's score properties, set `vamp:style` to a supported player style. It exports as standard MusicXML miscellaneous metadata: - -```xml - - Medium Swing - -``` - -The default is Medium Swing; `Med.`, `Med. Swing`, and `Medium` remain style aliases. Keys, modes, keyless notation, and local changes are preserved. There is no transposition reference or `vamp:tonic` property. +Musical genres and player-style presets are removed. There is no default genre, alias table, generated `style` metadata, or genre selection. The strict importer rejects every obsolete `vamp:style` miscellaneous-field hint, including an empty value, instead of discarding it. The XML writer does not emit that field. Keys, modes, keyless notation, and local changes remain preserved; there is no transposition reference or `vamp:tonic` property. Visual styling, font attributes, engraving `Style`, presentation fingerprints, and ordinary `` notation are unrelated and remain supported. Parenthesized chord symbols mark optional turnarounds, including Soundslice's text-only `(Fm7)` and `(Bb7)` exports. Alternatively, place staff text `vamp:optional-chord` at the same onset as exactly one harmony; this text may be hidden. Earlier arrangement sections retain those chords and their parentheses; the final ending omits them. The normalized harmony stores `parenthesized` display metadata separately from the generator-only `chord_optional` flag. Orphaned/duplicate markers and unknown `vamp:` properties are errors; consumed directives never enter runtime JSON. @@ -114,22 +113,22 @@ Ordinary ties still link adjacent matching tones across measures and systems. Tu A newly generated bundle has three top-level fields: - `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans. -- `presentation`: engraver name, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums. +- `presentation`: engraver name, style checksum, fixed width/height, ordered relative PNG paths with SHA-256 checksums, and the `title_image` content identifier for Pico artwork. - `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. +There are no authored-key/selected-key metadata pairs or musical genre/style fields. 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. Presentation's `style_sha256` identifies visual engraving settings and fonts, not a musical genre. Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice. ### Shared Part Generation -`parts.py` owns shared melody-copying and harmonic-rhythm construction. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. The former generic twelve-key transposer and independent practice/arrangement fretboard implementations are removed. +`parts.py` owns shared source validation, melody-copying, measure construction, rests, and solo slashes. `rhythm.py` loads snippets; `accompaniment.py` plans phrase cycles and realizes their chord events. `arrange/generate.py` supplies the existing seven-section song form; `practice/generate.py` supplies a single source pass. `pitch.py` resolves source instrument transposition and the shared written-octave convention. The former generic twelve-key transposer and independent practice/arrangement fretboard implementations are removed. Practice acoustic accompaniment is derived from structured harmony roots, kinds, degrees, inversions, and slash basses using the shared accompaniment rhythm. A missing harmony or N.C. produces rests, not guessed chords. Electric retains the exact melody, spelling, rhythm, ties, and tuplets; vocal retains those same authored notes and receives scat lyrics. Song vocal lyrics are unchanged. Practice optional-turnaround harmonies are played once, without creating extra sections. -Accompaniment attacks at each bar start and authored harmony onset, sustaining until the next harmony onset or bar end. Harmony carries into subsequent bars until changed or explicitly canceled by N.C.; melody attacks never suppress accompaniment. This same rule applies to electric accompaniment sections in songs. Song acoustic rhythm and solo-slash patterns are unchanged. +Acoustic accompaniment in songs and practice uses the planned foundation. Electric accompaniment sections in songs use its response plan; electric melody sections still copy the melody. Song sections remain Rhythm Lead-in, Vocal Melody, Guitar Melody, Guitar Solo, Guitar Solo, Vocal Melody, and Ending, with each part retaining its melody, accompaniment, rest, or solo-slash role. Optional turnarounds are removed before planning the final ending. The generated phrase cycles contain explicit events in reading order, not runtime loops or repeat signs. -Each source is parsed, validated, and normalized once for all three parts. Part builders treat the prepared source as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist. +Each source is parsed, validated, and normalized once for all three parts. The batch generator computes shared accompaniment plans before building those parts. Public `arrange_document`, `practice_document`, `generate_performance_scores`, and `generate_practice_scores` calls require a keyword `rhythms` argument holding a `generator.rhythm.Library`; internal part builders receive precomputed plans. Builders treat the prepared source and plans as read-only and own their generated notes and spans. Source-path checks reject colliding song slugs before writing parts: for example, `one-two.xml` and `one_two.xml` would both produce `one-two-acoustic.json` and cannot coexist. All three parts, for both songs and practice, use octave-transposing notation: written notes sound one octave lower. This includes the vocal part for the singer's range. Unmarked source notation uses that convention by default; copied melodies retain their written pitches and octave numbers. For example, written C4 sounds C3 (A-string fret 3), and written C#6 sounds C#5, within the 0-20 fret range. Generated accompaniment is notated using the same convention. @@ -161,10 +160,12 @@ At runtime the shared on-demand score loader serves both display and audio. A ba ### Engraving Boundary -`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `engrave_tree(root, fonts)` retains the asset-pipeline entry point. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving. +`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `generator.engraving.layout.layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `generator.engraving.raster.rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `generator.engraving.pipeline.engrave_tree(root, fonts)` is the asset-pipeline entry point. `fonts.Fonts`, `types.Style`, and `types.EngravingError` live in their respective engraving modules; page constants and `check_page` live in `validation`. Package initializers do not re-export these APIs. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving. Engraving supports a bounded process pool (`VAMP_ENGRAVING_JOBS`, default 2) and a build-only content-addressed page cache (`VAMP_ENGRAVING_CACHE_DIR`, default `build/build_common/engraving-cache`). Keys include the complete musical document, engraving implementation, fonts/metadata, style, Python and rendering dependency versions. Playback is deliberately excluded from page keys and is always rebuilt independently. Cache hits are copied into the current staged output and checked for checksums, PNG decoding, dimensions, blank pages and clipped ink. Missing or corrupt entries are regenerated. All semantic and asset validation, database construction and transactional publication remain in place. The cache is not installed and can be deleted at any time when builds are idle. CI saves it after the native build and reuses it for ARM packaging; generation prints stage timing and per-worker progress instead of remaining silent during engraving. +Reuse has two levels. A full-score hit restores complete page images for the same musical document and metadata. A notation-body group shares system layout and body pixels among documents with identical staff content, while retaining each document's title, subtitle, pagination, footer, and complete semantic bundle. Body keys include the rendering context and actual page-band geometry; incompatible placement cannot reuse those pixels. Each header/footer is composed separately, and pages whose drawing primitives cannot be separated safely use full-page rasterization. This reuse never merges exercise IDs, catalog records, or authored music. + `notation.py` interprets staff state, exact pitch positions and shared accidental state; `notes.py` and `attributes.py` construct symbols; `spans.py` handles beams, ties, tuplets, octave shifts and lyric continuations; `layout.py` handles spacing and pagination. `fonts.py` reads bundled OTF outlines and SMuFL metadata, caching metrics and outlines for one generation run. `raster.py` uses FontTools, aggdraw and Pillow only as low-level font and drawing libraries. All dependencies are pinned in `generator/requirements.txt` and remain build-only. Standard glyph names and their upstream notice are vendored alongside the fonts; no system music fonts are consulted. Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched and percussion material use their own staff positions. Unknown exact microtonal alterations use a rational semitone annotation rather than a rounded accidental; an unavailable explicit SMuFL glyph is an error. The broader model/engraver supports this notation, while automatic guitar and practice generation enforce the source contracts above. @@ -179,9 +180,11 @@ Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margin ### 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. +Generation validates sources and complete staged output before replacing the previous asset directory. Replacement is always complete; deleted sources cannot leave stale JSON, pages, audio, or Pico title assets. 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 and title-image identifiers, PNG pages, generated PCM WAVs, Pico title images, 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 `//` 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. +The page cache uses `/fullscore//` and `/notation//` directories, without a manual cache revision. `python3 -m generator --renderer-fingerprint` reports the same identity used by rendering and CI: source bytes, fonts, visual style, Python implementation/version, and rendering-library versions. Python compiler/build timestamps are excluded. CI keys append all generator and active XML inputs, including rhythms, and restore earlier snapshots only within the same renderer directory; only that directory is saved after a successful build. + +`cold namespace` means that renderer's persistent directory does not yet exist. A renderer or environment change starts a new namespace; deleting a namespace has the same effect. It does not disable reuse within the current run: shared notation is still laid out once per group and its body pixels can be reused as other titles are composed, even without a persistent cache. Conversely, a warm directory does not guarantee that a particular score is cached. Progress reports full-score hits, body hits, newly rasterized body bands, composed pages, missing/rejected entries, and phase timings. Worker phase timings are summed and may exceed wall time. Obsolete namespaces 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 `, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`. @@ -201,8 +204,13 @@ The display is rotated 90 degrees clockwise into a 320x240 landscape UI. The top - Yellow: retrying or remote request failed. - Green: connected to the Zero API. -The body stays blank until a performance or practice score is active, then it shows the active title, selected part, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. -The Pico text renderer uses an antialiased bitmap atlas generated from `package/assets/fonts/petaluma/PetalumaScript.otf`. The shared font CMake rule generates `generated/vamp_style/font.cpp` separately in each native/Pico build directory, using `generator/generate_pico_font.py --output `. Font and generator changes trigger regeneration. The generated atlas includes ASCII kerning pairs so display text such as `VAMP` uses the same spacing as the source font. The pinned Pillow build dependency is declared in `generator/requirements.txt`. +The body shows a fixed Product Vamp/status label when no score is active, then the active title, selected part, and page count. Touch controls are tap for next, double tap for previous, and tap-and-hold for select. All visible text uses Petaluma Script, matching score-page text and the Zero UI. + +`generator.pico_artwork` shapes text on the build host with the bundled `PetalumaScript.otf` and Pillow's Raqm support. Fixed labels (`VAMP`, connection/status messages, and `PAGE`), digits, and `/` become four-bit coverage sprites. The Pico CMake rule writes `generated/pico/static.p4` and embeds it through `generated/pico/artwork.S`. The Zero does not link this sprite atlas; Luma renders its dynamic UI text from the packaged OTF. + +Score generation renders titles as 296x64 four-bit coverage images under `pico/titles/.mask`. Each file is 9,480 bytes: an eight-byte `VPTI` header with little-endian width/height, followed by packed coverage. Identical image bytes share an asset without merging score identities. The generated `presentation.title_image` identifier is also stored in catalog metadata; no separate lookup manifest is required. These assets are generated and validated before staged output is published. Remote status includes `title_image=`; `POST /api/remote/title` with `{"id":""}` serves validated bytes only for catalog-registered titles. Pico fetches a changed title, validates its fixed format and bounds, rejects stale responses, and retains the selected image. Part labels and page counts use fixed sprites without reshaping or refetching title text. Commands take priority over image requests, and failed title requests display `UNAVAILABLE` until a retry succeeds. + +Pico performs coverage blending and sprite placement only: no OTF parsing, font shaping, glyph rasterization, or general text renderer runs in firmware. Missing glyphs and invalid source text are build errors. Font or generator changes rebuild both static firmware sprites and title artwork. Direct generation uses `python3 -m generator.pico_artwork --static-output ` for firmware sprites and `python3 -m generator.pico_artwork --assets ` for title assets. Python, Pillow/Raqm, and FontTools remain build-only dependencies. The root `CMakeLists.txt` is also the Pico firmware entrypoint when configured with `-DVAMP_PICO_FIRMWARE_PROJECT=ON`. That mode returns before the Zero/Linux targets are declared, so the Pico build can use the embedded ARM toolchain cleanly. diff --git a/cmake/font.cmake b/cmake/font.cmake deleted file mode 100644 index bb45924..0000000 --- a/cmake/font.cmake +++ /dev/null @@ -1,21 +0,0 @@ -find_package(Python3 COMPONENTS Interpreter REQUIRED) -execute_process( - COMMAND ${Python3_EXECUTABLE} -c "from PIL import features; assert features.check_feature('raqm')" - RESULT_VARIABLE VAMP_PILLOW_STATUS - ERROR_QUIET -) -if(NOT VAMP_PILLOW_STATUS EQUAL 0) - message(FATAL_ERROR "Font generation requires Pillow with Raqm support. Install libfribidi0 on Debian/Ubuntu, then run: ${Python3_EXECUTABLE} -m pip install -r ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt") -endif() - -set(VAMP_FONT_SOURCE ${CMAKE_CURRENT_BINARY_DIR}/generated/vamp_style/font.cpp) -add_custom_command( - OUTPUT ${VAMP_FONT_SOURCE} - COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/generator/generate_pico_font.py - --output ${VAMP_FONT_SOURCE} - DEPENDS - ${CMAKE_CURRENT_SOURCE_DIR}/generator/generate_pico_font.py - ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt - ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf - VERBATIM -) diff --git a/cmake/pico.cmake b/cmake/pico.cmake index cb2f7f0..8a5570b 100644 --- a/cmake/pico.cmake +++ b/cmake/pico.cmake @@ -36,7 +36,7 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) pico_sdk_init() -include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/font.cmake) +include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico_artwork.cmake) set(VAMP_MODULUS_INCLUDE_DIR "" CACHE PATH "Path to the Modulus public include directory") set(VAMP_RESOLVED_MODULUS_INCLUDE_DIR "${VAMP_MODULUS_INCLUDE_DIR}") @@ -76,7 +76,6 @@ endif() add_executable(product-vamp-pico2w src/vamp_style.cpp - ${VAMP_FONT_SOURCE} src/vamp_pico/display.cpp src/vamp_pico/http_client.cpp src/vamp_pico/input.cpp @@ -86,6 +85,7 @@ add_executable(product-vamp-pico2w src/vamp_protocol/page_control.cpp src/vamp_protocol/gesture.cpp ) +vamp_add_pico_artwork(product-vamp-pico2w) target_include_directories(product-vamp-pico2w PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${VAMP_RESOLVED_MODULUS_INCLUDE_DIR} diff --git a/cmake/pico_artwork.cmake b/cmake/pico_artwork.cmake new file mode 100644 index 0000000..93f7b74 --- /dev/null +++ b/cmake/pico_artwork.cmake @@ -0,0 +1,28 @@ +find_package(Python3 COMPONENTS Interpreter REQUIRED) +execute_process( + COMMAND ${Python3_EXECUTABLE} -c "import PIL.features; import fontTools.ttLib; assert PIL.features.check_feature('raqm')" + RESULT_VARIABLE VAMP_PILLOW_STATUS + ERROR_QUIET +) +if(NOT VAMP_PILLOW_STATUS EQUAL 0) + message(FATAL_ERROR "Pico artwork generation requires Pillow with Raqm and fontTools. Install libfribidi0 on Debian/Ubuntu, then run: ${Python3_EXECUTABLE} -m pip install -r ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt") +endif() + +# Zero uses packaged font files; only the Pico embeds this fixed sprite atlas. +function(vamp_add_pico_artwork target) + set(VAMP_PICO_ARTWORK_BINARY ${CMAKE_CURRENT_BINARY_DIR}/generated/pico/static.p4) + set(VAMP_PICO_ARTWORK_SOURCE ${CMAKE_CURRENT_BINARY_DIR}/generated/pico/artwork.S) + add_custom_command( + OUTPUT ${VAMP_PICO_ARTWORK_BINARY} + COMMAND ${Python3_EXECUTABLE} -m generator.pico_artwork --static-output ${VAMP_PICO_ARTWORK_BINARY} + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/generator/pico_artwork.py + ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt + ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf + VERBATIM + ) + configure_file(${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/artwork.S.in ${VAMP_PICO_ARTWORK_SOURCE} @ONLY) + set_source_files_properties(${VAMP_PICO_ARTWORK_SOURCE} PROPERTIES OBJECT_DEPENDS ${VAMP_PICO_ARTWORK_BINARY}) + target_sources(${target} PRIVATE ${VAMP_PICO_ARTWORK_SOURCE}) +endfunction() diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 04033af..d5bc29d 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -8,6 +8,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) find_package(modulus CONFIG REQUIRED COMPONENTS boost_json openssl raylib) find_package(Threads REQUIRED) +find_package(Python3 COMPONENTS Interpreter REQUIRED) find_package(ligo CONFIG REQUIRED) find_package(noto CONFIG REQUIRED) find_package(luma CONFIG REQUIRED) @@ -34,7 +35,6 @@ cmake_pop_check_state() if(NOT VAMP_DEPENDENCY_APIS_AVAILABLE) message(FATAL_ERROR "Product Vamp requires current Sonus sequence and Luma uncached-texture APIs. Run ./modulus.py configure --update after publishing the updated dependencies, or create their Conan packages locally before configuring --no-remote. See CMake's configure log for the compiler diagnostic.") endif() -include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/font.cmake) execute_process( COMMAND git rev-parse --short=12 HEAD @@ -49,7 +49,6 @@ endif() set(VAMP_STYLE_SOURCES src/vamp_style.cpp - ${VAMP_FONT_SOURCE} ) add_library(vamp-style STATIC ${VAMP_STYLE_SOURCES}) @@ -140,7 +139,6 @@ 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)") @@ -237,8 +235,8 @@ if(VAMP_BUILD_PICO_FIRMWARE) file(GLOB VAMP_PICO_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/CMakeLists.txt ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico.cmake - ${CMAKE_CURRENT_SOURCE_DIR}/cmake/font.cmake - ${CMAKE_CURRENT_SOURCE_DIR}/generator/generate_pico_font.py + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/pico_artwork.cmake + ${CMAKE_CURRENT_SOURCE_DIR}/generator/pico_artwork.py ${CMAKE_CURRENT_SOURCE_DIR}/generator/requirements.txt ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/PetalumaScript.otf ${CMAKE_CURRENT_SOURCE_DIR}/cmake/vamp_options.cmake @@ -250,6 +248,7 @@ if(VAMP_BUILD_PICO_FIRMWARE) ${CMAKE_CURRENT_SOURCE_DIR}/include/vamp_protocol/*.hpp ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_style.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_pico/*.S.in ${CMAKE_CURRENT_SOURCE_DIR}/src/vamp_protocol/*.cpp ) add_custom_command( diff --git a/conanfile.py b/conanfile.py index 7a1442a..19e1cd2 100644 --- a/conanfile.py +++ b/conanfile.py @@ -32,6 +32,7 @@ class product_vamp_conan(conan.ConanFile): "data/expected/*.json", "data/practice/*.xml", "data/songs/*.xml", + "data/rhythms/**/*.xml", "tests/fixtures/*", "tests/fixtures/**/*", "generator/*.py", @@ -39,6 +40,7 @@ class product_vamp_conan(conan.ConanFile): "generator/requirements.txt", "include/*.hpp", "include/vamp_style/*.hpp", + "src/vamp_pico/*.S.in", "include/vamp_pico/*.h", "include/vamp_pico/capi/*.h", "include/vamp_pico/*.hpp", diff --git a/generator/__main__.py b/generator/__main__.py index 88d411e..40cbcce 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -1,24 +1,28 @@ from __future__ import annotations import argparse +import pathlib import shutil import tempfile -from pathlib import Path -from time import monotonic +import time -from generator.arrange.generate import generate_performance_scores -from generator.practice.generate import generate_practice_scores -from generator.score import count_json_files, validate_generated_tree -from generator.summary import validate_expected_performance_summary, validate_practice_parts -from generator.parts import source_paths -from generator.engraving import Fonts, Style, engrave_tree -from generator.engraving.cache import renderer_fingerprint -from generator.playback import compile_tree -from generator.catalog import build_catalog +import generator.arrange.generate +import generator.catalog +import generator.engraving.cache +import generator.engraving.fonts +import generator.engraving.pipeline +import generator.engraving.types +import generator.parts +import generator.pico_artwork +import generator.playback +import generator.practice.generate +import generator.rhythm +import generator.score +import generator.summary RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt") -def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None: +def copy_runtime_fonts(static_assets_dir: pathlib.Path, output_dir: pathlib.Path) -> None: source = static_assets_dir / "fonts" / "petaluma" destination = output_dir / "fonts" / "petaluma" destination.mkdir(parents=True, exist_ok=True) @@ -27,12 +31,12 @@ def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.") - parser.add_argument("--data-dir", type=Path) - parser.add_argument("--output-dir", type=Path) + parser.add_argument("--data-dir", type=pathlib.Path) + parser.add_argument("--output-dir", type=pathlib.Path) parser.add_argument("--renderer-fingerprint", action="store_true", help="Print the engraving cache identity without generating assets.") - parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets")) + parser.add_argument("--static-assets-dir", type=pathlib.Path, default=pathlib.Path("package/assets")) parser.add_argument("--check", action="store_true") - parser.add_argument("--engraving-cache", type=Path) + parser.add_argument("--engraving-cache", type=pathlib.Path) parser.add_argument("--jobs", type=int, default=1, choices=range(1, 17), metavar="1-16") parser.add_argument("--progress", action="store_true") args = parser.parse_args() @@ -49,11 +53,11 @@ def generate_assets(args: argparse.Namespace) -> None: if cache_dir and (cache_dir.is_relative_to(output_dir) or output_dir.is_relative_to(cache_dir)): raise ValueError('engraving cache and generated assets must not overlap') progress = getattr(args, 'progress', False) - started = monotonic() + started = time.monotonic() def report(message): if progress: - print(f'Assets [{monotonic() - started:.0f}s]: {message}', flush=True) + print(f'Assets [{time.monotonic() - started:.0f}s]: {message}', flush=True) report('checking source paths') for source in sorted(data_dir.rglob('*.xml')): @@ -61,29 +65,33 @@ def generate_assets(args: argparse.Namespace) -> None: 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') - source_paths(data_dir / 'practice', practice=True) + generator.parts.source_paths(data_dir / 'songs') + generator.parts.source_paths(data_dir / 'practice', practice=True) + report('loading rhythm snippets') + rhythms = generator.rhythm.load_library(data_dir / 'rhythms') output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary: - staged = Path(temporary) / 'assets' + staged = pathlib.Path(temporary) / 'assets' staged.mkdir(parents=True, exist_ok=True) copy_runtime_fonts(static_assets_dir, staged) report('generating performance and practice parts') - performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs') - practice_count = generate_practice_scores(data_dir / 'practice', staged / 'practice') - validate_generated_tree(staged) + performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', rhythms=rhythms) + practice_count = generator.practice.generate.generate_practice_scores(data_dir / 'practice', staged / 'practice', rhythms=rhythms) + generator.score.validate_generated_tree(staged) if args.check: - validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') - validate_practice_parts(staged / 'practice') + generator.summary.validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') + generator.summary.validate_practice_parts(staged / 'practice') report('compiling playback') - compile_tree(staged) + generator.playback.compile_tree(staged) report('engraving pages') - engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress) + validated_pages = generator.engraving.pipeline.engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, jobs=getattr(args, 'jobs', 1), progress=progress) + report('rendering Pico titles') + generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma') report('validating score, page, and audio assets') - validate_generated_tree(staged) + generator.score.validate_generated_tree(staged, validated_pages) report('building catalog database') - build_catalog(staged) - backup = Path(temporary) / 'previous' + generator.catalog.build_catalog(staged) + backup = pathlib.Path(temporary) / 'previous' if output_dir.exists(): output_dir.rename(backup) try: @@ -94,14 +102,14 @@ def generate_assets(args: argparse.Namespace) -> None: raise report('publishing complete') print(f'Generated Product Vamp assets: {performance_count} performance scores, ' - f'{practice_count} practice parts, {count_json_files(output_dir)} total JSON files.') + f'{practice_count} practice parts, {generator.score.count_json_files(output_dir)} total JSON files.') if args.check: print('Generator check passed.') def main() -> int: args = parse_args() if args.renderer_fingerprint: - print(renderer_fingerprint(Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), Style())) + print(generator.engraving.cache.renderer_fingerprint(generator.engraving.fonts.Fonts(args.static_assets_dir / 'fonts' / 'petaluma'), generator.engraving.types.Style())) else: generate_assets(args) return 0 diff --git a/generator/accompaniment.py b/generator/accompaniment.py new file mode 100644 index 0000000..6c22d06 --- /dev/null +++ b/generator/accompaniment.py @@ -0,0 +1,221 @@ +from __future__ import annotations + +import copy +import dataclasses +import fractions + +import generator.model +import generator.rhythm + +@dataclasses.dataclass(frozen=True) +class Context: + measure: dict + previous: dict | None + offset: fractions.Fraction + intervals: tuple[tuple[fractions.Fraction, fractions.Fraction], ...] + attacks: frozenset[fractions.Fraction] + occupied: tuple[tuple[fractions.Fraction, fractions.Fraction], ...] + +@dataclasses.dataclass(frozen=True) +class Plan: + acoustic: tuple[generator.rhythm.Bar, ...] + electric: tuple[generator.rhythm.Bar, ...] + +def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None: + active = previous + for chord in measure['harmonies']: + if generator.model.fraction(chord['onset']) > onset: + break + active = chord + return active if active and active['kind'] != 'none' else None + +def carried_chords(measures: list[dict], initial: dict | None = None) -> list[dict | None]: + result = [] + for measure in measures: + result.append(initial) + if measure['harmonies']: + initial = measure['harmonies'][-1] + return result + +def context(measure: dict, previous: dict | None) -> Context: + length = generator.model.fraction(measure['duration']) + nominal = generator.model.meter_length(measure['meter']) + offset = nominal - length if measure.get('implicit') and length < nominal else fractions.Fraction() + boundaries = sorted({fractions.Fraction(), length} | {generator.model.fraction(h['onset']) for h in measure['harmonies']}) + intervals = tuple((a, b) for a, b in zip(boundaries, boundaries[1:]) if b > a and active_chord(measure, a, previous)) + attacks, occupied = set(), [] + for voice in measure['voices']: + for note in voice['notes']: + pitches = [p for p in note['pitches'] if not p.get('cue')] + if not pitches or note.get('cue'): + continue + start = generator.model.fraction(note['onset']) + end = start + generator.model.fraction(note['duration']) + if not all('tie_from' in p for p in pitches): + attacks.add(start) + occupied.append((start, end)) + return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied)) + +def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]: + return tuple(t - item.offset for t in bar.attacks() if any(a <= t - item.offset < b for a, b in item.intervals)) + +def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]: + return tuple((max(event.onset - item.offset, a), min(event.onset + event.duration - item.offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > item.offset for a, b in item.intervals if a <= max(event.onset - item.offset, 0) < b) + +def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], electric: bool) -> tuple: + missing_bars, uncovered, density, collisions, occupied, pulse = 0, 0, fractions.Fraction(), 0, 0, 0 + for item, acoustic in contexts: + attacks = sounding_attacks(bar, item) + spans = sounding_spans(bar, item) + missing_bars += bool(item.intervals) and not spans and not electric + uncovered += sum(not any(start < b and end > a for start, end in spans) for a, b in item.intervals) + duration = sum((b - a for a, b in item.intervals), fractions.Fraction()) + density += abs(len(attacks) - duration / (4 if electric else 2)) + collisions += sum((t in item.attacks) + (t in acoustic) for t in attacks) + occupied += sum(any(a <= t < b for a, b in item.occupied) for t in attacks) + pulse -= sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks) + return missing_bars, uncovered, density, collisions, occupied, pulse + +def add_ranks(left: tuple, right: tuple) -> tuple: + return tuple(a + b for a, b in zip(left, right)) + +def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], acoustic: list[generator.rhythm.Bar] | None = None) -> tuple[generator.rhythm.Bar, ...]: + electric = acoustic is not None + bars = {} + for snippet in snippets: + for index, bar in enumerate(snippet.bars): + bars.setdefault(bar, f'{snippet.id}:{index}') + phases = [[(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)] + scores = [{bar: rank(bar, phase, electric) for bar in bars} for phase in phases] + candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2] + single = [s for s in snippets if len(s.bars) == 1] + if single: + pair = tuple(min(single, key=lambda s: (scores[phase][s.bars[0]], s.id)) for phase in range(2)) + candidates.append((add_ranks(scores[0][pair[0].bars[0]], scores[1][pair[1].bars[0]]), '|'.join(s.id for s in pair), tuple(s.bars[0] for s in pair))) + score, _, pair = min(candidates, key=lambda candidate: candidate[:2]) + if score[0]: + failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item)) + raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar') + fourth = [(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 4 == 3] + second = pair[1] + alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1] + variation = min(alternatives, key=lambda bar: (rank(bar, fourth, electric), bars[bar])) if alternatives and fourth else second + if rank(variation, fourth, electric)[0]: + variation = second + result = (pair[0], second, pair[0], variation) + if electric and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)): + raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response') + return result + +def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan: + previous = carried_chords(measures, initial) + acoustic, responses = [], [] + start = 0 + while start < len(measures): + meter = measures[start]['time_signature'] + snippets = library.for_meter(meter) + end = start + 1 + while end < len(measures) and measures[end]['time_signature'] == meter and not measures[end].get('section_label'): + end += 1 + segment = measures[start:end] + for measure in segment: + if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])): + raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes') + contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)] + foundation = cycle(snippets, contexts) + chosen = [foundation[i % 4] for i in range(len(segment))] + response = cycle(snippets, contexts, chosen) if electric else foundation + acoustic.extend(chosen) + responses.extend(response[i % 4] for i in range(len(segment))) + start = end + return Plan(tuple(acoustic), tuple(responses)) + +def same_chord(left: dict | None, right: dict | None) -> bool: + return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion')) + +def normalize_beams(events: list[dict]) -> None: + for level in sorted({beam['level'] for event in events for beam in event.get('beams', [])}): + groups, group = [], [] + for event in events: + beam = next((b for b in event.get('beams', []) if b['level'] == level), None) + if not beam or beam['type'] in ('begin', 'forward hook', 'backward hook'): + groups.append(group) + group = [] + if beam: + group.append((event, beam)) + if beam['type'] in ('end', 'forward hook', 'backward hook'): + groups.append(group) + group = [] + groups.append(group) + for group in groups: + for index, (event, beam) in enumerate(group): + if len(group) > 1: + beam['type'] = 'begin' if index == 0 else 'end' if index == len(group) - 1 else 'continue' + elif level > 1 and any(b['level'] == level - 1 for b in event['beams']): + beam['type'] = 'backward hook' if beam['type'] in ('end', 'backward hook') else 'forward hook' + else: + event['beams'].remove(beam) + for event in events: + if not event.get('beams'): + event.pop('beams', None) + +def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> list[dict]: + item = context(measure, previous) + length = generator.model.fraction(measure['duration']) + events = [] + for source in bar.events: + start, end = max(fractions.Fraction(), source.onset - item.offset), min(length, source.onset + source.duration - item.offset) + if start >= end: + continue + chord = active_chord(measure, start, previous) if source.sounding else None + continuation = source.continuation and source.onset - item.offset == start + preceding = previous + if continuation: + for change in measure['harmonies']: + if generator.model.fraction(change['onset']) >= start: + break + preceding = change + if not same_chord(chord, preceding): + chord = None + sounding_end = end + for change in measure['harmonies']: + time = generator.model.fraction(change['onset']) + if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)): + sounding_end = time + written = {'type': source.written_type, 'dots': source.dots} if end - start == source.duration and sounding_end == end else None + event = generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written) + if chord: + event['_harmony'] = chord + if continuation: + event['_rhythm_continuation'] = True + if source.beams and written and chord: + event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams] + events.append(event) + if sounding_end < end: + events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', sounding_end, end - sounding_end, 'rest')) + normalize_beams(events) + return events + +def connect_continuations(document: dict) -> bool: + previous = None + changed = False + for measure in document['staves'][0]['measures']: + if measure.get('section_label'): + previous = None + for voice in measure['voices']: + for note in voice['notes']: + continuation = note.pop('_rhythm_continuation', False) + if continuation and previous and previous['pitches'] and note['pitches']: + note['pitches'] = copy.deepcopy(previous['pitches']) + for index, tone in enumerate(note['pitches']): + source = previous['pitches'][index] + tone.update(id=f'{note["id"]}-p{index + 1}', duration=copy.deepcopy(note['duration']), written=copy.deepcopy(note['written']), tie_from=source['id']) + tone.pop('tie_to', None) + source['tie_to'] = tone['id'] + changed = True + elif continuation: + note.update(kind='rest', pitches=[]) + note.pop('beams', None) + changed = True + previous = note + return changed diff --git a/generator/arrange/form.py b/generator/arrange/form.py index 1365cf4..a75f683 100644 --- a/generator/arrange/form.py +++ b/generator/arrange/form.py @@ -1,17 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Literal +import dataclasses +import typing -from generator.score import body_start_measure_index +import generator.score -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class ArrangementSection: label: str source_measure_index: int measure_count: int section_index: int - role: Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"] + role: typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"] def phrase_aware_edge_measure_count(body_measure_count: int) -> int: if body_measure_count >= 8: @@ -20,9 +20,9 @@ def phrase_aware_edge_measure_count(body_measure_count: int) -> int: return 4 return body_measure_count -def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]: +def section_role(part_type: str, section_index: int) -> typing.Literal["accompaniment", "melody", "vocal", "rest", "slash"]: if part_type == "acoustic": - return "slash" if section_index == 3 else "rhythm" + return "slash" if section_index == 3 else "accompaniment" if part_type == "electric": if section_index == 4: return "slash" @@ -36,7 +36,7 @@ def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accom def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]: if not measures: return [] - body_start = body_start_measure_index(measures) + body_start = generator.score.body_start_measure_index(measures) if body_start >= len(measures): return [] body_measure_count = len(measures) - body_start diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 4056116..57009f9 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -1,104 +1,133 @@ from __future__ import annotations import copy -from fractions import Fraction -from pathlib import Path +import fractions +import pathlib -from generator.arrange.form import full_arrangement_sections -from generator.guitar import place_guitar -from generator.parts import PART_TYPES, build_measure, prepare_document, propagated_chords, source_paths -from generator.model import fraction, key_label, make_event, meter_label, rational -from generator.musicxml import load_musicxml -from generator.score import copy_source_voices, make_arranged_document, reidentify, slugify, source_staff, write_json +import generator.accompaniment +import generator.arrange.form +import generator.guitar +import generator.model +import generator.musicxml +import generator.parts +import generator.rhythm +import generator.score FINAL_SECTION_INDEX = 6 -def arrange_document(document: dict, part_type: str) -> dict: - return _arrange_document(prepare_document(document), part_type) +def arrange_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict: + document = generator.parts.prepare_document(document) + plans = plan_arrangement(document, rhythms) if part_type != 'vocal' else {} + return _arrange_document(document, part_type, plans) -def _arrange_document(document: dict, part_type: str) -> dict: - if part_type not in PART_TYPES: +def section_source(source: list[dict], final: bool) -> list[dict]: + result = copy.deepcopy(source) + for measure in result: + measure['harmonies'] = [h for h in measure['harmonies'] if not final or not h.get('chord_optional')] + for chord in measure['harmonies']: + chord.pop('chord_optional', None) + return result + +def plan_arrangement(document: dict, rhythms: generator.rhythm.Library) -> dict[int, generator.accompaniment.Plan]: + source = generator.score.source_staff(document)['measures'] + result = {} + for section in generator.arrange.form.full_arrangement_sections(source, 'acoustic'): + if section.role == 'slash': + continue + measures = section_source(source, section.section_index == FINAL_SECTION_INDEX) + previous = generator.accompaniment.carried_chords(measures) + start = section.source_measure_index + result[section.section_index] = generator.accompaniment.plan_section(measures[start:start + section.measure_count], rhythms, initial=previous[start], electric=section.section_index in (1, 5, 6)) + return result + +def _arrange_document(document: dict, part_type: str, plans: dict[int, generator.accompaniment.Plan]) -> dict: + if part_type not in generator.parts.PART_TYPES: raise ValueError(f'unknown arrangement part: {part_type}') - staff = source_staff(document) + staff = generator.score.source_staff(document) source = staff['measures'] - sections = full_arrangement_sections(source, part_type) + sections = generator.arrange.form.full_arrangement_sections(source, part_type) output, tuplets, octave_shifts = [], [], [] - absolute = Fraction() + absolute = fractions.Fraction() source_starts = [] - source_time = Fraction() + source_time = fractions.Fraction() for m in source: source_starts.append(source_time) - source_time += fraction(m['duration']) + source_time += generator.model.fraction(m['duration']) for section in sections: - previous = propagated_chords(source, include_optional=section.section_index != FINAL_SECTION_INDEX) + section_measures = section_source(source, section.section_index == FINAL_SECTION_INDEX) + previous = generator.accompaniment.carried_chords(section_measures) segment = [] for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): - m = copy.deepcopy(source[si]) - m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')] - for h in m['harmonies']: - h.pop('chord_optional', None) - result = build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '') + m = section_measures[si] + rhythm = None + if section.role == 'accompaniment': + plan = plans[section.section_index] + rhythm = (plan.acoustic if part_type == 'acoustic' else plan.electric)[len(segment)] + result = generator.parts.build_measure(m, f'measure-{len(output) + len(segment) + 1}', section.role, part_type, previous[si], section.label if not segment else '', rhythm) segment.append(result) identity_segment = segment - segment_length = sum((fraction(m['duration']) for m in segment), Fraction()) + segment_length = sum((generator.model.fraction(m['duration']) for m in segment), fractions.Fraction()) if section.role in ('vocal', 'melody'): start = source_starts[section.source_measure_index] for shift in staff.get('octave_shifts', []): - low, high = max(start, fraction(shift['start'])), min(start + segment_length, fraction(shift['end'])) + low, high = max(start, generator.model.fraction(shift['start'])), min(start + segment_length, generator.model.fraction(shift['end'])) if low < high: - octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': rational(absolute + low - start), 'end': rational(absolute + high - start)}) + octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(absolute + low - start), 'end': generator.model.rational(absolute + high - start)}) if part_type == 'vocal' and section.section_index in (1, 5) and section.source_measure_index == 1 and source[0].get('implicit') and output: - pickup = build_measure(source[0], source[0]['id'], 'vocal', part_type, None) + pickup = generator.parts.build_measure(source[0], source[0]['id'], 'vocal', part_type, None) destination = output[-1] - pad = fraction(destination['duration']) - fraction(pickup['duration']) + pad = generator.model.fraction(destination['duration']) - generator.model.fraction(pickup['duration']) if pad < 0: raise ValueError('pickup is longer than the preceding arrangement measure') - voices = copy_source_voices(pickup, strip_lyrics=False) + voices = generator.score.copy_source_voices(pickup, strip_lyrics=False) for voice in voices: for n in voice['notes']: - n['onset'] = rational(fraction(n['onset']) + pad) + n['onset'] = generator.model.rational(generator.model.fraction(n['onset']) + pad) if pad: - voice['notes'].insert(0, make_event(f'pickup-rest-{voice["id"]}', Fraction(), pad, 'rest') | {'voice': voice['id']}) + voice['notes'].insert(0, generator.model.make_event(f'pickup-rest-{voice["id"]}', fractions.Fraction(), pad, 'rest') | {'voice': voice['id']}) destination['voices'] = voices identity_segment = [destination, *segment] if pickup['harmonies']: - destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad] + destination['harmonies'] = [h for h in destination['harmonies'] if generator.model.fraction(h['onset']) < pad] for h in pickup['harmonies']: - h['onset'] = rational(fraction(h['onset']) + pad) + h['onset'] = generator.model.rational(generator.model.fraction(h['onset']) + pad) destination['harmonies'].append(h) state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose')} if pad: - destination['attributes'].append({'onset': rational(pad), **state}) + destination['attributes'].append({'onset': generator.model.rational(pad), **state}) else: destination.update(state) - destination['key_signature'] = key_label(state['key']) - destination['time_signature'] = meter_label(state['meter']) + destination['key_signature'] = generator.model.key_label(state['key']) + destination['time_signature'] = generator.model.meter_label(state['meter']) for change in pickup.get('attributes', []): - destination['attributes'].append({**change, 'onset': rational(fraction(change['onset']) + pad)}) + destination['attributes'].append({**change, 'onset': generator.model.rational(generator.model.fraction(change['onset']) + pad)}) for shift in staff.get('octave_shifts', []): - low, high = fraction(shift['start']), min(fraction(pickup['duration']), fraction(shift['end'])) + low, high = generator.model.fraction(shift['start']), min(generator.model.fraction(pickup['duration']), generator.model.fraction(shift['end'])) if low < high: - offset = absolute - fraction(pickup['duration']) - octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': rational(offset + low), 'end': rational(offset + high)}) - tuplets.extend(reidentify(identity_segment, staff.get('tuplets', []))) + offset = absolute - generator.model.fraction(pickup['duration']) + octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)}) + tuplets.extend(generator.score.reidentify(identity_segment, staff.get('tuplets', []))) output.extend(segment) absolute += segment_length - result = make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) + result = generator.score.make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result['metadata'].setdefault('tempo', 124) if part_type != 'vocal': - place_guitar(result) + generator.guitar.place_guitar(result) + if generator.accompaniment.connect_continuations(result): + generator.guitar.place_guitar(result) return result -def generate_performance_scores(source_dir: Path, output_dir: Path) -> int: +def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int: count = 0 - for path in source_paths(source_dir): + for path in generator.parts.source_paths(source_dir): try: - document = prepare_document(load_musicxml(path)) + document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path)) + plans = plan_arrangement(document, rhythms) except ValueError as error: raise ValueError(f'{path}: {error}') from error - for part in PART_TYPES: + for part in generator.parts.PART_TYPES: try: - write_json(output_dir / f'{slugify(path.stem)}-{part}.json', _arrange_document(document, part)) + generator.score.write_json(output_dir / f'{generator.score.slugify(path.stem)}-{part}.json', _arrange_document(document, part, plans)) except ValueError as error: raise ValueError(f'{path}, part {part}: {error}') from error count += 1 diff --git a/generator/catalog.py b/generator/catalog.py index b273340..5bd1334 100644 --- a/generator/catalog.py +++ b/generator/catalog.py @@ -2,9 +2,10 @@ from __future__ import annotations import argparse import json +import pathlib +import re import sqlite3 import tempfile -from pathlib import Path APPLICATION_ID = 0x56414D50 COMMON_COLUMNS = """ @@ -15,10 +16,11 @@ COMMON_COLUMNS = """ 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) + total_pages INTEGER NOT NULL CHECK(total_pages BETWEEN 1 AND 10000), + title_image TEXT NOT NULL CHECK(length(title_image) = 64 AND title_image NOT GLOB '*[^0-9a-f]*') """ -def metadata(path: Path, root: Path) -> dict: +def metadata(path: pathlib.Path, root: pathlib.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') @@ -33,14 +35,17 @@ def metadata(path: Path, root: Path) -> dict: if not isinstance(meta[name], str): raise ValueError(f'{name} must be a string') key = meta['key_signature'] + title_image = presentation.get('title_image') + if not isinstance(title_image, str) or not re.fullmatch('[0-9a-f]{64}', title_image): + raise ValueError('presentation requires a generated Pico title image; regenerate assets') 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, source_path=relative.as_posix(), source_json=text, - measure_count=len(staff['measures']), total_pages=len(presentation['pages']), part=staff['name']) + measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name']) -def populate(connection: sqlite3.Connection, root: Path) -> None: +def populate(connection: sqlite3.Connection, root: pathlib.Path) -> None: if not root.is_dir(): raise ValueError(f'{root}: score asset directory not found') connection.executescript(f''' @@ -81,13 +86,13 @@ def populate(connection: sqlite3.Connection, root: Path) -> None: if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]: raise ValueError('catalog integrity check failed') -def build_catalog(root: Path, destination: Path | None = None) -> Path: +def build_catalog(root: pathlib.Path, destination: pathlib.Path | None = None) -> pathlib.Path: if not root.is_dir(): raise ValueError(f'{root}: score asset directory not found') destination = destination or root / 'catalog.db' destination.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp-catalog-', dir=destination.parent) as temporary: - staged = Path(temporary) / 'catalog.db' + staged = pathlib.Path(temporary) / 'catalog.db' connection = sqlite3.connect(staged) try: populate(connection, root) @@ -98,7 +103,7 @@ def build_catalog(root: Path, destination: Path | None = None) -> Path: if __name__ == '__main__': parser = argparse.ArgumentParser(description='Compile generated score bundles into a read-only runtime catalog.') - parser.add_argument('assets', type=Path) - parser.add_argument('destination', type=Path, nargs='?') + parser.add_argument('assets', type=pathlib.Path) + parser.add_argument('destination', type=pathlib.Path, nargs='?') arguments = parser.parse_args() build_catalog(arguments.assets, arguments.destination) diff --git a/generator/engraving/__init__.py b/generator/engraving/__init__.py index a440a6e..663e761 100644 --- a/generator/engraving/__init__.py +++ b/generator/engraving/__init__.py @@ -1,108 +1 @@ -from __future__ import annotations - -from concurrent.futures import ProcessPoolExecutor -import hashlib -import json -import multiprocessing -from pathlib import Path -import shutil - -from PIL import Image, ImageChops - -from generator.engraving.fonts import Fonts -from generator.engraving.cache import renderer_fingerprint, renderer_namespace, restore_pages, score_fingerprint, store_pages -from generator.engraving.layout import layout_score -from generator.engraving.raster import rasterize_page -from generator.engraving.types import EngravingError, Style -from generator.model import validate_document - -ENGINE_NAME = 'Vamp Python' -PAGE_WIDTH = 976 -PAGE_HEIGHT = 484 - -def check_page(path: Path) -> None: - with Image.open(path) as image: - image.load() - if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT): - raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page') - white = Image.new('RGBA', image.size, 'white') - white.alpha_composite(image.convert('RGBA')) - ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0) - bounds = ink.getbbox() - if not bounds: - raise EngravingError(f'{path.name}: engraver produced a blank page') - if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4: - raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output') - -def engrave_scores(paths: list[Path], root: Path, fonts: Path, cache_dir: Path | None, progress: bool, worker: int, renderer: str) -> tuple[int, int, int, int]: - family = Fonts(fonts) - style = Style() - fingerprint = family.fingerprint(style) - reused, missing, rejected = 0, 0, 0 - for index, path in enumerate(paths, 1): - document = json.loads(path.read_text(encoding='utf-8')) - try: - validate_document(document) - destination = path.with_suffix('.pages') - if destination.exists(): - shutil.rmtree(destination) - entry = cache_dir / score_fingerprint(document, renderer) if cache_dir else None - present = entry is not None and (entry.exists() or entry.is_symlink()) - pages = restore_pages(entry, destination) if entry else None - if pages is None: - if entry: - rejected += int(present) - missing += int(not present) - if destination.exists(): - shutil.rmtree(destination) - destination.mkdir() - layout = layout_score(document, style, family) - pages = [] - for page in layout.pages: - name = f'{page.number:04}.png' - target = destination / name - rasterize_page(page, family).save(target, format='PNG') - check_page(target) - pages.append({'path': name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) - if entry: - store_pages(entry, destination, pages) - 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 != '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') - if progress and (index % 50 == 0 or index == len(paths)): - print(f'Engraving worker {worker}: {index}/{len(paths)} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True) - except ValueError as error: - raise EngravingError(f'{path.relative_to(root)}: {error}') from error - return len(paths), reused, missing, rejected - -def engrave_tree(root: Path, fonts: Path, *, cache_dir: Path | None = None, jobs: int = 1, progress: bool = False) -> None: - if type(jobs) is not int or not 1 <= jobs <= 16: - raise ValueError('engraving jobs must be between 1 and 16') - if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())): - raise ValueError('engraving cache and generated assets must not overlap') - paths = sorted(root.rglob('*.json')) - if not paths: - return - renderer = renderer_fingerprint(Fonts(fonts), Style()) if cache_dir else '' - if cache_dir: - cache_dir = renderer_namespace(cache_dir.resolve(), renderer) - if progress: - state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace' - print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True) - elif progress: - print('Engraving cache disabled', flush=True) - jobs = min(jobs, len(paths)) - if jobs == 1: - results = [engrave_scores(paths, root, fonts, cache_dir, progress, 1, renderer)] - else: - with ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool: - futures = [pool.submit(engrave_scores, paths[index::jobs], root, fonts, cache_dir, progress, index + 1, renderer) for index in range(jobs)] - results = [future.result() for future in futures] - if progress: - total, reused, missing, rejected = (sum(row[index] for row in results) for index in range(4)) - print(f'Engraving complete: {total} scores, {reused} reused, {missing} missing, {rejected} rejected', flush=True) +"""Score engraving implementation; import the owning modules explicitly.""" diff --git a/generator/engraving/attributes.py b/generator/engraving/attributes.py index f1aaab1..29af69a 100644 --- a/generator/engraving/attributes.py +++ b/generator/engraving/attributes.py @@ -1,13 +1,13 @@ from __future__ import annotations -from fractions import Fraction +import fractions -from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction, key_from_fifths -from generator.engraving.fonts import Fonts -from generator.engraving.notation import ScoreIndex, accidental -from generator.engraving.types import Primitive, bounds, line +import generator.engraving.fonts +import generator.engraving.notation +import generator.engraving.types +import generator.model -def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]: +def staff_attributes(index: generator.engraving.notation.ScoreIndex, state: dict, time: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[generator.engraving.types.Primitive]: result = [] x = 0 references = (source,) @@ -22,18 +22,18 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font if octave := clef.get('octave_change', 0): label = str(abs(octave) * 7 + 1) result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True)) - x = bounds(result).right + space + x = generator.engraving.types.bounds(result).right + space if 'key' in fields and clef['sign'] != 'percussion': key = state['key'] steps = key.get('steps') if steps is None: order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf' - steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])] + steps = [{'letter': c, 'alter': key['alterations'][generator.model.LETTERS.index(c)]} for c in order if generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])] cancellation = key.get('cancel', 0) if cancellation: order = 'fcgdaeb' if cancellation > 0 else 'beadgcf' - old = key_from_fifths(cancellation)['alterations'] - steps = [{'letter': c, 'alter': 0} for c in order if alteration(old[LETTERS.index(c)]) and alteration(old[LETTERS.index(c)]) != alteration(key['alterations'][LETTERS.index(c)])] + steps + old = generator.model.key_from_fifths(cancellation)['alterations'] + steps = [{'letter': c, 'alter': 0} for c in order if generator.model.alteration(old[generator.model.LETTERS.index(c)]) and generator.model.alteration(old[generator.model.LETTERS.index(c)]) != generator.model.alteration(key['alterations'][generator.model.LETTERS.index(c)])] + steps for step in steps: pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source} position = index.pitch_position(pitch, time) @@ -42,9 +42,9 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font position -= 7 while position < 2: position += 7 - items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references) + items = generator.engraving.notation.accidental(fonts, generator.model.alteration(step['alter']), {'name': step.get('accidental', '')}, space, references) result.extend(p.moved(x, -position * space / 2) for p in items) - x += bounds(items).right - bounds(items).left + 4 + x += generator.engraving.types.bounds(items).right - generator.engraving.types.bounds(items).left + 4 if steps: x += space / 2 if 'meter' in fields: @@ -67,8 +67,8 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font items = [] cursor = 0 if y == -space and meter.get('symbol') in ('note', 'dotted-note'): - unit = Fraction(4, group['unit']) - kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None) + unit = fractions.Fraction(4, group['unit']) + kind = next((name for name, length in generator.model.TYPE_LENGTHS.items() if length == unit), None) glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up') if kind and glyph_name in fonts.names: glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references) @@ -82,36 +82,35 @@ def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Font items.append(glyph) cursor = glyph.box.right + 1 groups.append(items) - width = max(bounds(items).right for items in groups) + width = max(generator.engraving.types.bounds(items).right for items in groups) for items in groups: - result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items) + result.extend(p.moved(x + (width - generator.engraving.types.bounds(items).right) / 2, 0) for p in items) x += width + 6 if 'staff_details' in fields and state['staff_details'].get('capo'): result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references)) return result -def barline(style: str, x: float, top: float, source: str) -> list[Primitive]: +def barline(style: str, x: float, top: float, source: str) -> list[generator.engraving.types.Primitive]: if style == 'none': return [] references = (source,) if style in ('dashed', 'dotted'): dash, gap = (5, 4) if style == 'dashed' else (1, 4) - return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)] + return [generator.engraving.types.line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)] if style in ('tick', 'short'): - return [line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))] + return [generator.engraving.types.line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))] weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style] - return [line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)] + return [generator.engraving.types.line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)] -def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]: - from generator.engraving.notation import flags, head_glyph +def tempo(value: dict, fonts: generator.engraving.fonts.Fonts, x: float, y: float, space: float, source: str) -> list[generator.engraving.types.Primitive]: written = value['written'] references = (source,) - glyph = fonts.glyph(head_glyph(written, None), x, y, space * 0.7, 'tempo', references) + glyph = fonts.glyph(generator.engraving.notation.head_glyph(written, None), x, y, space * 0.7, 'tempo', references) items = [glyph] stem_x = glyph.box.right - 0.5 if written['type'] not in ('whole', 'breve', 'long', 'maxima'): - items.append(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1)) - if flags(written): + items.append(generator.engraving.types.line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1)) + if generator.engraving.notation.flags(written): name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up' items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references)) cursor = max(p.box.right for p in items) + 3 @@ -119,5 +118,5 @@ def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: s dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references) items.append(dot) cursor = dot.box.right + 3 - items.append(fonts.text(' = ' + str(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references)) + items.append(fonts.text(' = ' + str(generator.model.fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references)) return items diff --git a/generator/engraving/audit.py b/generator/engraving/audit.py index 3b640bb..23d1d86 100644 --- a/generator/engraving/audit.py +++ b/generator/engraving/audit.py @@ -1,48 +1,52 @@ from __future__ import annotations -from fractions import Fraction +import fractions -from generator.model import TYPE_LENGTHS, fraction -from generator import harmony -from generator.engraving.fonts import Fonts -from generator.engraving.notation import ScoreIndex, head_glyph -from generator.engraving.types import EngravingError, LayoutDocument +import generator.engraving.fonts +import generator.engraving.notation +import generator.engraving.types +import generator.harmony +import generator.model -def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: - index = ScoreIndex(document) +def audit_primitives(primitives, width, height, fonts): + for item in primitives: + if not item.box.finite(): + raise generator.engraving.types.EngravingError(f'{item.sources}: non-finite or inverted primitive geometry') + if item.box.left < 4 or item.box.top < 4 or item.box.right > width - 4 or item.box.bottom > height - 4: + raise generator.engraving.types.EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds') + if item.kind == 'glyph': + actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0]) + if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))): + raise generator.engraving.types.EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline') + +def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts) -> None: + index = generator.engraving.notation.ScoreIndex(document) fragments, primitives = {}, {} - previous_end = Fraction() + previous_end = fractions.Fraction() for page in layout.pages: + audit_primitives(page.primitives, page.width, page.height, fonts) for item in page.primitives: - if not item.box.finite(): - raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry') - if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4: - raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds') - if item.kind == 'glyph': - actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0]) - if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))): - raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline') for source in item.sources: primitives.setdefault(source, []).append(item) for system in page.systems: if system.start != previous_end or system.end <= system.start: - raise EngravingError('layout systems do not partition the score timeline') + raise generator.engraving.types.EngravingError('layout systems do not partition the score timeline') previous_end = system.end baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff') for anchor in system.anchors: if anchor.fragment.kind == 'tone': expected = index.pitch_position(index.tones[anchor.source][1], anchor.time) if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01: - raise EngravingError(f'{anchor.source}: displayed staff position changed pitch') + raise generator.engraving.types.EngravingError(f'{anchor.source}: displayed staff position changed pitch') role = 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01] if not candidates: - raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive') + raise generator.engraving.types.EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive') if role == 'head' and candidates[0].kind == 'glyph': event, tone = index.tones[anchor.source] - expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead'))) + expected_glyph = generator.engraving.notation.head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead'))) if candidates[0].value != expected_glyph: - raise EngravingError(f'{anchor.source}: notehead changed written duration or shape') + raise generator.engraving.types.EngravingError(f'{anchor.source}: notehead changed written duration or shape') if index.staff.get('tablature') and anchor.fragment.kind == 'tone': tone = index.tones[anchor.source][1] tab = tone['tablature'] @@ -51,7 +55,7 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: expected_x = min(a.x for a in system.anchors if a.event == anchor.event and a.time == anchor.time) tabs = [p for p in system.primitives if p.role == 'tab' and anchor.source in p.sources] if len(tabs) != 1 or tabs[0].value != str(tab['fret']) or abs(tabs[0].points[0][1] - expected_y) > 0.01 or abs(tabs[0].points[0][0] - expected_x) > 0.01: - raise EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment') + raise generator.engraving.types.EngravingError(f'{anchor.source}: tablature changed position or rhythmic alignment') for fragment in system.fragments: fragments.setdefault(fragment.source, []).append(fragment) # Staff, ledger, stem, beam and tie contacts are intentional. Distinct @@ -64,57 +68,57 @@ def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None: if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}: continue if item.box.overlaps(other.box, 0.1): - raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}') + raise generator.engraving.types.EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}') if previous_end != index.end: - raise EngravingError('layout omits the end of the score') + raise generator.engraving.types.EngravingError('layout omits the end of the score') for event in index.events: note = event.value for source in note['pitches'] or [note]: identifier = source['id'] pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start) if not pieces: - raise EngravingError(f'{identifier}: missing notation fragments') + raise generator.engraving.types.EngravingError(f'{identifier}: missing notation fragments') cursor = event.start for fragment in pieces: expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no' if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none': expected_visible = False if fragment.visible != expected_visible: - raise EngravingError(f'{identifier}: notation visibility changed') + raise generator.engraving.types.EngravingError(f'{identifier}: notation visibility changed') if fragment.start != cursor: - raise EngravingError(f'{identifier}: fragments overlap or omit metric time') + raise generator.engraving.types.EngravingError(f'{identifier}: fragments overlap or omit metric time') cursor = fragment.end - expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio + expected = generator.model.TYPE_LENGTHS[fragment.written_type] * (2 - fractions.Fraction(1, 2 ** fragment.dots)) * fragment.ratio if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected: - raise EngravingError(f'{identifier}: written fragment duration is inexact') + raise generator.engraving.types.EngravingError(f'{identifier}: written fragment duration is inexact') if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])): - raise EngravingError(f'{identifier}: visible notation has no drawing primitive') - if cursor != event.start + fraction(source['duration']): - raise EngravingError(f'{identifier}: rendered duration changed') + raise generator.engraving.types.EngravingError(f'{identifier}: visible notation has no drawing primitive') + if cursor != event.start + generator.model.fraction(source['duration']): + raise generator.engraving.types.EngravingError(f'{identifier}: rendered duration changed') if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]: if not any(p.role == 'accidental' for p in primitives.get(identifier, [])): - raise EngravingError(f'{identifier}: required accidental was omitted') + raise generator.engraving.types.EngravingError(f'{identifier}: required accidental was omitted') if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces): if not any(p.role == 'tie' for p in primitives.get(identifier, [])): - raise EngravingError(f'{identifier}: tie was omitted') + raise generator.engraving.types.EngravingError(f'{identifier}: tie was omitted') if not note.get('hidden'): for verse in note.get('lyrics', []): text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision')) identifier = note['id'] + ':lyric:' + verse['number'] if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])): - raise EngravingError(f'{identifier}: lyric text was omitted or changed') + raise generator.engraving.types.EngravingError(f'{identifier}: lyric text was omitted or changed') for _, _, measure in index.measures: for value in measure['harmonies']: - label = harmony.symbol(value) + label = generator.harmony.symbol(value) if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])): - raise EngravingError(f'{value["id"]}: harmony was omitted') + raise generator.engraving.types.EngravingError(f'{value["id"]}: harmony was omitted') for i, _ in enumerate(measure.get('tempos', [])): if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])): - raise EngravingError(f'{measure["id"]}: tempo was omitted') + raise generator.engraving.types.EngravingError(f'{measure["id"]}: tempo was omitted') for span in index.staff.get('tuplets', []): if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no': if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])): - raise EngravingError(f'{span["id"]}: tuplet was omitted') + raise generator.engraving.types.EngravingError(f'{span["id"]}: tuplet was omitted') for span in index.staff.get('octave_shifts', []): if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])): - raise EngravingError(f'{span["id"]}: octave shift was omitted') + raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted') diff --git a/generator/engraving/cache.py b/generator/engraving/cache.py index 9de1a8f..3039d02 100644 --- a/generator/engraving/cache.py +++ b/generator/engraving/cache.py @@ -1,19 +1,26 @@ from __future__ import annotations +import dataclasses import hashlib import importlib.metadata +import io import json -from pathlib import Path -import shutil +import pathlib import platform +import re +import shutil import tempfile -from PIL import features +import PIL.Image +import PIL.ImageChops +import PIL.features + +import generator.engraving.validation def renderer_identity(fonts, style) -> dict: - root = Path(__file__).resolve().parents[1] + root = pathlib.Path(__file__).resolve().parents[1] versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')} - versions.update({name: features.version(name) for name in features.get_supported()}) + versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()}) sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'harmony.py', root / 'requirements.txt'] return {'fonts_style': fonts.fingerprint(style), 'python': {'implementation': platform.python_implementation(), 'version': platform.python_version()}, 'dependencies': versions, 'sources': {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest() for path in sorted(sources)}} @@ -21,7 +28,7 @@ def renderer_identity(fonts, style) -> dict: 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: +def renderer_namespace(cache: pathlib.Path, renderer: str) -> pathlib.Path: namespace = cache / renderer if namespace.parent.is_symlink() or namespace.is_symlink(): raise ValueError('engraving cache namespaces must not be symlinks') @@ -31,10 +38,19 @@ def score_fingerprint(document: dict, renderer: str) -> str: music = {key: value for key, value in document.items() if key != 'playback'} return hashlib.sha256(json.dumps([renderer, music], sort_keys=True, ensure_ascii=False).encode()).hexdigest() -def restore_pages(entry: Path, destination: Path) -> list[dict] | None: - from generator.engraving import check_page +def notation_fingerprint(document: dict, renderer: str) -> str: + return hashlib.sha256(json.dumps([renderer, document['staves']], sort_keys=True, ensure_ascii=False).encode()).hexdigest() + +def band_fingerprint(notation: str, page, band) -> str: + context = [notation, page.width, page.height, page.supersampling, dataclasses.asdict(band)] + return hashlib.sha256(json.dumps(context, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + +def has_symlink(path: pathlib.Path) -> bool: + return any(parent.is_symlink() for parent in (path, *path.parents)) + +def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_pages: set[str] | None = None) -> list[dict] | None: try: - if entry.is_symlink(): + if has_symlink(entry) or has_symlink(destination): return None manifest = entry / 'pages.json' if manifest.is_symlink(): @@ -44,27 +60,29 @@ def restore_pages(entry: Path, destination: Path) -> list[dict] | None: return None destination.mkdir(parents=True, exist_ok=True) for index, page in enumerate(pages, 1): - if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png': + if not isinstance(page, dict) or set(page) != {'path', 'sha256'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']): return None path = entry / page['path'] - if path.is_symlink(): + if path.is_symlink() or not path.is_file(): return None target = destination / page['path'] - shutil.copyfile(path, target) - if hashlib.sha256(target.read_bytes()).hexdigest() != page['sha256']: + if target.is_symlink(): return None - check_page(target) + shutil.copyfile(path, target) + generator.engraving.validation.check_page(target, validated_pages, expected_sha256=page['sha256']) return pages except (OSError, ValueError, TypeError): return None -def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None: +def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict]) -> None: + if has_symlink(entry.parent): + raise ValueError('engraving cache directories must not be symlinks') entry.parent.mkdir(parents=True, exist_ok=True) if entry.is_symlink() or entry.is_file(): entry.unlink(missing_ok=True) entry.mkdir(exist_ok=True) with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary: - staged = Path(temporary) + staged = pathlib.Path(temporary) for page in pages: shutil.copyfile(destination / page['path'], staged / page['path']) (staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8') @@ -74,3 +92,48 @@ def store_pages(entry: Path, destination: Path, pages: list[dict]) -> None: if target.is_dir() and not target.is_symlink(): shutil.rmtree(target, ignore_errors=True) (staged / name).replace(target) + +def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.Image.Image | None: + try: + manifest, asset = entry / 'band.json', entry / 'band.png' + if has_symlink(entry) or manifest.is_symlink() or asset.is_symlink(): + return None + metadata = json.loads(manifest.read_text(encoding='utf-8')) + if not isinstance(metadata, dict) or set(metadata) != {'context', 'width', 'height', 'sha256'}: + return None + if metadata['context'] != key or type(metadata['width']) is not int or type(metadata['height']) is not int or (metadata['width'], metadata['height']) != (width, height): + return None + digest = metadata['sha256'] + if not isinstance(digest, str) or not re.fullmatch('[0-9a-f]{64}', digest): + return None + data = asset.read_bytes() + if hashlib.sha256(data).hexdigest() != digest: + return None + with PIL.Image.open(io.BytesIO(data)) as image: + if image.format != 'PNG' or image.size != (width, height) or image.mode != 'RGB': + return None + image.load() + ink = PIL.ImageChops.difference(image, PIL.Image.new('RGB', image.size, 'white')).getbbox() + if not ink or ink[0] < 3 or ink[1] < 3 or ink[2] > width - 3 or ink[3] > height - 3: + return None + return image.copy() + except (OSError, ValueError, TypeError): + return None + +def store_band(entry: pathlib.Path, key: str, image: PIL.Image.Image) -> None: + if has_symlink(entry.parent): + raise ValueError('engraving cache directories must not be symlinks') + entry.parent.mkdir(parents=True, exist_ok=True) + if entry.is_symlink() or entry.is_file(): + entry.unlink() + entry.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory(prefix='.engraving-', dir=entry.parent) as temporary: + staged = pathlib.Path(temporary) + image.save(staged / 'band.png', format='PNG') + metadata = {'context': key, 'width': image.width, 'height': image.height, 'sha256': hashlib.sha256((staged / 'band.png').read_bytes()).hexdigest()} + (staged / 'band.json').write_text(json.dumps(metadata), encoding='utf-8') + for name in ('band.png', 'band.json'): + target = entry / name + if target.is_dir() and not target.is_symlink(): + shutil.rmtree(target) + (staged / name).replace(target) diff --git a/generator/engraving/fonts.py b/generator/engraving/fonts.py index af91a71..3aba818 100644 --- a/generator/engraving/fonts.py +++ b/generator/engraving/fonts.py @@ -1,27 +1,27 @@ from __future__ import annotations -from dataclasses import asdict +import dataclasses import hashlib import json -from pathlib import Path +import pathlib -from fontTools.pens.boundsPen import BoundsPen -from fontTools.pens.recordingPen import DecomposingRecordingPen -from fontTools.ttLib import TTFont -from PIL import ImageFont +import PIL.ImageFont +import fontTools.pens.boundsPen +import fontTools.pens.recordingPen +import fontTools.ttLib -from generator.engraving.types import Box, EngravingError, Primitive, Style +import generator.engraving.types class Fonts: - def __init__(self, root: Path): + def __init__(self, root: pathlib.Path): self.root = root try: - self.music = TTFont(root / 'Petaluma.otf') - self.script = TTFont(root / 'PetalumaScript.otf') + self.music = fontTools.ttLib.TTFont(root / 'Petaluma.otf') + self.script = fontTools.ttLib.TTFont(root / 'PetalumaScript.otf') self.metadata = json.loads((root / 'petaluma_metadata.json').read_text()) self.names = json.loads((root / 'smufl_glyphnames.json').read_text()) except (OSError, ValueError) as error: - raise EngravingError(f'cannot load bundled engraving fonts: {error}') from error + raise generator.engraving.types.EngravingError(f'cannot load bundled engraving fonts: {error}') from error self.names.update(self.metadata.get('optionalGlyphs', {})) self.cmap = self.music.getBestCmap() self.text_cmap = self.script.getBestCmap() @@ -30,37 +30,37 @@ class Fonts: self.metrics = {} self.outlines = {} self.text_fonts = {} - def fingerprint(self, style: Style) -> str: - digest = hashlib.sha256(json.dumps(asdict(style), sort_keys=True).encode()) + def fingerprint(self, style: generator.engraving.types.Style) -> str: + digest = hashlib.sha256(json.dumps(dataclasses.asdict(style), sort_keys=True).encode()) for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'): digest.update(name.encode()) digest.update((self.root / name).read_bytes()) return digest.hexdigest() def glyph_name(self, name: str) -> str: if name not in self.names: - raise EngravingError(f'unknown SMuFL glyph {name!r}') + raise generator.engraving.types.EngravingError(f'unknown SMuFL glyph {name!r}') code = int(self.names[name]['codepoint'].removeprefix('U+'), 16) if code not in self.cmap: - raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}') + raise generator.engraving.types.EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}') return self.cmap[code] - def glyph_box(self, name: str, size: float) -> Box: + def glyph_box(self, name: str, size: float) -> generator.engraving.types.Box: if name not in self.metrics: - pen = BoundsPen(self.glyphs) + pen = fontTools.pens.boundsPen.BoundsPen(self.glyphs) self.glyphs[self.glyph_name(name)].draw(pen) if pen.bounds is None: - raise EngravingError(f'empty SMuFL glyph {name!r}') + raise generator.engraving.types.EngravingError(f'empty SMuFL glyph {name!r}') self.metrics[name] = pen.bounds left, bottom, right, top = self.metrics[name] scale = size / self.units - return Box(left * scale, -top * scale, right * scale, -bottom * scale) + return generator.engraving.types.Box(left * scale, -top * scale, right * scale, -bottom * scale) def outline(self, name: str): if name not in self.outlines: - pen = DecomposingRecordingPen(self.glyphs) + pen = fontTools.pens.recordingPen.DecomposingRecordingPen(self.glyphs) self.glyphs[self.glyph_name(name)].draw(pen) self.outlines[name] = pen return self.outlines[name] - def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> Primitive: - return Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size) + def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> generator.engraving.types.Primitive: + return generator.engraving.types.Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size) def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]: value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor) if value is None: @@ -70,14 +70,14 @@ class Fonts: def text_font(self, size: float): pixels = max(1, round(size)) if pixels not in self.text_fonts: - self.text_fonts[pixels] = ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels) + self.text_fonts[pixels] = PIL.ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels) return self.text_fonts[pixels] - def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> Primitive: + def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> generator.engraving.types.Primitive: missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()] if missing: - raise EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}') + raise generator.engraving.types.EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}') font = self.text_font(size * 4) left, top, right, bottom = font.getbbox(value, anchor='ls') offset = (left + right) / 8 if center else 0 x -= offset - return Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size) + return generator.engraving.types.Primitive('text', role, sources, generator.engraving.types.Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size) diff --git a/generator/engraving/layout.py b/generator/engraving/layout.py index 51428e6..2f1277a 100644 --- a/generator/engraving/layout.py +++ b/generator/engraving/layout.py @@ -1,33 +1,35 @@ from __future__ import annotations -from dataclasses import replace -from bisect import bisect_left, bisect_right -from fractions import Fraction +import bisect +import collections +import dataclasses +import fractions import math -from generator import harmony -from generator.engraving import tablature -from generator.model import validate_document -from generator.engraving.attributes import barline, staff_attributes, tempo -from generator.engraving.fonts import Fonts -from generator.engraving.notation import ScoreIndex -from generator.engraving.notes import draw_event -from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes -from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line +import generator.engraving.attributes +import generator.engraving.audit +import generator.engraving.fonts +import generator.engraving.notation +import generator.engraving.notes +import generator.engraving.spans +import generator.engraving.tablature +import generator.engraving.types +import generator.harmony +import generator.model def lyric_text(verse: dict) -> str: return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision')) -def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]: +def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]: items, anchors, fragments = [], [], [] events = index.events_at(time, include_sustained=time == start) grace_width = 0 occupied = [] for event in events: try: - drawn, placed, pieces = draw_event(index, event, start, end, fonts, style) + drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style) except ValueError as error: - raise EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error + raise generator.engraving.types.EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error x = grace_width collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')] while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied): @@ -38,7 +40,7 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo grace_width = max((p.box.right for p in drawn), default=grace_width) + 9 occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')) items.extend(drawn) - items.extend(tablature.column(index, event, placed, fonts, style)) + items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style)) anchors.extend(placed) fragments.extend(pieces) if event.start == time and not event.value.get('hidden'): @@ -51,47 +53,61 @@ def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fo measure_start, _, measure = index.measure_at(time) for owner, _, item in index.harmonies_by_onset.get(time, []): if owner is measure: - items.append(fonts.text(harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],))) + items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],))) for owner, i, value in index.tempos_by_onset.get(time, []): if owner is measure: - items.extend(tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}')) + items.extend(generator.engraving.attributes.tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}')) if time == measure_start: items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],))) if label := measure.get('section_label'): items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',))) return items, anchors, fragments -def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, style: Style, justify: bool = False) -> System: +def column_metrics(index, time, start, end, fonts, style, columns): + events = index.events_at(time, include_sustained=time == start) + finish = min(end, max((event.end for event in events), default=time)) + key = (time, start, finish) + if columns is not None and key in columns: + columns.move_to_end(key) + return columns[key] + drawn, placed, pieces = column(index, time, start, end, fonts, style) + result = drawn, placed, pieces, generator.engraving.types.bounds(drawn) + if columns is not None: + columns[key] = result + if len(columns) > 256: + columns.popitem(last=False) + return result + +def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System: items, anchors, fragments = [], [], [] state = index.state(start) measure = index.measure_at(start)[2] - header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details')) + header = generator.engraving.attributes.staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details')) items.extend(header) cursor = max((p.box.right for p in header), default=0) + 22 content_left = cursor - 9 - times = [start] + index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, end)] + times = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)] placements = [] groups = [] for ti, time in enumerate(times): item_start, anchor_start = len(items), len(anchors) - drawn, placed, pieces = column(index, time, start, end, fonts, style) + drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns) leading = [] measure_start, _, m = index.measure_at(time) if time == measure_start and time > start: previous = index.measure_at(time, before=True)[2] - leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id'])) + leading.extend(generator.engraving.attributes.barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id'])) old_state = index.state(time, before=True) fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k]) - leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields)) + leading.extend(p.moved(12, 0) for p in generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields)) if time > start: for owner, ci, change in index.attributes_by_onset.get(time, []): - extra = staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change)) + extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change)) leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra) if leading: - width = bounds(leading).right - bounds(leading).left + 14 - items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading) + width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14 + items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading) cursor += width - box = bounds(drawn) x = cursor - min(0, box.left) placements.append((time, x)) items.extend(p.moved(x, 0) for p in drawn) @@ -114,10 +130,10 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, last_measure = index.measure_at(end, before=True)[2] tab_items = [p for p in items if p.role.startswith('tab')] items = [p for p in items if not p.role.startswith('tab')] - items = draw_beams(index, anchors, items, start, end, content_left, right, style) - items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style) - items = place_text_lanes(items, fonts, style) - items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts) + items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style) + items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style) + items = generator.engraving.spans.place_text_lanes(items, fonts, style) + items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts) changes = [(0, state['staff_details']['lines'])] for time, x in placements: lines = index.state(time)['staff_details']['lines'] @@ -128,18 +144,18 @@ def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1 for number in range(lines): y = -number * style.space - staff_lines.append(line('staff', (), ((x1, y), (x2, y)), style.space * 0.13)) + staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13)) items = staff_lines + items if end in index.boundaries: - items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id'])) + items.extend(generator.engraving.attributes.barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id'])) if start in index.boundaries: - items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id'])) + items.extend(generator.engraving.attributes.barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id'])) else: items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],))) - items = tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) - return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items)) + items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style) + return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items)) -def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]: +def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.Page, ...]: pages = [] placed = [] header = [] @@ -154,10 +170,10 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) size -= 1 item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ()) if item.box.right > style.width - style.margin_x: - raise EngravingError('score title cannot fit the page without clipping') + raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping') result = [item] if number == 1: - subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name'), document['metadata'].get('style')) if value) + subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value) if subtitle: result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ())) return result @@ -166,61 +182,65 @@ def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) height = system.box.bottom - system.box.top if first: header = page_header(len(pages) + 1) - cursor = bounds(header).bottom + 24 + cursor = generator.engraving.types.bounds(header).bottom + 24 first = False if cursor + height > style.height - style.margin_y - 18: if not placed: - raise EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it') + raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it') number = len(pages) + 1 footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) - pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) + pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) placed = [] header = page_header(len(pages) + 1) - cursor = bounds(header).bottom + 24 + cursor = generator.engraving.types.bounds(header).bottom + 24 if cursor + height > style.height - style.margin_y - 18: - raise EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page') + raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page') dx, dy = style.margin_x + 1, cursor - system.box.top - moved = replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)) + moved = dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)) placed.append(moved) cursor += height + style.system_gap if placed: number = len(pages) + 1 footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True) - pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) + pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) return tuple(pages) -def layout_score(document: dict, style: Style, fonts: Fonts) -> LayoutDocument: - validate_document(document) - index = ScoreIndex(document) +def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]: + generator.model.validate_document(document) + index = generator.engraving.notation.ScoreIndex(document) if not index.measures: - raise EngravingError('score has no measures') + raise generator.engraving.types.EngravingError('score has no measures') systems = [] - start = Fraction() + start = fractions.Fraction() available = style.width - style.margin_x * 2 while start < index.end: - candidates = index.boundary_times[bisect_right(index.boundary_times, start):] + columns = collections.OrderedDict() + candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, start):] selected = None # Reserve the same title/credit and footer bands when considering a split, # so a wide pitch range can break vertically as well as horizontally. height_limit = style.height - style.margin_y * 2 - 110 for end in candidates: - trial = make_system(index, start, end, fonts, style) + trial = make_system(index, start, end, fonts, style, columns=columns) if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit: break selected = trial if selected is None: measure_end = candidates[0] - for end in index.column_times[bisect_right(index.column_times, start):bisect_left(index.column_times, measure_end)]: - trial = make_system(index, start, end, fonts, style) + for end in index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]: + trial = make_system(index, start, end, fonts, style, columns=columns) if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit: selected = trial else: break if selected is None: - raise EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height') - systems.append(make_system(index, start, selected.end, fonts, style, justify=selected.end < index.end)) + raise generator.engraving.types.EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height') + systems.append(make_system(index, start, selected.end, fonts, style, justify=True, columns=columns) if selected.end < index.end else selected) start = selected.end - layout = LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style)) - from generator.engraving.audit import audit_layout - audit_layout(document, layout, fonts) + return tuple(systems) + +def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument: + systems = layout_systems(document, style, fonts) + layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style)) + generator.engraving.audit.audit_layout(document, layout, fonts) return layout diff --git a/generator/engraving/notation.py b/generator/engraving/notation.py index 88bfa9d..ffcb334 100644 --- a/generator/engraving/notation.py +++ b/generator/engraving/notation.py @@ -1,24 +1,24 @@ from __future__ import annotations -from dataclasses import dataclass -from bisect import bisect_left, bisect_right -from fractions import Fraction +import bisect +import dataclasses +import fractions -from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for -from generator.engraving.fonts import Fonts -from generator.engraving.types import EngravingError, Primitive, bounds +import generator.engraving.fonts +import generator.engraving.types +import generator.model -ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'} +ACCIDENTAL_GLYPHS = {fractions.Fraction(0): 'accidentalNatural', fractions.Fraction(1): 'accidentalSharp', fractions.Fraction(-1): 'accidentalFlat', fractions.Fraction(2): 'accidentalDoubleSharp', fractions.Fraction(-2): 'accidentalDoubleFlat', fractions.Fraction(3): 'accidentalTripleSharp', fractions.Fraction(-3): 'accidentalTripleFlat', fractions.Fraction(1, 2): 'accidentalQuarterToneSharpStein', fractions.Fraction(-1, 2): 'accidentalQuarterToneFlatStein', fractions.Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', fractions.Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'} HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'} -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Event: measure: dict voice: str lane: int lanes: int - start: Fraction - end: Fraction + start: fractions.Fraction + end: fractions.Fraction value: dict class ScoreIndex: @@ -32,20 +32,20 @@ class ScoreIndex: self.tempos_by_onset = {} self.by_id = {} self.tones = {} - self.boundaries = {Fraction()} - self.columns = {Fraction()} + self.boundaries = {fractions.Fraction()} + self.columns = {fractions.Fraction()} self._states = {} self._beam_groups = None - time = Fraction() + time = fractions.Fraction() for measure in self.staff['measures']: - end = time + fraction(measure['duration']) + end = time + generator.model.fraction(measure['duration']) self.measures.append((time, end, measure)) self.boundaries.add(end) self.columns.update((time, end)) for lane, voice in enumerate(measure['voices']): for note in voice['notes']: - onset = time + fraction(note['onset']) - event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note) + onset = time + generator.model.fraction(note['onset']) + event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + generator.model.fraction(note['duration']), note) self.events.append(event) self.by_id[note['id']] = event self.columns.add(onset) @@ -54,7 +54,7 @@ class ScoreIndex: for field in ('attributes', 'harmonies', 'tempos'): bucket = {'attributes': self.attributes_by_onset, 'harmonies': self.harmonies_by_onset, 'tempos': self.tempos_by_onset}[field] for index, item in enumerate(measure.get(field, [])): - onset = time + fraction(item['onset']) + onset = time + generator.model.fraction(item['onset']) self.columns.add(onset) bucket.setdefault(onset, []).append((measure, index, item)) time = end @@ -72,15 +72,15 @@ class ScoreIndex: self.boundary_times = sorted(self.boundaries) self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', []))) self.accidentals = self.accidental_state() - def measure_at(self, time: Fraction, before: bool = False) -> tuple: - position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time) + def measure_at(self, time: fractions.Fraction, before: bool = False) -> tuple: + position = bisect.bisect_left(self.measure_starts, time) if before else bisect.bisect_right(self.measure_starts, time) return self.measures[max(0, position - 1)] - def events_at(self, time: Fraction, include_sustained: bool = False) -> list[Event]: + def events_at(self, time: fractions.Fraction, include_sustained: bool = False) -> list[Event]: attacks = self.events_by_onset.get(time, []) if not include_sustained: return attacks sustained = [] - position = bisect_left(self.event_starts, time) - 1 + position = bisect.bisect_left(self.event_starts, time) - 1 # Prefix maxima let system continuations stop searching once every earlier note has ended. while position >= 0 and self.event_max_ends[position] > time: event = self.events[position] @@ -88,18 +88,18 @@ class ScoreIndex: sustained.append(event) position -= 1 return list(reversed(sustained)) + attacks - def state(self, time: Fraction, before: bool = False) -> dict: + def state(self, time: fractions.Fraction, before: bool = False) -> dict: if (time, before) in self._states: return self._states[time, before] start, _, measure = self.measure_at(time, before) state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')} for change in measure.get('attributes', []): - onset = start + fraction(change['onset']) + onset = start + generator.model.fraction(change['onset']) if onset < time or (onset == time and not before): state.update({key: value for key, value in change.items() if key != 'onset'}) self._states[time, before] = state return state - def pitch_position(self, tone: dict, time: Fraction) -> float: + def pitch_position(self, tone: dict, time: fractions.Fraction) -> float: state = self.state(time) clef = state['clef'] pitch = tone.get('unpitched', tone) @@ -107,14 +107,14 @@ class ScoreIndex: reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4) clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2 octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0) - shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0 - return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1) + shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if generator.model.fraction(s['start']) <= time < generator.model.fraction(s['end'])) if 'unpitched' not in tone else 0 + return pitch['octave'] * 7 + generator.model.LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1) def accidental_state(self) -> dict[str, bool]: result = {} for start, end, measure in self.measures: memory = {} key = None - onsets = self.onsets[bisect_left(self.onsets, start):bisect_left(self.onsets, end)] + onsets = self.onsets[bisect.bisect_left(self.onsets, start):bisect.bisect_left(self.onsets, end)] for onset in onsets: state = self.state(onset) if key != state['key']: @@ -130,11 +130,11 @@ class ScoreIndex: result[tone['id']] = False continue identity = (tone['letter'], tone['octave']) - value = alteration(tone['alter']) - signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])]) + value = generator.model.alteration(tone['alter']) + signature = fractions.Fraction() if key.get('custom') else generator.model.alteration(key['alterations'][generator.model.LETTERS.index(tone['letter'])]) for step in key.get('steps', []): if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']: - signature = alteration(step['alter']) + signature = generator.model.alteration(step['alter']) result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value) simultaneous.setdefault(identity, set()).add(value) for identity, values in simultaneous.items(): @@ -146,11 +146,11 @@ class ScoreIndex: result[tone['id']] = True return result -def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict: - return written if (original_start, original_end) == (start, end) else written_for(end - start) +def fragment_written(written: dict, original_start: fractions.Fraction, original_end: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction) -> dict: + return written if (original_start, original_end) == (start, end) else generator.model.written_for(end - start) def flags(written: dict) -> int: - return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter')) + return max(0, generator.model.NOTE_TYPES.index(written['type']) - generator.model.NOTE_TYPES.index('quarter')) def head_glyph(written: dict, head: dict | str | None) -> str: head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'} @@ -170,13 +170,13 @@ def head_glyph(written: dict, head: dict | str | None) -> str: if name in ('circled', 'circle dot'): return 'noteheadCircled' + suffix if name not in HEADS: - raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name') + raise generator.engraving.types.EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name') shape = HEADS[name] if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'): suffix = 'Black' if suffix == 'Black' else 'White' return 'notehead' + shape + suffix -def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]: +def accidental(fonts: generator.engraving.fonts.Fonts, value: fractions.Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]: glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value) special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')} if display.get('name') in special and not display.get('smufl'): @@ -192,7 +192,7 @@ def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, source sign = '+' if value > 0 else '' items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)] if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes': - box = bounds(items) + box = generator.engraving.types.bounds(items) left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')') items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources))) return items diff --git a/generator/engraving/notes.py b/generator/engraving/notes.py index 2760942..bcaebd9 100644 --- a/generator/engraving/notes.py +++ b/generator/engraving/notes.py @@ -1,13 +1,14 @@ from __future__ import annotations -from fractions import Fraction +import dataclasses +import fractions -from generator.model import alteration, fraction -from generator.engraving.fonts import Fonts -from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph -from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line +import generator.engraving.fonts +import generator.engraving.notation +import generator.engraving.types +import generator.model -def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]: +def dots(fonts: generator.engraving.fonts.Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[generator.engraving.types.Primitive]: result = [] for i in range(count): result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources)) @@ -16,13 +17,13 @@ def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tup def rest_glyph(kind: str) -> str: return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind) -def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None: - ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})) +def time_modification(written: dict, items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, sources: tuple[str, ...]) -> None: + ratio = generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})) if ratio != 1 and items: - box = bounds(items) + box = generator.engraving.types.bounds(items) items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True)) -def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]: +def draw_event(index: generator.engraving.notation.ScoreIndex, event: generator.engraving.notation.Event, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]: note = event.value onset = max(start, event.start) finish = min(end, event.end) @@ -33,17 +34,17 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, state = index.state(onset) lines = state['staff_details']['lines'] if not note['pitches']: - written = fragment_written(note['written'], event.start, event.end, onset, finish) + written = generator.engraving.notation.fragment_written(note['written'], event.start, event.end, onset, finish) y = -style.space * (lines - 1) / 2 if position := note.get('rest_position'): y = -index.pitch_position(position, onset) * style.space / 2 elif event.lanes > 1: y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3 kind = 'slash' if note.get('notehead') == 'slash' else 'rest' - fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None) + fragment = generator.engraving.types.Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None) fragments.append(fragment) if visible: - glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type']) + glyph = generator.engraving.notation.head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type']) if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1: y -= style.space items.append(fonts.glyph(glyph, 0, y, size, kind, references)) @@ -51,20 +52,20 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'): stem_x = items[0].box.right stem_y = y - 3 * size - items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y)))) - if flags(written): + items.append(generator.engraving.types.line('stem', references, ((stem_x, y), (stem_x, stem_y)))) + if generator.engraving.notation.flags(written): flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up' items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references)) - anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment)) + anchors.append(generator.engraving.types.Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment)) if not note.get('tuplet_ids') or written != note['written']: time_modification(written, items, fonts, references) return items, anchors, fragments groups = {} for tone in note['pitches']: - tone_end = event.start + fraction(tone['duration']) + tone_end = event.start + generator.model.fraction(tone['duration']) if tone_end <= start and not note.get('grace'): continue - written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end)) + written = generator.engraving.notation.fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end)) stem = tone.get('stem', note.get('stem', 'auto')) identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem) groups.setdefault(identity, []).append((tone, written, tone_end)) @@ -82,7 +83,7 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, for position, tone, written, tone_end in positioned: sources = (note['id'], tone['id']) tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1) - glyph = head_glyph(written, tone.get('notehead', note.get('notehead'))) + glyph = generator.engraving.notation.head_glyph(written, tone.get('notehead', note.get('notehead'))) displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph) y = -position * style.space / 2 if previous is not None and abs(position - previous) < 2: @@ -91,13 +92,13 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, displaced = False x = group_x + (tone_size * 1.2 * -direction if displaced else 0) previous = position - fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position) + fragment = generator.engraving.types.Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), generator.model.fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position) fragments.append(fragment) if not displayed: continue if written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal': width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4) - head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2) + head = generator.engraving.types.line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2) else: head = fonts.glyph(glyph, x, y, tone_size, 'head', sources) group_items.append(head) @@ -105,15 +106,15 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources))) ledger = -2 while position <= ledger: - group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) + group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) ledger -= 2 ledger = lines * 2 while position >= ledger: - group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) + group_items.append(generator.engraving.types.line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8)) ledger += 2 if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start: - accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources) - box = bounds(accidental_items) + accidental_items = generator.engraving.notation.accidental(fonts, generator.model.alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources) + box = generator.engraving.types.bounds(accidental_items) dx = min(group_x, head.box.left) - 5 - box.right candidate = box.moved(dx, y) while any(candidate.overlaps(other, 2) for other in accidental_boxes): @@ -128,32 +129,31 @@ def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0] else: stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6 - stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75) - group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment)) + stem_y = y + direction * max(3.5 * size, (generator.engraving.notation.flags(written) + 1) * size * 0.75) + group_anchors.append(generator.engraving.types.Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, generator.engraving.notation.flags(written), fragment)) if group_anchors: stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors) anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y) written = members[0][1] if stem != 'none' and written['type'] not in ('whole', 'breve'): - group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12)) + group_items.append(generator.engraving.types.line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12)) if stem == 'double': other_x = anchor.x if direction == -1 else anchor.x + size * 1.3 other_y = anchor.y - direction * size * 3.5 - group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12)) - if flags(written): + group_items.append(generator.engraving.types.line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12)) + if generator.engraving.notation.flags(written): flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up') group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references)) - if flags(written): + if generator.engraving.notation.flags(written): flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down') group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references)) if note.get('grace', {}).get('slash'): - group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5)) + group_items.append(generator.engraving.types.line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5)) if note.get('tremolo', {}).get('type') == 'single': for mark in range(note['tremolo']['marks']): ty = stem_y - direction * (10 + mark * 5) - group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3)) - from dataclasses import replace - anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors) + group_items.append(generator.engraving.types.line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3)) + anchors.extend(dataclasses.replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors) if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']: time_modification(members[0][1], group_items, fonts, references) items.extend(group_items) diff --git a/generator/engraving/pipeline.py b/generator/engraving/pipeline.py new file mode 100644 index 0000000..0ccd7e1 --- /dev/null +++ b/generator/engraving/pipeline.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import collections +import concurrent.futures +import dataclasses +import hashlib +import json +import multiprocessing +import pathlib +import shutil +import tempfile +import time + +import PIL.Image + +import generator.engraving.audit +import generator.engraving.cache +import generator.engraving.fonts +import generator.engraving.layout +import generator.engraving.raster +import generator.engraving.types +import generator.engraving.validation +import generator.model + +@dataclasses.dataclass +class GroupResult: + scores: list = dataclasses.field(default_factory=list) + fullscore: int = 0 + bodyhits: int = 0 + bodies: int = 0 + renderedpages: int = 0 + rejected: int = 0 + timings: dict = dataclasses.field(default_factory=lambda: dict.fromkeys(('layout', 'pagination', 'raster', 'cache'), 0.0)) + +class BodyBands: + def __init__(self, root, persistent, result, limit=16 * 1024 * 1024): + self.root, self.persistent, self.result, self.limit = root, persistent, result, limit + self.images = collections.OrderedDict() + self.size = 0 + self.known = {} + self.root.mkdir(parents=True, exist_ok=True) + def get(self, notation, page, band, fonts): + key = generator.engraving.cache.band_fingerprint(notation, page, band) + if key in self.images: + self.images.move_to_end(key) + self.result.bodyhits += 1 + return self.images[key] + started = time.monotonic() + entry = self.root / (key + '.rgb') + image = None + if key in self.known: + if entry.is_symlink() or entry.stat().st_size != page.width * (band.bottom - band.top) * 3: + raise generator.engraving.types.EngravingError('temporary notation band was corrupted') + data = entry.read_bytes() + if hashlib.sha256(data).hexdigest() != self.known[key]: + raise generator.engraving.types.EngravingError('temporary notation band was corrupted') + image = PIL.Image.frombytes('RGB', (page.width, band.bottom - band.top), data) + elif self.persistent: + cached = self.persistent / 'notation' / key + image = generator.engraving.cache.restore_band(cached, key, page.width, band.bottom - band.top) + self.result.rejected += int(image is None and (cached.exists() or cached.is_symlink())) + self.result.timings['cache'] += time.monotonic() - started + if image is None: + started = time.monotonic() + image = generator.engraving.raster.rasterize_band(page, band.primitives, band.top, band.bottom, fonts) + self.result.timings['raster'] += time.monotonic() - started + self.result.bodies += 1 + if self.persistent: + started = time.monotonic() + generator.engraving.cache.store_band(self.persistent / 'notation' / key, key, image) + self.result.timings['cache'] += time.monotonic() - started + else: + self.result.bodyhits += 1 + if key not in self.known: + started = time.monotonic() + # Spill finalized pixels, so eviction never repeats rasterization or PNG decoding. + data = image.tobytes() + entry.write_bytes(data) + self.result.timings['cache'] += time.monotonic() - started + self.known[key] = hashlib.sha256(data).hexdigest() + size = image.width * image.height * 3 + while self.images and self.size + size > self.limit: + _, old = self.images.popitem(last=False) + self.size -= old.width * old.height * 3 + old.close() + self.images[key] = image + self.size += size + return image + def close(self): + for image in self.images.values(): + image.close() + self.images.clear() + self.size = 0 + +def clear_pages(destination): + if destination.is_symlink() or destination.is_file(): + destination.unlink() + elif destination.exists(): + shutil.rmtree(destination) + +def write_bundle(path, document, pages, fingerprint): + entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages] + music = {key: value for key, value in document.items() if key != 'playback'} + bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint, + 'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.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') + +def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False): + family = generator.engraving.fonts.Fonts(font_path) + style = generator.engraving.types.Style() + systems = None + scores = {} + audited = set() + result = GroupResult() + with tempfile.TemporaryDirectory(prefix='vamp-notation-') as temporary: + bodies = BodyBands(pathlib.Path(temporary), cache_dir, result) + try: + for path, score_key in paths: + try: + document = json.loads(path.read_text(encoding='utf-8')) + destination = path.with_suffix('.pages') + clear_pages(destination) + destination.mkdir() + if score_key in scores: + source, pages = scores[score_key] + for page in pages: + shutil.copyfile(source / page['path'], destination / page['path']) + result.fullscore += 1 + else: + if systems is None: + started = time.monotonic() + systems = generator.engraving.layout.layout_systems(document, style, family) + result.timings['layout'] += time.monotonic() - started + started = time.monotonic() + pages_layout = generator.engraving.layout.paginate(systems, document, family, style) + placement = tuple((page.width, page.height, tuple((system.start, system.end, system.box) for system in page.systems)) for page in pages_layout) + if placement not in audited: + layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint) + generator.engraving.audit.audit_layout(document, layout, family) + audited.add(placement) + else: + for page in pages_layout: + decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page-number')) + generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family) + result.timings['pagination'] += time.monotonic() - started + pages = [] + for page in pages_layout: + bands = generator.engraving.raster.page_bands(page) + if bands is None: + started = time.monotonic() + image = generator.engraving.raster.rasterize_page(page, family) + else: + body = bodies.get(notation, page, bands[1], family) + started = time.monotonic() + image = generator.engraving.raster.compose_page(page, family, bands, body) + target = destination / f'{page.number:04}.png' + with image: + image.save(target, format='PNG') + result.timings['raster'] += time.monotonic() - started + result.renderedpages += 1 + pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()}) + scores[score_key] = (destination, pages) + write_bundle(path, document, pages, fingerprint) + result.scores.append((path, score_key, pages)) + if progress and len(result.scores) % 50 == 0: + print(f'Engraving body {notation[:12]}: {len(result.scores)}/{len(paths)} scores, fullscore {result.fullscore}, bodyhits {result.bodyhits}, bodies {result.bodies}, renderedpages {result.renderedpages}', flush=True) + except ValueError as error: + raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error + finally: + bodies.close() + return result + +def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.Path | None = None, jobs: int = 1, progress: bool = False) -> set[str]: + if type(jobs) is not int or not 1 <= jobs <= 16: + raise ValueError('engraving jobs must be between 1 and 16') + if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())): + raise ValueError('engraving cache and generated assets must not overlap') + started = time.monotonic() + paths = sorted(root.rglob('*.json')) + validated_pages = set() + if not paths: + return validated_pages + family, style = generator.engraving.fonts.Fonts(fonts), generator.engraving.types.Style() + fingerprint = family.fingerprint(style) + renderer = generator.engraving.cache.renderer_fingerprint(family, style) + if cache_dir: + cache_dir = generator.engraving.cache.renderer_namespace(cache_dir, renderer) + if progress: + state = 'warm namespace' if cache_dir.is_dir() else 'cold namespace' + print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True) + elif progress: + print('Engraving cache disabled', flush=True) + totals = GroupResult() + missing, rejected, completed = 0, 0, 0 + groups = {} + for path in paths: + try: + document = json.loads(path.read_text(encoding='utf-8')) + generator.model.validate_document(document) + score_key = generator.engraving.cache.score_fingerprint(document, renderer) + destination = path.with_suffix('.pages') + clear_pages(destination) + entry = cache_dir / 'fullscore' / score_key if cache_dir else None + present = entry is not None and (entry.exists() or entry.is_symlink()) + pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None + if pages is None: + rejected += int(present) + missing += int(entry is not None and not present) + notation = generator.engraving.cache.notation_fingerprint(document, renderer) + groups.setdefault(notation, []).append((path, score_key)) + else: + write_bundle(path, document, pages, fingerprint) + totals.fullscore += 1 + completed += 1 + except ValueError as error: + raise generator.engraving.types.EngravingError(f'{path.relative_to(root)}: {error}') from error + prepare_time = time.monotonic() - started + if progress: + print(f'Engraving grouped: {len(paths)} scores, {len(groups)} notation bodies, {totals.fullscore} fullscore hits', flush=True) + validation_time = 0.0 + + def finish(result): + nonlocal completed, validation_time + started = time.monotonic() + for path, score_key, pages in result.scores: + destination = path.with_suffix('.pages') + for page in pages: + generator.engraving.validation.check_page(destination / page['path'], validated_pages, expected_sha256=page['sha256']) + validation_time += time.monotonic() - started + if cache_dir: + started = time.monotonic() + stored = set() + for path, score_key, pages in result.scores: + if score_key not in stored: + generator.engraving.cache.store_pages(cache_dir / 'fullscore' / score_key, path.with_suffix('.pages'), pages) + stored.add(score_key) + result.timings['cache'] += time.monotonic() - started + for field in ('fullscore', 'bodyhits', 'bodies', 'renderedpages', 'rejected'): + setattr(totals, field, getattr(totals, field) + getattr(result, field)) + for phase, elapsed in result.timings.items(): + totals.timings[phase] += elapsed + completed += len(result.scores) + if progress: + print(f'Engraving: {completed}/{len(paths)} scores, fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}', flush=True) + + jobs = min(jobs, len(groups)) + if jobs <= 1: + for notation, group in groups.items(): + finish(engrave_group(notation, group, root, fonts, cache_dir, fingerprint, progress)) + else: + with concurrent.futures.ProcessPoolExecutor(max_workers=jobs, mp_context=multiprocessing.get_context('spawn')) as pool: + futures = [pool.submit(engrave_group, notation, group, root, fonts, cache_dir, fingerprint, progress) for notation, group in groups.items()] + for future in concurrent.futures.as_completed(futures): + finish(future.result()) + if progress: + print(f'Engraving complete: {len(paths)} scores, {totals.fullscore} reused, {missing} missing, {rejected} rejected; fullscore {totals.fullscore}, bodyhits {totals.bodyhits}, bodies {totals.bodies}, renderedpages {totals.renderedpages}, rejected bodies {totals.rejected}', flush=True) + phases = {'prepare': prepare_time, **totals.timings, 'validation': validation_time} + print('Engraving timings: ' + ', '.join(f'{phase} {elapsed:.3f}s' for phase, elapsed in phases.items()) + f'; wall {time.monotonic() - started:.3f}s (worker phases summed)', flush=True) + return validated_pages diff --git a/generator/engraving/raster.py b/generator/engraving/raster.py index fe513a9..000caf2 100644 --- a/generator/engraving/raster.py +++ b/generator/engraving/raster.py @@ -1,13 +1,17 @@ from __future__ import annotations +import dataclasses +import math + +import PIL.Image +import PIL.ImageDraw import aggdraw -from fontTools.pens.basePen import BasePen -from PIL import Image, ImageDraw +import fontTools.pens.basePen -from generator.engraving.fonts import Fonts -from generator.engraving.types import Page +import generator.engraving.fonts +import generator.engraving.types -class OutlinePen(BasePen): +class OutlinePen(fontTools.pens.basePen.BasePen): def __init__(self, glyphs, scale: float, x: float, y: float): super().__init__(glyphs) self.path = aggdraw.Path() @@ -25,12 +29,15 @@ class OutlinePen(BasePen): def _endPath(self): pass -def rasterize_page(page: Page, fonts: Fonts) -> Image.Image: +def rasterize_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image: + return rasterize_band(page, page.primitives, 0, page.height, fonts) + +def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts) -> PIL.Image.Image: scale = page.supersampling - image = Image.new('RGB', (page.width * scale, page.height * scale), 'white') + image = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), 'white') context = aggdraw.Draw(image) - for item in page.primitives: - points = [(x * scale, y * scale) for x, y in item.points] + for item in primitives: + points = [(x * scale, y * scale - top * scale) for x, y in item.points] if item.kind == 'text': continue if item.kind == 'glyph': @@ -52,8 +59,43 @@ def rasterize_page(page: Page, fonts: Fonts) -> Image.Image: else: context.path(path, aggdraw.Pen('black', item.thickness * scale)) context.flush() - drawing = ImageDraw.Draw(image) - for item in page.primitives: + drawing = PIL.ImageDraw.Draw(image) + for item in primitives: if item.kind == 'text': - drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black') - return image.resize((page.width, page.height), Image.Resampling.LANCZOS) + x, y = item.points[0] + drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black') + return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS) + +@dataclasses.dataclass(frozen=True) +class Band: + top: int + bottom: int + primitives: tuple[generator.engraving.types.Primitive, ...] + +def page_bands(page: generator.engraving.types.Page) -> tuple[Band, Band, Band] | None: + body = tuple(p for system in page.systems for p in system.primitives) + header = tuple(p for p in page.primitives if p.role in ('title', 'subtitle')) + footer = tuple(p for p in page.primitives if p.role == 'page-number') + if not body or not header or not footer or page.primitives != header + body + footer: + return None + bands = [] + for primitives in (header, body, footer): + box = generator.engraving.types.bounds(primitives) + if not box.finite() or box.left < 4 or box.right > page.width - 4 or box.top < 4 or box.bottom > page.height - 4: + return None + # Six output pixels cover raster antialiasing and the three-pixel Lanczos + # support. Integer crop origins preserve the full-page sampling phase. + bands.append(Band(max(0, math.floor(box.top) - 6), min(page.height, math.ceil(box.bottom) + 6), primitives)) + if bands[0].bottom > bands[1].top or bands[1].bottom > bands[2].top: + return None + return tuple(bands) + +def compose_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, bands: tuple[Band, Band, Band], body: PIL.Image.Image) -> PIL.Image.Image: + image = PIL.Image.new('RGB', (page.width, page.height), 'white') + if body.mode != 'RGB' or body.size != (page.width, bands[1].bottom - bands[1].top): + raise generator.engraving.types.EngravingError('notation band has incompatible geometry') + image.paste(body, (0, bands[1].top)) + for band in (bands[0], bands[2]): + with rasterize_band(page, band.primitives, band.top, band.bottom, fonts) as decoration: + image.paste(decoration, (0, band.top)) + return image diff --git a/generator/engraving/spans.py b/generator/engraving/spans.py index 699b2d8..838f232 100644 --- a/generator/engraving/spans.py +++ b/generator/engraving/spans.py @@ -1,13 +1,13 @@ from __future__ import annotations -from fractions import Fraction +import fractions -from generator.model import beat_lengths, fraction -from generator.engraving.fonts import Fonts -from generator.engraving.notation import ScoreIndex, flags -from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon +import generator.engraving.fonts +import generator.engraving.notation +import generator.engraving.types +import generator.model -def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]: +def beam_groups(index: generator.engraving.notation.ScoreIndex) -> tuple[tuple[str, ...], ...]: if index._beam_groups is not None: return index._beam_groups groups = [] @@ -18,17 +18,17 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]: note = event.value primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None) measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id']) - beats = beat_lengths(index.state(event.start)['meter']) + beats = generator.model.beat_lengths(index.state(event.start)['meter']) offset = event.start - measure_start beat = 0 - edge = Fraction() + edge = fractions.Fraction() for duration in beats: edge += duration if offset < edge: break beat += 1 key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace'))) - eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden') + eligible = note['kind'] == 'note' and generator.engraving.notation.flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden') if not eligible or primary == 'begin' or (primary is None and key != previous_key): if current: groups.append(current) @@ -44,7 +44,7 @@ def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]: index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams')) return index._beam_groups -def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]: +def draw_beams(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]: additions = [] removed = set() for group in beam_groups(index): @@ -65,7 +65,7 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], x1 = left if index.by_id[group[0]].start < start else first.stem_x x2 = right if index.by_id[group[-1]].start >= end else last.stem_x for anchor in selected: - additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4)) + additions.append(generator.engraving.types.line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4)) for level in range(1, max(a.flags for a in selected) + 1): y = beam_y - direction * (level - 1) * style.space * 0.75 for i, anchor in enumerate(selected): @@ -88,18 +88,18 @@ def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], b = a + (8 if next_anchor else -8) else: continue - additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5)))) + additions.append(generator.engraving.types.polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5)))) if selected and x1 < selected[0].stem_x and selected[0].flags >= level: - additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5)) + additions.append(generator.engraving.types.line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5)) return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions -def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]: +def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]: result = [] by_tone = {a.source: a for a in anchors} by_event = {a.event: a for a in anchors} for identifier, (event, tone) in index.tones.items(): target = tone.get('tie_to') - tone_end = event.start + fraction(tone['duration']) + tone_end = event.start + generator.model.fraction(tone['duration']) anchor = by_tone.get(identifier) if anchor and anchor.fragment.end < tone_end: target = identifier @@ -118,11 +118,11 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8 y2 = destination.y + sign * 8 if destination else y1 if x2 > x1: - result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6)) + result.append(generator.engraving.types.curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6)) if anchor and anchor.fragment.start > event.start: y = anchor.y - anchor.direction * 8 if anchor.x - 2 > left: - result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6)) + result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6)) top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0)) tuplet_lanes = [] for span in index.staff.get('tuplets', []): @@ -158,33 +158,33 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], if q <= p: continue if span.get('line_shape') == 'curved': - result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3)) + result.append(generator.engraving.types.curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3)) else: - result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3)) + result.append(generator.engraving.types.line('tuplet-bracket', references, ((p, y), (q, y)), 1.3)) if a.start >= start: - result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3)) + result.append(generator.engraving.types.line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3)) if b.end <= end: - result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3)) - outer_top = min(top, bounds(result).top if result else top) + result.append(generator.engraving.types.line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3)) + outer_top = min(top, generator.engraving.types.bounds(result).top if result else top) for span in index.staff.get('octave_shifts', []): - a, b = fraction(span['start']), fraction(span['end']) + a, b = generator.model.fraction(span['start']), generator.model.fraction(span['end']) if b <= start or a >= end: continue members = [n for n in anchors if a <= n.time < b] x1 = min((n.x for n in members), default=left) if a >= start else left x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right above = span['octaves'] < 0 - y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24 + y = outer_top - 18 if above else max(generator.engraving.types.bounds(items + result).bottom, 0) + 24 label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb') text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],)) result.append(text) outer_top = min(outer_top, text.box.top - 4) cursor = text.box.right + 4 while cursor < x2: - result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1)) + result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1)) cursor += 10 if b <= end: - result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1)) + result.append(generator.engraving.types.line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1)) for event in index.events: tremolo = event.value.get('tremolo', {}) if tremolo.get('type') != 'start': @@ -199,18 +199,18 @@ def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right y = anchor.stem_y - anchor.direction * 10 for mark in range(tremolo['marks']): - result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5)) + result.append(generator.engraving.types.line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5)) return items + result -def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]: +def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]: music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')] - box = bounds(music) + box = generator.engraving.types.bounds(music) result = list(music) top = box.top - 12 for role in ('harmony', 'tempo', 'section'): lane = [p for p in items if p.role == role] if lane: - extent = bounds(lane) + extent = generator.engraving.types.bounds(lane) result.extend(p.moved(0, top - extent.bottom) for p in lane) top -= extent.bottom - extent.top + 10 lyrics = [p for p in items if p.role == 'lyric'] @@ -218,7 +218,7 @@ def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics) return result -def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]: +def draw_lyric_spans(index: generator.engraving.notation.ScoreIndex, items: list[generator.engraving.types.Primitive], anchors: list[generator.engraving.types.Anchor], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts) -> list[generator.engraving.types.Primitive]: result = list(items) labels = {p.sources[0]: p for p in items if p.role == 'lyric'} positions = {a.event: a for a in anchors} @@ -232,7 +232,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An if lyric_baseline is not None: break if lyric_baseline is None: - lyric_baseline = max(0, bounds(items).bottom) + 20 + lyric_baseline = max(0, generator.engraving.types.bounds(items).bottom) + 20 for voice, number in index.lyric_lanes: verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number] for i, (event, verse) in enumerate(verses): @@ -245,7 +245,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An continue target = following[0].start if following else max(e.end for e in index.events if e.voice == voice) if 'extend_to' in verse: - target = fraction(verse['extend_to']) + target = generator.model.fraction(verse['extend_to']) if target <= start or event.start >= end: continue identifier = event.value['id'] + ':lyric:' + number @@ -262,7 +262,7 @@ def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[An if x2 <= x1: continue if extend in ('start', 'continue'): - result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1)) + result.append(generator.engraving.types.line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1)) elif following: count = max(1, int((x2 - x1) / 90)) for dash in range(count): diff --git a/generator/engraving/tablature.py b/generator/engraving/tablature.py index 539665a..62f4928 100644 --- a/generator/engraving/tablature.py +++ b/generator/engraving/tablature.py @@ -1,8 +1,8 @@ from __future__ import annotations -from generator.model import fraction -from generator.engraving.notes import rest_glyph -from generator.engraving.types import bounds, curve, line +import generator.engraving.notes +import generator.engraving.types +import generator.model def column(index, event, anchors, fonts, style): if not index.staff.get('tablature') or not anchors: @@ -11,7 +11,7 @@ def column(index, event, anchors, fonts, style): sources = (event.value['id'],) if not event.value['pitches']: anchor = anchors[0] - glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else rest_glyph(anchor.fragment.written_type) + glyph = 'noteheadSlashHorizontalEnds' if anchor.fragment.kind == 'slash' else generator.engraving.notes.rest_glyph(anchor.fragment.written_type) return [fonts.glyph(glyph, x, -2 * style.space, style.space, 'tab-' + anchor.fragment.kind, sources)] result = [] for anchor in anchors: @@ -26,7 +26,7 @@ def staff(index, items, start, end, left, right, fonts, style): numbers = [p for p in items if p.role.startswith('tab')] if not index.staff.get('tablature'): return items - offset = bounds(notation).bottom + 32 + 5 * style.space + offset = generator.engraving.types.bounds(notation).bottom + 32 + 5 * style.space numbers = [p.moved(0, offset) for p in numbers] drawing = list(numbers) drawing.append(fonts.text('TAB', 0, offset - 2 * style.space, 15, 'tab-label', ())) @@ -36,14 +36,14 @@ def staff(index, items, start, end, left, right, fonts, style): cursor = 0 for a, b in cuts + [(right, right)]: if a > cursor: - drawing.append(line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13)) + drawing.append(generator.engraving.types.line('tab-staff', (), ((cursor, y), (a, y)), style.space * 0.13)) cursor = max(cursor, b) for bar in (p for p in notation if p.role == 'barline'): - drawing.append(line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness)) + drawing.append(generator.engraving.types.line('tab-barline', bar.sources, tuple((x, offset + y * 5 / 4) for x, y in bar.points), bar.thickness)) by_source = {source: p for p in numbers if p.role == 'tab' for source in p.sources[1:]} for identifier, (event, tone) in index.tones.items(): target = tone.get('tie_to') - finish = event.start + fraction(tone['duration']) + finish = event.start + generator.model.fraction(tone['duration']) if not target and not (event.start < start < finish or event.start < end < finish): continue if target: @@ -55,5 +55,5 @@ def staff(index, items, start, end, left, right, fonts, style): x2 = last.box.left - 2 if last else right y = offset - (6 - tone['tablature']['string']) * style.space + 8 if x2 > x1: - drawing.append(curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y)))) + drawing.append(generator.engraving.types.curve('tab-tie', (identifier,), ((x1, y), (x1 + (x2 - x1) / 3, y + 5), (x2 - (x2 - x1) / 3, y + 5), (x2, y)))) return notation + drawing diff --git a/generator/engraving/types.py b/generator/engraving/types.py index 2630baa..db79d33 100644 --- a/generator/engraving/types.py +++ b/generator/engraving/types.py @@ -1,13 +1,13 @@ from __future__ import annotations -from dataclasses import dataclass, replace -from fractions import Fraction +import dataclasses +import fractions import math class EngravingError(ValueError): pass -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Style: width: int = 976 height: int = 484 @@ -18,7 +18,7 @@ class Style: column_gap: float = 10 system_gap: float = 26 -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Box: left: float top: float @@ -31,7 +31,7 @@ class Box: def finite(self) -> bool: return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Primitive: kind: str role: str @@ -42,30 +42,30 @@ class Primitive: size: float = 0 thickness: float = 0 def moved(self, x: float, y: float) -> Primitive: - return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points)) + return dataclasses.replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points)) -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Fragment: source: str event: str - start: Fraction - end: Fraction + start: fractions.Fraction + end: fractions.Fraction written_type: str dots: int - ratio: Fraction + ratio: fractions.Fraction visible: bool kind: str position: float | None - def length(self) -> Fraction: + def length(self) -> fractions.Fraction: return self.end - self.start -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Anchor: source: str event: str voice: str - time: Fraction - end: Fraction + time: fractions.Fraction + end: fractions.Fraction x: float y: float stem_x: float @@ -74,18 +74,18 @@ class Anchor: flags: int fragment: Fragment def moved(self, x: float, y: float) -> Anchor: - return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y) + return dataclasses.replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y) -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class System: - start: Fraction - end: Fraction + start: fractions.Fraction + end: fractions.Fraction primitives: tuple[Primitive, ...] anchors: tuple[Anchor, ...] fragments: tuple[Fragment, ...] box: Box -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class Page: number: int width: int @@ -94,7 +94,7 @@ class Page: systems: tuple[System, ...] primitives: tuple[Primitive, ...] -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class LayoutDocument: pages: tuple[Page, ...] style: Style @@ -111,7 +111,7 @@ def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], return Primitive('line', role, sources, box, points, thickness=thickness) def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive: - return replace(line(role, sources, points, thickness), kind='curve') + return dataclasses.replace(line(role, sources, points, thickness), kind='curve') def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive: - return replace(line(role, sources, points, 0), kind='polygon') + return dataclasses.replace(line(role, sources, points, 0), kind='polygon') diff --git a/generator/engraving/validation.py b/generator/engraving/validation.py new file mode 100644 index 0000000..9939cb3 --- /dev/null +++ b/generator/engraving/validation.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import hashlib +import io +import pathlib +import re + +import PIL.Image +import PIL.ImageChops + +import generator.engraving.types + +ENGINE_NAME = 'Vamp Python' +PAGE_WIDTH = 976 +PAGE_HEIGHT = 484 + +def check_content(data: bytes, name: str) -> None: + with PIL.Image.open(io.BytesIO(data)) as image: + if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT): + raise generator.engraving.types.EngravingError(f'{name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page') + image.load() + white = PIL.Image.new('RGBA', image.size, 'white') + white.alpha_composite(image.convert('RGBA')) + ink = PIL.ImageChops.difference(white.convert('RGB'), PIL.Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0) + bounds = ink.getbbox() + if not bounds: + raise generator.engraving.types.EngravingError(f'{name}: engraver produced a blank page') + if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4: + raise generator.engraving.types.EngravingError(f'{name}: notation reaches the page edge; refusing potentially clipped output') + +def check_page(path: pathlib.Path, validated_pages: set[str] | None = None, *, expected_sha256: str | None = None) -> str: + # Trust content, never a filename or a caller-supplied checksum alone. + if path.is_symlink() or not path.is_file(): + raise generator.engraving.types.EngravingError(f'{path.name}: expected a regular PNG file') + data = path.read_bytes() + digest = hashlib.sha256(data).hexdigest() + if expected_sha256 is not None and (not isinstance(expected_sha256, str) or not re.fullmatch('[0-9a-f]{64}', expected_sha256) or digest != expected_sha256): + raise generator.engraving.types.EngravingError(f'{path.name}: score page checksum mismatch') + if validated_pages is None or digest not in validated_pages: + check_content(data, path.name) + if validated_pages is not None: + validated_pages.add(digest) + return digest diff --git a/generator/generate_pico_font.py b/generator/generate_pico_font.py deleted file mode 100644 index 268e04a..0000000 --- a/generator/generate_pico_font.py +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import difflib -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFont, features - -REPO = Path(__file__).resolve().parents[1] -FONT_PATH = REPO / "package/assets/fonts/petaluma/PetalumaScript.otf" -FONT_SIZE = 32 -FONT_HEIGHT = 36 -FONT_MAX_WIDTH = 28 -FIRST_CODEPOINT = 32 -LAST_CODEPOINT = 126 - -def cpp_char_literal(value: str) -> str: - if value == "'": - return "'\\''" - if value == "\\": - return "'\\\\'" - return f"'{value}'" - -def render_glyph(font: ImageFont.FreeTypeFont, value: str, y_offset: int) -> tuple[list[int], int, int]: - image = Image.new("L", (FONT_MAX_WIDTH, FONT_HEIGHT), 0) - draw = ImageDraw.Draw(image) - bbox = font.getbbox(value) - x = max(0, -bbox[0]) - draw.text((x, y_offset), value, font=font, fill=255) - pixels = [min(15, max(0, round(pixel / 17))) for pixel in image.get_flattened_data()] - visible = image.getbbox() - width = 0 if visible is None else min(FONT_MAX_WIDTH, visible[2]) - advance = min(255, max(0, round(font.getlength(value)))) - return pixels, width, advance - -def render_alpha_rows(values: list[int]) -> list[str]: - lines = [] - for row in range(FONT_HEIGHT): - start = row * FONT_MAX_WIDTH - row_values = values[start: start + FONT_MAX_WIDTH] - lines.append(" " + ", ".join(f"0x{value:02x}" for value in row_values) + ",") - return lines - -def pair_adjustments(font: ImageFont.FreeTypeFont) -> list[tuple[int, int]]: - chars = [chr(value) for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)] - adjustments = [] - for left in chars: - left_width = font.getlength(left) - for right in chars: - adjustment = round(font.getlength(left + right) - left_width - font.getlength(right)) - if adjustment == 0: - continue - key = (ord(left) << 8) | ord(right) - adjustments.append((key, int(adjustment))) - return sorted(adjustments) - -def generate() -> str: - if not features.check_feature("raqm"): - raise RuntimeError("Font generation requires Pillow with Raqm support; install libfribidi0 on Debian/Ubuntu.") - font = ImageFont.truetype(str(FONT_PATH), FONT_SIZE, layout_engine=ImageFont.Layout.RAQM) - min_top = min(font.getbbox(chr(value))[1] for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)) - y_offset = max(0, -min_top) - adjustments = pair_adjustments(font) - lines = [ - "#include ", - "", - "#include ", - "#include ", - "#include ", - "#include ", - "", - "namespace {", - "", - "struct pair_adjustment {", - " std::uint16_t key{};", - " std::int8_t adjustment{};", - "};", - "", - f"constexpr auto pair_adjustments = std::array{{{{", - ] - for key, adjustment in adjustments: - lines.append(f" {{0x{key:04x}, {adjustment}}},") - lines.extend( - [ - "}};", - "", - "auto pair_key(std::char_t left, std::char_t right) -> std::uint16_t {", - " auto l = static_cast(static_cast(left));", - " auto r = static_cast(static_cast(right));", - " return static_cast((l << 8U) | r);", - "}", - "", - "auto empty_glyph() -> vamp_style::font_glyph {", - " return {", - " .alpha = {},", - " .width = 0,", - " .advance = 8,", - " };", - "}", - "", - "}", - "", - "auto vamp_style::ui_font_glyph(std::char_t value) -> vamp_style::font_glyph {", - " switch (value) {", - ] - ) - for codepoint in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1): - value = chr(codepoint) - alpha, width, advance = render_glyph(font, value, y_offset) - lines.extend( - [ - f" case {cpp_char_literal(value)}:", - " return {", - " .alpha = {", - ] - ) - lines.extend(render_alpha_rows(alpha)) - lines.extend( - [ - " },", - f" .width = {width},", - f" .advance = {advance},", - " };", - ] - ) - lines.extend( - [ - " default:", - " return empty_glyph();", - " }", - "}", - "", - "auto vamp_style::ui_font_pair_adjust(std::char_t left, std::char_t right) -> std::int32_t {", - " auto key = pair_key(left, right);", - " auto found = std::lower_bound(pair_adjustments.begin(), pair_adjustments.end(), key, [](pair_adjustment const& item, std::uint16_t value) {", - " return item.key < value;", - " });", - " if (found == pair_adjustments.end() || found->key != key) {", - " return 0;", - " }", - " return found->adjustment;", - "}", - "", - "auto vamp_style::ui_text_width(std::string_view text, std::int32_t scale) -> std::int32_t {", - " if (text.empty() || scale <= 0) {", - " return 0;", - " }", - " auto width = std::int32_t{};", - " auto previous = std::char_t{};", - " for (auto ch : text) {", - " if (previous != std::char_t{}) {", - " width += vamp_style::ui_font_pair_adjust(previous, ch) * scale;", - " }", - " width += static_cast(vamp_style::ui_font_glyph(ch).advance) * scale;", - " previous = ch;", - " }", - " return std::max(std::int32_t{}, width);", - "}", - "", - ] - ) - return "\n".join(lines) - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--check", action="store_true") - args = parser.parse_args() - generated = generate() - if args.check: - if not args.output.is_file(): - parser.error(f"generated font is missing: {args.output}") - current = args.output.read_text(encoding="utf-8") - if current == generated: - print("pico font is up to date") - return 0 - diff = difflib.unified_diff( - current.splitlines(), - generated.splitlines(), - fromfile=str(args.output), - tofile=f"{args.output} (generated)", - lineterm="", - ) - print("\n".join(diff)) - return 1 - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(generated, encoding="utf-8") - print(f"generated {args.output}") - return 0 - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/generator/guitar.py b/generator/guitar.py index b3d0bf6..241a884 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -1,13 +1,14 @@ from __future__ import annotations -from fractions import Fraction -from dataclasses import dataclass -from functools import lru_cache -from itertools import groupby -from typing import NamedTuple +import dataclasses +import fractions +import functools +import itertools +import typing -from generator import harmony, pitch -from generator.model import NATURAL, alteration, fraction, make_event, pitch_number +import generator.harmony +import generator.model +import generator.pitch STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) MIN_FRET = 0 @@ -15,7 +16,7 @@ MAX_FRET = 20 MAX_FRET_SPAN = 4 WRITTEN_GUITAR_OCTAVE_OFFSET = 12 -class TabPosition(NamedTuple): +class TabPosition(typing.NamedTuple): string_number: int fret: int midi: int @@ -24,7 +25,7 @@ def within_fret_span(positions) -> bool: fretted = [position.fret for position in positions if position.fret > 0] return not fretted or max(fretted) - min(fretted) <= MAX_FRET_SPAN -def positions_for_pitch(midi: Fraction) -> tuple[TabPosition, ...]: +def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]: if midi.denominator != 1: return () return tuple(TabPosition(string, int(midi - open_midi), int(midi)) for string, open_midi in STANDARD_TUNING if MIN_FRET <= midi - open_midi <= MAX_FRET) @@ -35,20 +36,20 @@ def validate_tuning(state: dict, location: str) -> None: raise ValueError(f'{location}: guitar generation does not support capo configurations') tunings = details.get('tunings', []) expected = {7 - string: midi for string, midi in STANDARD_TUNING} - if tunings and (len(tunings) != 6 or {t['line']: pitch_number(t) for t in tunings} != expected): + if tunings and (len(tunings) != 6 or {t['line']: generator.model.pitch_number(t) for t in tunings} != expected): raise ValueError(f'{location}: guitar generation requires standard six-string tuning') def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, ...], int | None]: - degrees = harmony.degrees(definition) + degrees = generator.harmony.degrees(definition) root = definition['root'] - root_pc = NATURAL[root['letter']] + alteration(root['alter']) + root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) explicit = {d['value'] for d in definition.get('degrees', []) if d['type'] != 'subtract'} optional = {d for d, a in degrees.items() if not a and d not in explicit and (d == 5 and len(degrees) > 3 or d in (9, 11) and d < max(degrees))} - classes = {d: (root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} + classes = {d: (root_pc + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()} required = {value for d, value in classes.items() if d not in optional} allowed = set(classes.values()) bass = definition.get('bass') - bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None + bass_pc = (generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12 if bass else None if bass_pc is None and 'inversion' in definition: ordered = sorted(degrees) inversion = definition['inversion'] @@ -60,7 +61,7 @@ def chord_requirements(definition: dict) -> tuple[tuple[int, ...], tuple[int, .. required.add(bass_pc) return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc -@lru_cache(maxsize=256) +@functools.lru_cache(maxsize=256) def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True) -> tuple[tuple[TabPosition, ...], ...]: result = [] options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed) for string, open_midi in STANDARD_TUNING] @@ -92,26 +93,26 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]: spellings = {} - for degree, alter in harmony.degrees(definition).items(): - value = pitch.shift_pitch(definition['root'], degree - 1, harmony.degree_semitones(degree, alter)) - spellings[(NATURAL[value['letter']] + alteration(value['alter'])) % 12] = value + for degree, alter in generator.harmony.degrees(definition).items(): + value = generator.pitch.shift_pitch(definition['root'], degree - 1, generator.harmony.degree_semitones(degree, alter)) + spellings[(generator.model.NATURAL[value['letter']] + generator.model.alteration(value['alter'])) % 12] = value if bass := definition.get('bass'): - spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass + spellings[(generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12] = bass result = [] for position in sorted(positions, key=lambda p: p.midi): value = spellings[position.midi % 12].copy() - value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - NATURAL[value['letter']] - alteration(value['alter'])) // 12 - 1) + value['octave'] = int((position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET - generator.model.NATURAL[value['letter']] - generator.model.alteration(value['alter'])) // 12 - 1) value['tablature'] = {'string': position.string_number, 'fret': position.fret} result.append(value) return result -@lru_cache(maxsize=16384) +@functools.lru_cache(maxsize=16384) def movement(previous: tuple[TabPosition, ...], current: tuple[TabPosition, ...]) -> int: if not previous or not current: return 0 return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current))) -@lru_cache(maxsize=4096) +@functools.lru_cache(maxsize=4096) def position_center(positions: tuple[TabPosition, ...]) -> tuple[int, int]: frets = [p.fret for p in positions if p.fret] or [0] strings = [p.string_number for p in positions] @@ -131,7 +132,7 @@ def extend_positions(held, pending): if position.string_number not in occupied and within_fret_span((*held.values(), position)): yield from extend_positions({**held, identifier: position}, pending[1:]) -@dataclass +@dataclasses.dataclass class Placement: cost: tuple[int, int, int] active: tuple @@ -169,13 +170,13 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]: relax(x, y, x, y + 1) return grid -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class GuitarTone: pitch: dict - end: Fraction + end: fractions.Fraction positions: tuple[TabPosition, ...] -@dataclass(frozen=True) +@dataclasses.dataclass(frozen=True) class AttackCandidate: active: tuple choices: tuple @@ -208,38 +209,38 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]: events, tones = [], {} - absolute = Fraction() + absolute = fractions.Fraction() for measure in document['staves'][0]['measures']: - changes = sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])) + changes = sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])) state = {k: measure[k] for k in ('transpose', 'staff_details')} for change in [state, *changes]: state.update({k: change[k] for k in state if k in change}) validate_tuning(state, measure['id']) for voice in measure['voices']: for order, note in enumerate(voice['notes']): - onset = fraction(note['onset']) + onset = generator.model.fraction(note['onset']) state = {k: measure[k] for k in ('transpose', 'staff_details')} for change in changes: - if fraction(change['onset']) <= onset: + if generator.model.fraction(change['onset']) <= onset: state.update({k: change[k] for k in state if k in change}) for tone in note['pitches']: - sounding = pitch.sounding_pitch(tone, state) + sounding = generator.pitch.sounding_pitch(tone, state) options = positions_for_pitch(sounding) if not options: raise ValueError(f'{measure["id"]}, event {note["id"]}: sounding pitch {sounding} has no string/fret in {MIN_FRET}-{MAX_FRET}') - tones[tone['id']] = GuitarTone(tone, absolute + onset + fraction(tone['duration']), options) + tones[tone['id']] = GuitarTone(tone, absolute + onset + generator.model.fraction(tone['duration']), options) if '_harmony' in note: for string, _ in STANDARD_TUNING: - tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + fraction(note['duration']), ()) + tones[f'{note["id"]}-s{string}'] = GuitarTone({}, absolute + onset + generator.model.fraction(note['duration']), ()) if note['pitches'] or '_harmony' in note: events.append((absolute + onset, not bool(note.get('grace')), order if note.get('grace') else -1, note)) - absolute += fraction(measure['duration']) + absolute += generator.model.fraction(measure['duration']) return sorted(events, key=lambda e: e[:3]), tones -def retain_tone(tone: GuitarTone, onset: Fraction) -> bool: +def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool: return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to')) -def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict: +def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool) -> dict: continued = {p['tie_from'] for n in notes for p in n['pitches'] if 'tie_from' in p} held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} result = {} @@ -269,17 +270,17 @@ def advance_positions(notes: list[dict], onset: Fraction, tones: dict[str, Guita def place_guitar(document: dict) -> None: """Choose a minimum-cost path, retaining held strings in each search state.""" events, tones = guitar_events(document) - groups = [(key[0], [item[3] for item in values]) for key, values in groupby(events, key=lambda e: e[:3])] + groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])] states = {(): Placement((0, 0, 0), (), None, ())} for index, (onset, notes) in enumerate(groups): next_onset = groups[index + 1][0] if index + 1 < len(groups) else None - preserve_strings = next_onset is not None and any('_harmony' in n and onset + fraction(n['duration']) > next_onset for n in notes) + preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes) states = advance_positions(notes, onset, tones, states, preserve_strings) selected = min(states.values(), key=lambda s: (s.cost, s.active)) while selected.previous is not None: for note, choices in selected.choices: if definition := note.pop('_harmony', None): - note['pitches'] = make_event(note['id'], fraction(note['onset']), fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches'] + note['pitches'] = generator.model.make_event(note['id'], generator.model.fraction(note['onset']), generator.model.fraction(note['duration']), 'note', chord_pitches(definition, choices))['pitches'] else: for tone, position in choices: tone['tablature'] = {'string': position.string_number, 'fret': position.fret} diff --git a/generator/harmony.py b/generator/harmony.py index 51ba112..19cf7f1 100644 --- a/generator/harmony.py +++ b/generator/harmony.py @@ -2,7 +2,7 @@ from __future__ import annotations import re -from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name, rational +import generator.model # Semantic degrees are independent of guitar voicing omissions. KINDS = { @@ -49,7 +49,7 @@ def degrees(harmony: dict) -> dict[int, int]: raise ValueError(f'unsupported harmony kind: {kind}') result = KINDS[kind].copy() for change in harmony.get('degrees', []): - degree, alter, operation = change['value'], alteration(change['alter']), change['type'] + degree, alter, operation = change['value'], generator.model.alteration(change['alter']), change['type'] if type(degree) is not int or degree < 1: raise ValueError('invalid harmony degree') if operation == 'subtract': @@ -79,10 +79,10 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]: if harmony['kind'] == 'none': return () root = harmony['root'] - root_pc = NATURAL[root['letter']] + alteration(root['alter']) + root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()] if bass := harmony.get('bass'): - values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12) + values.append((generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12) return tuple(dict.fromkeys(values)) def chord_suffix(harmony: dict) -> str: @@ -98,7 +98,7 @@ def chord_suffix(harmony: dict) -> str: return suffix suffix = SUFFIXES.get(kind, kind) for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']): - amount = alteration(change['alter']) + amount = generator.model.alteration(change['alter']) sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})' suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value']) return suffix @@ -107,9 +107,9 @@ def symbol(harmony: dict) -> str: if harmony['kind'] == 'none': return 'N.C.' suffix = chord_suffix(harmony) - result = pitch_name(harmony['root']) + suffix + result = generator.model.pitch_name(harmony['root']) + suffix if bass := harmony.get('bass'): - result += '/' + pitch_name(bass) + result += '/' + generator.model.pitch_name(bass) return '(' + result + ')' if harmony.get('parenthesized') else result def parse_symbol(text: str) -> dict: @@ -137,11 +137,11 @@ def parse_symbol(text: str) -> dict: operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add' result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation}) remainder = remainder[modifier.end():] - result['root'] = parse_pitch_name(root) + result['root'] = generator.model.parse_pitch_name(root) for change in result['degrees']: - change['alter'] = rational(change['alter']) + change['alter'] = generator.model.rational(change['alter']) if bass: - result['bass'] = parse_pitch_name(bass) + result['bass'] = generator.model.parse_pitch_name(bass) if parenthesized: result['parenthesized'] = True degrees(result) @@ -152,12 +152,12 @@ def normalize_text(harmony: dict, text: str) -> None: parenthesized = text.startswith('(') and text.endswith(')') content = text[1:-1] if parenthesized else text try: - displayed = parse_symbol(pitch_name(harmony['root']) + content) + displayed = parse_symbol(generator.model.pitch_name(harmony['root']) + content) except ValueError: displayed = parse_symbol(content) text_only = harmony['kind'] == 'other' and not harmony['degrees'] if displayed['root'] != harmony['root']: - if not text_only or harmony['root'] != parse_pitch_name('C'): + if not text_only or harmony['root'] != generator.model.parse_pitch_name('C'): raise ValueError(f'contradictory harmony root in {text!r}') if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']: raise ValueError(f'contradictory harmony bass in {text!r}') diff --git a/generator/model.py b/generator/model.py index 117ef3e..f2a0818 100644 --- a/generator/model.py +++ b/generator/model.py @@ -1,56 +1,56 @@ from __future__ import annotations import copy -from fractions import Fraction -from typing import Any +import fractions +import typing 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)} +TYPE_LENGTHS = {name: fractions.Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)} NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11} LETTERS = 'cdefgab' MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6} -ACCIDENTALS = {'natural': Fraction(0), 'sharp': Fraction(1), 'flat': Fraction(-1), 'double-sharp': Fraction(2), 'sharp-sharp': Fraction(2), 'flat-flat': Fraction(-2), 'triple-sharp': Fraction(3), 'triple-flat': Fraction(-3), 'natural-sharp': Fraction(1), 'natural-flat': Fraction(-1), 'quarter-sharp': Fraction(1, 2), 'quarter-flat': Fraction(-1, 2), 'three-quarters-sharp': Fraction(3, 2), 'three-quarters-flat': Fraction(-3, 2)} +ACCIDENTALS = {'natural': fractions.Fraction(0), 'sharp': fractions.Fraction(1), 'flat': fractions.Fraction(-1), 'double-sharp': fractions.Fraction(2), 'sharp-sharp': fractions.Fraction(2), 'flat-flat': fractions.Fraction(-2), 'triple-sharp': fractions.Fraction(3), 'triple-flat': fractions.Fraction(-3), 'natural-sharp': fractions.Fraction(1), 'natural-flat': fractions.Fraction(-1), 'quarter-sharp': fractions.Fraction(1, 2), 'quarter-flat': fractions.Fraction(-1, 2), 'three-quarters-sharp': fractions.Fraction(3, 2), 'three-quarters-flat': fractions.Fraction(-3, 2)} -def accidental_name(value: Fraction) -> str: +def accidental_name(value: fractions.Fraction) -> str: for name, amount in ACCIDENTALS.items(): if amount == value: return name return 'other' -def rational(value: Fraction | int) -> dict[str, int]: - value = Fraction(value) +def rational(value: fractions.Fraction | int) -> dict[str, int]: + value = fractions.Fraction(value) if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER: raise ValueError('musical time exceeds the supported 64-bit rational range') return {'numerator': value.numerator, 'denominator': value.denominator} -def fraction(value: Any) -> Fraction: +def fraction(value: typing.Any) -> fractions.Fraction: if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}: raise ValueError('musical time must be a numerator/denominator object') n, d = value['numerator'], value['denominator'] if type(n) is not int or type(d) is not int or d <= 0 or abs(n) > MAX_INTEGER or d > MAX_INTEGER: raise ValueError('invalid musical time') - result = Fraction(n, d) + result = fractions.Fraction(n, d) if rational(result) != value: raise ValueError('musical time must be reduced') return result -def written_length(written: dict) -> Fraction: +def written_length(written: dict) -> fractions.Fraction: kind, dots = written['type'], written.get('dots', 0) if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16: raise ValueError('invalid written duration or augmentation-dot count (maximum 16)') - return TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots)) + return TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots)) -def written_for(length: Fraction) -> dict: +def written_for(length: fractions.Fraction) -> dict: for name, base in TYPE_LENGTHS.items(): for dots in range(17): - if length == base * (2 - Fraction(1, 2 ** dots)): + if length == base * (2 - fractions.Fraction(1, 2 ** dots)): return {'type': name, 'dots': dots} # Generated rational spans need an explicit time modification, not rounded ticks. base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th') return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])} -def metric_length(written: dict) -> Fraction: +def metric_length(written: dict) -> fractions.Fraction: ratio = fraction(written.get('ratio', rational(1))) if ratio <= 0: raise ValueError('written duration ratio must be positive') @@ -58,10 +58,10 @@ def metric_length(written: dict) -> Fraction: written_length(written['normal']) return written_length(written) * ratio -def alteration(value: int | dict) -> Fraction: - return Fraction(value) if type(value) is int else fraction(value) +def alteration(value: int | dict) -> fractions.Fraction: + return fractions.Fraction(value) if type(value) is int else fraction(value) -def pitch_number(pitch: dict) -> Fraction: +def pitch_number(pitch: dict) -> fractions.Fraction: letter = pitch['letter'].lower() alter, octave = alteration(pitch.get('alter', 0)), pitch['octave'] if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16: @@ -98,24 +98,24 @@ def key_label(key: dict) -> str: label = pitch_name(key['tonic']) return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode'] -def meter_length(meter: dict) -> Fraction: - return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), Fraction()) +def meter_length(meter: dict) -> fractions.Fraction: + return sum((fractions.Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), fractions.Fraction()) def meter_label(meter: dict) -> str: if meter.get('unmetered'): return 'senza misura' return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups']) -def beat_lengths(meter: dict) -> list[Fraction]: +def beat_lengths(meter: dict) -> list[fractions.Fraction]: result = [] for group in meter.get('groups', []): beats, unit = group['beats'], group['unit'] if len(beats) > 1: - result.extend(Fraction(b * 4, unit) for b in beats) + result.extend(fractions.Fraction(b * 4, unit) for b in beats) elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8: - result.extend([Fraction(12, unit)] * (beats[0] // 3)) + result.extend([fractions.Fraction(12, unit)] * (beats[0] // 3)) else: - result.extend([Fraction(4, unit)] * beats[0]) + result.extend([fractions.Fraction(4, unit)] * beats[0]) return result def validate_meter(meter: dict) -> None: @@ -128,7 +128,7 @@ def validate_meter(meter: dict) -> None: if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']): raise ValueError('meter count must be between 1 and 1024') -def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict: +def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict: if duration <= 0: raise ValueError('generated event duration must be positive') notation = written or written_for(duration) @@ -155,9 +155,9 @@ def validate_document(document: dict) -> None: raise ValueError('score instruments require unique nonempty IDs') ids: set[str] = set() events: dict[str, dict] = {} - tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {} + tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {} ownership: dict[str, str] = {} - absolute = Fraction() + absolute = fractions.Fraction() for measure in staves[0]['measures']: if {'repeats', 'endings', 'initial_attributes'} & measure.keys(): raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order') @@ -167,7 +167,7 @@ def validate_document(document: dict) -> None: voices = measure.get('voices', []) if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices): raise ValueError('measure requires uniquely identified voice lanes') - previous_ends = {v['id']: Fraction() for v in voices} + previous_ends = {v['id']: fractions.Fraction() for v in voices} for voice, note in ((v, n) for v in voices for n in v['notes']): if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']): raise ValueError('pass-specific tie metadata is unsupported; regenerate scores') @@ -206,17 +206,17 @@ def validate_document(document: dict) -> None: else: pitch_number(p) if staves[0].get('tablature'): - from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING - from generator.pitch import sounding_pitch + import generator.guitar + import generator.pitch tab = p.get('tablature', {}) string, fret = tab.get('string'), tab.get('fret') - if type(string) is not int or type(fret) is not int or string not in dict(STANDARD_TUNING) or not MIN_FRET <= fret <= MAX_FRET: + if type(string) is not int or type(fret) is not int or string not in dict(generator.guitar.STANDARD_TUNING) or not generator.guitar.MIN_FRET <= fret <= generator.guitar.MAX_FRET: raise ValueError(f'{p["id"]}: invalid generated tablature position') state = dict(measure) for change in measure.get('attributes', []): if fraction(change['onset']) <= onset: state.update(change) - if dict(STANDARD_TUNING)[string] + fret != sounding_pitch(p, state): + if dict(generator.guitar.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state): raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch') pd = fraction(p['duration']) if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones: @@ -269,7 +269,7 @@ def validate_document(document: dict) -> None: span_ids.add(span['id']) for note in events.values(): for verse in note.get('lyrics', []): - start = tones[note['pitches'][0]['id']][1] if note['pitches'] else Fraction() + start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.Fraction() if 'extend_to' in verse and not start < fraction(verse['extend_to']) <= absolute: raise ValueError('lyric extension endpoint is outside the score') if any(t not in span_ids for t in note.get('tuplet_ids', [])): diff --git a/generator/musicxml.py b/generator/musicxml.py index a9042a9..32f49b3 100644 --- a/generator/musicxml.py +++ b/generator/musicxml.py @@ -1,14 +1,13 @@ from __future__ import annotations import copy -from fractions import Fraction -from pathlib import Path +import fractions +import pathlib -from defusedxml import ElementTree +import defusedxml.ElementTree -from generator import harmony -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 +import generator.harmony +import generator.model class MusicXMLError(ValueError): pass @@ -18,11 +17,11 @@ LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-f IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'} class Importer: - def __init__(self, path: Path) -> None: + def __init__(self, path: pathlib.Path) -> None: self.path = path self.location = str(path) - self.divisions = Fraction(1) - self.key = key_from_fifths(0) + self.divisions = fractions.Fraction(1) + self.key = generator.model.key_from_fifths(0) self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'} self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0} self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} @@ -36,7 +35,7 @@ class Importer: self.octaves: list[dict] = [] self.voice_id = '1' self.staff_details = {'lines': 5, 'tunings': []} - self.absolute = Fraction() + self.absolute = fractions.Fraction() self.tremolo_starts = {} def fail(self, text: str): raise MusicXMLError(f'{self.location}: {text}') @@ -48,16 +47,16 @@ class Importer: for name in node.attrib: if name not in LAYOUT and name not in allowed: self.fail(f'unsupported {node.tag} attribute {name!r}') - def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> Fraction | int: + def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> fractions.Fraction | int: try: - result = Fraction(value or '') + result = fractions.Fraction(value or '') if integer and result.denominator != 1 or positive and result <= 0: raise ValueError() - rational(result) + generator.model.rational(result) return int(result) if integer else result except (ValueError, ZeroDivisionError, OverflowError): self.fail(f'invalid {"integer" if integer else "number"}: {value!r}') - def offset(self, node, cursor: Fraction) -> Fraction: + def offset(self, node, cursor: fractions.Fraction) -> fractions.Fraction: return cursor + self.number(node.findtext('offset', '0')) / self.divisions def identity(self, node) -> None: voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id) @@ -69,9 +68,9 @@ class Importer: if not voice.strip(): self.fail('empty voice identifier') self.voice_id = voice - def attributes(self, node, onset: Fraction) -> dict: + def attributes(self, node, onset: fractions.Fraction) -> dict: self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'}) - result = {'onset': rational(onset)} + result = {'onset': generator.model.rational(onset)} for item in node: if item.get('number', '1') != '1': self.fail('attributes for multiple staves are not supported') @@ -84,9 +83,9 @@ class Importer: self.attrs(item, {'number', 'print-object'}) self.children(item, {'cancel', 'fifths', 'mode', 'key-step', 'key-alter', 'key-accidental', 'key-octave'}) if item.find('fifths') is not None: - self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major')) + self.key = generator.model.key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major')) else: - self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []} + self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [generator.model.rational(0) for _ in range(7)], 'custom': True, 'steps': []} pending = None for field in item: if field.tag == 'key-step': @@ -96,7 +95,7 @@ class Importer: elif field.tag == 'key-alter': if pending is None: self.fail('key alteration without a step') - amount = rational(self.number(field.text)) + amount = generator.model.rational(self.number(field.text)) self.key['alterations']['cdefgab'.index(pending)] = amount self.key['steps'].append({'letter': pending, 'alter': amount}) pending = None @@ -155,7 +154,7 @@ class Importer: self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)} for tuning in item.findall('staff-tuning'): self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'}) - self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)}) + self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': generator.model.rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)}) result['staff_details'] = copy.deepcopy(self.staff_details) elif item.tag == 'transpose': self.children(item, {'diatonic', 'chromatic', 'octave-change'}) @@ -169,7 +168,7 @@ class Importer: self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12]) result['transpose'] = copy.deepcopy(self.transpose) return result - def read_harmony(self, node, cursor: Fraction) -> dict: + def read_harmony(self, node, cursor: fractions.Fraction) -> dict: self.attrs(node, {'print-object', 'print-frame', 'type'}) self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'}) if len(node.findall('kind')) != 1 or len(node.findall('root')) > 1: @@ -177,7 +176,7 @@ class Importer: self.identity(node) kind = node.find('kind') self.harmony_sequence += 1 - result = {'id': f'harmony-{self.harmony_sequence}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []} + result = {'id': f'harmony-{self.harmony_sequence}', 'onset': generator.model.rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []} self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'}) for tag in ('root', 'bass'): item = node.find(tag) @@ -189,7 +188,7 @@ class Importer: step = item.findtext(tag + '-step', '').lower() if len(step) != 1 or step not in 'cdefgab': self.fail('invalid harmony root/bass') - result[tag] = {'letter': step, 'alter': rational(self.number(item.findtext(tag + '-alter', '0')))} + result[tag] = {'letter': step, 'alter': generator.model.rational(self.number(item.findtext(tag + '-alter', '0')))} for degree in node.findall('degree'): self.children(degree, {'degree-value', 'degree-alter', 'degree-type'}) value = self.number(degree.findtext('degree-value'), integer=True, positive=True) @@ -197,15 +196,15 @@ class Importer: operation = degree.findtext('degree-type') # MusicXML alterations are relative to the kind; additions use dominant intervals. if operation == 'alter': - degree_alter += harmony.KINDS.get(result['kind'], {}).get(value, 0) + degree_alter += generator.harmony.KINDS.get(result['kind'], {}).get(value, 0) elif operation == 'add' and (value - 1) % 7 == 6: degree_alter -= 1 elif operation == 'subtract' and degree_alter: self.fail('cannot subtract a degree different from the one in the chord kind') - result['degrees'].append({'value': value, 'alter': rational(degree_alter), 'type': operation}) + result['degrees'].append({'value': value, 'alter': generator.model.rational(degree_alter), 'type': operation}) if kind.get('text') and result['kind'] != 'none': - harmony.normalize_text(result, kind.get('text')) - chord_degrees = harmony.degrees(result) + generator.harmony.normalize_text(result, kind.get('text')) + chord_degrees = generator.harmony.degrees(result) if node.find('inversion') is not None: inversion = self.number(node.findtext('inversion'), integer=True) result['inversion'] = inversion @@ -216,12 +215,11 @@ class Importer: root = result['root'] letter_index = 'cdefgab'.index(root['letter']) + degree - 1 bass_letter = 'cdefgab'[letter_index % 7] - from generator.model import NATURAL - target = NATURAL[root['letter']] + alteration(root['alter']) + harmony.degree_semitones(degree, chord_degrees[degree]) - result['bass'] = {'letter': bass_letter, 'alter': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))} - result['symbol'] = harmony.symbol(result) + target = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter']) + generator.harmony.degree_semitones(degree, chord_degrees[degree]) + result['bass'] = {'letter': bass_letter, 'alter': generator.model.rational(target - (12 * (letter_index // 7) + generator.model.NATURAL[bass_letter]))} + result['symbol'] = generator.harmony.symbol(result) return result - def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None: + def direction(self, node, cursor: fractions.Fraction, measure: dict, optional: list[fractions.Fraction]) -> None: self.attrs(node, {'print-object', 'directive', 'system'}) self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'}) onset = self.offset(node, cursor) @@ -245,7 +243,7 @@ class Importer: size = self.number(item.get('size', '8'), integer=True) if size not in (8, 15, 22): self.fail('octave shifts support 8, 15, or 22') - position = rational(self.absolute + onset) + position = generator.model.rational(self.absolute + onset) if action in ('up', 'down'): if number in self.open_octaves: self.fail('duplicate octave-shift start') @@ -261,20 +259,20 @@ class Importer: elif item.tag == 'metronome': self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'}) written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))} - written_length(written) + generator.model.written_length(written) bpm = self.number(item.findtext('per-minute'), positive=True) - measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': written, 'bpm': rational(bpm)}) + measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': written, 'bpm': generator.model.rational(bpm)}) sound = node.find('sound') if sound is not None: self.sound(sound, onset, measure) - def sound(self, node, onset: Fraction, measure: dict) -> None: + def sound(self, node, onset: fractions.Fraction, measure: dict) -> None: if {'dacapo', 'dalsegno', 'segno', 'coda', 'tocoda', 'fine', 'forward-repeat', 'time-only'} & node.attrib.keys(): self.fail('navigation jumps are unsupported; write the music out in reading order') self.attrs(node, {'tempo'}) self.children(node, set()) - if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])): - measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': rational(self.number(node.get('tempo'), positive=True))}) - def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, Fraction, bool]: + if node.get('tempo') and not any(generator.model.fraction(t['onset']) == onset for t in measure.get('tempos', [])): + measure.setdefault('tempos', []).append({'onset': generator.model.rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': generator.model.rational(self.number(node.get('tempo'), positive=True))}) + def read_note(self, node, cursor: fractions.Fraction, previous: dict | None) -> tuple[dict, fractions.Fraction, bool]: self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'}) if node.get('dynamics') or node.get('end-dynamics'): self.fail('note dynamics are not supported') @@ -288,34 +286,34 @@ class Importer: voice = self.voice_id is_chord = node.find('chord') is not None grace = node.find('grace') - duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions - onset = fraction(previous['onset']) if is_chord and previous else cursor - if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > fraction(previous['duration'])): + duration = fractions.Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions + onset = generator.model.fraction(previous['onset']) if is_chord and previous else cursor + if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > generator.model.fraction(previous['duration'])): self.fail('invalid chord tone or chord tone longer than its anchor') if is_chord and (grace is not None) != ('grace' in previous): self.fail('chord tones must share grace/regular timing') written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))} if written['type'] is None: - written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else written_for(duration) - written_length(written) + written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else generator.model.written_for(duration) + generator.model.written_length(written) modulation = node.find('time-modification') if modulation is not None: self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'}) actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True) normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True) - written['ratio'] = rational(Fraction(normal, actual)) + written['ratio'] = generator.model.rational(fractions.Fraction(normal, actual)) if modulation.find('normal-type') is not None: written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))} - written_length(written['normal']) + generator.model.written_length(written['normal']) elif modulation.findall('normal-dot'): self.fail(' requires ') measure_rest = node.find('rest') is not None and node.find('rest').get('measure') == 'yes' if grace is None and not measure_rest: - expected = written_length(written) * fraction(written.get('ratio', rational(1))) + expected = generator.model.written_length(written) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) if duration != expected: self.fail(f' duration {duration} disagrees with written duration/time-modification {expected}') self.sequence += 1 - note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []} + note = {'id': f'note-{self.sequence}', 'voice': voice, 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': generator.model.rational(onset), 'duration': generator.model.rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []} if is_chord: note = previous if grace is not None and not is_chord: @@ -324,7 +322,7 @@ class Importer: for name in ('steal-time-previous', 'steal-time-following', 'make-time'): if name in grace.attrib: value = self.number(grace.get(name), positive=True) - note['grace'][name] = rational(value / self.divisions if name == 'make-time' else value) + note['grace'][name] = generator.model.rational(value / self.divisions if name == 'make-time' else value) if node.find('cue') is not None and not is_chord: note['cue'] = True if node.find('rest') is not None: @@ -335,12 +333,12 @@ class Importer: note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)} else: pitch_node = node.find('pitch') - p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': rational(duration), 'written': written} + p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': generator.model.rational(duration), 'written': written} if pitch_node is not None: self.children(pitch_node, {'step', 'alter', 'octave'}) - p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)}) - pitch_number(p) - value = (voice, p['letter'], alteration(p['alter']), p['octave']) + p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': generator.model.rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)}) + generator.model.pitch_number(p) + value = (voice, p['letter'], generator.model.alteration(p['alter']), p['octave']) elif node.find('unpitched') is not None: self.children(node.find('unpitched'), {'display-step', 'display-octave'}) p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)} @@ -362,7 +360,7 @@ class Importer: accidental = node.find('accidental') if accidental is not None: self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'}) - if 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != alteration(p['alter']): + if 'unpitched' in p or accidental.text in generator.model.ACCIDENTALS and generator.model.ACCIDENTALS[accidental.text] != generator.model.alteration(p['alter']): self.fail('contradictory accidental') p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}} if node.find('notehead') is not None: @@ -384,7 +382,7 @@ class Importer: self.fail('unsupported tie notation') if 'stop' in ties: source = self.open_ties.pop(value, None) - if source is None or source['_absolute'] + fraction(source['duration']) != p['_absolute']: + if source is None or source['_absolute'] + generator.model.fraction(source['duration']) != p['_absolute']: self.fail('tie stop has no contiguous matching start') source['tie_to'], p['tie_from'] = p['id'], source['id'] if 'start' in ties: @@ -491,7 +489,7 @@ class Importer: self.active_tuplets.pop(number)['end'] = note['id'] return note, cursor if is_chord else cursor + duration, is_chord def read(self) -> dict: - root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot() + root = defusedxml.ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot() if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'): self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0') self.children(root, IGNORED_TOP | {'part'}) @@ -519,21 +517,18 @@ class Importer: instruments.append(instrument) if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')): self.fail('MIDI definition refers to an unknown score instrument') - fields = {} for item in root.findall('identification/miscellaneous/miscellaneous-field'): name = item.get('name', '') if name.startswith('vamp:'): - if name != 'vamp:style' or name in fields: - self.fail(f'unknown or duplicate Vamp property {name!r}') - fields[name] = item.text or '' - metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))} + self.fail(f'unsupported Vamp property {name!r}') + metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text)} measures = [] self.children(parts[0], {'measure'}) for index, bar in enumerate(parts[0]): self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}' self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'}) self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'}) - cursor, extent = Fraction(), Fraction() + cursor, extent = fractions.Fraction(), fractions.Fraction() voices = {} previous = None optional = [] @@ -567,10 +562,9 @@ class Importer: self.children(item, {'duration', 'voice', 'staff'}) self.identity(item) duration = self.number(item.findtext('duration'), positive=True) / self.divisions - from generator.model import make_event if item.find('voice') is not None: self.sequence += 1 - voices.setdefault(self.voice_id, []).append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id}) + voices.setdefault(self.voice_id, []).append(generator.model.make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id}) cursor += duration extent = max(extent, cursor) previous = None @@ -597,22 +591,22 @@ class Importer: cursor = extent if measure.pop('_right_barline_onset', cursor) != cursor: self.fail('right barline must occur at the end of its measure') - expected = meter_length(measure['meter']) + expected = generator.model.meter_length(measure['meter']) meter_changed = any('meter' in a for a in measure['attributes']) if expected and cursor != expected and not measure['implicit'] and not meter_changed: self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit') - measure['duration'] = rational(cursor) - measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter']) - measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: fraction(n['onset']))} for voice, events in voices.items()] - measure['harmonies'].sort(key=lambda h: fraction(h['onset'])) + measure['duration'] = generator.model.rational(cursor) + measure['key_signature'], measure['time_signature'] = generator.model.key_label(measure['key']), generator.model.meter_label(measure['meter']) + measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: generator.model.fraction(n['onset']))} for voice, events in voices.items()] + measure['harmonies'].sort(key=lambda h: generator.model.fraction(h['onset'])) used = set() for onset in optional: - matches = [h for h in measure['harmonies'] if fraction(h['onset']) == onset] + matches = [h for h in measure['harmonies'] if generator.model.fraction(h['onset']) == onset] if len(matches) != 1 or onset in used: self.fail('optional-chord marker must identify exactly one harmony and occur only once') matches[0]['chord_optional'] = True matches[0]['parenthesized'] = True - matches[0]['symbol'] = harmony.symbol(matches[0]) + matches[0]['symbol'] = generator.harmony.symbol(matches[0]) used.add(onset) measures.append(measure) self.absolute += cursor @@ -627,11 +621,11 @@ class Importer: metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])}) 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) + generator.model.validate_document(result) return result -def load_musicxml(path: Path) -> dict: - importer = Importer(Path(path)) +def load_musicxml(path: pathlib.Path) -> dict: + importer = Importer(pathlib.Path(path)) try: return importer.read() except MusicXMLError: diff --git a/generator/musicxml_writer.py b/generator/musicxml_writer.py index 90a4ac0..09fa6f3 100644 --- a/generator/musicxml_writer.py +++ b/generator/musicxml_writer.py @@ -1,21 +1,21 @@ from __future__ import annotations -from fractions import Fraction -from math import lcm -from pathlib import Path -from xml.etree import ElementTree +import fractions +import math +import pathlib +import xml.etree.ElementTree -from generator import harmony -from generator.model import alteration, fraction, validate_document +import generator.harmony +import generator.model def element(parent, tag, content=None, **attributes): - node = ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None}) + node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None}) if content is not None: node.text = str(content) return node def decimal(value) -> str: - value = alteration(value) if isinstance(value, dict) else Fraction(value) + value = generator.model.alteration(value) if isinstance(value, dict) else fractions.Fraction(value) denominator = value.denominator for prime in (2, 5): while denominator % prime == 0: @@ -37,7 +37,7 @@ def write_attributes(parent, state: dict, divisions=None) -> None: if 'cancel' in key: element(item, 'cancel', key['cancel']) if key.get('custom'): - steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if alteration(amount)]) + steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if generator.model.alteration(amount)]) for step in steps: element(item, 'key-step', step['letter'].upper()) element(item, 'key-alter', decimal(step['alter'])) @@ -85,7 +85,7 @@ def write_harmony(parent, value: dict, divisions: int) -> None: item = element(node, 'root') element(item, 'root-step', root['letter'].upper()) element(item, 'root-alter', decimal(root['alter'])) - element(node, 'kind', value['kind'], text=harmony.symbol(value) if value.get('parenthesized') else None) + element(node, 'kind', value['kind'], text=generator.harmony.symbol(value) if value.get('parenthesized') else None) if 'inversion' in value: element(node, 'inversion', value['inversion']) if 'bass' in value: @@ -94,20 +94,20 @@ def write_harmony(parent, value: dict, divisions: int) -> None: element(item, 'bass-alter', decimal(value['bass']['alter'])) for degree in value['degrees']: item = element(node, 'degree') - amount = alteration(degree['alter']) + amount = generator.model.alteration(degree['alter']) if degree['type'] == 'alter': - amount -= harmony.KINDS[value['kind']].get(degree['value'], 0) + amount -= generator.harmony.KINDS[value['kind']].get(degree['value'], 0) elif degree['type'] == 'add' and (degree['value'] - 1) % 7 == 6: amount += 1 element(item, 'degree-value', degree['value']) element(item, 'degree-alter', decimal(amount)) element(item, 'degree-type', degree['type']) - element(node, 'offset', int(fraction(value['onset']) * divisions)) + element(node, 'offset', int(generator.model.fraction(value['onset']) * divisions)) def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None: if event['kind'] == 'spacer': item = element(parent, 'forward') - element(item, 'duration', int(fraction(event['duration']) * divisions)) + element(item, 'duration', int(generator.model.fraction(event['duration']) * divisions)) element(item, 'voice', voice) return pitches = event['pitches'] or [None] @@ -117,7 +117,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di printing.update(pitch.get('print', {})) node = element(parent, 'note', **printing) if grace := event.get('grace'): - element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()}) + element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(generator.model.fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()}) if (pitch or event).get('cue', event.get('cue')): element(node, 'cue') if index: @@ -137,7 +137,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di element(item, 'alter', decimal(pitch['alter']) if pitch else '0') element(item, 'octave', pitch['octave'] if pitch else 4) if not event.get('grace'): - element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions)) + element(node, 'duration', int(generator.model.fraction(pitch['duration'] if pitch else event['duration']) * divisions)) for field, action in (('tie_from', 'stop'), ('tie_to', 'start')): if pitch and pitch.get(field): element(node, 'tie', type=action) @@ -151,7 +151,7 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di if pitch and (display := pitch.get('accidental_display')): element(node, 'accidental', display['name'], **{k: v for k, v in display.items() if k != 'name'}) if 'ratio' in written: - ratio = fraction(written['ratio']) + ratio = generator.model.fraction(written['ratio']) item = element(node, 'time-modification') element(item, 'actual-notes', ratio.denominator) element(item, 'normal-notes', ratio.numerator) @@ -204,15 +204,13 @@ def write_barline(bar, measure, location): node = element(bar, 'barline', location=location) element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular')) -def musicxml_tree(document: dict) -> ElementTree.ElementTree: - validate_document(document) - root = ElementTree.Element('score-partwise', {'version': '4.0'}) +def musicxml_tree(document: dict) -> xml.etree.ElementTree.ElementTree: + generator.model.validate_document(document) + root = xml.etree.ElementTree.Element('score-partwise', {'version': '4.0'}) metadata, staff = document['metadata'], document['staves'][0] element(element(root, 'work'), 'work-title', metadata['title']) identification = element(root, 'identification') element(identification, 'creator', metadata.get('composer', ''), type='composer') - miscellaneous = element(identification, 'miscellaneous') - element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style') defaults = element(root, 'defaults') element(defaults, 'music-font', font_family='Petaluma') element(defaults, 'word-font', font_family='Petaluma Script') @@ -240,23 +238,23 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree: for tag, value in instrument['midi'].items(): element(item, tag, value) part = element(root, 'part', id='P1') - absolute = Fraction() + absolute = fractions.Fraction() previous_state = {} for index, measure in enumerate(staff['measures']): bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no') write_barline(bar, measure, 'left') - times = [fraction(measure['duration'])] - times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration')) - times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']) - times.extend(fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, [])) - times.extend(fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end')) - divisions = lcm(*(t.denominator for t in times)) + times = [generator.model.fraction(measure['duration'])] + times.extend(generator.model.fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration')) + times.extend(generator.model.fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']) + times.extend(generator.model.fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, [])) + times.extend(generator.model.fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end')) + divisions = math.lcm(*(t.denominator for t in times)) state = {k: measure[k] for k in ('key', 'meter', 'clef', 'transpose', 'staff_details') if k in measure} write_attributes(bar, {k: v for k, v in state.items() if previous_state.get(k) != v}, divisions) previous_state.update(state) - cursor = Fraction() + cursor = fractions.Fraction() for change in measure.get('attributes', []): - onset = fraction(change['onset']) + onset = generator.model.fraction(change['onset']) if onset != cursor: element(element(bar, 'forward' if onset > cursor else 'backup'), 'duration', int(abs(onset - cursor) * divisions)) write_attributes(bar, change) @@ -269,7 +267,7 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree: if value.get('chord_optional'): direction = element(bar, 'direction', print_object='no') element(element(direction, 'direction-type'), 'words', 'vamp:optional-chord') - element(direction, 'offset', int(fraction(value['onset']) * divisions)) + element(direction, 'offset', int(generator.model.fraction(value['onset']) * divisions)) if measure.get('section_label'): element(element(element(bar, 'direction'), 'direction-type'), 'rehearsal', measure['section_label'], halign='left') for tempo in measure.get('tempos', []): @@ -279,35 +277,35 @@ def musicxml_tree(document: dict) -> ElementTree.ElementTree: for _ in range(tempo['written'].get('dots', 0)): element(metronome, 'beat-unit-dot') element(metronome, 'per-minute', decimal(tempo['bpm'])) - element(direction, 'offset', int(fraction(tempo['onset']) * divisions)) + element(direction, 'offset', int(generator.model.fraction(tempo['onset']) * divisions)) for si, span in enumerate(staff.get('octave_shifts', [])): for field in ('start', 'end'): - onset = fraction(span[field]) - absolute - inside = 0 <= onset < fraction(measure['duration']) if field == 'start' else 0 < onset <= fraction(measure['duration']) + onset = generator.model.fraction(span[field]) - absolute + inside = 0 <= onset < generator.model.fraction(measure['duration']) if field == 'start' else 0 < onset <= generator.model.fraction(measure['duration']) if not inside: continue direction = element(bar, 'direction') element(element(direction, 'direction-type'), 'octave-shift', type='stop' if field == 'end' else 'up' if span['octaves'] > 0 else 'down', size=abs(span['octaves']) * 7 + 1, number=si + 1) element(direction, 'offset', int(onset * divisions)) - cursor = Fraction() + cursor = fractions.Fraction() for voice in measure['voices']: if cursor: element(element(bar, 'backup'), 'duration', int(cursor * divisions)) - cursor = Fraction() + cursor = fractions.Fraction() for event in voice['notes']: - onset = fraction(event['onset']) + onset = generator.model.fraction(event['onset']) if onset > cursor: element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions)) write_note(bar, event, voice['id'], divisions, staff.get('tuplets', [])) - cursor = onset + fraction(event['duration']) - if cursor < fraction(measure['duration']): - element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions)) - cursor = fraction(measure['duration']) + cursor = onset + generator.model.fraction(event['duration']) + if cursor < generator.model.fraction(measure['duration']): + element(element(bar, 'forward'), 'duration', int((generator.model.fraction(measure['duration']) - cursor) * divisions)) + cursor = generator.model.fraction(measure['duration']) write_barline(bar, measure, 'right') - absolute += fraction(measure['duration']) - return ElementTree.ElementTree(root) + absolute += generator.model.fraction(measure['duration']) + return xml.etree.ElementTree.ElementTree(root) -def write_musicxml(path: Path, document: dict) -> None: +def write_musicxml(path: pathlib.Path, document: dict) -> None: tree = musicxml_tree(document) - ElementTree.indent(tree, space=' ') + xml.etree.ElementTree.indent(tree, space=' ') tree.write(path, encoding='utf-8', xml_declaration=True) diff --git a/generator/parts.py b/generator/parts.py index cf2acdc..947ed29 100644 --- a/generator/parts.py +++ b/generator/parts.py @@ -1,16 +1,18 @@ from __future__ import annotations -from fractions import Fraction -from pathlib import Path +import fractions +import pathlib -from generator.guitar import validate_tuning -from generator.model import beat_lengths, fraction, make_event -from generator.pitch import concert_document, shift_pitch -from generator.score import copy_source_voices, make_measure, slugify, source_staff +import generator.accompaniment +import generator.guitar +import generator.model +import generator.pitch +import generator.rhythm +import generator.score PART_TYPES = ('acoustic', 'electric', 'vocal') -def source_paths(directory: Path, *, practice: bool = False) -> list[Path]: +def source_paths(directory: pathlib.Path, *, practice: bool = False) -> list[pathlib.Path]: if not directory.is_dir(): raise ValueError(f'{"practice" if practice else "performance"} source directory not found: {directory}') paths = sorted(directory.rglob('*.xml')) @@ -21,7 +23,7 @@ def source_paths(directory: Path, *, practice: bool = False) -> list[Path]: raise ValueError(f'{path}: practice sources require a four-digit filename directly under data/practice') elif path.parent != directory: raise ValueError(f'{path}: performance sources belong directly under data/songs') - identity = path.stem if practice else slugify(path.stem) + identity = path.stem if practice else generator.score.slugify(path.stem) if identity in identities: raise ValueError(f'{identities[identity]} and {path}: conflicting output path {identity}-acoustic.json') identities[identity] = path @@ -29,81 +31,38 @@ def source_paths(directory: Path, *, practice: bool = False) -> list[Path]: def prepare_document(document: dict, *, practice: bool = False) -> dict: validate_source(document, practice=practice) - return concert_document(document, octave_transposing=True) - -def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]: - result = [] - current = None - for measure in measures: - result.append(current) - for h in measure.get('harmonies', []): - if include_optional or not h.get('chord_optional'): - current = h - return result - -def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None: - active = previous - for h in measure['harmonies']: - if fraction(h['onset']) > onset: - break - active = h - return active if active and active['kind'] != 'none' else None + return generator.pitch.concert_document(document, octave_transposing=True) def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]: - length = fraction(measure['duration']) + length = generator.model.fraction(measure['duration']) if role == 'rest': - return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}] - common_time = measure['time_signature'] == '4/4' and not any('meter' in a for a in measure.get('attributes', [])) - if role == 'rhythm' and common_time: - attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)] - elif role == 'accompaniment': - boundaries = sorted({Fraction(), length} | {fraction(h['onset']) for h in measure['harmonies'] if fraction(h['onset']) < length}) - attacks = [(start, end - start) for start, end in zip(boundaries, boundaries[1:])] - else: - attacks = [] - states = [(Fraction(), measure['meter'])] - states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a) - for index, (onset, meter) in enumerate(states): - end = states[index + 1][0] if index + 1 < len(states) else length - beats = beat_lengths(meter) - beat_index = 0 - while onset < end: - beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset) - duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2)) - attacks.append((onset, duration)) - onset += beat - beat_index += 1 + return [generator.model.make_event('rest', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}] + if role != 'slash': + raise ValueError(f'{role} requires a preplanned rhythm') events = [] - cursor = Fraction() - for onset, duration in attacks: - if onset >= length: - break - if onset > cursor: - events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest')) - duration = min(duration, length - onset) - chord = active_chord(measure, onset, previous) - if role == 'slash': - event = make_event(f'slash-{len(events)}', onset, duration, 'note') + states = [(fractions.Fraction(), measure['meter'])] + states.extend((generator.model.fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a) + for index, (onset, meter) in enumerate(states): + end = states[index + 1][0] if index + 1 < len(states) else length + beats = generator.model.beat_lengths(meter) + beat_index = 0 + while onset < end: + duration = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset) + event = generator.model.make_event(f'slash-{len(events)}', onset, duration, 'note') event['notehead'] = 'slash' - elif not chord: - event = make_event(f'rest-{len(events)}', onset, duration, 'rest') - else: - event = make_event(f'chord-{len(events)}', onset, duration, 'note') - event['_harmony'] = chord - events.append(event) - cursor = onset + duration - if cursor < length: - events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest')) + events.append(event) + onset += duration + beat_index += 1 return events def validate_source(document: dict, *, practice: bool = False) -> None: voices = set() - for measure in source_staff(document)['measures']: + for measure in generator.score.source_staff(document)['measures']: for state in [measure, *measure.get('attributes', [])]: if state.get('clef', {}).get('sign') in ('TAB', 'percussion'): raise ValueError(f'{measure["id"]}: parts require a pitched, non-TAB source') if 'staff_details' in state: - validate_tuning(state, measure['id']) + generator.guitar.validate_tuning(state, measure['id']) for voice in measure['voices']: voices.add(voice['id']) for note in voice['notes']: @@ -114,13 +73,13 @@ def validate_source(document: dict, *, practice: bool = False) -> None: if practice and len(voices) > 1: raise ValueError('practice requires one monophonic voice') -def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '') -> dict: - voices = copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None - notes = [n for v in voices for n in v['notes']] if voices else generated_voice(source, role, previous) +def build_measure(source: dict, identifier: str, role: str, part: str, previous: dict | None, label: str = '', rhythm: generator.rhythm.Bar | None = None) -> dict: + voices = generator.score.copy_source_voices(source, strip_lyrics=role == 'melody') if role in ('vocal', 'melody') else None + notes = [n for v in voices for n in v['notes']] if voices else generator.accompaniment.realize(source, rhythm, previous) if rhythm is not None else generated_voice(source, role, previous) if role in ('melody', 'vocal'): for note in notes: - note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']] - result = make_measure(identifier, source, [] if voices else notes, section_label=label) + note['pitches'] = [generator.pitch.shift_pitch(p, 7, 12) for p in note['pitches']] + result = generator.score.make_measure(identifier, source, [] if voices else notes, section_label=label) if voices: result['voices'] = voices result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1, 'explicit': True} diff --git a/generator/pico_artwork.py b/generator/pico_artwork.py new file mode 100644 index 0000000..ab10b1d --- /dev/null +++ b/generator/pico_artwork.py @@ -0,0 +1,172 @@ +"""Build-only PetalumaScript sprites and content-addressed Pico title images.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import pathlib +import struct + +import PIL.Image +import PIL.ImageDraw +import PIL.ImageFont +import PIL.features +import fontTools.ttLib + +TITLE_WIDTH = 296 +TITLE_HEIGHT = 64 +TITLE_HEADER = struct.pack('<4sHH', b'VPTI', TITLE_WIDTH, TITLE_HEIGHT) +TITLE_BYTES = len(TITLE_HEADER) + TITLE_WIDTH * TITLE_HEIGHT // 2 +MAX_TITLES = 8192 +LABELS = ('VAMP', 'CONNECTING', 'OFFLINE', 'BUSY', 'READY', 'LOADING', 'WIFI', 'ERROR', 'PAGE', 'Acoustic', 'Electric', 'Vocal', 'UNAVAILABLE') +SPRITES = LABELS + tuple('0123456789/') +DIGIT_WIDTH = 22 +DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma' + +class Renderer: + def __init__(self, font_path: pathlib.Path): + if not PIL.features.check_feature('raqm'): + raise RuntimeError('Pico artwork requires Pillow with Raqm support') + self.font_path = font_path + with fontTools.ttLib.TTFont(font_path) as font: + self.characters = set(font.getBestCmap()) + self.fonts = {} + def font(self, size: int) -> PIL.ImageFont.FreeTypeFont: + if size not in self.fonts: + self.fonts[size] = PIL.ImageFont.truetype(str(self.font_path), size, layout_engine=PIL.ImageFont.Layout.RAQM) + return self.fonts[size] + def validate(self, text: str) -> None: + if not isinstance(text, str) or not text.strip() or len(text) > 200: + raise ValueError('Pico title and part must contain 1 to 200 characters') + if any(ord(character) < 32 or ord(character) not in self.characters for character in text): + raise ValueError(f'Unsupported PetalumaScript text: {text!r}') + def line(self, text: str, width: int, height: int, size: int) -> PIL.Image.Image: + self.validate(text) + if width < 5 or height < 3: + raise ValueError('Pico text tile is too small') + font = self.font(size) + while size > 14: + box = font.getbbox(text) + if box[2] - box[0] <= width - 4 and box[3] - box[1] <= height - 2: + break + size -= 1 + font = self.font(size) + if font.getbbox(text)[2] - font.getbbox(text)[0] > width - 4: + if font.getbbox('...')[2] - font.getbbox('...')[0] > width - 4: + raise ValueError('Pico text tile cannot fit an ellipsis') + original = text + while original and font.getbbox(original + '...')[2] - font.getbbox(original + '...')[0] > width - 4: + original = original[:-1] + text = original + '...' + box = font.getbbox(text) + if box[3] - box[1] > height - 2: + raise ValueError('Pico text tile is too short') + image = PIL.Image.new('L', (width, height)) + PIL.ImageDraw.Draw(image).text(((width - box[2] + box[0]) // 2 - box[0], + (height - box[3] + box[1]) // 2 - box[1]), text, font=font, fill=255) + return image + def digit(self, text: str) -> PIL.Image.Image: + font = self.font(24) + box = font.getbbox(text) + if text not in '0123456789/' or len(text) != 1 or box[2] - box[0] > DIGIT_WIDTH - 4 or box[1] + 2 < 0 or box[3] + 2 > 28: + raise ValueError('Invalid Pico counter tile') + image = PIL.Image.new('L', (DIGIT_WIDTH, 28)) + PIL.ImageDraw.Draw(image).text(((DIGIT_WIDTH - box[2] + box[0]) // 2 - box[0], 2), text, font=font, fill=255) + return image + def title(self, title: str) -> bytes: + self.validate(title) + for size in range(32, 15, -1): + lines = [''] + for word in title.split(): + candidate = (lines[-1] + ' ' + word).strip() + box = self.font(size).getbbox(candidate) + if lines[-1] and box[2] - box[0] > TITLE_WIDTH - 4: + lines.append(word) + else: + lines[-1] = candidate + if len(lines) <= 2: + break + if len(lines) > 2: + lines = [lines[0], ' '.join(lines[1:])] + image = PIL.Image.new('L', (TITLE_WIDTH, TITLE_HEIGHT)) + for index, line in enumerate(lines): + image.paste(self.line(line, TITLE_WIDTH, 32, size), (0, (TITLE_HEIGHT - len(lines) * 32) // 2 + index * 32)) + return TITLE_HEADER + pack_coverage(image) + +def pack_coverage(image: PIL.Image.Image) -> bytes: + pixels = image.tobytes() + if len(pixels) % 2: + pixels += b'\0' + levels = [(pixel + 8) // 17 for pixel in pixels] + return bytes((levels[index] << 4) | levels[index + 1] for index in range(0, len(levels), 2)) + +def generate_static(output: pathlib.Path, font_root: pathlib.Path = DEFAULT_FONT_ROOT) -> None: + renderer = Renderer(font_root / 'PetalumaScript.otf') + directory = bytearray(struct.pack('<4sHH', b'VPSA', len(SPRITES), 0)) + payload = bytearray() + for text in SPRITES: + image = renderer.line(text, 176, 28, 24) if text in LABELS else renderer.digit(text) + if text in LABELS: + box = image.getbbox() + image = image.crop((max(0, box[0] - 1), 0, min(image.width, box[2] + 1), image.height)) + directory.extend(struct.pack(' None: + """Call after score generation, before catalog validation and atomic promotion. + + Adds presentation.title_image to score bundles. The identifier is SHA-256 of + the complete fixed-size .mask file, shared across parts with the same title. + """ + renderer = Renderer(font_root / 'PetalumaScript.otf') + destination = root / 'pico' / 'titles' + destination.mkdir(parents=True, exist_ok=True) + titles = {} + for category in ('songs', 'practice'): + for source in sorted((root / category).rglob('*.json')): + document = json.loads(source.read_text(encoding='utf-8')) + title = document['music']['metadata']['title'] + if title not in titles: + if len(titles) >= MAX_TITLES: + raise ValueError('Too many Pico titles') + image = renderer.title(title) + titles[title] = hashlib.sha256(image).hexdigest() + (destination / f'{titles[title]}.mask').write_bytes(image) + document['presentation']['title_image'] = titles[title] + source.write_text(json.dumps(document, ensure_ascii=False, separators=(',', ':')) + '\n', encoding='utf-8') + +def validate_title(root: pathlib.Path, identifier: str) -> None: + if not isinstance(identifier, str) or len(identifier) != 64 or any(character not in '0123456789abcdef' for character in identifier): + raise ValueError('Invalid Pico title identifier; regenerate assets') + try: + asset_root = root.resolve(strict=True) + directory = (root / 'pico' / 'titles').resolve(strict=True) + source = (directory / f'{identifier}.mask').resolve(strict=True) + if not directory.is_relative_to(asset_root) or source.parent != directory: + raise ValueError('Pico title escapes the generated title directory') + if source.stat().st_size != TITLE_BYTES: + raise ValueError('Invalid Pico title size') + with source.open('rb') as stream: + image = stream.read(TITLE_BYTES + 1) + if len(image) != TITLE_BYTES or image[:len(TITLE_HEADER)] != TITLE_HEADER or hashlib.sha256(image).hexdigest() != identifier: + raise ValueError('Invalid Pico title header or digest') + except OSError as error: + raise ValueError(f'Pico title unavailable: {error}') from error + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--font-root', type=pathlib.Path, default=DEFAULT_FONT_ROOT) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--static-output', type=pathlib.Path) + group.add_argument('--assets', type=pathlib.Path) + arguments = parser.parse_args() + if arguments.static_output: + generate_static(arguments.static_output, arguments.font_root) + else: + generate_titles(arguments.assets, arguments.font_root) + +if __name__ == '__main__': + main() diff --git a/generator/pitch.py b/generator/pitch.py index d08ebc5..42b4fa0 100644 --- a/generator/pitch.py +++ b/generator/pitch.py @@ -2,38 +2,37 @@ from __future__ import annotations import copy -from generator import harmony -from generator.model import LETTERS, NATURAL, alteration, fraction, key_from_fifths, key_label, pitch_number, rational +import generator.harmony +import generator.model def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict: output = copy.deepcopy(pitch) if 'unpitched' in pitch: return output original_octave = pitch.get('octave', 4) - diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps + diatonic = original_octave * 7 + generator.model.LETTERS.index(pitch['letter']) + steps octave, index = divmod(diatonic, 7) - output['letter'] = LETTERS[index] + output['letter'] = generator.model.LETTERS[index] output['octave'] = octave - output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])) + output['alter'] = generator.model.rational(generator.model.pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + generator.model.NATURAL[output['letter']])) if 'octave' not in pitch: output.pop('octave') if 'accidental_display' in output: - from generator.model import accidental_name - output['accidental_display']['name'] = accidental_name(alteration(output['alter'])) + output['accidental_display']['name'] = generator.model.accidental_name(generator.model.alteration(output['alter'])) return output def shift_key(key: dict, steps: int, semitones: int) -> dict: tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None alterations = [0] * 7 - for index, letter in enumerate(LETTERS): + for index, letter in enumerate(generator.model.LETTERS): p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones) - alterations[LETTERS.index(p['letter'])] = p['alter'] + alterations[generator.model.LETTERS.index(p['letter'])] = p['alter'] result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations} if 'steps' in key: result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']] if not key.get('custom') and tonic: for fifths in range(-14, 15): - candidate = key_from_fifths(fifths, key['mode']) + candidate = generator.model.key_from_fifths(fifths, key['mode']) if candidate['tonic'] == tonic and candidate['alterations'] == alterations: result['fifths'] = fifths break @@ -41,7 +40,7 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict: result['custom'] = True if 'cancel' in key: # A cancellation references the old signature, not a count of naturals. - canceled = shift_key(key_from_fifths(key['cancel']), steps, semitones) + canceled = shift_key(generator.model.key_from_fifths(key['cancel']), steps, semitones) if canceled.get('custom'): raise ValueError('transposed key cancellation cannot be represented by fifths') result['cancel'] = canceled['fifths'] @@ -59,7 +58,7 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic def interval(onset): state = dict(initial) for change in changes: - if fraction(change['onset']) <= onset: + if generator.model.fraction(change['onset']) <= onset: state.update({key: change[key] for key in initial if key in change}) transpose, clef = state['transpose'], state['clef'] steps = transpose['diatonic'] + 7 * transpose['octave_change'] @@ -69,17 +68,17 @@ def concert_document(document: dict, *, octave_transposing: bool = False) -> dic return steps, semitones measure['key'] = shift_key(measure['key'], *interval(0)) - measure['key_signature'] = key_label(measure['key']) + measure['key_signature'] = generator.model.key_label(measure['key']) for n in (n for v in measure['voices'] for n in v['notes']): - n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']] + n['pitches'] = [shift_pitch(p, *interval(generator.model.fraction(n['onset']))) for p in n['pitches']] for h in measure['harmonies']: for field in ('root', 'bass'): if field in h: - h[field] = shift_pitch(h[field], *interval(fraction(h['onset']))) - h['symbol'] = harmony.symbol(h) + h[field] = shift_pitch(h[field], *interval(generator.model.fraction(h['onset']))) + h['symbol'] = generator.harmony.symbol(h) for change in measure.get('attributes', []): if 'key' in change: - change['key'] = shift_key(change['key'], *interval(fraction(change['onset']))) + change['key'] = shift_key(change['key'], *interval(generator.model.fraction(change['onset']))) if 'transpose' in change: change['transpose'] = copy.deepcopy(zero) measure['transpose'] = copy.deepcopy(zero) @@ -92,4 +91,4 @@ def sounding_pitch(pitch: dict, state: dict): if 'unpitched' in pitch: raise ValueError('unpitched notes have no fretted or generated-tone interpretation') transpose = state['transpose'] - return pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change'] + return generator.model.pitch_number(pitch) + transpose['chromatic'] + 12 * transpose['octave_change'] diff --git a/generator/playback.py b/generator/playback.py index 81484d7..1d795ec 100644 --- a/generator/playback.py +++ b/generator/playback.py @@ -1,16 +1,16 @@ from __future__ import annotations -from array import array -from fractions import Fraction +import array +import fractions import hashlib import json import math -from pathlib import Path +import pathlib import sys import wave -from generator.model import TYPE_LENGTHS, fraction, rational -from generator import pitch +import generator.model +import generator.pitch SAMPLE_RATE = 48000 PROFILE = 'soft-struck' @@ -19,10 +19,10 @@ MAX_EVENTS = 1000000 class PlaybackUnavailable(ValueError): pass -def sounding_pitch(tone: dict, state: dict) -> Fraction: +def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction: if 'unpitched' in tone: raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation') - value = pitch.sounding_pitch(tone, state) + value = generator.pitch.sounding_pitch(tone, state) frequency = 440 * 2 ** (float(value - 69) / 12) if not 20 <= frequency <= 18000: raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)') @@ -30,19 +30,19 @@ def sounding_pitch(tone: dict, state: dict) -> Fraction: def compile_timeline(document: dict) -> dict: events, notes, linked = [], {}, {} - absolute = Fraction() + absolute = fractions.Fraction() for measure in document['staves'][0]['measures']: for voice in measure['voices']: state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')} - changes = iter(sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset']))) + changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset']))) change = next(changes, None) for note in voice['notes']: - onset = fraction(note['onset']) - while change is not None and fraction(change['onset']) <= onset: + onset = generator.model.fraction(note['onset']) + while change is not None and generator.model.fraction(change['onset']) <= onset: state.update({key: change[key] for key in state if key in change}) change = next(changes, None) notes[note['id']] = (note, absolute + onset, dict(state), voice['id']) - absolute += fraction(measure['duration']) + absolute += generator.model.fraction(measure['duration']) adjustments = {} insertions = {} lanes = {} @@ -63,25 +63,25 @@ def compile_timeline(document: dict) -> dict: raise PlaybackUnavailable('grace group mixes conflicting timing modes') mode = next(iter(modes), 'steal-time-following') if mode == 'make-time': - lengths = [fraction(g.get(mode, rational(0))) for g in specified] - total = sum(lengths, Fraction()) - insertions[onset] = max(insertions.get(onset, Fraction()), total) + lengths = [generator.model.fraction(g.get(mode, generator.model.rational(0))) for g in specified] + total = sum(lengths, fractions.Fraction()) + insertions[onset] = max(insertions.get(onset, fractions.Fraction()), total) else: donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None if donor is None: raise PlaybackUnavailable('grace notes have no preceding duration to steal') - percentages = [fraction(g[mode]) if mode in g else Fraction(25, len(pending)) for g in specified] + percentages = [generator.model.fraction(g[mode]) if mode in g else fractions.Fraction(25, len(pending)) for g in specified] if not 0 < sum(percentages) < 100: raise PlaybackUnavailable('grace group must steal between zero and 100 percent') - lengths = [fraction(donor['duration']) * amount / 100 for amount in percentages] - total = sum(lengths, Fraction()) - delay, extension = adjustments.get(donor['id'], (Fraction(), Fraction())) + lengths = [generator.model.fraction(donor['duration']) * amount / 100 for amount in percentages] + total = sum(lengths, fractions.Fraction()) + delay, extension = adjustments.get(donor['id'], (fractions.Fraction(), fractions.Fraction())) if mode == 'steal-time-previous': cursor -= total else: delay += total extension -= total - if fraction(donor['duration']) + extension <= 0 or any(fraction(p['duration']) + extension <= 0 for p in donor['pitches']): + if generator.model.fraction(donor['duration']) + extension <= 0 or any(generator.model.fraction(p['duration']) + extension <= 0 for p in donor['pitches']): raise PlaybackUnavailable(f'{donor["id"]}: grace timing consumes a donor or chord tone') adjustments[donor['id']] = (delay, extension) for item, length in zip(pending, lengths): @@ -95,60 +95,60 @@ def compile_timeline(document: dict) -> dict: for identifier, (note, onset, state, voice) in notes.items(): if note.get('cue'): continue - delay, extension = adjustments.get(identifier, (Fraction(), Fraction())) - performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), Fraction()) + delay, extension = adjustments.get(identifier, (fractions.Fraction(), fractions.Fraction())) + performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), fractions.Fraction()) tremolo = note.get('tremolo') if tremolo and tremolo['type'] == 'stop': continue for pitch in note['pitches']: if pitch.get('cue'): continue - length = fraction(pitch['duration']) + extension + length = generator.model.fraction(pitch['duration']) + extension if not note.get('grace'): - length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction()) + length += sum((amount for time, amount in insertions.items() if onset < time < onset + generator.model.fraction(pitch['duration'])), fractions.Fraction()) if length <= 0: raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone') value = sounding_pitch(pitch, state) if pitch.get('tie_from'): previous = linked.get(pitch['tie_from']) - if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value: + if previous is None or generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) != performed or generator.model.fraction(previous['pitch']) != value: raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch') - previous['duration'] = rational(fraction(previous['duration']) + length) + previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length) linked[pitch['id']] = previous continue if tremolo and tremolo['marks']: if pitch.get('tie_to'): raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback') written = pitch['written'] - step = min(Fraction(1), TYPE_LENGTHS[written['type']]) * fraction(written.get('ratio', rational(1))) / 2 ** tremolo['marks'] + step = min(fractions.Fraction(1), generator.model.TYPE_LENGTHS[written['type']]) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) / 2 ** tremolo['marks'] if tremolo['type'] == 'start': partner = notes[tremolo['partner']] - if partner[1] != onset + fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']): + if partner[1] != onset + generator.model.fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']): raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords') step *= 2 - length += fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration']) - cursor, attack = Fraction(), 0 + length += generator.model.fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration']) + cursor, attack = fractions.Fraction(), 0 while cursor < length: amount = value if tremolo['type'] == 'start' and attack % 2: other = partner[0]['pitches'][note['pitches'].index(pitch)] amount = sounding_pitch(other, partner[2]) - events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))}) + events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))}) cursor += step attack += 1 if len(events) > MAX_EVENTS: raise PlaybackUnavailable('playback exceeds one million tone events') else: - event = {'id': pitch['id'], 'pitch': rational(value), 'onset': rational(performed), 'duration': rational(length)} + event = {'id': pitch['id'], 'pitch': generator.model.rational(value), 'onset': generator.model.rational(performed), 'duration': generator.model.rational(length)} events.append(event) linked[pitch['id']] = event if len(events) > MAX_EVENTS: raise PlaybackUnavailable('playback exceeds one million tone events') - events.sort(key=lambda e: (fraction(e['onset']), e['id'])) - return {'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events} + events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id'])) + return {'available': True, 'duration': generator.model.rational(absolute + sum(insertions.values(), fractions.Fraction())), 'events': events} -def write_pcm(path: Path, values) -> str: - samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values)) +def write_pcm(path: pathlib.Path, values) -> str: + samples = array.array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values)) if sys.byteorder != 'little': samples.byteswap() path.parent.mkdir(parents=True, exist_ok=True) @@ -157,9 +157,9 @@ def write_pcm(path: Path, values) -> str: output.writeframes(samples.tobytes()) return hashlib.sha256(path.read_bytes()).hexdigest() -def generate_tone(root: Path, pitch: Fraction) -> dict: +def generate_tone(root: pathlib.Path, pitch: fractions.Fraction) -> dict: frequency = 440 * 2 ** (float(pitch - 69) / 12) - period = Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE) + period = fractions.Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE) loop_length, cycles = period.denominator, period.numerator attack = 12000 name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24] @@ -174,7 +174,7 @@ def generate_tone(root: Path, pitch: Fraction) -> dict: checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length))) return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length} -def compile_tree(root: Path) -> None: +def compile_tree(root: pathlib.Path) -> None: tones = {} release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400} release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400))) @@ -184,8 +184,8 @@ def compile_tree(root: Path) -> None: playback = compile_timeline(document) used = {} for event in playback['events']: - event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration'))) - pitch = fraction(event.pop('pitch')) + event['end'] = generator.model.rational(generator.model.fraction(event['onset']) + generator.model.fraction(event.pop('duration'))) + pitch = generator.model.fraction(event.pop('pitch')) if pitch not in tones: tones[pitch] = generate_tone(root, pitch) tone = tones[pitch] @@ -197,7 +197,7 @@ def compile_tree(root: Path) -> None: 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: +def validate_assets(playback: dict, root: pathlib.Path, cache: dict | None = None) -> None: if not isinstance(playback, dict) or type(playback.get('available')) is not bool: raise ValueError('playback manifest requires boolean availability') if not playback['available']: @@ -208,7 +208,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No def check_sample(value): name = value['path'] - relative = Path(name) + relative = pathlib.Path(name) if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name: raise ValueError('invalid generated audio path') path = (root / relative).resolve() @@ -225,7 +225,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No pcm = audio.readframes(value['frames']) if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2: raise ValueError('invalid or truncated generated PCM WAV') - samples = array('h') + samples = array.array('h') samples.frombytes(pcm) if sys.byteorder != 'little': samples.byteswap() @@ -234,7 +234,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No cache[key] = samples return samples - duration = fraction(playback['duration']) + duration = generator.model.fraction(playback['duration']) if duration <= 0 or len(playback['events']) > MAX_EVENTS: raise ValueError('invalid playback timeline length') release = check_sample(playback['release']) @@ -250,10 +250,10 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No raise ValueError('invalid sample identity or loop') check_sample(sample) samples[sample['id']] = sample - previous = Fraction() + previous = fractions.Fraction() identities = set() for event in playback['events']: - onset, end = fraction(event['onset']), fraction(event['end']) + onset, end = generator.model.fraction(event['onset']), generator.model.fraction(event['end']) if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration: raise ValueError('invalid playback event identity, reference or timing') identities.add(event['id']) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index adbb088..a7856f5 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -1,44 +1,53 @@ from __future__ import annotations -from pathlib import Path +import pathlib -from generator.guitar import place_guitar -from generator.musicxml import load_musicxml -from generator.parts import PART_TYPES, build_measure, prepare_document, propagated_chords, source_paths -from generator.scat import add_scat -from generator.score import make_arranged_document, reidentify, source_staff, write_json +import generator.accompaniment +import generator.guitar +import generator.musicxml +import generator.parts +import generator.rhythm +import generator.scat +import generator.score -def practice_document(document: dict, part_type: str) -> dict: - return _practice_document(prepare_document(document, practice=True), part_type) +def practice_document(document: dict, part_type: str, *, rhythms: generator.rhythm.Library) -> dict: + document = generator.parts.prepare_document(document, practice=True) + plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False) if part_type == 'acoustic' else None + return _practice_document(document, part_type, plan) -def _practice_document(document: dict, part_type: str) -> dict: - if part_type not in PART_TYPES: +def _practice_document(document: dict, part_type: str, plan: generator.accompaniment.Plan | None) -> dict: + if part_type not in generator.parts.PART_TYPES: raise ValueError(f'unknown practice part: {part_type}') - staff = source_staff(document) - previous = propagated_chords(staff['measures']) + staff = generator.score.source_staff(document) + previous = generator.accompaniment.carried_chords(staff['measures']) role = {'acoustic': 'accompaniment', 'electric': 'melody', 'vocal': 'vocal'}[part_type] - measures = [build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', '')) for i, m in enumerate(staff['measures'])] + if part_type == 'acoustic' and plan is None: + raise ValueError('acoustic practice requires a rhythm plan') + measures = [generator.parts.build_measure(m, f'measure-{i + 1}', role, part_type, previous[i], m.get('section_label', ''), plan.acoustic[i] if part_type == 'acoustic' else None) for i, m in enumerate(staff['measures'])] for measure in measures: for chord in measure['harmonies']: chord.pop('chord_optional', None) - tuplets = reidentify(measures, staff.get('tuplets', [])) - result = make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else []) + tuplets = generator.score.reidentify(measures, staff.get('tuplets', [])) + result = generator.score.make_arranged_document(document, staff, staff_id='staff-practice-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=measures, tuplets=tuplets, octave_shifts=staff.get('octave_shifts', []) if role in ('melody', 'vocal') else []) if part_type == 'vocal': - add_scat(result) + generator.scat.add_scat(result) else: - place_guitar(result) + generator.guitar.place_guitar(result) + if generator.accompaniment.connect_continuations(result): + generator.guitar.place_guitar(result) return result -def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: - sources = source_paths(source_dir, practice=True) +def generate_practice_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, rhythms: generator.rhythm.Library) -> int: + sources = generator.parts.source_paths(source_dir, practice=True) for path in sources: try: - document = prepare_document(load_musicxml(path), practice=True) + document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path), practice=True) + plan = generator.accompaniment.plan_section(generator.score.source_staff(document)['measures'], rhythms, electric=False) except ValueError as error: raise ValueError(f'{path}: {error}') from error - for part in PART_TYPES: + for part in generator.parts.PART_TYPES: try: - write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part)) + generator.score.write_json(output_dir / path.stem / (part + '.json'), _practice_document(document, part, plan)) except ValueError as error: raise ValueError(f'{path}, part {part}: {error}') from error - return len(sources) * len(PART_TYPES) + return len(sources) * len(generator.parts.PART_TYPES) diff --git a/generator/rhythm.py b/generator/rhythm.py new file mode 100644 index 0000000..abb9443 --- /dev/null +++ b/generator/rhythm.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import dataclasses +import fractions +import pathlib +import types + +import generator.model +import generator.musicxml + +@dataclasses.dataclass(frozen=True) +class Event: + onset: fractions.Fraction + duration: fractions.Fraction + sounding: bool + written_type: str + dots: int = 0 + beams: tuple[tuple[int, str], ...] = () + continuation: bool = False + +@dataclasses.dataclass(frozen=True) +class Bar: + events: tuple[Event, ...] + length: fractions.Fraction + def attacks(self) -> tuple[fractions.Fraction, ...]: + return tuple(e.onset for e in self.events if e.sounding and not e.continuation) + def beat(self, number: int) -> tuple: + return tuple((max(e.onset, number) - number, min(e.onset + e.duration, number + 1) - max(e.onset, number), e.sounding, e.continuation or e.onset < number) for e in self.events if e.onset < number + 1 and e.onset + e.duration > number) + +@dataclasses.dataclass(frozen=True) +class Snippet: + id: str + meter: str + bars: tuple[Bar, ...] + +@dataclasses.dataclass(frozen=True, init=False) +class Library: + snippets: types.MappingProxyType + def __init__(self, snippets: tuple[Snippet, ...]): + grouped = {} + identities = set() + for snippet in sorted(snippets, key=lambda s: s.id): + if snippet.id in identities: + raise ValueError(f'duplicate rhythm identity: {snippet.id}') + identities.add(snippet.id) + grouped.setdefault(snippet.meter, []).append(snippet) + object.__setattr__(self, 'snippets', types.MappingProxyType({meter: tuple(values) for meter, values in grouped.items()})) + def for_meter(self, meter: str) -> tuple[Snippet, ...]: + if meter not in ('2/4', '3/4', '4/4', '5/4') or meter not in self.snippets: + raise ValueError(f'accompaniment requires rhythm snippets for {meter}; supported meters are 2/4, 3/4, 4/4, and 5/4') + return self.snippets[meter] + +def read_snippet(path: pathlib.Path, identifier: str) -> Snippet: + document = generator.musicxml.load_musicxml(path) + staff = document['staves'][0] + if len(staff['measures']) not in (1, 2) or staff.get('tuplets'): + raise ValueError(f'{path}: rhythm snippets require one or two bars without tuplets') + meter = staff['measures'][0]['time_signature'] + bars = [] + for measure in staff['measures']: + length = generator.model.fraction(measure['duration']) + if measure['time_signature'] != meter or length != generator.model.meter_length(measure['meter']): + raise ValueError(f'{path}, measure {measure["number"]}: rhythm bars must be complete and use one meter') + if len(measure['voices']) != 1 or any('meter' in a for a in measure.get('attributes', [])): + raise ValueError(f'{path}: rhythm snippets require one voice without meter changes') + events = [] + cursor = fractions.Fraction() + for note in measure['voices'][0]['notes']: + onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration']) + if onset != cursor or duration <= 0 or note.get('grace') or note['kind'] not in ('note', 'rest') or note.get('tremolo'): + raise ValueError(f'{path}, measure {measure["number"]}: rhythm events must fill the bar sequentially') + if 'ratio' in note['written']: + raise ValueError(f'{path}, measure {measure["number"]}: rhythm snippets do not support time modifications') + events.append(Event(onset, duration, note['kind'] == 'note', note['written']['type'], note['written'].get('dots', 0), tuple((b['level'], b['type']) for b in note.get('beams', [])), any('tie_from' in p for p in note['pitches']))) + cursor += duration + if cursor != length: + raise ValueError(f'{path}: rhythm events do not fill the bar') + bars.append(Bar(tuple(events), length)) + return Snippet(identifier, meter, tuple(bars)) + +def load_library(root: pathlib.Path) -> Library: + if not root.is_dir(): + raise ValueError(f'{root}: rhythm library not found') + snippets = [] + for path in sorted(root.rglob('*.xml')): + relative = path.relative_to(root) + if len(relative.parts) != 3 or relative.parts[0] not in ('2-4', '3-4', '4-4', '5-4') or relative.parts[1] not in ('1', '2'): + raise ValueError(f'{path}: rhythm sources belong under meter/series') + snippet = read_snippet(path, relative.as_posix()) + if snippet.meter != relative.parts[0].replace('-', '/'): + raise ValueError(f'{path}: rhythm meter disagrees with its directory') + snippets.append(snippet) + if not snippets: + raise ValueError(f'{root}: rhythm library is empty') + return Library(tuple(snippets)) diff --git a/generator/scat.py b/generator/scat.py index b3da9a7..27194e4 100644 --- a/generator/scat.py +++ b/generator/scat.py @@ -1,21 +1,21 @@ from __future__ import annotations -from fractions import Fraction +import fractions -from generator.model import beat_lengths, fraction, rational +import generator.model -def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]: - start, meter = Fraction(), measure['meter'] +def pulse(measure: dict, onset: fractions.Fraction) -> tuple[fractions.Fraction | None, bool]: + start, meter = fractions.Fraction(), measure['meter'] for change in measure.get('attributes', []): - if fraction(change['onset']) <= onset and 'meter' in change: - start, meter = fraction(change['onset']), change['meter'] - beats = beat_lengths(meter) + if generator.model.fraction(change['onset']) <= onset and 'meter' in change: + start, meter = generator.model.fraction(change['onset']), change['meter'] + beats = generator.model.beat_lengths(meter) if not beats: return None, False # Pickups occupy the final beats of their meter. phase = onset - start if measure.get('implicit') and not start: - phase += max(Fraction(), sum(beats) - fraction(measure['duration'])) + phase += max(fractions.Fraction(), sum(beats) - generator.model.fraction(measure['duration'])) phase %= sum(beats) for beat in beats: if phase < beat: @@ -23,7 +23,7 @@ def pulse(measure: dict, onset: Fraction) -> tuple[Fraction | None, bool]: phase -= beat raise ValueError('invalid meter pulse') -def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: bool, previous: str) -> str: +def syllable(duration: fractions.Fraction, beat: fractions.Fraction | None, onbeat: bool, final: bool, previous: str) -> str: if duration >= (beat if beat is not None else 1): return 'doo' if final: @@ -33,7 +33,7 @@ def syllable(duration: Fraction, beat: Fraction | None, onbeat: bool, final: boo def add_scat(document: dict) -> None: events, pitches = [], {} - absolute = Fraction() + absolute = fractions.Fraction() boundaries = set() for measure in document['staves'][0]['measures']: if measure.get('section_label'): @@ -42,34 +42,34 @@ def add_scat(document: dict) -> None: for note in voice['notes']: for field in ('lyrics', 'lyric', 'lyric_notation'): note.pop(field, None) - onset = absolute + fraction(note['onset']) + onset = absolute + generator.model.fraction(note['onset']) for tone in note['pitches']: pitches[tone['id']] = (note, tone, onset) events.append((note, measure, onset)) - absolute += fraction(measure['duration']) + absolute += generator.model.fraction(measure['duration']) attacks = [(n, m, t) for n, m, t in events if n['pitches'] and not n.get('cue') and not n['pitches'][0].get('cue') and not n['pitches'][0].get('tie_from')] previous, previous_end = '', None for index, (note, measure, onset) in enumerate(attacks): tone = note['pitches'][0] - end = onset + fraction(tone['duration']) + end = onset + generator.model.fraction(tone['duration']) chain = [] while target := tone.get('tie_to'): continuation, tone, time = pitches[target] chain.append(continuation) - end = time + fraction(tone['duration']) + end = time + generator.model.fraction(tone['duration']) following = attacks[index + 1][2] if index + 1 < len(attacks) else None final = following is None or following > end or any(onset < b <= following for b in boundaries) if previous_end is None or onset > previous_end or onset in boundaries: previous = '' - beat, onbeat = pulse(measure, fraction(note['onset'])) + beat, onbeat = pulse(measure, generator.model.fraction(note['onset'])) text = syllable(end - onset, beat, onbeat, final, previous) values = [{'kind': 'syllabic', 'text': 'single', 'attributes': {}}, {'kind': 'text', 'text': text, 'attributes': {}}] verse = {'number': '1', 'syllables': values} if chain: values.append({'kind': 'extend', 'text': '', 'attributes': {'type': 'start'}}) - verse['extend_to'] = rational(end) + verse['extend_to'] = generator.model.rational(end) for position, continuation in enumerate(chain): - continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': rational(end)}] + continuation['lyrics'] = [{'number': '1', 'syllables': [{'kind': 'extend', 'text': '', 'attributes': {'type': 'stop' if position == len(chain) - 1 else 'continue'}}], 'extend_to': generator.model.rational(end)}] note['lyrics'] = [verse] note['lyric'] = text note['lyric_notation'] = {'text': text, 'syllabic': 'single', 'extend': 'start' if chain else ''} diff --git a/generator/score.py b/generator/score.py index e96762d..089041d 100644 --- a/generator/score.py +++ b/generator/score.py @@ -1,46 +1,45 @@ from __future__ import annotations import copy -import hashlib import json +import pathlib import re -from pathlib import Path -from generator.model import validate_document +import generator.engraving.validation +import generator.model +import generator.pico_artwork +import generator.playback -def load_json(path: Path) -> dict: +def load_json(path: pathlib.Path) -> dict: document = json.loads(path.read_text(encoding='utf-8')) return validate_json(document, path) -def validate_json(document: dict, path: Path) -> dict: +def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str] | None = None) -> dict: if not isinstance(document, dict): raise ValueError('expected a score object') if 'music' in document: - from generator.engraving import ENGINE_NAME, PAGE_HEIGHT, PAGE_WIDTH, check_page presentation = document.get('presentation', {}) 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('width'), presentation.get('height')) != (ENGINE_NAME, PAGE_WIDTH, PAGE_HEIGHT): + if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.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): raise ValueError('invalid score page manifest') name = page['path'] - relative = Path(name) + relative = pathlib.Path(name) if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png': raise ValueError('invalid score page path') resolved = (path.parent / relative).resolve() if not resolved.is_relative_to(path.parent.resolve()): raise ValueError('score page escapes its asset directory') - 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) + generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256']) document = document['music'] - validate_document(document) + generator.model.validate_document(document) return document -def write_json(path: Path, document: dict) -> None: - validate_document(document) +def write_json(path: pathlib.Path, document: dict) -> None: + generator.model.validate_document(document) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') @@ -121,14 +120,16 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]: 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 {'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: +def count_json_files(root: pathlib.Path) -> int: return sum(1 for p in root.rglob('*.json') if p.is_file()) -def validate_generated_tree(root: Path) -> None: +def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None: audio_cache = {} + validated_pages = set() if validated_pages is None else validated_pages for path in root.rglob('*.json'): document = json.loads(path.read_text(encoding='utf-8')) - validate_json(document, path) + validate_json(document, path, validated_pages) + if 'presentation' in document: + generator.pico_artwork.validate_title(root, document['presentation'].get('title_image')) if 'playback' in document: - from generator.playback import validate_assets - validate_assets(document['playback'], root, audio_cache) + generator.playback.validate_assets(document['playback'], root, audio_cache) diff --git a/generator/styles.py b/generator/styles.py deleted file mode 100644 index 0dd0e2a..0000000 --- a/generator/styles.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -DEFAULT_STYLE = "Medium Swing" - -BUILT_IN_STYLES = ( - # Jazz - "Afro 12/8", - "Ballad Double Time Feel", - "Ballad Even", - "Ballad Melodic", - "Ballad Swing", - "Blue Note", - "Bossa Nova", - "Doo Doo Cats", - "Double Time Swing", - "Even 8ths", - "Even 8ths Open", - "Even 16ths", - "Guitar Trio", - "Gypsy Jazz", - "Latin", - "Latin/Swing", - "Long Notes", - "Medium Swing", - "Medium Up Swing", - "Medium Up Swing 2", - "New Orleans Swing", - "Second Line", - "Slow Swing", - "Swing Two/Four", - "Trad Jazz", - "Up Tempo Swing", - "Up Tempo Swing 2", - # Latin - "Argentina: Tango", - "Brazil: Bossa Acoustic", - "Brazil: Bossa Electric", - "Brazil: Samba", - "Cuba: Bolero", - "Cuba: Cha Cha Cha", - "Cuba: Son Montuno 2-3", - "Cuba: Son Montuno 3-2", - # Pop - "Bluegrass", - "Country", - "Disco", - "Funk", - "Glam Funk", - "House", - "Reggae", - "RnB", - "Rock", - "Rock 12/8", - "Shuffle", - "Slow Rock", - "Smooth", - "Soul", - "Virtual Funk", -) - -BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES) - -STYLE_ALIASES = { - "Med.": DEFAULT_STYLE, - "Med. Swing": DEFAULT_STYLE, - "Medium": DEFAULT_STYLE, -} - -def canonical_style(value: object) -> str: - if value is None: - return DEFAULT_STYLE - if not isinstance(value, str): - raise ValueError("style must be a string") - style = value.strip() - if not style: - return DEFAULT_STYLE - style = STYLE_ALIASES.get(style, style) - if style not in BUILT_IN_STYLE_SET: - raise ValueError(f"style {value!r} is not a supported player style") - return style diff --git a/generator/summary.py b/generator/summary.py index 4325ae8..0c053c7 100644 --- a/generator/summary.py +++ b/generator/summary.py @@ -1,39 +1,39 @@ from __future__ import annotations import json -from pathlib import Path -from typing import Any +import pathlib +import typing -from generator.score import load_json, measure_notes -from generator.parts import PART_TYPES -from generator.model import fraction, pitch_name +import generator.model +import generator.parts +import generator.score -def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]: +def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]: staves = document.get("staves") if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict): raise ValueError("generated score document must contain an arranged staff") return staves[0] -def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]: - notes = measure_notes(measure) - summary: dict[str, Any] = { +def _measure_summary(staff: dict[str, typing.Any], measure: dict[str, typing.Any], measure_number: int) -> dict[str, typing.Any]: + notes = generator.score.measure_notes(measure) + summary: dict[str, typing.Any] = { "measure": measure_number, "section_label": str(measure.get("section_label", "")), - "harmonies": [{"onset": str(fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]], - "duration": str(fraction(measure["duration"])), + "harmonies": [{"onset": str(generator.model.fraction(h["onset"])), "symbol": h["symbol"]} for h in measure["harmonies"]], + "duration": str(generator.model.fraction(measure["duration"])), "slash_count": sum(1 for note in notes if note.get("kind", "note") == "note" and note.get("notehead") == "slash"), } if staff.get("arrangement_part_type") == "vocal": summary["vocal_skeleton"] = [ - f'{fraction(note["onset"])}+{fraction(note["duration"])} ' + f'{generator.model.fraction(note["onset"])}+{generator.model.fraction(note["duration"])} ' + note['written']['type'] + '.' * note['written'].get('dots', 0) - + ' ' + note['kind'] + ' ' + ','.join(pitch_name(p) + str(p['octave']) for p in note['pitches']) + + ' ' + note['kind'] + ' ' + ','.join(generator.model.pitch_name(p) + str(p['octave']) for p in note['pitches']) + ' ' + note.get('lyric', '') for note in notes ] return summary -def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]: +def performance_document_summary(document: dict[str, typing.Any]) -> dict[str, typing.Any]: metadata = document.get("metadata") if not isinstance(metadata, dict): metadata = {} @@ -60,13 +60,13 @@ def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]: "measures": measure_summaries, } -def performance_tree_summary(root: Path) -> dict[str, Any]: +def performance_tree_summary(root: pathlib.Path) -> dict[str, typing.Any]: return { - path.name: performance_document_summary(load_json(path)) + path.name: performance_document_summary(generator.score.load_json(path)) for path in sorted(root.glob("*.json")) } -def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None: +def validate_expected_performance_summary(expected_path: pathlib.Path, generated_songs_dir: pathlib.Path) -> None: if not expected_path.exists(): raise ValueError(f"missing generator compatibility summary: {expected_path}") with expected_path.open("r", encoding="utf-8") as stream: @@ -85,10 +85,10 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally") raise ValueError("performance generator drift") -def validate_practice_parts(generated_practice_dir: Path) -> None: +def validate_practice_parts(generated_practice_dir: pathlib.Path) -> None: if not generated_practice_dir.exists(): return - expected_names = {part + '.json' for part in PART_TYPES} + expected_names = {part + '.json' for part in generator.parts.PART_TYPES} for exercise_dir in sorted(path for path in generated_practice_dir.iterdir() if path.is_dir()): json_names = {path.name for path in exercise_dir.glob("*.json")} if json_names != expected_names: diff --git a/include/vamp_pico/artwork.hpp b/include/vamp_pico/artwork.hpp new file mode 100644 index 0000000..ef298b6 --- /dev/null +++ b/include/vamp_pico/artwork.hpp @@ -0,0 +1,137 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + + +namespace vamp_pico { + +constexpr auto digit_width = std::size_t{22}; +constexpr auto max_pages = std::uint32_t{10000}; + +inline auto counter_text(std::uint32_t page, std::uint32_t pages) -> std::array { + auto text = std::array{}; + if (pages == 0U || pages > max_pages || page == 0U || page > pages) { + return text; + } + auto first = std::to_chars(text.data(), text.data() + text.size() - 1U, page); + *first.ptr = '/'; + std::to_chars(first.ptr + 1U, text.data() + text.size() - 1U, pages); + return text; +} + +enum class sprite : std::size_t { + VAMP, + CONNECTING, + OFFLINE, + BUSY, + READY, + LOADING, + WIFI, + ERROR, + PAGE, + ACOUSTIC, + ELECTRIC, + VOCAL, + UNAVAILABLE, + DIGIT_0, + DIGIT_1, + DIGIT_2, + DIGIT_3, + DIGIT_4, + DIGIT_5, + DIGIT_6, + DIGIT_7, + DIGIT_8, + DIGIT_9, + SLASH, + COUNT, +}; + +struct coverage_image { + std::string_view pixels{}; + std::size_t width{}; + std::size_t height{}; + auto at(std::size_t x, std::size_t y) const -> std::uint8_t { + if (x >= width || y >= height || (y * width + x) / 2U >= pixels.size()) { + return 0U; + } + auto index = y * width + x; + auto packed = static_cast(pixels[index / 2U]); + return (index & 1U) == 0U ? packed >> 4U : packed & 15U; + } +}; + +inline auto static_sprite(std::string_view atlas, sprite value) -> coverage_image { + constexpr auto count = static_cast(sprite::COUNT); + auto index = static_cast(value); + if (atlas.size() < 8U + count * 8U || atlas.substr(0U, 4U) != "VPSA" || + static_cast(atlas[4]) != count || atlas[5] != 0 || atlas[6] != 0 || atlas[7] != 0 || index >= count) { + return {}; + } + auto word = [&](std::size_t offset) -> std::size_t { + return static_cast(atlas[offset]) | (static_cast(static_cast(atlas[offset + 1U])) << 8U); + }; + auto entry = 8U + index * 8U; + auto width = word(entry); + auto height = word(entry + 2U); + auto offset = word(entry + 4U) | (word(entry + 6U) << 16U); + auto size = (width * height + 1U) / 2U; + if (width == 0U || width > 176U || height != 28U || offset < 8U + count * 8U || offset > atlas.size() || size > atlas.size() - offset) { + return {}; + } + return {atlas.substr(offset, size), width, height}; +} + +class title_cache { +public: + auto select(std::string_view identifier) -> std::bool_t { + if (identifier == identifier_.data()) { + return false; + } + identifier_.fill(0); + if (vamp_protocol::valid_title_id(identifier)) { + std::copy(identifier.begin(), identifier.end(), identifier_.begin()); + } + ready_ = false; + failed_ = false; + return true; + } + auto accept(std::string_view identifier, std::string_view bytes) -> std::bool_t { + if (identifier != identifier_.data() || !vamp_protocol::valid_title_id(identifier) || !vamp_protocol::valid_title_image(bytes)) { + return false; + } + std::copy(bytes.begin() + vamp_protocol::title_header_size, bytes.end(), pixels_.begin()); + ready_ = true; + failed_ = false; + return true; + } + auto fail(std::string_view identifier) -> std::bool_t { + if (identifier != identifier_.data() || ready_) { + return false; + } + failed_ = true; + return true; + } + auto ready() const -> std::bool_t { + return ready_; + } + auto failed() const -> std::bool_t { + return failed_; + } + auto image() const -> coverage_image { + return ready_ ? coverage_image{{pixels_.data(), pixels_.size()}, vamp_protocol::title_width, vamp_protocol::title_height} : coverage_image{}; + } +private: + std::array identifier_{}; + std::array pixels_{}; + std::bool_t ready_{}; + std::bool_t failed_{}; +}; + +} diff --git a/include/vamp_pico/display.hpp b/include/vamp_pico/display.hpp index 26d3d13..eaea67b 100644 --- a/include/vamp_pico/display.hpp +++ b/include/vamp_pico/display.hpp @@ -3,7 +3,8 @@ #include #include -#include +#include +#include #include @@ -12,27 +13,32 @@ namespace vamp_pico { constexpr auto display_width = std::int32_t{320}; constexpr auto display_height = std::int32_t{240}; -constexpr auto color_black = std::uint16_t{0x0000}; -constexpr auto color_white = std::uint16_t{0xffff}; -constexpr auto color_gray = std::uint16_t{0x8410}; +enum class link_status { CONNECTING, + OFFLINE, + CONNECTED, + ERROR }; class display { public: auto init() -> void; - auto fill(std::uint16_t color) -> void; - auto fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void; - auto draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale = 2) -> void; - auto show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void; - auto show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active, std::bool_t busy = false) -> void; + auto show_remote(link_status link, remote_display_state const &state, std::bool_t busy = false) -> void; + auto accept_title(std::string_view identifier, std::string_view image) -> std::bool_t; + auto reject_title(std::string_view identifier) -> void; + auto title_ready() const -> std::bool_t; + // At most four scanlines per call, with an 8 ms minimum write interval. + auto poll(std::uint64_t now_ms) -> void; private: - std::bool_t remote_valid_{}; - vamp_style::color last_status_{}; - std::bool_t last_active_{}; - std::bool_t last_busy_{}; - std::array last_title_{}; - std::array last_part_{}; - std::uint32_t last_page_{}; - std::uint32_t last_pages_{}; + auto dirty(std::size_t begin, std::size_t end) -> void; + title_cache title_{}; + std::array dirty_{}; + link_status link_{link_status::CONNECTING}; + std::bool_t active_{}; + std::bool_t busy_{}; + sprite part_{sprite::COUNT}; + std::uint32_t page_{}; + std::uint32_t pages_{}; + std::uint64_t last_write_ms_{}; + std::size_t next_row_{}; }; } diff --git a/include/vamp_pico/http_client.hpp b/include/vamp_pico/http_client.hpp index 8bc4f1d..ef475e3 100644 --- a/include/vamp_pico/http_client.hpp +++ b/include/vamp_pico/http_client.hpp @@ -19,6 +19,7 @@ struct remote_display_state { std::char_t title[96]{}; std::char_t part[48]{}; std::char_t mode[24]{}; + std::array title_image{}; std::uint32_t page{}; std::uint32_t pages{}; }; @@ -56,6 +57,9 @@ public: auto take_result() -> std::optional; // Discards the request/result and returns to IDLE. Call before Wi-Fi deinit. auto cancel() -> void; + // View remains valid until start/cancel. Copy only after matching the selected ID. + auto title_image() const -> std::string_view; + auto current_kind() const -> request_kind; private: friend struct http_client_callbacks; tcp_pcb *pcb_{}; @@ -66,8 +70,9 @@ private: std::uint64_t timeout_ms_{}; std::array request_buffer_{}; std::size_t request_size_{}; - std::array response_buffer_{}; + std::array response_buffer_{}; std::size_t response_size_{}; + std::size_t title_offset_{}; }; } diff --git a/include/vamp_pico/input.hpp b/include/vamp_pico/input.hpp index c4358a7..6304483 100644 --- a/include/vamp_pico/input.hpp +++ b/include/vamp_pico/input.hpp @@ -14,11 +14,13 @@ enum class request_kind { DISPLAY, COMMAND, HEARTBEAT, + TITLE, }; struct remote_request { request_kind kind{request_kind::DISPLAY}; vamp_protocol::page_command command{vamp_protocol::page_command::NEXT}; + std::array title_id{}; }; enum class enqueue_result { @@ -33,6 +35,8 @@ public: auto next(std::uint64_t now_ms) -> std::optional; auto complete(std::uint64_t now_ms) -> void; auto queued() const -> std::size_t; + auto want_title(std::string_view identifier) -> void; + auto cancel_title() -> void; private: std::array commands_{}; std::size_t head_{}; @@ -40,6 +44,9 @@ private: std::bool_t in_flight_{}; std::bool_t completed_{}; std::uint64_t completed_ms_{}; + std::array title_id_{}; + std::bool_t title_pending_{}; + request_kind active_kind_{request_kind::DISPLAY}; }; } diff --git a/include/vamp_protocol/page_control.hpp b/include/vamp_protocol/page_control.hpp index 69d07f9..984b1f9 100644 --- a/include/vamp_protocol/page_control.hpp +++ b/include/vamp_protocol/page_control.hpp @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include namespace vamp_protocol { @@ -12,6 +15,33 @@ constexpr std::char_t remote_previous_path[] = "/api/remote/previous"; constexpr std::char_t remote_select_path[] = "/api/remote/select"; constexpr std::char_t remote_heartbeat_path[] = "/api/remote/heartbeat"; constexpr std::char_t remote_display_path[] = "/api/remote/display"; +constexpr std::char_t remote_title_path[] = "/api/remote/title"; +constexpr auto title_width = std::size_t{296}; +constexpr auto title_height = std::size_t{64}; +constexpr auto title_header_size = std::size_t{8}; +constexpr auto title_image_size = title_header_size + title_width * title_height / 2U; +constexpr auto title_id_size = std::size_t{64}; + +inline auto valid_title_id(std::string_view value) -> std::bool_t { + if (value.size() != title_id_size) { + return false; + } + for (auto character : value) { + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) { + return false; + } + } + return true; +} + +// VPTI, little-endian uint16 dimensions, then row-major coverage, high nibble first. + +inline auto valid_title_image(std::string_view bytes) -> std::bool_t { + return bytes.size() == title_image_size && bytes.substr(0U, 4U) == "VPTI" && + static_cast(bytes[4]) == (title_width & 255U) && + static_cast(bytes[5]) == (title_width >> 8U) && + static_cast(bytes[6]) == title_height && bytes[7] == 0; +} enum class page_command { PREVIOUS, diff --git a/include/vamp_style.hpp b/include/vamp_style.hpp index 1401669..2d84e28 100644 --- a/include/vamp_style.hpp +++ b/include/vamp_style.hpp @@ -1,12 +1,15 @@ #pragma once #include +#include #include namespace vamp_style { +constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"}; + struct color { std::uint8_t r{}; std::uint8_t g{}; diff --git a/include/vamp_style/font.hpp b/include/vamp_style/font.hpp deleted file mode 100644 index ca3324e..0000000 --- a/include/vamp_style/font.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - - -namespace vamp_style { - -constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"}; -constexpr auto ui_font_height = std::int32_t{36}; -constexpr auto ui_font_max_width = std::int32_t{28}; - -struct font_glyph { - std::array alpha{}; - std::uint8_t width{}; - std::uint8_t advance{}; -}; - -auto ui_font_glyph(std::char_t value) -> vamp_style::font_glyph; -auto ui_font_pair_adjust(std::char_t left, std::char_t right) -> std::int32_t; -auto ui_text_width(std::string_view text, std::int32_t scale) -> std::int32_t; - -} diff --git a/include/vamp_zero/portal.hpp b/include/vamp_zero/portal.hpp index d5b5def..03d5141 100644 --- a/include/vamp_zero/portal.hpp +++ b/include/vamp_zero/portal.hpp @@ -37,5 +37,6 @@ private: auto make_router(vamp_zero::control_state &state) -> ligo::service_router; auto portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value; +auto validated_title_path(std::string const &assets_path, std::string const &identifier) -> std::expected; } diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index 2d0ae7f..b8cd3bb 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -40,6 +40,7 @@ struct library_part { std::string source_path{}; std::size_t measure_count{}; std::size_t total_pages{}; + std::string title_image{}; }; struct song_group { diff --git a/src/vamp_pico/artwork.S.in b/src/vamp_pico/artwork.S.in new file mode 100644 index 0000000..455fe3e --- /dev/null +++ b/src/vamp_pico/artwork.S.in @@ -0,0 +1,8 @@ +.section .rodata.vamp_pico_artwork,"a",%progbits +.balign 4 +.global vamp_pico_artwork_begin +.global vamp_pico_artwork_end +vamp_pico_artwork_begin: +.incbin "@VAMP_PICO_ARTWORK_BINARY@" +vamp_pico_artwork_end: +.section .note.GNU-stack,"",%progbits diff --git a/src/vamp_pico/display.cpp b/src/vamp_pico/display.cpp index 0f47669..e269392 100644 --- a/src/vamp_pico/display.cpp +++ b/src/vamp_pico/display.cpp @@ -1,7 +1,5 @@ #include #include -#include -#include #include #include @@ -10,7 +8,6 @@ #include #include -#include namespace { @@ -73,53 +70,50 @@ auto set_window(std::int32_t x0, std::int32_t y0, std::int32_t x1, std::int32_t command(0x2c); } -auto same_color(vamp_style::color left, vamp_style::color right) -> std::bool_t { - return left.r == right.r && left.g == right.g && left.b == right.b && left.a == right.a; +extern "C" std::char_t const vamp_pico_artwork_begin[]; +extern "C" std::char_t const vamp_pico_artwork_end[]; + +auto sprite_image(vamp_pico::sprite value) -> vamp_pico::coverage_image { + return vamp_pico::static_sprite({vamp_pico_artwork_begin, + static_cast(vamp_pico_artwork_end - vamp_pico_artwork_begin)}, + value); } -template -auto same_text(std::array const &cached, std::char_t const *text) -> std::bool_t { - auto const *value = text == nullptr ? "" : text; - return std::strncmp(cached.data(), value, Count) == 0; +auto channel(std::uint8_t fg, std::uint8_t bg, std::uint8_t alpha) -> std::uint8_t { + return static_cast((fg * alpha + bg * (15U - alpha) + 7U) / 15U); } -template -auto copy_text(std::array &cached, std::char_t const *text) -> void { - auto const *value = text == nullptr ? "" : text; - auto count = std::min(Count - 1U, std::strlen(value)); - std::memcpy(cached.data(), value, count); - cached[count] = '\0'; +auto blend(vamp_style::color fg, std::uint8_t alpha) -> std::uint16_t { + auto bg = vamp_style::background; + return vamp_style::rgb565({channel(fg.r, bg.r, alpha), channel(fg.g, bg.g, alpha), channel(fg.b, bg.b, alpha), 255}); } -auto blend_channel(std::uint8_t foreground, std::uint8_t background, std::uint8_t alpha) -> std::uint8_t { - auto fg = static_cast(foreground) * alpha; - auto bg = static_cast(background) * (15U - alpha); - return static_cast((fg + bg + 7U) / 15U); -} - -auto blend(vamp_style::color foreground, vamp_style::color background, std::uint8_t alpha) -> vamp_style::color { - if (alpha >= 15U) { - return foreground; - } - return { - .r = blend_channel(foreground.r, background.r, alpha), - .g = blend_channel(foreground.g, background.g, alpha), - .b = blend_channel(foreground.b, background.b, alpha), - .a = 255, - }; -} - -auto draw_centered_text(vamp_pico::display &display, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t preferred_scale, std::int32_t max_width) -> void { - if (text == nullptr || text[0] == '\0') { +auto overlay(std::array &row, std::size_t y, vamp_pico::coverage_image image, std::size_t x, std::size_t top, vamp_style::color ink) -> void { + if (y < top || y - top >= image.height) { return; } - auto scale = preferred_scale; - while (scale > 1 && vamp_style::ui_text_width(text, scale) > max_width) { - --scale; + for (auto column = std::size_t{}; column < image.width && x + column < row.size(); ++column) { + row[x + column] = blend(ink, image.at(column, y - top)); } - auto width = vamp_style::ui_text_width(text, scale); - auto x = std::max(12, (vamp_pico::display_width - width) / 2); - display.draw_text(x, y, text, color, scale); +} + +auto centered(std::array &row, std::size_t y, vamp_pico::sprite value, std::size_t top, vamp_style::color ink) -> void { + auto image = sprite_image(value); + overlay(row, y, image, (row.size() - image.width) / 2U, top, ink); +} + +auto status_color(vamp_pico::link_status link) -> vamp_style::color { + switch (link) { + case vamp_pico::link_status::CONNECTING: + return vamp_style::connecting; + case vamp_pico::link_status::CONNECTED: + return vamp_style::connected; + case vamp_pico::link_status::ERROR: + return vamp_style::danger; + case vamp_pico::link_status::OFFLINE: + return vamp_style::retrying; + } + return vamp_style::retrying; } } @@ -155,135 +149,114 @@ auto vamp_pico::display::init() -> void { command(0x13); command(0x29); sleep_ms(20); - fill(vamp_pico::color_black); - remote_valid_ = false; + dirty(0U, display_height); } -auto vamp_pico::display::fill(std::uint16_t color) -> void { - fill_rect(0, 0, vamp_pico::display_width, vamp_pico::display_height, color); +auto vamp_pico::display::dirty(std::size_t begin, std::size_t end) -> void { + std::fill(dirty_.begin() + begin, dirty_.begin() + end, true); } -auto vamp_pico::display::fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void { - if (width <= 0 || height <= 0) { - return; +auto vamp_pico::display::show_remote(link_status link, remote_display_state const &state, std::bool_t busy) -> void { + if (link != link_ || busy != busy_) { + dirty(0U, 24U); + dirty(144U, 172U); } - auto x0 = std::clamp(x, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1}); - auto y0 = std::clamp(y, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1}); - auto x1 = std::clamp(x + width - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_width - std::int32_t{1}); - auto y1 = std::clamp(y + height - std::int32_t{1}, std::int32_t{0}, vamp_pico::display_height - std::int32_t{1}); - if (x1 < x0 || y1 < y0) { - return; + auto title_changed = title_.select(state.active ? std::string_view{state.title_image.data()} : std::string_view{}); + if (active_ != state.active || title_changed) { + dirty(56U, 120U); + dirty(144U, 172U); } - set_window(x0, y0, x1, y1); - auto hi = static_cast((color >> 8) & 0xff); - auto lo = static_cast(color & 0xff); - auto row = std::array{}; - for (auto i = std::size_t{}; i < row.size(); i += 2U) { - row[i] = hi; - row[i + 1U] = lo; + if (state.active && state.title_image[0] == 0 && !title_.failed()) { + reject_title(""); } - gpio_put(pin_lcd_dc, 1); - select(); - for (auto yy = y0; yy <= y1; ++yy) { - spi_write_blocking(spi1, row.data(), static_cast(x1 - x0 + 1) * 2U); + if (active_ != state.active || page_ != state.page || pages_ != state.pages) { + dirty(188U, 216U); } - deselect(); + auto part = std::string_view{state.part}; + auto part_sprite = part == "Acoustic" ? sprite::ACOUSTIC : part == "Electric" ? sprite::ELECTRIC + : part == "Vocal" ? sprite::VOCAL + : sprite::COUNT; + if (part_ != part_sprite) { + dirty(144U, 172U); + } + part_ = part_sprite; + active_ = state.active; + page_ = state.page; + pages_ = state.pages; + link_ = link; + busy_ = busy; } -auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale) -> void { - if (text == nullptr || scale <= 0) { +auto vamp_pico::display::accept_title(std::string_view identifier, std::string_view image) -> std::bool_t { + if (!title_.accept(identifier, image)) { + return false; + } + dirty(56U, 120U); + dirty(144U, 172U); + return true; +} + +auto vamp_pico::display::title_ready() const -> std::bool_t { + return title_.ready(); +} + +auto vamp_pico::display::reject_title(std::string_view identifier) -> void { + if (title_.fail(identifier)) { + dirty(144U, 172U); + } +} + +auto vamp_pico::display::poll(std::uint64_t now_ms) -> void { + if (now_ms - last_write_ms_ < 8U) { return; } - auto foreground = vamp_style::color{ - .r = static_cast(((color >> 11U) & 0x1fU) * 255U / 31U), - .g = static_cast(((color >> 5U) & 0x3fU) * 255U / 63U), - .b = static_cast((color & 0x1fU) * 255U / 31U), - .a = 255, - }; - auto cursor = x; - auto previous = std::char_t{}; - for (auto i = std::size_t{}; i < std::strlen(text); ++i) { - if (previous != std::char_t{}) { - cursor += vamp_style::ui_font_pair_adjust(previous, text[i]) * scale; + last_write_ms_ = now_ms; + auto written = std::size_t{}; + for (auto scanned = std::size_t{}; scanned < dirty_.size() && written < 4U; ++scanned) { + auto y = next_row_; + next_row_ = (next_row_ + 1U) % dirty_.size(); + if (!dirty_[y]) { + continue; } - auto glyph = vamp_style::ui_font_glyph(text[i]); - for (auto row = std::int32_t{}; row < vamp_style::ui_font_height; ++row) { - auto col = std::int32_t{}; - while (col < static_cast(glyph.width)) { - auto row_offset = static_cast(row * vamp_style::ui_font_max_width); - while (col < static_cast(glyph.width) && - glyph.alpha[row_offset + static_cast(col)] == 0U) { - ++col; - } - auto start = col; - auto alpha = start < static_cast(glyph.width) - ? glyph.alpha[row_offset + static_cast(start)] - : std::uint8_t{}; - while (col < static_cast(glyph.width) && - glyph.alpha[row_offset + static_cast(col)] == alpha) { - ++col; - } - if (col > start) { - auto run_color = vamp_style::rgb565(blend(foreground, vamp_style::background, alpha)); - fill_rect(cursor + start * scale, y + row * scale, (col - start) * scale, scale, run_color); - } + static auto row = std::array{}; + row.fill(vamp_style::rgb565(y < 24U ? (busy_ ? vamp_style::danger : status_color(link_)) : vamp_style::background)); + if (active_ && title_.ready()) { + overlay(row, y, title_.image(), 12U, 56U, vamp_style::ink); + } else if (!active_) { + centered(row, y, sprite::VAMP, 74U, vamp_style::ink); + } + auto label = busy_ ? sprite::BUSY : link_ == link_status::ERROR ? sprite::ERROR + : link_ == link_status::CONNECTING ? sprite::CONNECTING + : link_ == link_status::OFFLINE ? sprite::OFFLINE + : active_ && title_.failed() ? sprite::UNAVAILABLE + : active_ && !title_.ready() ? sprite::LOADING + : active_ && part_ != sprite::COUNT ? part_ + : sprite::READY; + centered(row, y, label, 144U, vamp_style::muted); + if (active_ && pages_ != 0U && y >= 188U && y < 216U) { + auto digits = counter_text(page_, pages_); + auto size = std::strlen(digits.data()); + auto label_image = sprite_image(sprite::PAGE); + auto width = label_image.width + 8U + static_cast(size) * digit_width; + auto x = (row.size() - width) / 2U; + if (size != 0U) { + overlay(row, y, label_image, x, 188U, vamp_style::muted); + } + x += label_image.width + 8U; + for (auto index = std::size_t{}; index < size; ++index) { + auto value = digits[index] == '/' ? sprite::SLASH : static_cast(static_cast(sprite::DIGIT_0) + digits[index] - '0'); + overlay(row, y, sprite_image(value), x, 188U, vamp_style::muted); + x += digit_width; } } - cursor += static_cast(glyph.advance) * scale; - if (cursor >= vamp_pico::display_width) { - return; + static auto bytes = std::array{}; + for (auto x = std::size_t{}; x < row.size(); ++x) { + bytes[x * 2U] = static_cast(row[x] >> 8U); + bytes[x * 2U + 1U] = static_cast(row[x]); } - previous = text[i]; + set_window(0, static_cast(y), display_width - 1, static_cast(y)); + data(bytes.data(), bytes.size()); + dirty_[y] = false; + ++written; } } - -auto vamp_pico::display::show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void { - remote_valid_ = false; - fill(vamp_style::rgb565(vamp_style::background)); - fill_rect(0, 0, vamp_pico::display_width, 28, accent); - draw_text(14, 48, "VAMP", vamp_style::rgb565(vamp_style::ink), 2); - if (line1 != nullptr) { - draw_text(92, 54, line1, vamp_style::rgb565(vamp_style::ink), 1); - } - if (line2 != nullptr) { - draw_text(14, 96, line2, vamp_style::rgb565(vamp_style::muted), 1); - } -} - -auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active, std::bool_t busy) -> void { - auto const *title_text = title == nullptr ? "" : title; - auto const *part_text = part == nullptr ? "" : part; - auto status_changed = !remote_valid_ || !same_color(last_status_, status) || last_busy_ != busy; - auto content_changed = !remote_valid_ || last_active_ != active || last_busy_ != busy || !same_text(last_title_, title_text) || - !same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages; - if (!remote_valid_) { - fill(vamp_style::rgb565(vamp_style::background)); - } - if (status_changed) { - fill_rect(0, 0, vamp_pico::display_width, 24, vamp_style::rgb565(busy ? vamp_style::danger : status)); - } - if (content_changed) { - fill_rect(0, 24, vamp_pico::display_width, vamp_pico::display_height - 24, vamp_style::rgb565(vamp_style::background)); - if (active && title_text[0] != '\0') { - draw_centered_text(*this, 66, title_text, vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24); - draw_centered_text(*this, 118, busy ? "BUSY" : part_text, vamp_style::rgb565(busy ? vamp_style::ink : vamp_style::muted), 1, vamp_pico::display_width - 24); - if (pages != 0U) { - auto page_text = std::array{}; - auto written = std::snprintf(page_text.data(), page_text.size(), "PAGE %" PRIu32 "/%" PRIu32, page, pages); - if (written > 0) { - draw_centered_text(*this, 168, page_text.data(), vamp_style::rgb565(vamp_style::muted), 1, vamp_pico::display_width - 24); - } - } - } else if (busy) { - draw_centered_text(*this, 118, "BUSY", vamp_style::rgb565(vamp_style::ink), 1, vamp_pico::display_width - 24); - } - } - last_status_ = status; - last_active_ = active; - last_busy_ = busy; - copy_text(last_title_, title_text); - copy_text(last_part_, part_text); - last_page_ = page; - last_pages_ = pages; - remote_valid_ = true; -} diff --git a/src/vamp_pico/http_client.cpp b/src/vamp_pico/http_client.cpp index 8c68b8b..5cf5722 100644 --- a/src/vamp_pico/http_client.cpp +++ b/src/vamp_pico/http_client.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -30,6 +31,65 @@ auto response_is_success(std::string_view response) -> std::bool_t { response[12] == ' ' && response.find("\r\n\r\n") != std::string_view::npos; } +auto header_equal(std::string_view name, std::string_view expected) -> std::bool_t { + if (name.size() != expected.size()) { + return false; + } + for (auto index = std::size_t{}; index < name.size(); ++index) { + auto character = name[index]; + if (character >= 'A' && character <= 'Z') { + character += 'a' - 'A'; + } + if (character != expected[index]) { + return false; + } + } + return true; +} + +auto title_body_offset(std::string_view response) -> std::size_t { + if (response.substr(0U, 13U) != "HTTP/1.1 200 " && response.substr(0U, 13U) != "HTTP/1.0 200 ") { + return 0U; + } + auto marker = response.find("\r\n\r\n"); + if (marker == std::string_view::npos || marker + 4U > 1024U) { + return 0U; + } + auto length_seen = false; + auto type_seen = false; + auto cursor = response.find("\r\n") + 2U; + while (cursor < marker) { + auto end = response.find("\r\n", cursor); + auto line = response.substr(cursor, end - cursor); + auto colon = line.find(':'); + if (colon == std::string_view::npos) { + return 0U; + } + auto name = line.substr(0U, colon); + auto value = line.substr(colon + 1U); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1U); + } + if (header_equal(name, "content-length")) { + auto length = std::size_t{}; + auto parsed = std::from_chars(value.data(), value.data() + value.size(), length); + if (length_seen || parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || length != vamp_protocol::title_image_size) { + return 0U; + } + length_seen = true; + } else if (header_equal(name, "content-type")) { + if (type_seen || value != "application/octet-stream") { + return 0U; + } + type_seen = true; + } else if (header_equal(name, "transfer-encoding") || header_equal(name, "content-encoding")) { + return 0U; + } + cursor = end + 2U; + } + return length_seen && type_seen && vamp_protocol::valid_title_image(response.substr(marker + 4U)) ? marker + 4U : 0U; +} + auto clear_callbacks(tcp_pcb *pcb) -> void { tcp_arg(pcb, nullptr); tcp_recv(pcb, nullptr); @@ -65,7 +125,8 @@ struct http_client_callbacks { return finish(client, error == ERR_OK && response_is_success(response)); } auto size = buffer->tot_len; - auto remaining = client.response_buffer_.size() - client.response_size_; + auto capacity = client.request_.kind == request_kind::TITLE ? client.response_buffer_.size() : 1024U; + auto remaining = capacity - client.response_size_; auto valid = error == ERR_OK && size <= remaining; if (valid) { auto copied = pbuf_copy_partial(buffer, client.response_buffer_.data() + client.response_size_, size, 0); @@ -113,17 +174,27 @@ auto vamp_pico::http_client::start(remote_request request, std::uint64_t now_ms) status_ = http_status::IN_FLIGHT; success_ = false; response_size_ = 0U; + title_offset_ = 0U; started_ms_ = now_ms; - timeout_ms_ = request.kind == request_kind::DISPLAY ? 1000U : 700U; + timeout_ms_ = request.kind == request_kind::TITLE ? 1500U : request.kind == request_kind::DISPLAY ? 1000U + : 700U; auto const *path = vamp_protocol::remote_display_path; if (request.kind == request_kind::COMMAND) { path = vamp_protocol::page_command_path(request.command); } else if (request.kind == request_kind::HEARTBEAT) { path = vamp_protocol::remote_heartbeat_path; + } else if (request.kind == request_kind::TITLE) { + path = vamp_protocol::remote_title_path; + if (request.title_id.back() != 0 || !vamp_protocol::valid_title_id(request.title_id.data())) { + http_client_callbacks::finish(*this, false); + return http_start_result::STARTED; + } } auto written = std::int32_t{}; if (request.kind == request_kind::DISPLAY) { written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "GET %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT); + } else if (request.kind == request_kind::TITLE) { + written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: 73\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"id\":\"%s\"}", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT, request.title_id.data()); } else { written = std::snprintf(request_buffer_.data(), request_buffer_.size(), "POST %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: 2\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}", path, VAMP_ZERO_HOST, VAMP_ZERO_PORT); } @@ -162,7 +233,10 @@ auto vamp_pico::http_client::take_result() -> std::optional { auto result = http_result{}; result.request = request_; result.success = success_; - if (result.success && result.request.kind != request_kind::HEARTBEAT) { + if (result.success && result.request.kind == request_kind::TITLE) { + title_offset_ = title_body_offset({response_buffer_.data(), response_size_}); + result.success = title_offset_ != 0U; + } else if (result.success && result.request.kind != request_kind::HEARTBEAT) { auto text = std::string_view{response_buffer_.data(), response_size_}; auto marker = text.find("\r\n\r\n"); result.success = marker != std::string_view::npos && parse_remote_display_body(text.substr(marker + 4U), result.display); @@ -176,4 +250,13 @@ auto vamp_pico::http_client::cancel() -> void { http_client_callbacks::finish(*this, false); status_ = http_status::IDLE; response_size_ = 0U; + title_offset_ = 0U; +} + +auto vamp_pico::http_client::title_image() const -> std::string_view { + return title_offset_ == 0U ? std::string_view{} : std::string_view{response_buffer_.data() + title_offset_, vamp_protocol::title_image_size}; +} + +auto vamp_pico::http_client::current_kind() const -> request_kind { + return request_.kind; } diff --git a/src/vamp_pico/input.cpp b/src/vamp_pico/input.cpp index 5748293..3da418c 100644 --- a/src/vamp_pico/input.cpp +++ b/src/vamp_pico/input.cpp @@ -1,4 +1,5 @@ #include +#include auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) -> enqueue_result { @@ -7,6 +8,7 @@ auto vamp_pico::request_scheduler::enqueue(vamp_protocol::page_command command) } commands_[(head_ + size_) % commands_.size()] = command; ++size_; + title_pending_ = false; return enqueue_result::QUEUED; } @@ -20,10 +22,15 @@ auto vamp_pico::request_scheduler::next(std::uint64_t now_ms) -> std::optional void { return; } in_flight_ = false; - completed_ = true; - completed_ms_ = now_ms; + if (active_kind_ != request_kind::TITLE) { + completed_ = true; + completed_ms_ = now_ms; + } } auto vamp_pico::request_scheduler::queued() const -> std::size_t { return size_; } + +auto vamp_pico::request_scheduler::want_title(std::string_view identifier) -> void { + title_pending_ = size_ == 0U && vamp_protocol::valid_title_id(identifier); + title_id_.fill(0); + if (title_pending_) { + std::copy(identifier.begin(), identifier.end(), title_id_.begin()); + } +} + +auto vamp_pico::request_scheduler::cancel_title() -> void { + if (in_flight_ && active_kind_ == request_kind::TITLE) { + in_flight_ = false; + } + title_pending_ = false; +} diff --git a/src/vamp_pico/main.cpp b/src/vamp_pico/main.cpp index a5d7ad2..891c318 100644 --- a/src/vamp_pico/main.cpp +++ b/src/vamp_pico/main.cpp @@ -8,96 +8,100 @@ #include -namespace { - -enum class link_status { - CONNECTING, - RETRYING, - CONNECTED, -}; - -auto color_for(link_status status) -> vamp_style::color { - switch (status) { - case link_status::CONNECTING: - return vamp_style::connecting; - case link_status::RETRYING: - return vamp_style::retrying; - case link_status::CONNECTED: - return vamp_style::connected; - } - return vamp_style::retrying; -} - -auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote, std::bool_t busy = false) -> void { - display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active, busy); -} - -auto connect_wifi(vamp_pico::display &display) -> std::bool_t { - auto empty = vamp_pico::remote_display_state{}; - render(display, link_status::CONNECTING, empty); - if (cyw43_arch_init() != 0) { - display.show_boot("WIFI", "ERROR", vamp_style::rgb565(vamp_style::danger)); - return false; - } - cyw43_arch_enable_sta_mode(); - while (cyw43_arch_wifi_connect_timeout_ms(VAMP_PICO_WIFI_SSID, VAMP_PICO_WIFI_PASSPHRASE, CYW43_AUTH_WPA2_AES_PSK, 15000) != 0) { - render(display, link_status::RETRYING, empty); - sleep_ms(2000); - render(display, link_status::CONNECTING, empty); - } - render(display, link_status::RETRYING, empty); - return true; -} - -} - auto main() -> int { stdio_init_all(); - auto display = vamp_pico::display{}; + // Artwork and network buffers live outside the Pico's small main stack. + static auto display = vamp_pico::display{}; + static auto client = vamp_pico::http_client{}; auto touch = vamp_pico::touch_controller{}; display.init(); touch.init(); - if (!connect_wifi(display)) { - while (true) { - sleep_ms(1000); - } - } auto remote = vamp_pico::remote_display_state{}; - auto status = link_status::RETRYING; + auto status = vamp_pico::link_status::CONNECTING; auto gesture = vamp_protocol::gesture_state{}; auto scheduler = vamp_pico::request_scheduler{}; - // Keep the owned request buffers off the Pico's small main stack. - static auto client = vamp_pico::http_client{}; auto busy_until_ms = std::uint64_t{}; - render(display, status, remote); + auto wifi_ready = cyw43_arch_init() == 0; + if (wifi_ready) { + cyw43_arch_enable_sta_mode(); + } + auto wifi_up = false; + auto joining = false; + auto retry_at_ms = std::uint64_t{}; + auto join_started_ms = std::uint64_t{}; while (true) { auto point = vamp_pico::touch_point{}; auto touching = touch.read(point); auto now_ms = time_us_64() / 1000U; + auto link = CYW43_LINK_DOWN; + if (wifi_ready) { + cyw43_arch_lwip_begin(); + link = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); + cyw43_arch_lwip_end(); + } + if (link != CYW43_LINK_UP) { + if (wifi_up) { + client.cancel(); + scheduler = vamp_pico::request_scheduler{}; + retry_at_ms = now_ms + 2000U; + } + wifi_up = false; + if (joining && (link < 0 || now_ms - join_started_ms >= 15000U)) { + joining = false; + retry_at_ms = now_ms + 2000U; + } + if (wifi_ready && !joining && now_ms >= retry_at_ms) { + joining = cyw43_arch_wifi_connect_async(VAMP_PICO_WIFI_SSID, VAMP_PICO_WIFI_PASSPHRASE, CYW43_AUTH_WPA2_AES_PSK) == 0; + join_started_ms = now_ms; + retry_at_ms = now_ms + 2000U; + } + status = !wifi_ready ? vamp_pico::link_status::ERROR : joining ? vamp_pico::link_status::CONNECTING + : vamp_pico::link_status::OFFLINE; + } else { + wifi_up = true; + joining = false; + } if (auto command = gesture.update(touching, now_ms); command) { - if (scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) { + if (!wifi_up || scheduler.enqueue(*command) == vamp_pico::enqueue_result::BUSY) { busy_until_ms = now_ms + 1000U; + } else if (client.current_kind() == vamp_pico::request_kind::TITLE) { + // An image request is expendable; a command must not wait for its timeout. + client.cancel(); + scheduler.cancel_title(); } } - auto network = client.poll(now_ms); - if (network == vamp_pico::http_status::READY) { - auto result = client.take_result(); - scheduler.complete(now_ms); - network = vamp_pico::http_status::IDLE; - if (result && result->success) { - remote = result->display; - status = link_status::CONNECTED; - } else { - status = link_status::RETRYING; - remote = vamp_pico::remote_display_state{}; + if (wifi_up) { + auto network = client.poll(now_ms); + if (network == vamp_pico::http_status::READY) { + auto result = client.take_result(); + scheduler.complete(now_ms); + network = vamp_pico::http_status::IDLE; + if (result && result->request.kind == vamp_pico::request_kind::TITLE) { + if (result->success && scheduler.queued() == 0U) { + display.accept_title(result->request.title_id.data(), client.title_image()); + } else if (!result->success) { + display.reject_title(result->request.title_id.data()); + } + } else if (result && result->success) { + remote = result->display; + status = vamp_pico::link_status::CONNECTED; + display.show_remote(status, remote); + if (remote.active && !display.title_ready()) { + scheduler.want_title(remote.title_image.data()); + } + } else { + status = vamp_pico::link_status::OFFLINE; + } + } + if (network == vamp_pico::http_status::IDLE) { + if (auto request = scheduler.next(now_ms); request) { + client.start(*request, now_ms); + } } } - if (network == vamp_pico::http_status::IDLE) { - if (auto request = scheduler.next(now_ms); request) { - client.start(*request, now_ms); - } - } - render(display, status, remote, now_ms < busy_until_ms); + auto busy = now_ms < busy_until_ms || scheduler.queued() != 0U; + display.show_remote(status, remote, busy); + display.poll(now_ms); sleep_ms(8); } } diff --git a/src/vamp_pico/remote_display.cpp b/src/vamp_pico/remote_display.cpp index f6b1dd7..9f52bf3 100644 --- a/src/vamp_pico/remote_display.cpp +++ b/src/vamp_pico/remote_display.cpp @@ -57,6 +57,11 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo parsed.active = value == "1"; } else if (key == "title") { copy_value(value, parsed.title, sizeof(parsed.title)); + } else if (key == "title_image") { + if (!value.empty() && !vamp_protocol::valid_title_id(value)) { + return false; + } + copy_value(value, parsed.title_image.data(), parsed.title_image.size()); } else if (key == "part") { copy_value(value, parsed.part, sizeof(parsed.part)); } else if (key == "mode") { diff --git a/src/vamp_zero/app.cpp b/src/vamp_zero/app.cpp index c32cc57..9276e7b 100644 --- a/src/vamp_zero/app.cpp +++ b/src/vamp_zero/app.cpp @@ -205,6 +205,10 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> boost::asio::awaitable> { for (auto const &part : catalog.performance.parts) { + auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image); + if (!title) { + co_return std::unexpected(title.error()); + } auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id}; auto valid = co_await validate_score_pages(executor, config, std::move(reference)); if (!valid) { @@ -213,6 +217,10 @@ auto validate_catalog(boost::asio::any_io_executor executor, vamp_zero::config c } for (auto const &exercise : catalog.practice.exercises) { for (auto const &practice_part : exercise.parts) { + auto title = vamp_zero::validated_title_path(config.runtime.assets_path, practice_part.title_image); + if (!title) { + co_return std::unexpected(title.error()); + } auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PRACTICE, .id = practice_part.id}; auto valid = co_await validate_score_pages(executor, config, std::move(reference)); if (!valid) { diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index 8df0535..efb44f3 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -88,8 +88,13 @@ auto count_value(noto::sqlite_value const &value) -> std::size_t { return count == nullptr || *count <= 0 ? 0U : static_cast(*count); } +auto valid_title_image(noto::sqlite_value const &value) -> std::bool_t { + auto text = text_value(value); + return text.size() == 64U && std::all_of(text.begin(), text.end(), [](std::char_t c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }); +} + auto compatible_table(noto::connection &connection, std::string const &table) -> boost::asio::awaitable { - auto columns = std::vector{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages"}; + auto columns = std::vector{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages", "title_image"}; if (table == "practice_exercises") { columns.push_back("exercise_id"); } @@ -159,7 +164,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec co_return std::unexpected(connected.error()); } auto selected = - co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages FROM " + co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM " "songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); @@ -180,7 +185,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec break; } auto const &row = **next; - if (row.size() != 8U || count_value(row[5]) == 0U || count_value(row[7]) == 0U) { + if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) { status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata")); break; } @@ -198,6 +203,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec .source_path = std::move(*source), .measure_count = count_value(row[5]), .total_pages = count_value(row[7]), + .title_image = text_value(row[8]), }); } auto closed = co_await close_catalog_query(connection, *selected, std::move(status)); @@ -214,7 +220,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo if (!connected) { co_return std::unexpected(connected.error()); } - auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id " + auto selected = co_await connection.async_prepare("SELECT id, title, composer, key_signature, source_path, measure_count, total_pages, part, exercise_id, title_image " "FROM practice_exercises ORDER BY exercise_id, id"); if (!selected) { auto close = co_await connection.async_close(); @@ -235,7 +241,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo break; } auto const &row = **next; - if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty()) { + if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[6]) == 0U || text_value(row[8]).empty() || text_value(row[3]).empty() || !valid_title_image(row[9])) { status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid practice metadata")); break; } @@ -253,6 +259,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo .source_path = std::move(*source), .measure_count = count_value(row[5]), .total_pages = count_value(row[6]), + .title_image = text_value(row[9]), }; exercises.push_back({ .id = text_value(row[8]), diff --git a/src/vamp_zero/page_control.cpp b/src/vamp_zero/page_control.cpp index 8114922..e4e3311 100644 --- a/src/vamp_zero/page_control.cpp +++ b/src/vamp_zero/page_control.cpp @@ -435,6 +435,7 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::jso auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string { auto title = std::string{}; + auto title_image = std::string{}; auto part = std::string{}; auto page = std::size_t{}; auto pages = std::size_t{}; @@ -443,6 +444,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s if (auto const *selected = active_part(state); selected != nullptr) { active = true; title = selected->title; + title_image = selected->title_image; part = selected->part; page = state.page_index + 1U; pages = selected->total_pages; @@ -453,6 +455,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s active = true; title = selected->title; part = practice_part == nullptr ? std::string{} : practice_part->part; + title_image = practice_part == nullptr ? std::string{} : practice_part->title_image; page = state.page_index + 1U; pages = practice_part == nullptr ? 0U : practice_part->total_pages; } @@ -461,6 +464,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s output << "mode=" << vamp_zero::to_string(state.mode) << "\n"; output << "active=" << (active ? "1" : "0") << "\n"; output << "title=" << sanitize_line_value(title) << "\n"; + output << "title_image=" << title_image << "\n"; output << "part=" << sanitize_line_value(part) << "\n"; output << "page=" << page << "\n"; output << "pages=" << pages << "\n"; diff --git a/src/vamp_zero/portal.cpp b/src/vamp_zero/portal.cpp index 948be99..81d43c1 100644 --- a/src/vamp_zero/portal.cpp +++ b/src/vamp_zero/portal.cpp @@ -1,24 +1,111 @@ #include #include +#include #include +#include +#include +#include #include #include +#include #include #include #include #include #include +#include #include #include +#include namespace { +auto read_asset(std::filesystem::path const &root, std::filesystem::path const &relative, std::size_t limit, std::string *resolved = nullptr) -> std::optional { + auto error = std::error_code{}; + auto canonical_root = std::filesystem::canonical(root, error); + if (error) { + return std::nullopt; + } + auto path = std::filesystem::canonical(canonical_root / relative, error); + auto contained = path.lexically_relative(canonical_root); + if (error || contained.empty() || *contained.begin() == ".." || !std::filesystem::is_regular_file(path, error) || error) { + return std::nullopt; + } + auto size = std::filesystem::file_size(path, error); + if (error || size == 0U || size > limit) { + return std::nullopt; + } + auto bytes = std::string(static_cast(size), '\0'); + auto input = std::ifstream{path, std::ios::binary}; + if (!input.read(bytes.data(), static_cast(bytes.size())) || input.peek() != std::ifstream::traits_type::eof()) { + return std::nullopt; + } + if (resolved != nullptr) { + *resolved = path.string(); + } + return bytes; +} + +struct title_file { + std::string path; + std::string bytes; +}; + +auto read_title(std::filesystem::path const &root, std::string const &identifier) -> std::optional { + if (!vamp_protocol::valid_title_id(identifier)) { + return std::nullopt; + } + auto resolved = std::string{}; + auto bytes = read_asset(root, std::filesystem::path{"pico/titles"} / (identifier + ".mask"), vamp_protocol::title_image_size, &resolved); + if (!bytes || !vamp_protocol::valid_title_image(*bytes)) { + return std::nullopt; + } + auto error = std::error_code{}; + auto directory = std::filesystem::canonical(root / "pico/titles", error); + if (error || std::filesystem::path{resolved}.parent_path() != directory) { + return std::nullopt; + } + auto digest = std::array{}; + SHA256(reinterpret_cast(bytes->data()), bytes->size(), digest.data()); + auto hash = std::string{}; + constexpr auto digits = std::string_view{"0123456789abcdef"}; + for (auto byte : digest) { + hash.push_back(digits[byte >> 4U]); + hash.push_back(digits[byte & 15U]); + } + if (hash != identifier) { + return std::nullopt; + } + return title_file{std::move(resolved), std::move(*bytes)}; +} + +class title_assets { +public: + title_assets(std::filesystem::path root, vamp_zero::catalog const &catalog) + : root_{std::move(root)} { + for (auto const &part : catalog.performance.parts) { + identifiers_.insert(part.title_image); + } + for (auto const &exercise : catalog.practice.exercises) { + for (auto const &part : exercise.parts) { + identifiers_.insert(part.title_image); + } + } + } + auto image(std::string const &identifier) const -> std::optional { + return identifiers_.contains(identifier) ? read_title(root_, identifier) : std::nullopt; + } +private: + std::filesystem::path root_; + std::set identifiers_{}; +}; + auto portal_html() -> std::string { - return R"html(Product Vamp

Product Vamp

Loading...