Compare commits
9 Commits
d2f493c17b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 150394d598 | |||
| 1bacfd0f33 | |||
| 3f63b255ac | |||
| bc7baea7c4 | |||
| 300097a37e | |||
| 841a3f974e | |||
| 3a01b4b6bd | |||
| a0e13434e4 | |||
| 23b606e47f |
@@ -11,7 +11,7 @@ The catalog stores shared, immutable menu metadata. Complete score bundles stay
|
||||
|
||||
Device controls:
|
||||
|
||||
On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**.
|
||||
On the Zero, tap menu rows and switches directly; Tempo has minus/plus buttons. The main menu offers **Performance** and **Settings**. **Settings → Solos** switches both guitar parts between written solos (On) and beat slashes for improvisation (Off), including any solo pickup. Chord symbols and the rest of the arrangement stay in place. Solos starts On and remembers your choice until the app restarts; it applies when you next open a part on either device.
|
||||
|
||||
On the Pico (and on the Zero while viewing a score):
|
||||
|
||||
@@ -102,17 +102,21 @@ Six roles generate independently: acoustic accompaniment/solo; electric accompan
|
||||
|
||||
Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
|
||||
|
||||
Accompaniment uses independently authored two-bar jazz pattern families: Charleston and shifted Charleston, forward-motion syncopation, anticipated heavy beats, syncopated riffs, quarters, dotted spacing, shifting motifs, call-response and anticipated beat four. Acoustic distributes a single figure between bass and upper chord, normally 1–3 total attacks per full bar, without mandatory beat-one bass. Separate attacks are quarters/eighths and never overlap; a combined bass-plus-upper chord is allowed only when both attack and sustain together for more than one beat. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustained chords and space. The source melody and harmonic rhythm guide complete-pattern selection. Half-beat upper/electric anticipations may establish the next written chord; bass stays on current harmony. Explicit cross-bar holds use ties, never automatic merging of repeated attacks. Canonical patterns repeat at corresponding form positions with documented harmonic/pickup exceptions, and changing only tempo leaves the music unchanged.
|
||||
Accompaniment uses independently authored two-bar jazz pattern families, chosen against each song's original melody and harmonic rhythm. Acoustic repeats two patterns (A–B); electric repeats four (A–B–C–D). Occasional source-phrase-ending fills recur at the same positions in every iteration; both instruments allow at most one per eight full measures, including the repeat join. Acoustic normally has 1–3 attack positions per full bar with quarter/eighth bass and upper-chord notes played together or in sequential pairs. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustains and space. Cross-bar holds use ties; repeated attacks remain attacks. Pattern phase follows source-form position, including partial entries and odd-length forms. Meter changes use compatible cycles, with explicit diagnostics if fewer distinct patterns are available. Tempo changes do not reshuffle the accompaniment.
|
||||
|
||||
Each instrument has 24 distinct two-bar patterns in 4/4, 24 in 3/4, and 24 for each of the 3+2 and 2+3 groupings in 5/4. Written additive grouping takes precedence. Otherwise, the original chord changes and melody choose one consistent 5/4 grouping per continuous meter section, shared as source analysis by the independently generated parts. Explicit meter matching prevents treating 6/8 as 3/4. The learned solo model and playback count-in are unchanged by this accompaniment expansion.
|
||||
|
||||
Both accompaniment parts balance movement with sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Acoustic uses a register center of MIDI 53 and electric uses 60. Compact spans and preferred chord families remain higher priorities, including for the final sustained chord. Electric melody's supporting chords use the same balance after the existing melody-string and foreground priorities; the original melody pitches and rhythm remain fixed. Voicing preferences do not reseed accompaniment rhythms.
|
||||
|
||||
The first new acoustic accompaniment attack in every measure includes bass, either alone or with the upper chord in a combined sustain longer than one beat. This can be a delayed entrance; no beat-one attack is required. Pattern fitting exchanges upper/bass assignments when needed, without inserting attacks. Tied carries and silent bars do not require a new bass attack.
|
||||
Acoustic attacks normally play bass and upper chord together. Consecutive attacks exactly an eighth note apart split into bass–upper pairs within the same harmony; an odd leftover attack plays both together. Pairing continues across barlines and two-bar pattern boundaries, but stops at chord changes, chorus boundaries and source-defined entries/handoffs. The first attack for each new chord therefore includes bass, including a qualified anticipated entrance. Delayed entrances remain delayed. Every new acoustic attack releases the previous sound: a bass eighth followed by an upper-chord quarter has no bass underneath the quarter. Complete-cycle selection, fills and final realization use this same release rule. Bass and upper notes share one engraved part; uninterrupted holds may still tie across barlines.
|
||||
|
||||
Each song has six canonical roles: electric melody, electric accompaniment, electric solo, acoustic accompaniment, acoustic solo, and vocal melody. Matching source-form positions reuse the same musical events and TAB whenever a role returns. Final-turnaround omission changes only affected harmony; source-defined pickups/handoffs and the three-bar instrumental closing sustain remain explicit exceptions.
|
||||
|
||||
**Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me.
|
||||
|
||||
Training, authored score reuse, and prediction share contextual harmony validation based on actual notes, timing, and chords. Explanatory labels and justification tags are not required. Unsupported relationships fail with a diagnostic rather than silently changing notes. Both solo roles use strings 1–6 and frets 1–12 without open strings. No dynamics, accents, velocity shaping, or random timing are added.
|
||||
|
||||
`./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ...` builds one independent role as JSON. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`. Solos are included in the normal arranged scores and pages; separate solo MusicXML files are not generated. Authored XMLs remain under `data/training`, and regression tests compare their sounding events directly with generated score data.
|
||||
`./modulus.py python -m generator.compose --song ... --role acoustic_solo --data-dir data --output ...` builds one independent role as JSON. To exercise prediction for a registered song, pass `--settings` with a JSON file containing `{"predict": true}`. Each song produces five score bundles: the normal acoustic, electric and vocal parts, plus acoustic and electric `_improv` variants. The variants reuse the same accompaniment and melody, replacing the full solo allocation (including pickup rests) with meter-aware slashes in notation and TAB. Their subtitles identify them as Improvisation. Normal scores retain written solos; separate solo MusicXML files are not generated. Authored XMLs remain under `data/training`, and regression tests compare their sounding events directly with generated score data.
|
||||
|
||||
See [Shared Part Generation](TECHNICAL_GUIDE.md#shared-part-generation) and [Authored Repertoire and Learned Solos](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) for the active pipeline and limitations. Keep temporary comparisons and one-off diagnostics under `/tmp/`; `build/` retains product outputs, tools and dependencies.
|
||||
|
||||
@@ -166,6 +170,8 @@ 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. Configure installs the optional Modulus asset dependencies. Exact musical timing is retained through layout, with line breaks between complete measures. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
|
||||
|
||||
Every normal build also writes printable PDFs to `build/<target>/generated_assets/print/`: written-solo and improvisation versions of each acoustic and electric part, plus one vocal part per song, including every arranged iteration and the ending. Print at actual size on portrait US Letter paper. Pages have white backgrounds, black notation, at least half-inch printer margins, and multiple systems per sheet; guitar parts include TAB. These are fresh 300 DPI renders, without the duplicated lines used by playback transition pages. PDFs use the existing Modulus-managed Pillow dependency, are cached separately, and remain build-only artifacts rather than being installed on the device.
|
||||
|
||||
`generator/palette.json` preserves the sheet music’s existing dark palette across Zero, Pico, score pages, and previews. Menus use the same Petaluma Script lettering, quiet rules, tune/composer hierarchy and restrained accent colors for a jazz Real Book feel. Performance status shows Count-in, Playing or Sustain, BPM, page progress and a hold-to-exit hint. CMake generates the shared C++ colors from the same palette used by Python. Palette changes invalidate the engraving cache without changing music, spacing, or pagination.
|
||||
|
||||
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.
|
||||
@@ -183,3 +189,4 @@ To inspect the fixed artwork independently, run `./modulus.py python -m generato
|
||||
## License
|
||||
|
||||
Product Vamp is released under the MIT License. Bundled third-party fonts keep their own licenses next to the font files.
|
||||
|
||||
|
||||
+78
-13
@@ -37,9 +37,9 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip
|
||||
|
||||
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product_vamp_start_zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp_zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
|
||||
|
||||
At startup the Zero validates the packaged `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Performance and Settings. Settings holds Backing, Metronome, and Tempo; its values apply for the current session. Song -> Part -> Score navigation offers Acoustic, Electric, and Vocal without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
|
||||
At startup the Zero validates the packaged `catalog.db` application identifier and required table structure, opens it read-only through Noto, and loads menu metadata. It never creates or migrates a database, scans score JSON, or repairs metadata. The main menu contains Performance and Settings. Settings holds Backing, Metronome, Solos, and Tempo; its values apply for the current session. Song -> Part -> Score navigation offers Acoustic, Electric, and Vocal without key selection. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host. The Zero displays engraved PNGs and retains at most two page textures.
|
||||
|
||||
Runtime catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database in one transaction before deployment; metadata and embedded JSON are published together after an integrity check. The `songs` table uses strict column types, primary-key and path uniqueness constraints, and JSON validity checks. Menu rows are ordered by title, composer, part, and ID with binary collation, using the matching `songs_menu` index; C++ groups the ordered rows in one pass without sorting them again. Noto provides read-only connections, bound score lookups, application-ID checks, and quick integrity checks. Product-specific schema and asset-path validation remain in the application. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
|
||||
Runtime catalogs contain immutable metadata shared by navigation, the HTTP service, and the UI. Song groups reference canonical records by index. SQLite retains complete bundles, while cached measure/page counts let menu queries avoid reading musical JSON. Python constructs the complete database in one transaction before deployment; metadata and embedded JSON are published together after an integrity check. The `songs` table includes a required integer `solos_enabled` flag (0 or 1), matching the boolean in music metadata. Catalog generation and startup validate paired variants for arranged guitar parts and a single shared Vocal. This schema requires rebuilt assets and updated Zero/Pico software; runtime migration is not supported. The `songs` table uses strict column types, primary-key and path uniqueness constraints, and JSON validity checks. Menu rows are ordered by title, composer, part, and ID with binary collation, using the matching `songs_menu` index; C++ groups the ordered rows in one pass without sorting them again. Noto provides read-only connections, bound score lookups, application-ID checks, and quick integrity checks. Product-specific schema and asset-path validation remain in the application. There are no custom rows, runtime imports, metadata-repair triggers, or persisted page-state tables. Selecting a score loads only that bundle asynchronously, discarding stale results after navigation; the view keeps at most two page textures.
|
||||
|
||||
`--once` (also `runtime.exit_after_startup`) is a headless readiness check: it checks database integrity and validates the catalog and referenced page assets, checks runtime-directory writes and the configured server bind, and exits without opening audio/display devices or publishing a readiness heartbeat. Page checks include containment, checksums, CPU-only PNG decoding, and exact 976x484 dimensions. The configured heartbeat destination must be absent or a regular file, never a directory, symlink, or special file. Runtime writes and readiness probes use exclusive sibling temporary files; readiness leaves an existing heartbeat unchanged and removes its probes. `--health-check` instead probes the running service's read-only `/api/health` endpoint using the configured timeout. A stopped/unresponsive service or an invalid response fails the check; it does not create or repair files. `--check-config` remains configuration-only.
|
||||
|
||||
@@ -49,16 +49,26 @@ The Zero menus use direct taps: release on a row to select it, tap switches to t
|
||||
|
||||
In score view, Zero and Pico share one gesture state machine: a second release within 220 ms of the first release produces previous, a hold of at least 700 ms produces select once, and other taps produce next. A late second release preserves both single taps; holding consumes a pending first tap. Zero schedules pending-tap deadlines without polling and discards canceled or obsolete timer callbacks.
|
||||
|
||||
Score loading uses a dedicated single-worker executor for SQLite reads and JSON parsing. Completion returns to the control executor. The loader retains one in-flight request, coalesces pending selections, and discards stale results, keeping database work off HTTP and playback polling.
|
||||
|
||||
## Score Generation
|
||||
|
||||
The build runs `./modulus.py python -m generator` against `data/` and writes build-only assets under the CMake binary tree. Every song produces acoustic, electric, and vocal parts in its authored musical key. Source counts are discovered, not fixed in code. Generator-only optional chord markers are consumed before publishing.
|
||||
|
||||
Songs live directly in `data/songs/`. Each source produces acoustic, electric, and vocal parts under `songs/`, with matching page directories. Adding, renaming, or removing a source requires no registry edit. Regeneration replaces the output tree and catalog. The two current songs generate six parts.
|
||||
Songs live directly in `data/songs/`. Each source produces acoustic, electric, and vocal parts under `songs/`, with matching page directories. Adding, renaming, or removing a source requires no registry edit. Regeneration replaces the output tree and catalog. Each song generates five bundles: the usual three parts and acoustic/electric `_improv` alternatives. The two current songs generate ten bundles. Solos defaults to On at each app start; Off selects the improvisation guitar variants while Vocal is shared. Part menus retain three rows, using the selected variant’s normal and playback page counts. Pico status includes `solos=0/1`.
|
||||
|
||||
The pipeline is Soundslice/MuseScore -> uncompressed `.xml` -> musical model and rhythm plans -> part generation -> semantic JSON, supplied WAVs, 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. Packaged music describes each generated part, not an archival copy of the XML.
|
||||
|
||||
### Musical Data
|
||||
|
||||
Generator dependencies run from pure musical values and source analysis toward composition, assembly, and publication. `model` contains primitive values; `validation` checks score documents; `score` contains pure score operations; and `score_io` owns bundle/file validation. `source` owns canonical source identity and immutable source events. `arrange.form.FormPlan` supplies source-derived sections, ownership windows, and pickup stops to both accompaniment and role assembly.
|
||||
|
||||
`composition` generates one role from an explicit immutable `vocabulary.CompositionContext`; `production` schedules typed worker requests and publishes the assembled parts. Lower musical functions receive that context explicitly. The scoped vocabulary API remains available for standalone callers and tests. Cached vocabulary loads return owned copies so one caller cannot alter another caller's resources.
|
||||
|
||||
Role cache identities include the transitive generator imports of the composition entrypoint. Engraving cache identities similarly include renderer dependencies. Cache identities never seed musical choices. `cache` provides shared content identity and staged-file publication mechanics, while role, PNG, and PDF adapters retain their own validation. `engraving.worker` owns rendering and its bounded image cache; `engraving.pipeline` groups and schedules work. `performance` contains pure timing contracts, and `audio_assets` validates and copies supplied recordings.
|
||||
|
||||
The reviewed output contract is checked by `tests.test_output_contract` for complete synthetic role scores, including learned solos, and by `python -m tests.asset_contract <generated-assets-directory>` for every non-diagnostic asset. CTest runs the asset check against the normal build output. Musical output, JSON/catalog serialization, PNGs, and PDFs must remain unchanged during structural refactors; diagnostic text and cache identities may change.
|
||||
|
||||
`data/songs/` contains authoritative source scores. `data/training/manifest.json` pairs each authored solo with a source and a slot (1 or 2), and declares its pitch convention. Both slots train **one pooled model**. Slot 1 maps to acoustic playback and slot 2 to electric playback; slot and instrument names are not model features. The four XML files retain their notes, harmony, timing, ties, rests, Autumn Leaves pickup and All of Me's full-bar opening. Explanatory labels such as `Lead in`, `b9` and `6` were removed; they were not model features. Written chord extensions remain intact.
|
||||
|
||||
`data/models/solo.json` is an explicitly trained, versioned statistical model. Its short-context counts, rhythm and register statistics are learned from the paired examples. Provenance records input hashes and the training version. This is supervised use of the supplied solos, not independence from them and not a neural network pretrained on an external corpus.
|
||||
@@ -103,6 +113,8 @@ Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties
|
||||
|
||||
An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 171 arrangement bars: the eight-bar intro, five 32-bar passes, and three written held-ending measures.
|
||||
|
||||
Improvisation assembly reuses the independent accompaniment and melody roles and fills each source-defined solo window with regular beat slashes. The complete pickup allocation also becomes slashes, including rests within that allocation; music before a partial pickup and subsequent melody handoffs are preserved. Slash events carry no pitches or fret positions, and render in both the staff and TAB. This does not rerun solo composition or change backing recordings, measure numbering, harmony, or performance duration. Both variants receive normal/playback pages and build-only printable PDFs.
|
||||
|
||||
Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. The recording’s three-bar sustain tail has no added score section or metronome clicks.
|
||||
|
||||
### Runtime Schema
|
||||
@@ -123,31 +135,72 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
|
||||
|
||||
`parts.py` retains exact source notes, rests, lyrics and ties. `melody.py` harmonizes eligible sustained electric source-melody notes using their actual chord, preserving source pitches while splitting and reattacking held notes at harmony changes. Guitar notation applies its octave transposition once. `comping.Canonical` independently chooses compact chord voicings and rhythm from the source form and shared policy, then reuses that part at the same source-form position throughout the arrangement. There is no generated-solo response or reuse of a generated melody's grips. Sparse bars and deliberate releases are legal.
|
||||
|
||||
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. `chord_families.py` classifies actual shapes as drop-two, close-position, compact-open, compact-doubled or three-note shells, ranked by versioned policy. Melody adaptation also permits strings 2–4 when the exact source pitch cannot sit above the chord on string 1. Within the selected span tier, its lowest feasible string number and foreground position precede chord size. A primary grip with the melody on string 2 beats a four-fret grip with it on string 1. Short monophonic melody notes retain their movement-based placement; Electric Solo is unchanged. These electric roles use the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice.
|
||||
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. `chord_families.py` classifies actual shapes as drop-two, close-position, compact-open, compact-doubled or three-note shells, ranked by versioned policy. Electric melody chords must also use strings 1–3 or 1–4, always including string 1. The exact source pitch is retained even when a legal supporting chord tone must sound above it. Within the selected span tier, the melody's lowest feasible string number and foreground position precede chord size. A primary grip with the melody on string 2 beats a four-fret grip with it on string 1. Short monophonic melody notes and solos use the shared hand-window placement objective. A harmonization with no legal chord retains the existing reported single-note fallback. These electric roles use the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice.
|
||||
|
||||
Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Coverage checks exercise 35 common qualities in all 12 keys. Four-fret fallbacks support previously unavailable acoustic Bb9, BbM9, Bb7#9 and Bb6add9; for example, Bb9 uses fifth-bass frets 8/10/7/9 on strings 5/4/3/2. Acoustic Bbm9 and Bm9 still have no grip meeting all constraints. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones.
|
||||
|
||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank, then combined grip effort, hand-center travel, register and position costs, followed by missing optional tones and register tie-breaking. Acoustic prefers declared degree-order families on either bass string, then a combined travel/register/position cost, optional-tone coverage and register tie-breaking. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
|
||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank; acoustic prefers declared degree-order families on either bass string. Both then minimize hand-window travel, the passage maximum hand position, secondary finger/grip effort, missing optional tones and sounding-register distance, in that order. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
|
||||
|
||||
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
|
||||
|
||||
`comping_rhythm.py` selects complete source-only figures from version 4 of `composition/accompaniment.json`. Entries contain stable IDs, a family, exact one/two-bar lengths, and timed events labeled bass, upper, combined, or electric. The ten 4/4 families have statement/answer variants, with additional held-answer/space variants and explicit 3/4, 5/4 and short-bar alternatives. No independent bass/upper Cartesian product or unconstrained onset enumeration remains. Role pools have different complete two-bar onset signatures; clipped partial figures and N.C. passages can coincide. Unknown measure lengths fail explicitly rather than being proportionally stretched.
|
||||
`comping_rhythm.py` selects recurring source-only figures from version 6 of `composition/accompaniment.json`. The normal acoustic cycle has two two-bar slots; electric has four. Each template and anticipation mode is evaluated at every occurrence of its slot before retrieval limits apply. Up to eight diverse candidates per slot enter bounded complete-cycle comparison. Distinct slots differ in attack/duration relationships, not just IDs, bass/upper assignments or anticipation flags. Repetition is not penalized. The source melody and harmonic rhythm affect selection; title, iteration, generated solo and tempo do not. `groove_cycles` reports slot identities, anticipation modes, aggregate cost, compared cycles and any reduction caused by vocabulary or form length.
|
||||
|
||||
Normal acoustic figures use 1–3 total attacks per full bar, interleaving bass and upper chords without a downbeat-bass quota. Ordinary events last a quarter or eighth and must release before the next event. Only a single combined event lasting more than one quarter-note beat can sound bass and upper together; its components start/release together. This is checked again on complete sounding tie chains. Electric figures normally have 0–2 attacks per bar and mix stabs, sustains and space. A figure may cross its internal barline: flat sounding events are selected and voiced first, then projected into notation with exact ties. Repeated attacks remain attacks. Figures do not extend beyond their selected two-bar span.
|
||||
Meter-specific full patterns declare `meter` and, for 5/4, `grouping: [3, 2]` or `[2, 3]`; `meter: partial` marks the bounded one-bar pickup vocabulary. The loader checks nominal lengths, grouping, quarter/eighth arithmetic, electric lane overlap and distinct acoustic/electric onset patterns. Acoustic lane overlap is checked after automatic assignment. Each role has 24 distinct attack/duration figures per selectable pool: 4/4 (11 families), 3/4 (12 families), and each 5/4 grouping (12 families). The 4/4 pool removes one duplicate acoustic figure, develops another previously duplicated answer, and adds three electric variants. Patterns are independently authored from general principles, with research URLs and provenance in the resource, rather than transcriptions. The counts describe available choices, not required musical activity.
|
||||
|
||||
The acoustic `first_attack: bass_or_combined` policy applies separately to every measure, including pickups. After removing unavailable events under missing harmony and participation limits, fitting exchanges an upper-first event with the earliest later bass event in that measure, or assigns it to bass when no exchange is possible. Timing and attack count stay intact apart from existing harmonic clipping. Reassigned bass loses any anticipation permission and binds to current harmony. Anticipations and releases are resolved afterward, and invalid figures are rejected. Delayed entrances remain delayed; a combined first attack must last more than one beat. A tied carry is not an attack and a silent measure remains silent. Pattern reports record `layer_assignment` changes, and complete sounding-event validation checks bass-first again after projection. The three-bar ending remains one combined chord attack.
|
||||
`grouping_for` is a pure source-only decision. It honors written 3+2 or 2+3, otherwise compares actual chord changes at the secondary boundary, then melody entrances following at least an eighth-note rest, then other melody attacks; equal evidence defaults to 3+2. An unchanged chord marking is not a change. The selection stays fixed through a continuous meter section, and each role derives it independently from the same source. Fills cannot borrow another grouping. No accents or playback swing changes are inferred. `groove_cycles` additionally reports grouping, its evidence and the eligible pattern count. Equal-duration compound meters (such as 6/8) do not borrow the 3/4 vocabulary; unsupported comping meters fail explicitly.
|
||||
|
||||
Direct events release at changed harmony. Eligible upper/electric events also offer a half-beat anticipation: the upcoming written chord must begin exactly half a beat after the attack, the event must continue into that chord, and it must release by the following change or protected participation boundary. The selected grip belongs to that upcoming chord. Bass and combined acoustic events cannot anticipate. Direct alternatives remain available. Missing harmony/N.C. stays silent; incompatible short durations reject a complete candidate rather than introducing unexplained subdivisions.
|
||||
Accompaniment dependency keys and choice streams include only full-pattern meters and partial lengths present in that source, plus the applicable instrument policy. Adding unused-meter patterns therefore does not reseed an existing song; unrelated role policies remain excluded. Research prose is excluded from musical seeds. This schema migration changes the accompaniment choice-key domain once; it does not promise identical previous 4/4 tie-break winners. Source melodies, registered solos, notation format and playback count-in are unchanged.
|
||||
|
||||
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. A bounded dynamic program selects the form path, with a modest preference against immediate exact repetition. Family representatives survive the candidate cutoff. Source entrances, movement and space affect selection, while no generated role does. Effective final turnarounds and pickup masks revalidate the whole affected figure between fixed canonical neighbors. Reports identify the chosen pattern, its start measure, projected events, continuations, anticipation target times, score contributions and source-context exceptions. Tempo affects metadata/cache identity but never musical choice.
|
||||
The cycle begins at the first full source-body measure. A partial-form entry first fits the incoming harmony, then slices the sounded figure instead of restarting it; this prevents a previously clipped chord hold from reappearing as a new attack. An odd final bar uses the appropriate half-pattern. Meter changes restart a compatible cycle. Unsupported durations fail explicitly, and limited vocabularies report a shorter cycle. Optional final-harmony changes rebind the same selected pattern. Canonical grips are fixed wherever the chord and interval start remain unchanged, so a new turnaround cannot move neighboring unaffected voicings.
|
||||
|
||||
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the form selector additionally applies its documented transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
|
||||
Fills replace only a two-bar figure's ending bar, using a same-family variation that improves the existing source-fit objective. Eligible boundaries are source section endings, form endings, or a melody breath of at least one quarter-note beat at the block end. Carried events from the opening bar are protected. The largest improvements are retained with an eight-measure minimum separation around the whole cyclic form, equally for both roles; no fill quota applies. Fills do not change cycle phase and recur at identical source positions. Reports record the base slot, selected fill, source index and reason.
|
||||
|
||||
Normal acoustic patterns use 1–3 attack positions per full bar. Their event layer is `acoustic`; the instrument's versioned `attack_assignment` policy is `combined_except_eighth_pairs`. Authored bass/upper decisions are replaced by chronological assignment across the complete canonical source form: ordinary attacks are combined, while adjacent half-beat-separated attacks in the same effective harmony become bass then upper. Pairs do not overlap; an odd leftover attack is combined. Bar and two-bar pattern boundaries do not reset pairing. Source-defined entries, handoffs and chorus boundaries do; the same boundary set applies at repeated form positions. Chord changes break pairs, but repeated identical chord labels do not. An anticipation uses its upcoming harmonic region for this decision. First attacks of new harmonic spans still include bass (`first_attack: bass_per_harmony`).
|
||||
|
||||
Quarter/eighth articulations and explicitly authored sustains release at the earliest of their intended end, the next acoustic attack in either lane, or a required harmonic/handoff boundary. Bass cannot ring beneath a later upper attack, nor can an upper chord ring beneath a later bass attack. Combined attacks have one shared release. Shortening that would strand an anticipation first rebinds it to current harmony and recomputes assignment. `acoustic_context` and `realize_acoustic_form` implement the shared source-form contract used by cycle comparison, fill selection and final source-harmony variants, before grip placement or notation splitting. Raw candidates retain authored releases so a changed neighboring attack can be evaluated afresh. Fills must improve the resolved whole-form cost without changing events outside their editable second bar. Concrete block caches include their assigned event signature: a neighboring chord change can break a pair even when this block's own harmony is unchanged. The renderer verifies actual sounding lane events against the selected assignment. The completed validator follows sounding tie chains and rejects any overlap between distinct acoustic attacks, even across different lanes. Placement validates all simultaneously sounding strings, fret spans and anchors. Bass and upper events merge into one notated part. Uninterrupted cross-bar holds and the combined three-bar closing sustain remain intact.
|
||||
|
||||
Electric normally uses 0–2 attack positions per bar, with stabs, sustains and space. Direct events release at changed harmony. An eligible acoustic/electric template can anticipate a chord by half a beat when it continues into that chord; the grip comes from the upcoming harmony and releases by its following change. Acoustic anticipations also include bass on the new chord's first attack. Missing harmony remains silent. Protected participation limits retain the chosen figure, shortening only its affected releases and recording exceptions. Cross-bar sounding notes become ties, never automatic merging of repeated attacks.
|
||||
|
||||
All six production roles receive reuse audits. Musical comparisons include rests, pitch durations, ties and TAB while ignoring regenerated event IDs and global measure numbers. Source-defined pickup/participation changes and effective final-turnaround harmony are reported separately. The final three-bar hold remains instrumental.
|
||||
|
||||
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. Cycle selection aggregates them over all occurrences, retaining family diversity before the final bounded comparison. `groove_cycles.retrieval_cost` is the isolated-figure estimate used for shortlisting. `cost` is the actual selected base cycle's cost before optional fills; acoustic cycles are assigned and released together before this comparison. Acoustic `final_cost` reports the complete source-form result after fills and any meter-section joins. The selected score is never overwritten by a post-selection report. Reports identify the base slot, realized pattern, projected events, continuations, anticipation target times and source-context exceptions. Tempo affects metadata/cache identity but never musical choice. This release correction leaves musical data and role choice seeds unchanged; code fingerprints invalidate stale concrete caches.
|
||||
|
||||
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the cycle selector aggregates these by the number of measures in each occurrence, without an anti-repetition transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
|
||||
|
||||
Acoustic sequential-release verification on October 7, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 451 tests in 162.318 seconds. Coverage includes release in both lane directions, cross-bar pairing in 3/4, 4/4 and both 5/4 groupings, expired and surviving anticipations, complete-cycle ranking versus isolated retrieval, fills protecting neighboring assignments, malformed overlap before/after notation merging, canonical reuse, cold/warm caches and parallel roles. `python3 modulus.py format --check` passed. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed both generator/asset checks; 394 score JSON/PNG artifacts match across targets. Acoustic composition took 2.1 seconds for All of Me and 2.2 seconds for Autumn Leaves; native assets finished in 31 seconds and the cached ARM build in 11 seconds. Compared with the saved pre-change output, all attack pitches, onsets and TAB positions are identical: only 67 Autumn Leaves bass releases are shorter. Its arrangement measure 3 now contains bass [0, 1/2), upper chord [1/2, 3/2), and combined chord [2, 3), without ties. All of Me and the ten other-role documents are unchanged. Canonical reuse audits pass, and both three-bar instrumental endings retain one twelve-beat attack. Both acoustic opening pages were visually inspected; no audio audition was performed. The comparison baseline remains outside the repository under `/tmp/vamp-sequential-acoustic-yt_irkfd`.
|
||||
|
||||
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
||||
|
||||
Both accompaniment roles add `register_weight * abs(mean sounding MIDI - center)` and `high_position_weight * max(0, mean fret - comfortable_max_fret)` to their existing grip effort and movement tier, once per active harmonic span. The three role policies (`acoustic_accompaniment`, `electric_accompaniment`, `electric_melody`) use weights 1 and 1 and threshold 9; their register centers are 53, 60 and 60 respectively. All arithmetic is rational. Electric melody adds the same costs only to harmonized attacks, below compact-span, melody-string, foreground, chord-size and family priorities. Exact melody pitches are never transposed to meet the register center; monophonic placement is unchanged. Scoring and scored-pruning caches explicitly include the normalized preference tuple. Tied continuations incur no second chord cost. Existing travel costs and source-form joins remain in place; high positions can still win when avoiding a costly shift. The ending uses its own accompaniment role's local cost and incoming grip.
|
||||
Guitar placement shares the `hand-window-2` model in `generator/hand_position.py`. **Both chords and single notes** may occupy any legal four-consecutive-fret window. Position 7 covers frets 7–10; a chord occupying only frets 7–8 can use positions 5, 6 or 7, according to its neighbors. Position is the bottom of the selected window, not necessarily an occupied fret. This is an approximate positioning model, not an anatomical finger assignment. Sounded-note limits remain binding: a solo note cannot exceed fret 12 merely because its hand window extends farther.
|
||||
|
||||
Each selected accompaniment span reports `voicing_cost` contributions for movement, base grip effort, weighted register, weighted position and their combined effort. These describe the balanced tier, not the higher span/family priorities; repeat-join travel is assigned to the first canonical span. Both accompaniment roles keep `voicing` settings in concrete cache dependencies but exclude them from rhythmic choice seeds, including settings overrides. Acoustic rhythm selection can change under its new bass-first policy, but changing voicing weights alone never reshuffles rhythm. Each role still composes independently.
|
||||
After the existing compact-span, chord-family and electric-melody foreground priorities, selection minimizes **total absolute hand-position travel**, then the **maximum hand position across the complete path**. Finger changes and string crossings within the same window contribute zero travel. Only after those two criteria tie do relative finger adjustment, string crossings, grip complexity and time pressure decide between paths. Acoustic bass is one voice even across strings 5 and 6; upper voices follow their strings. Added/removed chord voices contribute one secondary adjustment each. Solo secondary effort uses elapsed time from the preceding attack, not a tied notation fragment. A genuine continuation retains pitch, TAB and hand position without another movement charge.
|
||||
|
||||
A low fret is not rewarded independently at every event. All three voicing policies retain only `register_weight` (default 1), weighting distance from their sounding-register center after travel, peak position, secondary effort and optional-tone coverage. Centers remain 53 for acoustic accompaniment and 60 for electric accompaniment/melody. Exact melody and solo pitches are never transposed to meet these preferences. Canonical accompaniment selection includes source-form repeat joins and solves source-derived variations between actual selected neighboring grips **and hand positions**. Unchanged intervals retain both. The instrumental ending receives the last accompaniment grip and selected window; it does not infer the hand from the lowest occupied fret.
|
||||
|
||||
The dynamic programs retain concrete grips/held strings, selected hand position and distinct earlier peaks. A later high position can equalize two earlier peaks and expose a subordinate tie-break, so retaining only one lowest-peak history is unsafe. Strictly worse primary travel at an identical endpoint/hand can still be discarded. Equal fret averages do not imply equal placement states. Stable ordering resolves equal complete costs.
|
||||
|
||||
Role reports expose `total_travel`, `shift_count`, `largest_shift`, `peak_position` and actual selected hand positions. Accompaniment costs separate travel from secondary `effort`; individual spans separate hand shift, finger adjustment, string changes, grip effort and register tie-breaking. Repeat travel is assigned to the first canonical span. Solo reports call the time-weighted subordinate value `secondary_effort`. Cache fingerprints include placement code while accompaniment rhythm seeds remain unchanged. Every role remains independently composed; the solo model, pitches and rhythms are unchanged.
|
||||
|
||||
The pre-window baseline is preserved outside production inputs under `/tmp/vamp-hand-window-baseline-dwh4anzh`. Verification compares sounded content and rhythms separately from TAB, and checks all electric melody chords for strings 1–3 or 1–4. Independent exhaustive test oracles cover complete grip/window paths, fixed neighbors, cyclic repeats and single-note paths; tests also cover a greedy-position trap, future peak equalization, zero-cost within-window string changes and tied-event invariance.
|
||||
|
||||
Hand-window verification on October 8, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 472 tests in 100.928 seconds. The subsequent asset build exposed a print-footer overlap: Letter pagination had reserved a nominal font height instead of the actual page-number outline. The new tight-page regression reproduced that failure, and `python3 modulus.py python -m unittest tests.test_printing` passed all eight tests after measured footer bounds replaced the estimate. Content and collision audits remain enabled.
|
||||
|
||||
`python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` then passed both asset checks and produced six complete PDFs (42 pages). Native regeneration took 33 seconds with cached roles and a cold engraving namespace; ARM reused the assets in six seconds. Before the footer repair, uncached composition completed in five seconds for all twelve roles, with solos taking about 0.5 seconds each. `python3 modulus.py test --target x86_64_gcc12_debug` passed in 13.27 seconds. All 400 score JSON, normal/playback PNG, report and PDF files match between native and ARM.
|
||||
|
||||
Against the saved pre-window baseline, all four solos retain exactly the same pitches, timing and ties; vocal documents and accompaniment rhythm reports remain identical. Electric melody retains its exact source notes and timing; supporting chord tones may change to enforce string 1. All 46 All of Me and 33 Autumn Leaves electric melody chord fragments now use strings 1–3 or 1–4; previously 13 and three fragments respectively omitted string 1. Short monophonic melody remains permissible.
|
||||
|
||||
| Solo | Total hand travel, before → after | Number of shifts, before → after | Peak hand position, before → after |
|
||||
|---|---:|---:|---:|
|
||||
| All of Me acoustic | 131 → 12 | 87 → 10 | 9 → 7 |
|
||||
| All of Me electric | 128 → 9 | 87 → 7 | 9 → 7 |
|
||||
| Autumn Leaves acoustic | 141 → 2 | 85 → 2 | 9 → 3 |
|
||||
| Autumn Leaves electric | 106 → 6 | 77 → 6 | 9 → 5 |
|
||||
|
||||
Travel is the sum of absolute window-position changes, measured in frets. An independent dynamic program over every legal string/fret/window confirmed that each new solo achieves the minimum raw travel for its unchanged sounding notes. This verifies the placement objective, not anatomical comfort or musical preference. The canonical accompaniment cycles now travel 10/14 frets for All of Me/Autumn Leaves acoustic, and 0/6 for electric; they retain source-based repeat consistency. No listening assessment was performed.
|
||||
|
||||
The sibling Modulus Conan helper was repaired to call `modulus.build_context.build_context`, replacing the removed `modulus.cli.build_context` API. Its Python suite passed 132 tests with four existing skips. A locally exported and built Modulus package passed `python3 modulus.py configure --target x86_64_gcc12_release`, followed by the release build and runtime test above. The offline attempt first identified missing cached Ligo sources; allowing ordinary Conan source retrieval completed configuration. An unwritable generated engraving cache was also restored to the build user's ownership. The Modulus source fix must be published through that repository's normal release process before remote CI using `modulus/latest` receives it; no remote package was published during this repair.
|
||||
|
||||
Recurring-groove verification on October 6, 2026: the full Python regression command passed 423 tests in 149.85 seconds. After the final partial-entry grip fix and one additional real-evaluator fill test, the focused accompaniment/arrangement command passed 59 tests in 21.64 seconds. The native test target passed in 30.58 seconds. The initial native rebuild took 42 seconds, including 7 seconds for independently generated roles and 33.62 seconds of engraving; accompaniment roles took 0.8–1.6 seconds each. The following ARM build reused all role/score caches and took 11 seconds. All four solo documents, both electric melodies and both vocals exactly matched the prechange baseline; native/ARM score assets matched byte for byte. The chosen base cycles differed by song and retained all two/four requested slots. Autumn Leaves selected the authored overlapping-quarter acoustic pattern. No optional fill improved these production winners, so no fills were forced; independently authored tests verify real source-fit selection of a fill. An acoustic score/TAB page was visually checked for overlap ties in a single part. Recordings were unchanged and no listening assessment was performed. Baseline snapshots stayed under `/tmp/`.
|
||||
|
||||
Shared-preference and bass-first verification on October 6, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 408 tests in 61.35 seconds, and the native test target passed all 124 cases. Both product builds passed asset checks; 139 native/ARM score and page files matched byte for byte. All 273 acoustic measures containing new attacks started with bass, including 69 delayed entrances. Tied carries did not trigger new attacks. Both solos, vocals, electric accompaniment rhythms, and electric foreground melody pitches/timing/TAB matched the saved baseline exactly. Some All of Me melody support chords changed; Autumn Leaves' complete electric melody document did not. All of Me's canonical electric accompaniment mean fret dropped from 7.26 to 4.13 and movement cost from 57 to 48; Autumn Leaves stayed at 2.95 and 46. Recordings were unchanged. These are score checks and page inspections, not a listening assessment.
|
||||
|
||||
@@ -275,6 +328,18 @@ Bundles preserve the complete `music` object. Presentation identifies the `Vamp
|
||||
|
||||
Pages are 976x484 pixels with nominal 10-pixel guitar or 12-pixel vocal staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. The two acoustic rest lanes sit closer to the staff, with collision offsets preserving their separation. Systems and pages contain complete measures; tied notes and notation spans may continue across their boundaries. Presentation fragments preserve exact timing and never change the semantic JSON. A measure that cannot fit at readable spacing fails with source and measure context; the uniform part-wide scaling described above fits pairs of complete systems vertically. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
|
||||
|
||||
### Printable Parts
|
||||
|
||||
`generator.printing.generate_tree` runs after screen engraving inside the normal asset staging transaction. It reads each completed song/part's musical document, not its screen PNGs or overlapping playback views, and writes `print/<score-stem>.pdf`. Every arranged measure and the instrumental ending appear once in order. Acoustic/electric parts retain standard notation and TAB; vocal parts retain lyrics and rests. No music, runtime bundle fields or catalog rows are changed. Both CMake installation and runtime image copying exclude the `print` directory.
|
||||
|
||||
Print layout uses portrait US Letter (612 × 792 PDF points), a minimum half-inch clear margin, and fixed five-point guitar/six-point vocal staff spacing. It reserves the actual overhang of centered measure numbers inside the margin. Complete systems fill the available page height with consistent notation size; the final page keeps natural vertical spacing. Headers identify the tune, composer and instrument; printed page numbers are independent of device pagination. The existing musical/fragment/TAB audits remain active, with variable systems per page allowed only by the print caller. Screen audits still require two systems except the odd final system.
|
||||
|
||||
The shared primitive renderer accepts explicit colors and output scale while retaining unchanged screen defaults. Print draws black on white directly at 2550 × 3300 pixels (300 DPI), then writes grayscale image pages through Pillow's PDF writer at quality 95. This is a raster PDF, not searchable/vector notation. One page is rendered and appended at a time to bound image memory. The pinned Pillow parser checks page count, Letter media boxes and image resolution/color; tests decode the embedded JPEGs and verify clear margins. No new Python package is required: Modulus's existing `assets` group owns Pillow, fonttools and aggdraw. Timestamps and output-path metadata are omitted for byte-reproducible PDFs.
|
||||
|
||||
With `--engraving-cache`, print results use a separate `print/<print-renderer>/<score-key>/` namespace. Keys cover score content, print settings, fonts, renderer source and dependency versions. Cached PDFs have checked hashes and page metadata; missing or corrupt entries regenerate. Native and ARM builds share the cache locally. This print namespace is separate from the screen-only CI cache snapshot. Output PDFs are validated before the build publishes; failure preserves the previous complete asset directory. Stale PDFs disappear when source parts disappear. Temporary print files live only in build/cache staging.
|
||||
|
||||
Print rollout verification: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 458 tests; the seven `tests.test_printing` tests were rerun after the final footer-margin adjustment. `python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` passed with byte-identical PDFs on both targets. All of Me has 10/8/5 acoustic/electric/vocal pages and Autumn Leaves has 9/7/4, totaling 43. All pages were checked for Letter size, 300 DPI grayscale images and pure-white half-inch margins; representative opening, solo, lyric and ending pages were visually inspected. All 394 preexisting score JSON/screen PNG files stayed byte-identical. PDF generation took 9.7 seconds uncached and 0.1 seconds cached on this development machine; this is the print stage only, not a clean whole-product build benchmark. Physical printing was not tested.
|
||||
|
||||
### 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, 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, supplied WAV recordings, 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
if(NOT EXISTS "${SOURCE}/catalog.db")
|
||||
message(FATAL_ERROR "Missing generated catalog: ${SOURCE}/catalog.db")
|
||||
endif()
|
||||
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE)
|
||||
file(COPY "${SOURCE}/" DESTINATION "${DESTINATION}" PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
|
||||
|
||||
+1
-1
@@ -195,7 +195,7 @@ install(TARGETS vamp_zero
|
||||
)
|
||||
install(FILES package/config/vamp.example.json DESTINATION zero/config)
|
||||
install(DIRECTORY ${VAMP_GENERATED_ASSETS_DIR}/ DESTINATION zero/share/product_vamp
|
||||
PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE)
|
||||
PATTERN "*.json" EXCLUDE PATTERN "analysis" EXCLUDE PATTERN "print" EXCLUDE)
|
||||
install(PROGRAMS
|
||||
package/zero/install_zero.sh
|
||||
package/zero/start_ap.sh
|
||||
|
||||
+5385
-391
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,7 @@
|
||||
"bass": "primary",
|
||||
"center": 53,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
"register_weight": 1
|
||||
}
|
||||
},
|
||||
"electric_accompaniment": {
|
||||
@@ -20,18 +18,14 @@
|
||||
"bass": "essential",
|
||||
"center": 60,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
"register_weight": 1
|
||||
}
|
||||
},
|
||||
"electric_melody": {
|
||||
"seed": "head",
|
||||
"center": 60,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
"register_weight": 1
|
||||
}
|
||||
},
|
||||
"vocal_melody": {
|
||||
|
||||
+14
-11
@@ -2,14 +2,14 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import modulus.filesystem
|
||||
|
||||
import generator.arrange.generate
|
||||
import generator.audio_assets
|
||||
import generator.catalog
|
||||
import generator.engraving.cache
|
||||
import generator.engraving.fonts
|
||||
@@ -17,11 +17,12 @@ import generator.engraving.pipeline
|
||||
import generator.engraving.types
|
||||
import generator.parts
|
||||
import generator.pico_artwork
|
||||
import generator.performance
|
||||
import generator.printing
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.score_io
|
||||
import generator.summary
|
||||
import platform
|
||||
import os
|
||||
import modulus.filesystem
|
||||
|
||||
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||
|
||||
@@ -86,7 +87,7 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
staged.mkdir(parents=True, exist_ok=True)
|
||||
copy_runtime_fonts(static_assets_dir, staged)
|
||||
report('generating performance parts')
|
||||
performance_count = generator.arrange.generate.generate_performance_scores(data_dir / 'songs', staged / 'songs', composition_jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
|
||||
performance_count = generator.production.generate_tree(data_dir / 'songs', staged / 'songs', jobs=getattr(args, 'composition_jobs', 2), cache_dir=getattr(args, 'composition_cache', None), progress=progress, timings=timings)
|
||||
if getattr(args, 'trace_level', 'summary') == 'summary':
|
||||
for path in (staged / 'analysis').glob('*.roles'):
|
||||
roles = json.loads(path.read_text())
|
||||
@@ -94,19 +95,21 @@ def generate_assets(args: argparse.Namespace) -> None:
|
||||
trace = role.get('details', {}).get('selection_trace', {})
|
||||
trace.pop('decisions', None)
|
||||
path.write_text(json.dumps(roles, sort_keys=True, indent=2))
|
||||
generator.score.validate_generated_tree(staged)
|
||||
generator.score_io.validate_generated_tree(staged)
|
||||
if args.check:
|
||||
generator.summary.validate_expected_performance_summary(pathlib.Path(__file__).resolve().parents[1] / 'tests' / 'fixtures' / 'performance_summary.json', staged / 'songs')
|
||||
report('copying supplied recordings')
|
||||
song_ids = {generator.score.slugify(path.stem) for path in (data_dir / 'songs').glob('*.xml')}
|
||||
audio_assets = generator.performance.copy_audio(static_assets_dir, staged, song_ids)
|
||||
generator.performance.validate_recording_lengths(staged, audio_assets)
|
||||
audio_assets = generator.audio_assets.copy_audio(static_assets_dir, staged, song_ids)
|
||||
generator.audio_assets.validate_recording_lengths(staged, audio_assets)
|
||||
report('engraving pages')
|
||||
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('generating printable parts')
|
||||
generator.printing.generate_tree(staged, static_assets_dir / 'fonts' / 'petaluma', cache_dir=cache_dir, progress=progress)
|
||||
report('rendering Pico titles')
|
||||
generator.pico_artwork.generate_titles(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||
report('validating score, page, and audio assets')
|
||||
generator.score.validate_generated_tree(staged, validated_pages)
|
||||
generator.score_io.validate_generated_tree(staged, validated_pages)
|
||||
report('building catalog database')
|
||||
generator.catalog.build_catalog(staged, audio_assets=audio_assets)
|
||||
with modulus.filesystem.publish_directories([(staged, output_dir)]):
|
||||
|
||||
+47
-18
@@ -7,6 +7,7 @@ import itertools
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.harmonic_time
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HarmonySpan:
|
||||
@@ -109,10 +110,10 @@ def combine(document: dict) -> None:
|
||||
b = fragments[end][0] if end in fragments else originals[end]
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
|
||||
def validate_comping(document: dict) -> None:
|
||||
"""Check complete sounding events, not barline or notation fragments."""
|
||||
def sounding_lanes(measures):
|
||||
"""Recover complete string-lane events, preserving ties versus reattacks."""
|
||||
tones, starts, cursor = {}, {}, fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
for measure in measures:
|
||||
for note in generator.score.measure_notes(measure):
|
||||
for pitch in note['pitches']:
|
||||
tones[pitch['id']] = pitch
|
||||
@@ -121,6 +122,8 @@ def validate_comping(document: dict) -> None:
|
||||
groups = {}
|
||||
for identifier, pitch in tones.items():
|
||||
if pitch.get('tie_from') in tones:
|
||||
if tones[pitch['tie_from']].get('tie_to') != identifier:
|
||||
raise ValueError('broken acoustic sustain link')
|
||||
continue
|
||||
start, tail = starts[identifier], pitch
|
||||
seen = {identifier}
|
||||
@@ -128,28 +131,54 @@ def validate_comping(document: dict) -> None:
|
||||
target = tail['tie_to']
|
||||
if target in seen:
|
||||
raise ValueError('cyclic acoustic sustain')
|
||||
continuation = tones[target]
|
||||
if continuation.get('tie_from') != tail['id'] or starts[target] != starts[tail['id']] + generator.model.fraction(tail['duration']):
|
||||
raise ValueError('broken acoustic sustain link or timing')
|
||||
if generator.model.pitch_number(continuation) != generator.model.pitch_number(tail) or continuation.get('tablature') != tail.get('tablature'):
|
||||
raise ValueError('acoustic sustain changes pitch or position')
|
||||
seen.add(target)
|
||||
tail = tones[target]
|
||||
tail = continuation
|
||||
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
|
||||
string = pitch.get('tablature', {}).get('string')
|
||||
if string is None:
|
||||
raise ValueError('acoustic comping requires placed notes')
|
||||
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
|
||||
return groups
|
||||
|
||||
def validate_assignment(measures, events):
|
||||
expected = {}
|
||||
for e in events:
|
||||
lanes = ['bass', 'upper'] if e.lane == 'combined' else [e.lane]
|
||||
expected.setdefault((e.start, e.end), set()).update(lanes)
|
||||
if sounding_lanes(measures) != expected:
|
||||
raise ValueError('rendered acoustic attacks disagree with the selected bass/upper assignment')
|
||||
|
||||
def validate_comping(document: dict) -> None:
|
||||
"""Check complete sounding events, not barline or notation fragments."""
|
||||
groups = sounding_lanes(document['staves'][0]['measures'])
|
||||
events = []
|
||||
for (start, end), lanes in groups.items():
|
||||
if len(lanes) == 2:
|
||||
if end - start <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif end - start not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('separate acoustic comping requires quarter/eighth notes')
|
||||
if end <= start or (end - start < 1 and end - start != fractions.Fraction(1, 2)):
|
||||
raise ValueError('acoustic comping requires quarter/eighth notes or authored sustains')
|
||||
events.append((start, end))
|
||||
events.sort()
|
||||
if any(a[1] > b[0] for a, b in zip(events, events[1:])):
|
||||
raise ValueError('acoustic bass and upper must release together or remain separate')
|
||||
cursor = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
first = next((event for event in events if cursor <= event[0] < cursor + length), None)
|
||||
if first and 'bass' not in groups[first]:
|
||||
raise ValueError(f'measure {measure["number"]}: first acoustic attack must include bass')
|
||||
cursor += length
|
||||
# A combined attack is one interval with both lanes. Separate attacks must
|
||||
# be sequential, including bass/upper alternation and cross-bar ties.
|
||||
for i, a in enumerate(events):
|
||||
for b in events[i + 1:]:
|
||||
if a[1] > b[0]:
|
||||
raise ValueError('acoustic sound must release before the next attack')
|
||||
spans = []
|
||||
for span in generator.harmonic_time.read(document['staves'][0]):
|
||||
if spans and generator.accompaniment.same_chord(spans[-1].chord, span.chord):
|
||||
spans[-1] = dataclasses.replace(spans[-1], end=span.end)
|
||||
else:
|
||||
spans.append(span)
|
||||
for span in spans:
|
||||
if span.chord is None:
|
||||
continue
|
||||
first = next((event for event in events if span.start <= event[0] < span.end), None)
|
||||
# A bounded anticipated bass can already be sounding into this harmony.
|
||||
anticipated = any(span.start - fractions.Fraction(1, 2) <= a < span.start < b and 'bass' in groups[a, b] for a, b in events)
|
||||
if first and not anticipated and not any('bass' in groups[e] for e in events if e[0] == first[0]):
|
||||
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: first acoustic attack for a chord must include bass')
|
||||
|
||||
@@ -8,6 +8,7 @@ import typing
|
||||
import generator.acoustic
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.hand_position
|
||||
|
||||
class Voicing(typing.NamedTuple):
|
||||
bass: generator.fretboard.TabPosition
|
||||
@@ -35,12 +36,14 @@ def candidates(requirements: generator.fretboard.ChordRequirements, complete_tri
|
||||
return tuple(sorted(result))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: Voicing, current: Voicing) -> int:
|
||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
||||
def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int:
|
||||
return generator.hand_position.motion(previous, current, old_hand, hand, True).shift
|
||||
|
||||
class Cost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
travel: int = 0
|
||||
peak_position: int = 0
|
||||
effort: int = 0
|
||||
missing_tones: int = 0
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -48,14 +51,20 @@ class Placement:
|
||||
cost: Cost
|
||||
voicing: Voicing | None = None
|
||||
previous: Placement | None = None
|
||||
hand: int = 0
|
||||
|
||||
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
||||
result = []
|
||||
result = {}
|
||||
for voicing in options:
|
||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||
cost, index = min((Cost(p.cost.extended_grips + generator.fretboard.span_penalty(voicing), p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
result.append(Placement(cost, voicing, states[index]))
|
||||
return result
|
||||
for hand in generator.hand_position.regions(voicing):
|
||||
for previous in states:
|
||||
motion = generator.hand_position.motion(previous.voicing, voicing, previous.hand, hand, True)
|
||||
cost = Cost(previous.cost.extended_grips + generator.fretboard.span_penalty(voicing), previous.cost.travel + motion.shift, max(previous.cost.peak_position, hand), previous.cost.effort + motion.ergonomics, previous.cost.missing_tones + missing)
|
||||
key = (voicing, hand, cost.peak_position)
|
||||
if key not in result or cost < result[key].cost:
|
||||
result[key] = Placement(cost, voicing, previous, hand)
|
||||
return list(result.values())
|
||||
|
||||
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
||||
states = [Placement(Cost())]
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
"""One learned accompaniment per instrument, reused at authored form positions."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.electric
|
||||
import generator.guitar
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
def harmonic_key(measure, previous):
|
||||
|
||||
@@ -25,28 +18,30 @@ def harmonic_key(measure, previous):
|
||||
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
|
||||
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
|
||||
|
||||
def audit(source, documents):
|
||||
def audit(source, documents, *, role_documents=False):
|
||||
"""Compare music, not generated IDs, at every reused form position."""
|
||||
authored = generator.score.source_staff(source)['measures']
|
||||
result = {}
|
||||
for part, document in documents.items():
|
||||
if part == 'vocal':
|
||||
for name, document in documents.items():
|
||||
part = name.split('_')[0] if role_documents else name
|
||||
wanted = {'solo': 'slash', 'melody': 'vocal' if part == 'vocal' else 'melody'}.get(name.split('_')[-1], 'accompaniment') if role_documents else 'accompaniment'
|
||||
if part == 'vocal' and not role_documents:
|
||||
continue
|
||||
sections = generator.arrange.form.full_arrangement_sections(authored, part)
|
||||
entry_positions = {s.source_measure_index for s in sections if s.role == 'accompaniment'}
|
||||
entry_positions = {s.source_measure_index for s in sections if s.role == wanted}
|
||||
measures = document['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
seen, exceptions, offset, reused = {}, [], 0, 0
|
||||
for si, section in enumerate(sections):
|
||||
following = sections[si + 1] if si + 1 < len(sections) else None
|
||||
for j in range(section.measure_count):
|
||||
if section.role != 'accompaniment':
|
||||
if section.role != wanted:
|
||||
continue
|
||||
m = measures[offset + j]
|
||||
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
if wanted == 'accompaniment' and j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
|
||||
continue
|
||||
if j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
|
||||
if wanted == 'accompaniment' and j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
|
||||
continue
|
||||
position = section.source_measure_index + j
|
||||
@@ -60,16 +55,16 @@ def audit(source, documents):
|
||||
boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else ''
|
||||
key = (position, context, boundary)
|
||||
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
|
||||
for v in m['voices'] for n in v['notes'] if n['pitches'])
|
||||
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p.get('tablature', {}).get('string'), p.get('tablature', {}).get('fret'), bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False, bool(p.get('tie_to'))) for p in n['pitches']))
|
||||
for v in m['voices'] for n in v['notes'])
|
||||
if key in seen:
|
||||
if signature != seen[key]:
|
||||
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
|
||||
raise ValueError(f'{name}, arrangement measure {m["number"]}: canonical {wanted} changed')
|
||||
reused += 1
|
||||
else:
|
||||
if any(k[0] == key[0] for k in seen):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
|
||||
seen[key] = signature
|
||||
offset += section.measure_count
|
||||
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
|
||||
result[name] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Assemble source-defined role windows without composing or respelling."""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.composition_types
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.validation
|
||||
|
||||
def mask(document, allowed):
|
||||
"""Apply source-defined role exclusions, without observing another part."""
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
for voice in measure['voices']:
|
||||
selected = []
|
||||
for n in voice['notes']:
|
||||
start = absolute + generator.model.fraction(n['onset'])
|
||||
end = start + generator.model.fraction(n['duration'])
|
||||
intervals = [(max(start, a), min(end, b)) for a, b in allowed if a < end and b > start]
|
||||
for a, b in intervals:
|
||||
note = copy.deepcopy(n)
|
||||
if a != start:
|
||||
note['onset'] = generator.model.rational(a - absolute)
|
||||
for pitch in note['pitches']:
|
||||
pitch.pop('tie_from', None)
|
||||
generator.arrange.pickup.truncate(note, b - absolute)
|
||||
if a < b:
|
||||
selected.append(note)
|
||||
voice['notes'] = fill(selected, length, measure['id'] + '_' + voice['id'], voice['id'])
|
||||
absolute += length
|
||||
staff = document['staves'][0]
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
|
||||
|
||||
def fill(notes, length, prefix, voice='voice_arranged'):
|
||||
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
|
||||
result = []
|
||||
cursor = fractions.Fraction()
|
||||
|
||||
def rests(end):
|
||||
nonlocal cursor
|
||||
for duration, written in generator.accompaniment.written_durations(end - cursor):
|
||||
result.append(generator.model.make_event(prefix + '_rest_' + str(len(result)), cursor, duration, 'rest', written=written) | {'voice': voice})
|
||||
cursor += duration
|
||||
|
||||
for n in notes:
|
||||
start = generator.model.fraction(n['onset'])
|
||||
if start < cursor:
|
||||
raise ValueError(prefix + ': overlapping independently generated roles')
|
||||
rests(start)
|
||||
n['voice'] = voice
|
||||
result.append(n)
|
||||
cursor = start + generator.model.fraction(n['duration'])
|
||||
rests(length)
|
||||
return result
|
||||
|
||||
def improvisation(source, template, part):
|
||||
"""Fill the source's solo allocation, including pickups, with beat slashes."""
|
||||
document = copy.deepcopy(template)
|
||||
staff = document['staves'][0]
|
||||
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
|
||||
staff[field] = []
|
||||
for measure in staff['measures']:
|
||||
measure['id'] = part + '_improv_' + measure['id']
|
||||
measure['voices'] = [{'id': 'voice_arranged', 'notes': generator.parts.generated_voice(measure, 'slash')}]
|
||||
generator.score.reidentify(staff['measures'], [])
|
||||
plan = generator.arrange.form.FormPlan.read(source.document(), part)
|
||||
mask(document, [(a, b) for a, b, role in plan.windows if role == part + '_solo'])
|
||||
return document
|
||||
|
||||
def assemble(source, results, *, solos_enabled=True):
|
||||
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
|
||||
documents = {}
|
||||
for part in generator.parts.PART_TYPES:
|
||||
selected = [results[r]['document'] for r in generator.composition_types.ROLES
|
||||
if r.startswith(part + '_') and (solos_enabled or not r.endswith('_solo'))]
|
||||
if not solos_enabled and part != 'vocal':
|
||||
selected.append(improvisation(source, selected[0], part))
|
||||
document = copy.deepcopy(selected[0])
|
||||
staff = document['staves'][0]
|
||||
if part == 'vocal':
|
||||
documents[part] = document
|
||||
continue
|
||||
staff['tuplets'] = []
|
||||
staff['octave_shifts'] = []
|
||||
staff['scale_spans'] = []
|
||||
for other in selected:
|
||||
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
|
||||
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
|
||||
for i, m in enumerate(staff['measures']):
|
||||
# Tuplet rests belong to the source/role rhythm and must survive union.
|
||||
lanes = {}
|
||||
for d in selected:
|
||||
for voice in d['staves'][0]['measures'][i]['voices']:
|
||||
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
|
||||
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids') or n.get('notehead') == 'slash']
|
||||
if sounding:
|
||||
lanes.setdefault(lane, []).extend(sounding)
|
||||
lanes = lanes or {'voice_arranged': []}
|
||||
m['voices'] = [{'id': lane, 'notes': fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
|
||||
# Attribute changes belong to source melody pickups and are assembly metadata.
|
||||
for d in selected:
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = owners[span['start']]
|
||||
generator.validation.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
documents[part] = document
|
||||
return documents
|
||||
+15
-23
@@ -4,44 +4,30 @@ import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.ending_plan
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
|
||||
BARS = 3
|
||||
|
||||
def template(source):
|
||||
result = copy.deepcopy(source[-1])
|
||||
# An ending is three full bars in the final active meter, never a pickup.
|
||||
for change in result.get('attributes', []):
|
||||
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
|
||||
if key in change:
|
||||
result[key] = copy.deepcopy(change[key])
|
||||
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
|
||||
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
|
||||
result['time_signature'] = generator.model.meter_label(result['meter'])
|
||||
return result
|
||||
|
||||
def duration(source):
|
||||
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
|
||||
import generator.vocabulary
|
||||
|
||||
def chord(source):
|
||||
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
|
||||
prior = generator.accompaniment.carried_chords(effective)[-1]
|
||||
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
|
||||
|
||||
def append(source, output, part, *, sounding=False, center=55):
|
||||
def append(source, output, part, *, sounding=False, center=55, composition=None, incoming=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
if not source or not output:
|
||||
return
|
||||
model = template(source)
|
||||
model = generator.arrange.ending_plan.template(source)
|
||||
harmony = chord(source)
|
||||
grip = ()
|
||||
if sounding:
|
||||
if harmony is None:
|
||||
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part)
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part, composition=composition)
|
||||
recent = {}
|
||||
for m in reversed(output):
|
||||
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
|
||||
@@ -55,10 +41,16 @@ def append(source, output, part, *, sounding=False, center=55):
|
||||
if part == 'acoustic':
|
||||
bass = next((recent[s] for s in (5, 6) if s in recent), None)
|
||||
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
|
||||
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v))
|
||||
if incoming is not None:
|
||||
previous, hand = incoming
|
||||
else:
|
||||
hand = None
|
||||
costs = {v: generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition) for v in options}
|
||||
path, _, _ = generator.comping.select_grips((options,), (costs,), part, 'ending-position', left=previous, left_hand=hand)
|
||||
grip = path[0]
|
||||
length = generator.model.fraction(model['duration'])
|
||||
previous = None
|
||||
for index in range(BARS):
|
||||
for index in range(generator.arrange.ending_plan.BARS):
|
||||
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
|
||||
measure['ending_sustain'] = True
|
||||
if index == 0 and harmony:
|
||||
@@ -77,4 +69,4 @@ def append(source, output, part, *, sounding=False, center=55):
|
||||
cursor += duration
|
||||
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
output.append(measure)
|
||||
generator.score.reidentify(output[-BARS:], [])
|
||||
generator.score.reidentify(output[-generator.arrange.ending_plan.BARS:], [])
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Source-derived ending geometry, independent of instrument composition."""
|
||||
import copy
|
||||
import fractions
|
||||
|
||||
import generator.model
|
||||
|
||||
BARS = 3
|
||||
|
||||
def template(source):
|
||||
result = copy.deepcopy(source[-1])
|
||||
# An ending is three full bars in the final active meter, never a pickup.
|
||||
for change in result.get('attributes', []):
|
||||
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
|
||||
if key in change:
|
||||
result[key] = copy.deepcopy(change[key])
|
||||
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
|
||||
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
|
||||
result['time_signature'] = generator.model.meter_label(result['meter'])
|
||||
return result
|
||||
|
||||
def duration(source):
|
||||
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
|
||||
@@ -1,8 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.arrange.ending_plan
|
||||
import generator.arrange.pickup
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@@ -55,3 +59,57 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra
|
||||
for label, start, count, index in specs
|
||||
if count > 0
|
||||
]
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class FormPlan:
|
||||
sections: tuple[ArrangementSection, ...]
|
||||
windows: tuple[tuple[fractions.Fraction, fractions.Fraction, str], ...]
|
||||
stops: tuple[tuple[int, fractions.Fraction] | None, ...]
|
||||
@classmethod
|
||||
def read(cls, document, part):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
sections = tuple(full_arrangement_sections(source, part))
|
||||
stops = tuple(section_stop(source, section, sections[i + 1] if i + 1 < len(sections) else None) for i, section in enumerate(sections))
|
||||
return cls(sections, _windows(document, part, sections), stops)
|
||||
|
||||
def section_stop(source, section, following):
|
||||
if following is None or not generator.score.body_start_measure_index(source):
|
||||
return None
|
||||
last = section.source_measure_index + section.measure_count - 1
|
||||
pad = generator.arrange.pickup.offset(source[last], source[0])
|
||||
if following.role == 'slash':
|
||||
return last, pad
|
||||
if following.role == 'melody' and generator.arrange.pickup.enters(following, source):
|
||||
attacks = [generator.model.fraction(note['onset']) for note in generator.score.measure_notes(source[0]) if note['pitches']]
|
||||
if attacks:
|
||||
return last, pad + min(attacks)
|
||||
return None
|
||||
|
||||
def windows(document, part):
|
||||
return FormPlan.read(document, part).windows
|
||||
|
||||
def _windows(document, part, sections):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for section in sections:
|
||||
duration = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
|
||||
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
|
||||
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
start = cursor - generator.model.fraction(source[0]['duration']) + min(attacks)
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, cursor, part + '_' + role))
|
||||
end = cursor + duration
|
||||
start = cursor
|
||||
if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start = cursor - generator.model.fraction(source[0]['duration'])
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
result.append((cursor, cursor + generator.arrange.ending_plan.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
@@ -2,36 +2,31 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.arrange.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.comping
|
||||
import generator.electric
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.melody
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.solo.arrangement
|
||||
import generator.comping
|
||||
import generator.vocabulary
|
||||
import generator.production
|
||||
|
||||
FINAL_SECTION_INDEX = 5
|
||||
|
||||
def arrange_document(document: dict, part_type: str) -> dict:
|
||||
def arrange_document(document: dict, part_type: str, *, composition=None) -> dict:
|
||||
"""Canonical source-only template; solos are inserted by production roles."""
|
||||
composition = composition or generator.vocabulary.CompositionContext.create(generator.vocabulary.load(), part_type + '_accompaniment')
|
||||
document = generator.parts.prepare_document(document)
|
||||
with generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
|
||||
backing = generator.comping.Canonical(document, part_type, 'canonical') if part_type != 'vocal' else None
|
||||
return _arrange_document(document, part_type, accompaniment=backing)
|
||||
backing = generator.comping.Canonical(document, part_type, 'canonical', composition=composition) if part_type != 'vocal' else None
|
||||
return _arrange_document(document, part_type, accompaniment=backing, composition=composition)
|
||||
|
||||
def section_source(source: list[dict], section, next_section=None) -> list[dict]:
|
||||
result = copy.deepcopy(source)
|
||||
@@ -44,12 +39,14 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
|
||||
generator.arrange.pickup.harmonies(destination, result[0])
|
||||
return result
|
||||
|
||||
def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, accompaniment=None) -> dict:
|
||||
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None, placement_report=None) -> dict:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
if part_type not in generator.parts.PART_TYPES:
|
||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||
staff = generator.score.source_staff(document)
|
||||
source = staff['measures']
|
||||
sections = generator.arrange.form.full_arrangement_sections(source, part_type)
|
||||
plan = plan or generator.arrange.form.FormPlan.read(document, part_type)
|
||||
sections = plan.sections
|
||||
output, tuplets, octave_shifts, voicing_origins = [], [], [], []
|
||||
absolute = fractions.Fraction()
|
||||
source_starts = []
|
||||
@@ -62,16 +59,8 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
|
||||
previous = generator.accompaniment.carried_chords(section_measures)
|
||||
segment = []
|
||||
stop = None
|
||||
stop = plan.stops[index]
|
||||
next_section = sections[index + 1] if index + 1 < len(sections) else None
|
||||
if next_section and generator.score.body_start_measure_index(source):
|
||||
last = section.source_measure_index + section.measure_count - 1
|
||||
if next_section.role == 'slash':
|
||||
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
|
||||
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
|
||||
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
|
||||
m = section_measures[si]
|
||||
if role == 'accompaniment' and accompaniment is not None:
|
||||
@@ -111,7 +100,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
|
||||
generator.arrange.ending.append(source, output, part_type,
|
||||
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
|
||||
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55))
|
||||
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition, incoming=accompaniment.last_position if accompaniment is not None else None)
|
||||
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
|
||||
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)
|
||||
@@ -123,15 +112,14 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
||||
generator.melody.harmonize(result)
|
||||
generator.guitar.place_guitar(result)
|
||||
report = generator.guitar.place_guitar(result, composition=composition)
|
||||
if generator.accompaniment.connect_continuations(result):
|
||||
generator.guitar.place_guitar(result)
|
||||
report = generator.guitar.place_guitar(result, composition=composition)
|
||||
if placement_report is not None:
|
||||
placement_report.update(report)
|
||||
# Source labels identify the head while arranging; published labels identify
|
||||
# the complete performance, including its lead-in and repeated choruses.
|
||||
for number, measure in enumerate(output, 1):
|
||||
measure['number'] = str(number)
|
||||
generator.fretboard.validate_document(result)
|
||||
return result
|
||||
|
||||
def generate_performance_scores(source_dir: pathlib.Path, output_dir: pathlib.Path, *, composition_jobs=2, cache_dir=None, progress=False, timings=None) -> int:
|
||||
return generator.production.generate_tree(source_dir, output_dir, jobs=composition_jobs, cache_dir=cache_dir, progress=progress, timings=timings)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Validate and copy supplied recordings without synthesis or transformation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import wave
|
||||
|
||||
import generator.model
|
||||
import generator.performance
|
||||
|
||||
TEMPOS = tuple(range(60, 121, 10))
|
||||
|
||||
def describe_wav(path, root, *, song_id='', bpm=0):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError(f'{path}: expected a contained regular WAV')
|
||||
try:
|
||||
with wave.open(str(path), 'rb') as audio:
|
||||
channels, rate, frames = audio.getnchannels(), audio.getframerate(), audio.getnframes()
|
||||
if audio.getcomptype() != 'NONE' or audio.getsampwidth() != 2 or channels not in (1, 2) or not 8000 <= rate <= 192000 or frames <= 0:
|
||||
raise ValueError('expected nonempty mono/stereo PCM16 WAV')
|
||||
remaining = frames
|
||||
while remaining:
|
||||
count = min(remaining, 65536)
|
||||
if len(audio.readframes(count)) != count * channels * 2:
|
||||
raise ValueError('truncated WAV data')
|
||||
remaining -= count
|
||||
except (wave.Error, EOFError) as error:
|
||||
raise ValueError(f'{path}: invalid WAV: {error}') from error
|
||||
with path.open('rb') as source:
|
||||
digest = hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
return dict(path=path.relative_to(root).as_posix(), sha256=digest, channels=channels, sample_rate=rate, frames=frames, song_id=song_id, bpm=bpm)
|
||||
|
||||
def copy_audio(static, destination, song_ids):
|
||||
entries = []
|
||||
inputs = [(static / 'sounds' / name, '', 0) for name in ('beat1.wav', 'beat2.wav')]
|
||||
for path in sorted((static / 'backing').glob('*.wav')):
|
||||
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
|
||||
if not match:
|
||||
raise ValueError(f'{path}: unsupported backing filename or tempo')
|
||||
if match[1] not in song_ids:
|
||||
continue
|
||||
inputs.append((path, match[1], int(match[2])))
|
||||
for source, song_id, bpm in inputs:
|
||||
description = describe_wav(source, static, song_id=song_id, bpm=bpm)
|
||||
target = destination / description['path']
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
entries.append(description)
|
||||
return entries
|
||||
|
||||
def validate_recording_lengths(root, entries):
|
||||
durations = {}
|
||||
for path in sorted((root / 'songs').glob('*.json')):
|
||||
document = json.loads(path.read_text())
|
||||
performance = generator.performance.timing(document)
|
||||
song_id = performance['song_id']
|
||||
duration = generator.model.fraction(performance['duration']) + performance['count_in_beats']
|
||||
if song_id in durations and durations[song_id] != duration:
|
||||
raise ValueError(f'{song_id}: parts disagree about performance duration')
|
||||
durations[song_id] = duration
|
||||
for entry in entries:
|
||||
if entry['song_id']:
|
||||
seconds = fractions.Fraction(entry['frames'], entry['sample_rate'])
|
||||
if seconds < durations[entry['song_id']] * 60 / entry['bpm']:
|
||||
raise ValueError(f"{entry['path']}: backing recording is shorter than count-in and score")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Content identities and atomic storage shared by generator cache adapters."""
|
||||
from __future__ import annotations
|
||||
import ast
|
||||
import contextlib
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def imports(content):
|
||||
modules = set()
|
||||
for node in ast.walk(ast.parse(content)):
|
||||
if isinstance(node, ast.Import):
|
||||
modules.update(alias.name for alias in node.names if alias.name.startswith('generator.'))
|
||||
elif isinstance(node, ast.ImportFrom) and node.module and node.module.startswith('generator'):
|
||||
modules.add(node.module)
|
||||
modules.update(node.module + '.' + alias.name for alias in node.names)
|
||||
return tuple(sorted(modules))
|
||||
|
||||
def source_identity(entrypoints):
|
||||
"""Conservatively include the transitive imports of explicit stage roots.
|
||||
|
||||
Read bytes on every call, so edits in a long-lived build process invalidate
|
||||
both code content and newly introduced dependencies. Only parsing is cached.
|
||||
"""
|
||||
pending, sources = list(entrypoints), {}
|
||||
while pending:
|
||||
module = pending.pop()
|
||||
relative = module.split('.')[1:]
|
||||
path = ROOT.joinpath(*relative).with_suffix('.py') if relative else ROOT / '__init__.py'
|
||||
if not path.is_file():
|
||||
path = ROOT.joinpath(*relative, '__init__.py')
|
||||
if path in sources or not path.is_file():
|
||||
continue
|
||||
content = path.read_bytes()
|
||||
sources[path] = hashlib.sha256(content).hexdigest()
|
||||
pending.extend(imports(content))
|
||||
pending.extend('.'.join(module.split('.')[:i]) for i in range(1, len(module.split('.'))))
|
||||
return {path.relative_to(ROOT).as_posix(): sources[path] for path in sorted(sources)}
|
||||
|
||||
def fingerprint(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
||||
|
||||
def write_json(path, value):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as output:
|
||||
temporary = pathlib.Path(output.name)
|
||||
json.dump(value, output, sort_keys=True)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
def has_symlink(path):
|
||||
return any(parent.is_symlink() for parent in (path, *path.parents))
|
||||
|
||||
@contextlib.contextmanager
|
||||
def stage_entry(entry, prefix):
|
||||
if has_symlink(entry.parent):
|
||||
raise ValueError('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=prefix, dir=entry.parent) as temporary:
|
||||
yield pathlib.Path(temporary)
|
||||
|
||||
def publish_files(entry, staged, names):
|
||||
"""Publish complete files in order, with the artifact manifest last."""
|
||||
for name in names:
|
||||
target = entry / name
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target)
|
||||
(staged / name).replace(target)
|
||||
+18
-5
@@ -10,7 +10,7 @@ import sys
|
||||
if __name__ == '__main__' and __package__ is None:
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
|
||||
|
||||
import generator.performance
|
||||
import generator.audio_assets
|
||||
|
||||
APPLICATION_ID = 0x56414D50
|
||||
SONG_COLUMNS = """
|
||||
@@ -45,10 +45,21 @@ def metadata(path: pathlib.Path, root: pathlib.Path) -> dict:
|
||||
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')
|
||||
solos_enabled = meta.get('solos_enabled', True)
|
||||
if type(solos_enabled) is not bool:
|
||||
raise ValueError('solos_enabled must be a boolean')
|
||||
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']), title_image=title_image, part=staff['name'])
|
||||
measure_count=len(staff['measures']), total_pages=len(presentation['pages']), title_image=title_image, part=staff['name'], solos_enabled=int(solos_enabled))
|
||||
|
||||
VARIANT_ERRORS = """
|
||||
SELECT json_extract(source_json, '$.music.metadata.song_id'), part FROM songs
|
||||
WHERE json_extract(source_json, '$.music.staves[0].arranged') = 1
|
||||
GROUP BY json_extract(source_json, '$.music.metadata.song_id'), part
|
||||
HAVING (part IN ('Acoustic', 'Electric') AND (count(*) != 2 OR sum(solos_enabled) != 1))
|
||||
OR (part = 'Vocal' AND (count(*) != 1 OR sum(solos_enabled) != 1))
|
||||
"""
|
||||
|
||||
def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=None) -> None:
|
||||
if not root.is_dir():
|
||||
@@ -58,19 +69,19 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=No
|
||||
for path in sorted(root.glob('sounds/*.wav')):
|
||||
if path.name not in ('beat1.wav', 'beat2.wav'):
|
||||
raise ValueError(f'{path}: unknown metronome sample')
|
||||
audio_assets.append(generator.performance.describe_wav(path, root))
|
||||
audio_assets.append(generator.audio_assets.describe_wav(path, root))
|
||||
for path in sorted(root.glob('backing/*.wav')):
|
||||
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
|
||||
if not match:
|
||||
raise ValueError(f'{path}: invalid backing filename')
|
||||
audio_assets.append(generator.performance.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
|
||||
audio_assets.append(generator.audio_assets.describe_wav(path, root, song_id=match[1], bpm=int(match[2])))
|
||||
folder = root / 'songs'
|
||||
if (folder.exists() or folder.is_symlink()) and not folder.is_dir():
|
||||
raise ValueError(f'{folder}: score asset directory not found')
|
||||
with connection:
|
||||
connection.execute('BEGIN')
|
||||
connection.execute(f'PRAGMA application_id = {APPLICATION_ID}')
|
||||
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL) STRICT')
|
||||
connection.execute(f'CREATE TABLE songs ({SONG_COLUMNS}, part TEXT NOT NULL, solos_enabled INTEGER NOT NULL CHECK(solos_enabled IN (0, 1))) STRICT')
|
||||
connection.execute('CREATE INDEX songs_menu ON songs(title, composer, part, id)')
|
||||
connection.execute('CREATE TABLE resources (id TEXT PRIMARY KEY, value TEXT NOT NULL CHECK(json_valid(value))) STRICT')
|
||||
connection.execute('INSERT INTO resources VALUES (?, ?)', ('audio', json.dumps(audio_assets)))
|
||||
@@ -82,6 +93,8 @@ def populate(connection: sqlite3.Connection, root: pathlib.Path, audio_assets=No
|
||||
connection.execute(f'INSERT INTO songs ({", ".join(row)}) VALUES ({", ".join("?" for _ in row)})', tuple(row.values()))
|
||||
except (ValueError, KeyError, TypeError, sqlite3.Error) as error:
|
||||
raise ValueError(f'{path}: {error}') from error
|
||||
if connection.execute(VARIANT_ERRORS).fetchall():
|
||||
raise ValueError('missing or duplicate solo variants; regenerate score assets')
|
||||
if connection.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
|
||||
raise ValueError('catalog integrity check failed')
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Transposable guitar families, derived from the written chord and legal grips."""
|
||||
import generator.harmony
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
|
||||
def acoustic(chord, grip):
|
||||
def acoustic(chord, grip, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
|
||||
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
@@ -14,7 +15,7 @@ def acoustic(chord, grip):
|
||||
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
|
||||
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
|
||||
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
|
||||
policy = generator.vocabulary.accompaniment('acoustic')['voicing_families']
|
||||
policy = composition.accompaniment('acoustic')['voicing_families']
|
||||
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
|
||||
upper = tuple(p.midi % 12 for p in grip[1:])
|
||||
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
|
||||
@@ -37,10 +38,11 @@ def electric(grip):
|
||||
return 'drop-two'
|
||||
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
|
||||
|
||||
def coverage(chord, options, part):
|
||||
def coverage(chord, options, part, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
if part == 'electric':
|
||||
return {name: sum(electric(v) == name for v in options) for name in generator.vocabulary.accompaniment('electric')['voicing_families']['rank']}
|
||||
families = [acoustic(chord, v) for v in options]
|
||||
return {name: sum(electric(v) == name for v in options) for name in composition.accompaniment('electric')['voicing_families']['rank']}
|
||||
families = [acoustic(chord, v, composition=composition) for v in options]
|
||||
result = []
|
||||
for string in (5, 6):
|
||||
for degree in (1, 5):
|
||||
|
||||
+232
-96
@@ -2,24 +2,26 @@
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import json
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping_rhythm
|
||||
import generator.chord_families
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.chord_families
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.hand_position
|
||||
import generator.model
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.solo.policy
|
||||
import generator.vocabulary
|
||||
|
||||
def semantic_key(chord):
|
||||
return json.dumps({k: chord[k] for k in ('root', 'kind', 'degrees', 'bass', 'inversion') if k in chord}, sort_keys=True)
|
||||
@@ -36,12 +38,15 @@ def requirements(chord, part):
|
||||
raise ValueError('acoustic accompaniment requires a written bass, root or fifth')
|
||||
return dataclasses.replace(result, basses=basses)
|
||||
|
||||
def voicings(key, part, *, composition=None):
|
||||
return _voicings(key, part, composition or generator.vocabulary.current())
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def voicings(key, part):
|
||||
def _voicings(key, part, composition):
|
||||
chord = json.loads(key)
|
||||
required = requirements(chord, part)
|
||||
if part == 'electric':
|
||||
options = generator.guitar.chord_positions(required, prune=False, profile='electric')
|
||||
options = generator.guitar.chord_positions(required, prune=False, profile='electric', composition=composition)
|
||||
elif part == 'acoustic':
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
plain = degrees in ({1: 0, 3: 0, 5: 0}, {1: 0, 3: -1, 5: 0})
|
||||
@@ -57,76 +62,88 @@ def voicings(key, part):
|
||||
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
|
||||
return tuple(sorted(options))
|
||||
|
||||
def movement(part, previous, current):
|
||||
if not previous or not current:
|
||||
return 0
|
||||
if part == 'acoustic':
|
||||
return generator.acoustic_voicing.movement(previous, current)
|
||||
return generator.guitar.movement(previous, current)
|
||||
def movement(part, previous, current, old_hand, hand):
|
||||
return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
family: int = 0
|
||||
travel: int = 0
|
||||
peak_position: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
missing_tones: int = 0
|
||||
register: fractions.Fraction = fractions.Fraction()
|
||||
def plus(self, other):
|
||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
||||
def moved(self, distance):
|
||||
return self._replace(effort=self.effort + distance)
|
||||
return VoicingCost(*(max(a, b) if field == 'peak_position' else a + b for field, a, b in zip(self._fields, self, other)))
|
||||
def moved(self, distance, ergonomics=0):
|
||||
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
|
||||
|
||||
def voicing_preferences(part, grip, center):
|
||||
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center))
|
||||
|
||||
def local_cost(part, key, grip, center):
|
||||
def local_cost(part, key, grip, center, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
required = requirements(json.loads(key), part)
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
||||
preference = sum(voicing_preferences(part, grip, center))
|
||||
register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
|
||||
if part == 'electric':
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
|
||||
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
|
||||
# Familiar degree orders on either bass string, then balanced whole-form
|
||||
# movement, sounding register and hand position. Neither is an absolute ban.
|
||||
family = generator.chord_families.acoustic(json.loads(key), grip)
|
||||
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
|
||||
return VoicingCost(extended_grips=value.extended_grips, family=3 * value.string_shortfall + value.family_penalty, effort=value.effort, missing_tones=value.missing_tones, register=register)
|
||||
# Keep the familiar families, then compare physical effort before position.
|
||||
# Sounding register only breaks otherwise equal placement decisions.
|
||||
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
|
||||
return VoicingCost(extended_grips=generator.fretboard.span_penalty(grip), family=int(not family['preferred']), missing_tones=len(set(required.allowed) - {p.midi % 12 for p in grip}), register=register)
|
||||
|
||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
||||
"""Exact small-state dynamic programming; never prune by hand center.
|
||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
|
||||
"""Return complete grips, cost and hand windows, including repeat travel.
|
||||
|
||||
Both roles balance register/position with travel. Electric includes its
|
||||
base grip effort; acoustic follows all four voices and bass-string changes.
|
||||
Compact span and family remain higher tiers.
|
||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
||||
A concrete grip may occupy several windows. Preserve distinct earlier peaks
|
||||
at a common grip/hand, and fix both starting values for a cyclic path.
|
||||
"""
|
||||
if not layers:
|
||||
return (), VoicingCost()
|
||||
# Every accompaniment transition is feasible (travel is a cost). A wider
|
||||
# option cannot beat a primary grip at the same span, regardless of travel.
|
||||
# This exact dominance reduction avoids growing the cubic repeat search.
|
||||
return (), VoicingCost(), ()
|
||||
|
||||
def hands(grip, fixed=None):
|
||||
options = generator.hand_position.regions(grip)
|
||||
if fixed is not None:
|
||||
return tuple(h for h in options if h == fixed)
|
||||
return options
|
||||
|
||||
retained = []
|
||||
for i, layer in enumerate(layers):
|
||||
tier = min(costs[i][v].extended_grips for v in layer)
|
||||
retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier))
|
||||
layers = tuple(retained)
|
||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
|
||||
fixed = fixed_hands[i] if fixed_hands is not None else None
|
||||
options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed))
|
||||
if not options:
|
||||
raise ValueError(f'accompaniment span {i}: no grip fits the committed hand position')
|
||||
retained.append(options)
|
||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v, _ in layer} for i, layer in enumerate(retained)]
|
||||
winners = []
|
||||
for first in layers[0] if close else [None]:
|
||||
states = {left: (VoicingCost(), (), ())}
|
||||
for i, layer in enumerate(layers):
|
||||
for first in retained[0] if close else [None]:
|
||||
states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)}
|
||||
for i, layer in enumerate(retained):
|
||||
following = {}
|
||||
for grip in [first] if i == 0 and first is not None else layer:
|
||||
local = costs[i][grip]
|
||||
choices = []
|
||||
for previous, (cost, ties, path) in states.items():
|
||||
total = cost.plus(local).moved(movement(part, previous, grip))
|
||||
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
|
||||
following[grip] = min(choices)
|
||||
states = following
|
||||
for grip, (cost, ties, path) in states.items():
|
||||
travel = movement(part, grip, first if close else right)
|
||||
winners.append((cost.moved(travel), ties, path))
|
||||
cost, _, path = min(winners)
|
||||
return path, cost
|
||||
for grip, hand in [first] if i == 0 and first is not None else layer:
|
||||
local = costs[i][grip]._replace(peak_position=hand)
|
||||
for (previous, old_hand, _), (cost, ties, path, positions) in states.items():
|
||||
motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic')
|
||||
total = cost.plus(local).moved(motion.shift, motion.ergonomics)
|
||||
candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand))
|
||||
key = (grip, hand, total.peak_position)
|
||||
if key not in following or candidate < following[key]:
|
||||
following[key] = candidate
|
||||
# Future travel is identical at a common concrete grip and hand.
|
||||
best = {}
|
||||
for (grip, hand, _), value in following.items():
|
||||
primary = value[0][:3]
|
||||
best[grip, hand] = min(best.get((grip, hand), primary), primary)
|
||||
states = {key: value for key, value in following.items() if value[0][:3] == best[key[:2]]}
|
||||
endpoints = [first] if close else [(right, h) for h in hands(right, right_hand)]
|
||||
for (grip, hand, _), (cost, ties, path, positions) in states.items():
|
||||
for endpoint, end_hand in endpoints:
|
||||
motion = generator.hand_position.motion(grip, endpoint, hand, end_hand, part == 'acoustic')
|
||||
total = cost.plus(VoicingCost(peak_position=end_hand)).moved(motion.shift, motion.ergonomics)
|
||||
winners.append((total, ties, path, positions))
|
||||
if not winners:
|
||||
raise ValueError('accompaniment boundary grip does not fit the committed hand position')
|
||||
cost, _, path, positions = min(winners)
|
||||
return path, cost, positions
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class HarmonyChoice:
|
||||
@@ -137,6 +154,7 @@ class HarmonyChoice:
|
||||
events: tuple
|
||||
figure: dict
|
||||
options: tuple
|
||||
hand: int | None = None
|
||||
@property
|
||||
def key(self):
|
||||
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
||||
@@ -182,14 +200,21 @@ def split_measures(flat, inputs, part):
|
||||
return tuple(output)
|
||||
|
||||
class Canonical:
|
||||
def __init__(self, document, part, seed):
|
||||
def __init__(self, document, part, seed, *, composition=None, plan=None):
|
||||
self.composition = composition or generator.vocabulary.current()
|
||||
self.part, self.seed = part, seed
|
||||
self.policy = generator.vocabulary.role(part + '_accompaniment')
|
||||
self.policy = self.composition.role(part + '_accompaniment')
|
||||
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
||||
self.cache_positions = {}
|
||||
self.last_position = ((), 0)
|
||||
self.measures = generator.score.source_staff(document)['measures']
|
||||
# A held accompaniment chord may continue into the bar containing the
|
||||
# source melody pickup. Keep its voice identity through that splice.
|
||||
self.voice_id = self.measures[0]['voices'][0]['id']
|
||||
self.melody = generator.comping_rhythm.melody_contexts(document)
|
||||
self.first = generator.score.body_start_measure_index(self.measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
|
||||
plan = plan or generator.arrange.form.FormPlan.read(document, part)
|
||||
sections = plan.sections
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
|
||||
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(self.measures)
|
||||
@@ -199,23 +224,36 @@ class Canonical:
|
||||
size = 1
|
||||
if index + 1 < len(self.measures):
|
||||
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
|
||||
if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
|
||||
if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in self.composition.accompaniment(part)['patterns']):
|
||||
size = 2
|
||||
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
|
||||
index += size
|
||||
layers = [self.candidates(group) for group in self.groups]
|
||||
selected = generator.comping_rhythm.select(layers, seed)
|
||||
self.acoustic_cache = {}
|
||||
self.pair_breaks = {self.first, len(self.measures)}
|
||||
self.pair_stops = []
|
||||
for section in sections:
|
||||
if section.role == 'accompaniment':
|
||||
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
|
||||
self.pair_stops = [stop for section, stop in zip(sections, plan.stops) if section.role == 'accompaniment' and stop is not None]
|
||||
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks] + self.pair_stops
|
||||
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed, breaks=breaks, composition=self.composition)
|
||||
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
|
||||
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
|
||||
selected = [r for _, _, r in choices]
|
||||
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
|
||||
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
||||
active = tuple(span for spans in prepared for span in spans if span.events)
|
||||
path, self.path_cost = self.select(active, close=self.closed)
|
||||
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
|
||||
path, self.path_cost, self.hand_order = self.select(active, close=self.closed)
|
||||
self.base = dict(zip((s.key for s in active), path))
|
||||
self.hands = dict(zip((s.key for s in active), self.hand_order))
|
||||
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
||||
self.rhythms = {}
|
||||
previous = path[-1] if self.closed and path else ()
|
||||
previous = (path[-1], self.hand_order[-1]) if self.closed and path else ((), 0)
|
||||
for group, rhythm, spans in zip(self.groups, selected, prepared):
|
||||
grips = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
|
||||
grips = tuple((self.base[s.key], self.hands[s.key]) for s in spans if s.events)
|
||||
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
|
||||
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, grips, previous, False)
|
||||
cursor = fractions.Fraction()
|
||||
for m, _, i in group:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
@@ -227,6 +265,17 @@ class Canonical:
|
||||
def report(self):
|
||||
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
|
||||
@property
|
||||
def placement_report(self):
|
||||
previous = self.hand_order[-1] if self.closed and self.hand_order else None
|
||||
shifts = []
|
||||
for hand in self.hand_order:
|
||||
shifts.append(abs(hand - previous) if previous is not None else 0)
|
||||
previous = hand
|
||||
return {'version': generator.hand_position.VERSION, 'priority': 'hand travel, maximum hand position, ergonomics, coverage/register, stable tie',
|
||||
**generator.hand_position.summary(shifts), 'peak_position': self.path_cost.peak_position,
|
||||
'cost': {k: str(v) for k, v in self.path_cost._asdict().items()},
|
||||
'positions': [{'source_index': i, 'onset': str(t), 'hand_position': h} for (i, t, _), h in zip(self.order, self.hand_order)]}
|
||||
@property
|
||||
def rhythm_report(self):
|
||||
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
|
||||
@staticmethod
|
||||
@@ -239,7 +288,40 @@ class Canonical:
|
||||
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
|
||||
lengths.append(generator.model.fraction(m['duration']))
|
||||
cursor += lengths[-1]
|
||||
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
|
||||
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit, composition=self.composition)
|
||||
def acoustic_realizations(self, sources, bounds=None, stop=None):
|
||||
"""Assign the canonical attack stream once, before splitting it into blocks.
|
||||
|
||||
A variant uses the same patterns with the source's actual harmony and
|
||||
participation boundaries. No selected notes from another role enter here.
|
||||
"""
|
||||
carried = generator.accompaniment.carried_chords(sources)
|
||||
full = tuple((m, carried[i], i) for i, m in enumerate(sources))
|
||||
key = (self.key(full), bounds, stop)
|
||||
if key in self.acoustic_cache:
|
||||
return self.acoustic_cache[key]
|
||||
starts = [fractions.Fraction()]
|
||||
for m in sources:
|
||||
starts.append(starts[-1] + generator.model.fraction(m['duration']))
|
||||
breaks = [(i, fractions.Fraction()) for i in self.pair_breaks | set(bounds or ())] + self.pair_stops
|
||||
if stop:
|
||||
breaks.append(stop)
|
||||
groups, raw = [], []
|
||||
for original in self.groups:
|
||||
group = tuple(full[i] for _, _, i in original)
|
||||
index = group[0][2]
|
||||
pattern, anticipate = self.patterns[index]
|
||||
limit = starts[stop[0]] + stop[1] - starts[index] if stop and any(i == stop[0] for _, _, i in group) else None
|
||||
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
|
||||
if rhythm is None:
|
||||
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
|
||||
raw.append(rhythm)
|
||||
groups.append(group)
|
||||
context = generator.comping_rhythm.acoustic_context(groups, self.melody, breaks)
|
||||
realized = generator.comping_rhythm.realize_acoustic_form(raw, context)
|
||||
result = {g[0][2]: r for g, r in zip(groups, realized)}
|
||||
self.acoustic_cache[key] = result
|
||||
return result
|
||||
def prepare(self, inputs, rhythm):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
spans = []
|
||||
@@ -253,18 +335,21 @@ class Canonical:
|
||||
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
|
||||
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
|
||||
try:
|
||||
options = voicings(semantic_key(chord), self.part)
|
||||
options = voicings(semantic_key(chord), self.part, composition=self.composition)
|
||||
except ValueError as error:
|
||||
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
|
||||
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
||||
return tuple(spans)
|
||||
def select(self, spans, *, left=(), right=(), close=False):
|
||||
def select(self, spans, *, left=(), right=(), left_hand=None, right_hand=None, close=False):
|
||||
layers = tuple(s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
|
||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
|
||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, left_hand=left_hand, right_hand=right_hand, close=close, fixed_hands=tuple(s.hand for s in spans))
|
||||
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
|
||||
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
|
||||
original_group = group
|
||||
sources = sources or self.measures
|
||||
if self.part == 'acoustic' and source != sources[index]:
|
||||
sources = [source if i == index else m for i, m in enumerate(sources)]
|
||||
carried = generator.accompaniment.carried_chords(sources)
|
||||
low, high = bounds or (self.first, len(sources))
|
||||
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
|
||||
@@ -272,67 +357,115 @@ class Canonical:
|
||||
if stop and any(i == stop[0] for _, _, i in group):
|
||||
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
|
||||
key = self.key(group, limit)
|
||||
complete = None
|
||||
if self.part == 'acoustic' and original_group[0][2] in self.patterns:
|
||||
complete = self.acoustic_realizations(sources, (low, high), stop)[original_group[0][2]]
|
||||
key = (key, complete.signature)
|
||||
if key not in self.cache:
|
||||
choices = self.candidates(group, limit)
|
||||
start, end = group[0][2], group[-1][2]
|
||||
# Preserve the canonical whole figure where still legal under changed
|
||||
# effective harmony. Recompute only genuinely different source context.
|
||||
original = self.block_rhythms.get(start)
|
||||
matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
|
||||
rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
|
||||
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
|
||||
if original_group[0][2] in self.patterns:
|
||||
pattern, anticipate = self.patterns[original_group[0][2]]
|
||||
if self.part == 'acoustic':
|
||||
end_time = offset + (limit if limit is not None else sum(generator.model.fraction(m['duration']) for m, _, _ in group))
|
||||
rhythm = dataclasses.replace(complete, events=tuple(dataclasses.replace(e, start=max(e.start, offset) - offset, end=min(e.end, end_time) - offset, harmonic_time=e.harmonic_time - offset if e.harmonic_time is not None and e.harmonic_time >= offset else None)
|
||||
for e in complete.events if e.start < end_time and e.end > offset))
|
||||
elif offset:
|
||||
# Fit the incoming context before slicing. A raw template sustain
|
||||
# may already have ended at a chord change; projecting it first
|
||||
# would resurrect that note as an unintended new downbeat attack.
|
||||
context = tuple((source if i == index else sources[i], prior if i == index else carried[i], i)
|
||||
for _, _, i in original_group if i <= end)
|
||||
incoming = generator.comping_rhythm.realize(pattern, context, self.part, self.melody, anticipate=anticipate, limit=offset + limit if limit is not None else None, boundary=True)
|
||||
if incoming is None:
|
||||
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
|
||||
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
|
||||
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
|
||||
if self.part != 'acoustic':
|
||||
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
|
||||
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
|
||||
if rhythm is None:
|
||||
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit protected boundary')
|
||||
else:
|
||||
# A source pickup outside the repeating body uses the existing partial
|
||||
# vocabulary. It never selects or changes the body groove.
|
||||
rhythm = min(self.candidates(group, limit), key=lambda r: (r.cost, generator.solo.policy.stable_rank(self.seed, r.signature)))
|
||||
spans = self.prepare(group, rhythm)
|
||||
before = [v for i, _, v in self.order if i < start]
|
||||
after = [v for i, _, v in self.order if i > end]
|
||||
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
|
||||
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
|
||||
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
|
||||
# Keep every unchanged harmonic interval's canonical grip, even when a
|
||||
# neighbor loses an optional turnaround or a section starts mid-pattern.
|
||||
fixed = []
|
||||
for span in spans:
|
||||
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
|
||||
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
|
||||
fixed.append(dataclasses.replace(span, options=(self.base[original.key],), hand=self.hands[original.key]) if original else span)
|
||||
spans = tuple(fixed)
|
||||
before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start]
|
||||
after = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i > end]
|
||||
left = before[-1] if before else (self.order[-1][2], self.hand_order[-1]) if self.closed and self.order else ((), 0)
|
||||
right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0)
|
||||
selected, _, hands = self.select(tuple(s for s in spans if s.events), left=left[0], right=right[0], left_hand=left[1], right_hand=right[1])
|
||||
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, tuple(zip(selected, hands)), left, True, limit)
|
||||
placement = self.cache_positions[key][index - group[0][2]]
|
||||
if placement is not None:
|
||||
self.last_position = placement
|
||||
return copy.deepcopy(self.cache[key][index - group[0][2]])
|
||||
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
length = generator.model.fraction(flat['duration'])
|
||||
index = inputs[0][2]
|
||||
attacks, rows = [], []
|
||||
attacks, rows, placed_events = [], [], []
|
||||
choices = iter(selected)
|
||||
for span in spans:
|
||||
grip = next(choices) if span.events else ()
|
||||
grip, hand = next(choices) if span.events else ((), 0)
|
||||
motion = generator.hand_position.motion(previous[0], grip, previous[1], hand, self.part == 'acoustic')
|
||||
bass = grip[0] if grip and self.part == 'acoustic' else None
|
||||
degrees = generator.harmony.degrees(span.chord)
|
||||
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
||||
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
|
||||
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
|
||||
'family': (generator.chord_families.acoustic(span.chord, grip, composition=self.composition) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
|
||||
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part, composition=self.composition),
|
||||
'fret_span': generator.fretboard.fret_span(grip),
|
||||
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
|
||||
'span_candidates': generator.chord_families.span_coverage(span.options),
|
||||
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
|
||||
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
|
||||
'bass_string': bass.string_number if bass else None,
|
||||
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
|
||||
'movement': motion.shift, 'voicing_candidates': len(span.options)})
|
||||
if grip:
|
||||
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55))
|
||||
travel = movement(self.part, previous, grip)
|
||||
effort = generator.guitar.voicing_cost(grip, 'electric').effort if self.part == 'electric' else 0
|
||||
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
|
||||
local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition)
|
||||
rows[-1]['hand_position'] = hand
|
||||
rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers,
|
||||
'string_changes': motion.strings, 'grip_effort': str(local.effort), 'register_tiebreak': str(local.register),
|
||||
'secondary_effort': str(motion.ergonomics + local.effort)}
|
||||
for event in span.events:
|
||||
placed_events.append((event.start, event.end, (grip, hand)))
|
||||
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
|
||||
for lane in lanes:
|
||||
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
|
||||
attacks.append((lane, event.start, event.end, positions, span.chord))
|
||||
if grip:
|
||||
previous = grip
|
||||
previous = (grip, hand)
|
||||
key = self.key(inputs, limit)
|
||||
if self.part == 'acoustic':
|
||||
key = (key, rhythm.signature)
|
||||
measure_hands, cursor = [], fractions.Fraction()
|
||||
for m, _, _ in inputs:
|
||||
end = cursor + generator.model.fraction(m['duration'])
|
||||
played = sorted((a, b, p) for a, b, p in placed_events if a < end and b > cursor)
|
||||
measure_hands.append(played[-1][2] if played else None)
|
||||
cursor = end
|
||||
self._reports[key] = rows
|
||||
voices = []
|
||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||
voice_id = lane if self.part == 'acoustic' else self.voice_id
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
def emit(a, b, positions=(), chord=None):
|
||||
previous = None
|
||||
for duration, written in generator.accompaniment.written_durations(b - a):
|
||||
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
|
||||
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
n['voice'] = lane
|
||||
n['voice'] = voice_id
|
||||
if previous and pitches:
|
||||
for old, new in zip(previous['pitches'], n['pitches']):
|
||||
old['tie_to'], new['tie_from'] = new['id'], old['id']
|
||||
@@ -346,16 +479,19 @@ class Canonical:
|
||||
cursor = end
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
voices.append({'id': lane, 'notes': notes})
|
||||
voices.append({'id': voice_id, 'notes': notes})
|
||||
result = split_measures({'voices': voices}, inputs, self.part)
|
||||
if self.part == 'acoustic':
|
||||
generator.acoustic.validate_assignment(result, rhythm.events)
|
||||
reports, cursor = [], fractions.Fraction()
|
||||
for m, _, i in inputs:
|
||||
duration = generator.model.fraction(m['duration'])
|
||||
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
|
||||
**next((self.locations[g[0][2]] for g in self.groups if any(j == i for _, _, j in g)), {}),
|
||||
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
|
||||
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
|
||||
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
|
||||
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
|
||||
cursor += duration
|
||||
self._rhythm_reports[key] = reports
|
||||
return result
|
||||
return result, tuple(measure_hands)
|
||||
|
||||
+393
-54
@@ -1,13 +1,73 @@
|
||||
"""Complete, source-only comping figures. Time is in rational quarter notes."""
|
||||
import dataclasses
|
||||
import fractions
|
||||
import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.solo.analysis
|
||||
import generator.solo.policy
|
||||
import generator.source
|
||||
import generator.vocabulary
|
||||
|
||||
def meter_context(measure):
|
||||
"""Nominal meter and explicit additive grouping, independent of bar length.
|
||||
|
||||
In particular, a three-quarter-note pickup or a 6/8 bar is not 3/4.
|
||||
"""
|
||||
groups = measure['meter']['groups']
|
||||
if not groups or any(g['unit'] != 4 for g in groups):
|
||||
raise ValueError(f"source measure {measure['number']}: no comping vocabulary for {measure['time_signature']}")
|
||||
beats = tuple(b for g in groups for b in g['beats'])
|
||||
total = sum(beats)
|
||||
if total not in (3, 4, 5) or (len(beats) > 1 and beats not in ((3, 2), (2, 3))):
|
||||
raise ValueError(f"source measure {measure['number']}: unsupported comping grouping {measure['time_signature']}")
|
||||
return f'{total}/4', beats if len(beats) > 1 else ()
|
||||
|
||||
def grouping_for(run, melody):
|
||||
"""Choose one source-only quintuple feel, shared by independent roles.
|
||||
|
||||
Written grouping wins. Otherwise compare actual chord changes, entrances
|
||||
after an eighth-note breath, then all melodic attacks at the group boundary.
|
||||
Counts are evidence, not inferred accents or performance dynamics.
|
||||
"""
|
||||
meter, explicit = meter_context(run[0][0][0])
|
||||
if meter != '5/4':
|
||||
return (), {'reason': 'simple_meter'}
|
||||
if explicit:
|
||||
return explicit, {'reason': 'written'}
|
||||
evidence = {}
|
||||
for boundary in (3, 2):
|
||||
changes, entrances, attacks = 0, 0, 0
|
||||
for group in run:
|
||||
for m, prior, i in group:
|
||||
changes += sum(a == boundary and c is not None for a, _, c in spans(m, prior))
|
||||
context = melody[i]
|
||||
for a, _ in context:
|
||||
if a != boundary:
|
||||
continue
|
||||
attacks += 1
|
||||
previous = max((b for x, b in context if x < a), default=fractions.Fraction())
|
||||
entrances += a - previous >= fractions.Fraction(1, 2)
|
||||
evidence[boundary] = (changes, entrances, attacks)
|
||||
boundary = 2 if evidence[2] > evidence[3] else 3
|
||||
return (boundary, 5 - boundary), {'reason': 'source' if evidence[2] != evidence[3] else 'default_3+2',
|
||||
'boundary_evidence': {str(k): dict(zip(('chord_changes', 'melody_entrances', 'melody_attacks'), v)) for k, v in evidence.items()}}
|
||||
|
||||
def patterns_for(policy, measure, lengths, grouping=()):
|
||||
meter, explicit = meter_context(measure)
|
||||
grouping = explicit or grouping or ((3, 2) if meter == '5/4' else ())
|
||||
# Untagged in-memory figures are allowed for independently authored unit
|
||||
# tests; the resource loader requires metadata for every production entry.
|
||||
return [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths)
|
||||
and (p.get('meter') == 'partial' or (p.get('meter', meter) == meter and tuple(p.get('grouping', grouping)) == grouping))]
|
||||
|
||||
def relevant_policy(policy, measures):
|
||||
"""An unused meter's vocabulary must not influence this role's decisions."""
|
||||
meters = {meter_context(m)[0] for m in measures}
|
||||
lengths = {generator.model.fraction(m['duration']) for m in measures}
|
||||
return {**policy, 'patterns': [p for p in policy['patterns'] if p.get('meter') in meters
|
||||
or (p.get('meter') == 'partial' and fractions.Fraction(p['lengths'][0]) in lengths)]}
|
||||
|
||||
@dataclasses.dataclass(frozen=True, order=True)
|
||||
class Attack:
|
||||
lane: str
|
||||
@@ -30,7 +90,7 @@ class Rhythm:
|
||||
return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
|
||||
|
||||
def melody_contexts(document):
|
||||
melody = generator.solo.analysis.attacks(document)
|
||||
melody = generator.source.attacks(document)
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for m in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
@@ -71,24 +131,59 @@ def validate_events(events, part, lengths=None):
|
||||
if duration <= 0:
|
||||
raise ValueError('comping event must have positive sounding duration')
|
||||
if part == 'acoustic':
|
||||
if e.lane == 'combined':
|
||||
if duration <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
|
||||
if e.lane not in ('bass', 'upper', 'combined') or (duration < 1 and duration != fractions.Fraction(1, 2)):
|
||||
raise ValueError('acoustic attacks require quarter/eighth notes or authored sustains')
|
||||
elif e.lane != 'electric':
|
||||
raise ValueError('electric comping requires chord events')
|
||||
if any(a.end > b.start for a, b in zip(ordered, ordered[1:])):
|
||||
raise ValueError('separate comping events cannot overlap; use one combined sustain')
|
||||
if part == 'acoustic' and lengths is not None:
|
||||
cursor = fractions.Fraction()
|
||||
for length in lengths:
|
||||
first = next((e for e in ordered if cursor <= e.start < cursor + length), None)
|
||||
if first and first.lane not in ('bass', 'combined'):
|
||||
raise ValueError('first acoustic attack in each measure must include bass')
|
||||
cursor += length
|
||||
for i, a in enumerate(ordered):
|
||||
for b in ordered[i + 1:]:
|
||||
if a.end > b.start:
|
||||
raise ValueError('comping sound must release before the next attack')
|
||||
|
||||
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
|
||||
def assign_acoustic(events, harmony, breaks=()):
|
||||
"""Assign attacks, not notation fragments, across a complete source span.
|
||||
|
||||
Pair left to right within an effective harmony. An anticipated attack is
|
||||
already playing its target harmony. Form/participation boundaries break
|
||||
pairs; ordinary barlines and computational blocks do not.
|
||||
"""
|
||||
ordered = sorted(events, key=lambda e: e.start)
|
||||
if len({e.start for e in ordered}) != len(ordered):
|
||||
raise ValueError('acoustic rhythm must have one event per attack position')
|
||||
|
||||
def region(event):
|
||||
time = event.harmonic_time if event.harmonic_time is not None else event.start
|
||||
return next(i for i, (a, b, _) in enumerate(harmony) if a <= time < b)
|
||||
|
||||
lanes, i = ['combined'] * len(ordered), 0
|
||||
while i + 1 < len(ordered):
|
||||
a, b = ordered[i:i + 2]
|
||||
if b.start - a.start == fractions.Fraction(1, 2) and region(a) == region(b) and not any(a.start < t <= b.start for t in breaks):
|
||||
lanes[i:i + 2] = ['bass', 'upper']
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
result = [dataclasses.replace(e, lane=lane) for e, lane in zip(ordered, lanes)]
|
||||
# One rhythm-guitar stream: every new attack releases the previous sound,
|
||||
# including a change of lane. A combined attack has one shared release.
|
||||
for i, a in enumerate(result):
|
||||
following = result[i + 1].start if i + 1 < len(result) else a.end
|
||||
result[i] = dataclasses.replace(a, end=min(a.end, following))
|
||||
# Shortening must not strand an anticipation before its promised arrival.
|
||||
# Rebind that event to the current chord, then reconsider its pair once.
|
||||
expired = {e.start for e in result if e.harmonic_time is not None and e.end <= e.harmonic_time}
|
||||
if expired:
|
||||
rebound = []
|
||||
for e in ordered:
|
||||
if e.start in expired:
|
||||
boundary = next(b for a, b, _ in harmony if a <= e.start < b)
|
||||
e = dataclasses.replace(e, harmonic_time=None, end=min(e.end, boundary))
|
||||
rebound.append(e)
|
||||
return assign_acoustic(rebound, harmony, breaks)
|
||||
validate_events(result, 'acoustic')
|
||||
return tuple(result)
|
||||
|
||||
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, assign=True):
|
||||
"""Bind a complete figure to written harmony, with bounded early arrivals."""
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
limit = length if limit is None else min(length, limit)
|
||||
@@ -105,30 +200,23 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
|
||||
continue
|
||||
available.append((dict(row), start, end, active))
|
||||
lengths = tuple(map(fractions.Fraction, pattern.get('lengths', (length,))))
|
||||
if part == 'acoustic' and generator.vocabulary.accompaniment(part)['first_attack'] == 'bass_or_combined':
|
||||
cursor = fractions.Fraction()
|
||||
for span in lengths:
|
||||
local = [(row, start) for row, start, _, _ in available if cursor <= start < cursor + span]
|
||||
if local and local[0][0]['layer'] == 'upper':
|
||||
first, onset = local[0]
|
||||
later = next(((row, start) for row, start in local[1:] if row['layer'] == 'bass'), None)
|
||||
first['layer'] = 'bass'
|
||||
first.pop('anticipate', None)
|
||||
if later:
|
||||
later[0]['layer'] = 'upper'
|
||||
exceptions.append(('layer_assignment', f'bass first at {onset}' + (f'; upper exchanged with bass at {later[1]}' if later else '; no later bass to exchange')))
|
||||
cursor += span
|
||||
for row, start, end, active in available:
|
||||
_, boundary, chord = active
|
||||
target = None
|
||||
if anticipate and row.get('anticipate') and row['layer'] in ('upper', 'electric'):
|
||||
if anticipate and row.get('anticipate'):
|
||||
future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None)
|
||||
if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]):
|
||||
target, boundary = future[0], future[1]
|
||||
end = min(end, boundary)
|
||||
events.append(Attack(row['layer'], start, end, target))
|
||||
events.append(Attack('acoustic' if part == 'acoustic' else row['layer'], start, end, target))
|
||||
try:
|
||||
validate_events(events, part, lengths)
|
||||
if part == 'acoustic' and assign:
|
||||
events = assign_acoustic(events, harmony)
|
||||
exceptions.append(('layer_assignment', 'combined attacks; same-harmony eighth pairs use bass then upper'))
|
||||
elif part == 'acoustic' and any(e.end <= e.start or (e.end - e.start < 1 and e.end - e.start != fractions.Fraction(1, 2)) for e in events):
|
||||
raise ValueError('acoustic articulation cannot fit this boundary')
|
||||
if part != 'acoustic' or assign:
|
||||
validate_events(events, part, lengths)
|
||||
except ValueError:
|
||||
return None
|
||||
return tuple(events), tuple(exceptions)
|
||||
@@ -143,8 +231,11 @@ def evaluate(events, part, melody, harmony, lengths):
|
||||
sounding = any(e.start < cursor + length and e.end > cursor for e in events)
|
||||
target = (1 if busy else 1.5) if part == 'electric' else (2 if busy else 2.5)
|
||||
# Sustained occupancy and planned space are not missing attacks.
|
||||
activity += .22 * max(0, abs(len(local) - target) - .75) if not (sounding and not local) else 0
|
||||
for e in local:
|
||||
activity += .22 * max(0, abs(len({e.start for e in local}) - target) - .75) if not (sounding and not local) else 0
|
||||
# A bass paired with the upper chord is one rhythmic attack, regardless
|
||||
# of whether unequal releases require two internal lane events.
|
||||
attacks_by_time = {e.start: max((v for v in local if v.start == e.start), key=lambda v: v.end) for e in local}
|
||||
for e in attacks_by_time.values():
|
||||
gap = not any(a <= e.start < b for a, b in melody)
|
||||
entrance = any(a == e.start and (i == 0 or a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
|
||||
support += -.2 if entrance else -.12 if gap else .18 if busy and e.start in attacks else 0
|
||||
@@ -156,13 +247,12 @@ def evaluate(events, part, melody, harmony, lengths):
|
||||
divisor = max(1, len(lengths))
|
||||
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
|
||||
|
||||
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None):
|
||||
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=(), composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
lengths = lengths or (generator.model.fraction(measure['duration']),)
|
||||
policy = generator.vocabulary.accompaniment(part)
|
||||
policy = composition.accompaniment(part)
|
||||
result = []
|
||||
for pattern in policy['patterns']:
|
||||
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
|
||||
continue
|
||||
for pattern in patterns_for(policy, measure, lengths, grouping):
|
||||
for anticipate in (False, True):
|
||||
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
|
||||
if fitted is None:
|
||||
@@ -191,18 +281,267 @@ def representatives(candidates, limit, seed):
|
||||
retained.extend(c for c in ordered if c not in retained)
|
||||
return tuple(retained[:limit])
|
||||
|
||||
def transition(previous, current):
|
||||
if previous is None:
|
||||
return 0.
|
||||
return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
|
||||
def project(pattern, offset, lengths):
|
||||
"""Take a form-position slice, rather than restarting a pattern at an entry."""
|
||||
end = offset + sum(lengths)
|
||||
rows = []
|
||||
for row in pattern['events']:
|
||||
a = fractions.Fraction(row['onset'])
|
||||
b = a + fractions.Fraction(row['duration'])
|
||||
if a < end and b > offset:
|
||||
rows.append({**row, 'onset': str(max(a, offset) - offset), 'duration': str(min(b, end) - max(a, offset))})
|
||||
return {**pattern, 'lengths': list(map(str, lengths)), 'events': rows}
|
||||
|
||||
def select(layers, seed, left=None, right=None):
|
||||
"""Compare complete two-bar paths, not independently chosen bar fragments."""
|
||||
states = [(0., (), (), left)]
|
||||
for i, layer in enumerate(layers):
|
||||
following = []
|
||||
for c in layer:
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.pattern_id, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
|
||||
following.append(min(options, key=lambda item: item[:2]))
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
|
||||
def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False, assign=True):
|
||||
flat = flatten(inputs)
|
||||
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs)
|
||||
fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, assign=assign)
|
||||
if fitted is None and boundary:
|
||||
# Keep the selected onset/layers at a protected handoff, using the longest
|
||||
# permitted short articulation that fits rather than another groove.
|
||||
clipped = project(pattern, fractions.Fraction(), (min(sum(lengths), limit) if limit is not None else sum(lengths),))
|
||||
rows = []
|
||||
for row in clipped['events']:
|
||||
a, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
|
||||
_, b, _ = next(s for s in spans(flat, None) if s[0] <= a < s[1])
|
||||
duration = min(duration, b - a)
|
||||
if part == 'acoustic' and duration <= 1:
|
||||
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= duration), fractions.Fraction())
|
||||
if duration:
|
||||
rows.append({**row, 'duration': str(duration)})
|
||||
fitted = fit({**pattern, 'events': rows}, flat, None, part, anticipate=False, limit=limit, assign=assign)
|
||||
if fitted is not None:
|
||||
fitted = fitted[0], (*fitted[1], ('release', 'shortened articulation at source-defined boundary'))
|
||||
if fitted is None:
|
||||
return None
|
||||
events, exceptions = fitted
|
||||
context, cursor = [], fractions.Fraction()
|
||||
for m, _, i in inputs:
|
||||
context.extend((a + cursor, b + cursor) for a, b in melody[i])
|
||||
cursor += generator.model.fraction(m['duration'])
|
||||
return Rhythm(events, (('pattern', pattern['family']),), evaluate(events, part, context, spans(flat, None), lengths), exceptions,
|
||||
pattern['id'] + ('.anticipated' if any(e.harmonic_time is not None for e in events) else ''))
|
||||
|
||||
def identity(pattern):
|
||||
"""Layer swaps and anticipation permissions alone are not different rhythms."""
|
||||
return tuple((fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events'])
|
||||
|
||||
def acoustic_context(groups, melody, breaks=()):
|
||||
"""Immutable source timing for assigning and evaluating a complete stream.
|
||||
|
||||
Breaks are (source measure index, offset) pairs. Ordinary storage boundaries
|
||||
are absent: adjacent figures must see each other's attacks before scoring.
|
||||
"""
|
||||
blocks, starts, cursor = [], {}, fractions.Fraction()
|
||||
for group in groups:
|
||||
start, context, lengths = cursor, [], []
|
||||
for m, _, i in group:
|
||||
starts[i] = cursor
|
||||
context.extend((a + cursor - start, b + cursor - start) for a, b in melody[i])
|
||||
lengths.append(generator.model.fraction(m['duration']))
|
||||
cursor += lengths[-1]
|
||||
starts[i + 1] = cursor
|
||||
blocks.append((start, cursor, tuple(context), spans(flatten(group), None), tuple(lengths)))
|
||||
full = tuple(row for group in groups for row in group)
|
||||
return {'blocks': tuple(blocks), 'harmony': spans(flatten(full), None),
|
||||
'breaks': tuple(sorted(starts[i] + t for i, t in breaks if i in starts))}
|
||||
|
||||
def realize_acoustic_form(rhythms, context):
|
||||
"""The shared release/lane contract for selection, fills and final output.
|
||||
|
||||
Inputs retain authored releases. Re-evaluate those releases whenever an
|
||||
edited neighboring figure changes the next attack; never restore duration
|
||||
from previously shortened notation.
|
||||
"""
|
||||
if len(rhythms) != len(context['blocks']):
|
||||
raise ValueError('acoustic realization requires one rhythm per source block')
|
||||
events = []
|
||||
for rhythm, (start, _, _, _, _) in zip(rhythms, context['blocks']):
|
||||
events.extend(dataclasses.replace(e, start=e.start + start, end=e.end + start, harmonic_time=e.harmonic_time + start if e.harmonic_time is not None else None) for e in rhythm.events)
|
||||
assigned = assign_acoustic(events, context['harmony'], context['breaks'])
|
||||
result = []
|
||||
for rhythm, (start, end, melody, harmony, lengths) in zip(rhythms, context['blocks']):
|
||||
local = tuple(dataclasses.replace(e, start=e.start - start, end=e.end - start, harmonic_time=e.harmonic_time - start if e.harmonic_time is not None else None)
|
||||
for e in assigned if start <= e.start < end)
|
||||
exceptions = tuple(e for e in rhythm.exceptions if e[0] != 'layer_assignment')
|
||||
result.append(dataclasses.replace(rhythm, events=local, contributions=evaluate(local, 'acoustic', melody, harmony, lengths),
|
||||
pattern_id=rhythm.pattern_id.removesuffix('.anticipated') + ('.anticipated' if any(e.harmonic_time is not None for e in local) else ''),
|
||||
exceptions=(*exceptions, ('layer_assignment', 'combined attacks or bass/upper eighth pairs; release at every next attack'))))
|
||||
return tuple(result)
|
||||
|
||||
def form_cost(rhythms, groups):
|
||||
return sum(r.cost * len(g) for r, g in zip(rhythms, groups))
|
||||
|
||||
def choose_cycles(groups, part, melody, seed, *, breaks=(), composition=None):
|
||||
"""Select a small recurring vocabulary against every occurrence in the form.
|
||||
|
||||
No local shortlist precedes whole-form evaluation. A pattern/mode is retained
|
||||
as one decision even where a chord boundary changes its legal release.
|
||||
"""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
policy = composition.accompaniment(part)
|
||||
settings = policy['groove']
|
||||
selected, locations, reports = [], [], []
|
||||
runs = []
|
||||
for group in groups:
|
||||
m = group[0][0]
|
||||
key = (meter_context(m), generator.model.fraction(m['duration']))
|
||||
if not runs or runs[-1][0] != key:
|
||||
runs.append((key, []))
|
||||
runs[-1][1].append(group)
|
||||
for ((meter, _), length), run in runs:
|
||||
grouping, grouping_evidence = grouping_for(run, melody)
|
||||
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
|
||||
if not patterns:
|
||||
patterns = patterns_for(policy, run[0][0][0], (length,), grouping)
|
||||
requested = settings['cycle_patterns']
|
||||
identities = {p['id']: identity(p) for p in patterns}
|
||||
available = len(set(identities.values()))
|
||||
cache, estimates = {}, {}
|
||||
context = acoustic_context(run, melody, breaks) if part == 'acoustic' else None
|
||||
for i, group in enumerate(run):
|
||||
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group)
|
||||
local_context = acoustic_context((group,), melody, breaks) if context else None
|
||||
for p in patterns:
|
||||
for anticipate in (False, True):
|
||||
value = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
|
||||
cache[i, p['id'], anticipate] = value
|
||||
try:
|
||||
estimates[i, p['id'], anticipate] = realize_acoustic_form((value,), local_context)[0] if context and value is not None else value
|
||||
except ValueError:
|
||||
estimates[i, p['id'], anticipate] = None
|
||||
winner, examined = None, 0
|
||||
for width in range(min(requested, len(run), available), 0, -1):
|
||||
layers = []
|
||||
for slot in range(width):
|
||||
unique = {}
|
||||
for p in patterns:
|
||||
for anticipate in (False, True):
|
||||
values = [estimates[i, p['id'], anticipate] for i in range(slot, len(run), width)]
|
||||
if any(v is None for v in values):
|
||||
continue
|
||||
cost = sum(v.cost * len(run[i]) for i, v in zip(range(slot, len(run), width), values))
|
||||
rank = (cost, generator.solo.policy.stable_rank(seed, (meter, slot, p['id'], anticipate)))
|
||||
option = (rank, p, anticipate)
|
||||
key = identities[p['id']]
|
||||
if key not in unique or rank < unique[key][0]:
|
||||
unique[key] = option
|
||||
ordered = sorted(unique.values(), key=lambda o: o[0])
|
||||
diverse, families = [], set()
|
||||
for option in ordered:
|
||||
if option[1]['family'] not in families:
|
||||
diverse.append(option)
|
||||
families.add(option[1]['family'])
|
||||
diverse.extend(o for o in ordered if o not in diverse)
|
||||
layers.append(diverse[:settings['slot_candidates']])
|
||||
for cycle in itertools.product(*layers):
|
||||
if len({identities[o[1]['id']] for o in cycle}) != width:
|
||||
continue
|
||||
examined += 1
|
||||
cost = sum(o[0][0] for o in cycle)
|
||||
if context:
|
||||
values = [cache[i, cycle[i % width][1]['id'], cycle[i % width][2]] for i in range(len(run))]
|
||||
try:
|
||||
cost = form_cost(realize_acoustic_form(values, context), run)
|
||||
except ValueError:
|
||||
continue
|
||||
rank = (cost, tuple(o[0][1] for o in cycle))
|
||||
if winner is None or rank < winner[0]:
|
||||
winner = rank, cycle
|
||||
if winner is not None:
|
||||
break
|
||||
if winner is None:
|
||||
raise ValueError(f'source measure {run[0][0][0]["number"]}: no legal recurring {part} groove for {meter}; check harmonic releases and pattern vocabulary')
|
||||
cycle = winner[1]
|
||||
report = {'source_start': run[0][0][2], 'source_end': run[-1][-1][2], 'meter': meter,
|
||||
'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns),
|
||||
'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle],
|
||||
'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined,
|
||||
'retrieval_cost': sum(o[0][0] for o in cycle),
|
||||
'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None}
|
||||
reports.append(report)
|
||||
for i, group in enumerate(run):
|
||||
_, p, anticipate = cycle[i % len(cycle)]
|
||||
selected.append((p, anticipate, cache[i, p['id'], anticipate]))
|
||||
locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None})
|
||||
context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None
|
||||
add_fills(groups, selected, locations, part, melody, seed, context=context, composition=composition)
|
||||
if context:
|
||||
realized = realize_acoustic_form([r for _, _, r in selected], context)
|
||||
selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)]
|
||||
for report in reports:
|
||||
report['final_cost'] = sum(r.cost * len(g) for g, r in zip(groups, realized) if report['source_start'] <= g[0][2] <= report['source_end'])
|
||||
report['attack_assignment'] = 'sequential_combined_except_eighth_pairs'
|
||||
return selected, locations, reports
|
||||
|
||||
def add_fills(groups, selected, locations, part, melody, seed, *, context=None, composition=None):
|
||||
"""Fixed source-position answers; no iteration seed or generated lead input."""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
policy = composition.accompaniment(part)
|
||||
spacing = policy['groove']['fill_spacing_measures']
|
||||
if not groups:
|
||||
return
|
||||
body_start, body_end = groups[0][0][2], groups[-1][-1][2] + 1
|
||||
body_length = body_end - body_start
|
||||
options = []
|
||||
if body_length < spacing:
|
||||
return
|
||||
original = realize_acoustic_form([r for _, _, r in selected], context) if context else tuple(r for _, _, r in selected)
|
||||
|
||||
def proposal(i, candidate, baseline):
|
||||
if not context:
|
||||
return candidate.cost < baseline[i].cost - 1e-9, baseline[i].cost - candidate.cost
|
||||
raw = [r for _, _, r in selected]
|
||||
raw[i] = candidate
|
||||
try:
|
||||
revised = realize_acoustic_form(raw, context)
|
||||
except ValueError:
|
||||
return False, 0
|
||||
middle = generator.model.fraction(groups[i][0][0]['duration'])
|
||||
# A second-bar fill must not alter the preceding/following block's lane
|
||||
# assignment or release, nor any part of its own first bar.
|
||||
if any(a.events != b.events for j, (a, b) in enumerate(zip(baseline, revised)) if j != i):
|
||||
return False, 0
|
||||
if tuple(e for e in baseline[i].events if e.start < middle) != tuple(e for e in revised[i].events if e.start < middle):
|
||||
return False, 0
|
||||
gain = form_cost(baseline, groups) - form_cost(revised, groups)
|
||||
return gain > 1e-9, gain
|
||||
|
||||
for i, (group, (base, anticipate, _)) in enumerate(zip(groups, selected)):
|
||||
current = original[i]
|
||||
if len(group) != 2:
|
||||
continue
|
||||
last, _, index = group[-1]
|
||||
length = generator.model.fraction(last['duration'])
|
||||
reason = 'form_end' if i == len(groups) - 1 else 'section_end' if groups[i + 1][0][0].get('section_label') else None
|
||||
if reason is None and max((min(b, length) for a, b in melody[index]), default=fractions.Fraction()) <= length - 1:
|
||||
reason = 'melody_breath'
|
||||
if reason is None:
|
||||
continue
|
||||
middle = generator.model.fraction(group[0][0]['duration'])
|
||||
if any(e.start < middle < e.end for e in current.events):
|
||||
continue
|
||||
for variation in policy['patterns']:
|
||||
if variation['family'] != base['family'] or tuple(variation['lengths']) != tuple(base['lengths']) or variation.get('meter') != base.get('meter') or tuple(variation.get('grouping', ())) != tuple(base.get('grouping', ())):
|
||||
continue
|
||||
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
|
||||
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
|
||||
[r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]}
|
||||
candidate = realize(hybrid, group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
|
||||
if candidate is None:
|
||||
continue
|
||||
if not context and tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
|
||||
continue
|
||||
valid, gain = proposal(i, candidate, original)
|
||||
if not valid:
|
||||
continue
|
||||
options.append((-gain, generator.solo.policy.stable_rank(seed, (index, hybrid['id'])), i, index, hybrid, anticipate, candidate, reason))
|
||||
chosen = []
|
||||
for _, _, i, index, pattern, anticipate, candidate, reason in sorted(options):
|
||||
if any(min(abs(index - other), body_length - abs(index - other)) < spacing for other in chosen):
|
||||
continue
|
||||
if context and not proposal(i, candidate, realize_acoustic_form([r for _, _, r in selected], context))[0]:
|
||||
continue
|
||||
selected[i] = pattern, anticipate, candidate
|
||||
locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason}
|
||||
chosen.append(index)
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
"""Explicit single-role build. Does not publish or consume an existing ensemble."""
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
import generator.composition
|
||||
import generator.composition_types
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.production
|
||||
import generator.source
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--song', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--role', choices=generator.production.ROLES, required=True)
|
||||
parser.add_argument('--role', choices=generator.composition_types.ROLES, required=True)
|
||||
parser.add_argument('--data-dir', type=pathlib.Path, default=pathlib.Path('data'))
|
||||
parser.add_argument('--output', type=pathlib.Path, required=True)
|
||||
parser.add_argument('--cache-dir', type=pathlib.Path)
|
||||
parser.add_argument('--settings', type=pathlib.Path)
|
||||
args = parser.parse_args()
|
||||
source = generator.production.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
|
||||
source = generator.source.SourceAnalysis.read(generator.parts.prepare_document(generator.musicxml.load_musicxml(args.song)))
|
||||
start = time.monotonic()
|
||||
result = generator.production.generate_role(source, args.role, args.data_dir, json.loads(args.settings.read_text()) if args.settings else {}, args.cache_dir)
|
||||
result = generator.composition.generate_role(source, args.role, args.data_dir, json.loads(args.settings.read_text()) if args.settings else {}, args.cache_dir)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(result, indent=2))
|
||||
print(f'{args.role}: {time.monotonic() - start:.2f}s wall; cached={result["cache_hit"]}; role generation/cache lookup {result["seconds"]:.2f}s')
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Independent, reproducible role generation from source and policy inputs.
|
||||
|
||||
Only source documents and production resources enter generate_role. Completed
|
||||
roles and published assets are downstream values, never composition inputs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import copy
|
||||
import dataclasses
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.assembly
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.composition_types
|
||||
import generator.fretboard
|
||||
import generator.input_audit
|
||||
import generator.role_cache
|
||||
import generator.score
|
||||
import generator.solo.arrangement
|
||||
import generator.solo.authored
|
||||
import generator.solo.composer
|
||||
import generator.solo.learned
|
||||
import generator.solo.notation
|
||||
import generator.solo.report
|
||||
import generator.source
|
||||
import generator.validation
|
||||
import generator.vocabulary
|
||||
|
||||
VERSION = 'independent-roles-1'
|
||||
|
||||
def dependencies(role, resources, measures=None):
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
|
||||
accompaniment = {}
|
||||
if role.endswith('_accompaniment'):
|
||||
policy = generator.vocabulary.accompaniment(role.split('_')[0], resources)
|
||||
accompaniment = {'accompaniment': generator.comping_rhythm.relevant_policy(policy, measures) if measures is not None else policy}
|
||||
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
|
||||
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
|
||||
**accompaniment,
|
||||
**{name: resources[name] for name in names}}
|
||||
|
||||
def _resource_key(directory, role):
|
||||
return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_model(directory, fingerprint):
|
||||
return generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
|
||||
|
||||
def generate_role(source, role, data_dir, settings=None, cache_dir=None) -> generator.composition_types.RoleResult:
|
||||
started = time.monotonic()
|
||||
with generator.input_audit.composition(data_dir, role, cache_dir) as reads:
|
||||
result = _generate_role(source, role, data_dir, settings, cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace'] = {**result['trace'], 'input_boundary': 'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
|
||||
result['observed_reads'] = sorted(reads)
|
||||
result['seconds'] = time.monotonic() - started
|
||||
return result
|
||||
|
||||
def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
if role not in generator.composition_types.ROLES:
|
||||
raise ValueError('Unknown generation role: ' + role)
|
||||
data_dir = pathlib.Path(data_dir)
|
||||
resources = generator.vocabulary.load(data_dir / 'composition')
|
||||
settings = settings or {}
|
||||
registered = generator.solo.authored.lookup(source, role.removesuffix('_solo'), data_dir) if role.endswith('_solo') and not settings.get('predict', False) else None
|
||||
algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
|
||||
'vocabulary': dependencies(role, resources, generator.score.source_staff(source.document())['measures']), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': generator.role_cache.code_key()}
|
||||
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
|
||||
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
||||
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
|
||||
cached = generator.role_cache.restore(path, key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
started = time.monotonic()
|
||||
document = source.document()
|
||||
part = role.split('_')[0]
|
||||
plan = generator.arrange.form.FormPlan.read(document, part)
|
||||
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
|
||||
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
|
||||
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
|
||||
choice_data, choice_settings = inputs['vocabulary'], settings
|
||||
if role.endswith('_accompaniment'):
|
||||
# Voicing weights invalidate the concrete cache, but must not reshuffle
|
||||
# rhythm patterns. Removing this new field preserves the pre-policy seed.
|
||||
choice_data = copy.deepcopy(choice_data)
|
||||
choice_data['roles']['role'].pop('voicing', None)
|
||||
for field in ('provenance', 'license', 'research'):
|
||||
choice_data['accompaniment'].pop(field, None)
|
||||
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
|
||||
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
|
||||
details = {}
|
||||
solo_report = ''
|
||||
composition_timings = {}
|
||||
composition = generator.vocabulary.CompositionContext.create(resources, role, settings | {'choice_seed': seed})
|
||||
if role.endswith('_solo'):
|
||||
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'slash'}, composition=composition)
|
||||
si = 3 if part == 'acoustic' else 4
|
||||
first, count, offset, length = generator.solo.arrangement.regions(document)[si]
|
||||
# The template/harmony comes directly from source form, not another generated part.
|
||||
authored = generator.score.source_staff(document)['measures']
|
||||
section = plan.sections[si]
|
||||
effective = generator.arrange.generate.section_source(authored, section, plan.sections[si + 1])
|
||||
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count])
|
||||
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
start, end = next((a - offset, b - offset) for a, b, r in plan.windows if r == role)
|
||||
notation_first = first
|
||||
if start < 0:
|
||||
# The preceding arrangement harmony comes from the source form. Do
|
||||
# not invent harmony from a comparison score's unlabelled lead-in.
|
||||
template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1]))
|
||||
notation_first -= 1
|
||||
initial = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
|
||||
if registered:
|
||||
solo = generator.solo.authored.realize(registered, generator.source.analyze(document), template, part, start, end, initial, composition=composition)
|
||||
else:
|
||||
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
|
||||
solo = generator.solo.composer.compose(generator.source.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=composition.role().get('expressions', True), start=start, composition=composition)
|
||||
composition_timings = {k: v for k, v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
relative = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in solo.notes())
|
||||
output = generator.solo.notation.measures(result['staves'][0]['measures'][notation_first:first + count], relative, 'solo_' + part)
|
||||
generator.solo.notation.replace_region(result, notation_first, output)
|
||||
details = generator.solo.report.structure(solo, composition=composition)
|
||||
details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first}
|
||||
solo_report = generator.solo.report.describe(solo, first, offset, detail=details, composition=composition)
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
|
||||
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
|
||||
details = {'canonical_comping': canonical.report, 'guitar_placement': canonical.placement_report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
|
||||
else:
|
||||
placement_report = {}
|
||||
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition, placement_report=placement_report)
|
||||
if placement_report:
|
||||
details['guitar_placement'] = placement_report
|
||||
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
|
||||
generator.arrange.assembly.mask(result, allowed)
|
||||
if role == "acoustic_accompaniment":
|
||||
generator.acoustic.validate_comping(result)
|
||||
staff = result['staves'][0]
|
||||
for measure in staff['measures']:
|
||||
measure['id'] = role + '_' + measure['id']
|
||||
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
|
||||
if part != 'vocal':
|
||||
result['staves'][0]['tablature'] = True
|
||||
generator.validation.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
value = {'role': role, 'document': result, 'trace': trace, 'details': details, 'report': solo_report, 'seconds': time.monotonic() - started, 'composition_timings': composition_timings, 'cache_hit': False}
|
||||
# Sidecar diagnostics use the same rational-safe representation as reports.
|
||||
# Identical insertion order after cold generation and cache loading also
|
||||
# keeps serialized scores and the catalog byte-for-byte reproducible.
|
||||
value = json.loads(json.dumps(value, default=str, sort_keys=True))
|
||||
generator.role_cache.store(path, key, value)
|
||||
return value
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Worker requests and role results; score serialization stays unchanged."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import pathlib
|
||||
import typing
|
||||
|
||||
import generator.source
|
||||
|
||||
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class RoleRequest:
|
||||
source: generator.source.SourceAnalysis
|
||||
role: str
|
||||
directory: pathlib.Path
|
||||
settings_json: str = '{}'
|
||||
cache: pathlib.Path | None = None
|
||||
|
||||
class RoleResult(typing.TypedDict):
|
||||
role: str
|
||||
document: dict
|
||||
trace: dict
|
||||
details: dict
|
||||
report: str
|
||||
seconds: float
|
||||
composition_timings: dict
|
||||
cache_hit: bool
|
||||
observed_reads: typing.NotRequired[list[str]]
|
||||
@@ -19,16 +19,17 @@ def audit_primitives(primitives, width, height, fonts):
|
||||
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:
|
||||
def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocument, fonts: generator.engraving.fonts.Fonts, *, systems_per_page: int | None = 2) -> None:
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
fragments, primitives = {}, {}
|
||||
previous_end = fractions.Fraction()
|
||||
scale = layout.pages[0].systems[0].scale
|
||||
for page in layout.pages:
|
||||
if len(page.systems) != 2 and (page is not layout.pages[-1] or len(page.systems) != 1):
|
||||
if not page.systems or (systems_per_page is not None and len(page.systems) != systems_per_page and (page is not layout.pages[-1] or not 1 <= len(page.systems) < systems_per_page)):
|
||||
raise generator.engraving.types.EngravingError('pages must contain two music systems, except an odd final system')
|
||||
if len(page.systems) == 2 and page.systems[0].box.bottom + layout.style.system_gap > page.systems[1].box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
for first, second in zip(page.systems, page.systems[1:]):
|
||||
if first.box.bottom + layout.style.system_gap > second.box.top + 0.01:
|
||||
raise generator.engraving.types.EngravingError('music systems overlap their page rows')
|
||||
audit_primitives(page.primitives, page.width, page.height, fonts)
|
||||
header = generator.engraving.types.bounds([p for p in page.primitives if p.role in ('title', 'subtitle')])
|
||||
footer = generator.engraving.types.bounds([p for p in page.primitives if p.role == 'page_number'])
|
||||
|
||||
@@ -9,12 +9,12 @@ import pathlib
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import PIL.Image
|
||||
import PIL.ImageChops
|
||||
import PIL.features
|
||||
|
||||
import generator.cache
|
||||
import generator.engraving.validation
|
||||
import generator.theme
|
||||
|
||||
@@ -22,9 +22,10 @@ def renderer_identity(fonts, style) -> dict:
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
versions = {name: importlib.metadata.version(name) for name in ('Pillow', 'fonttools', 'aggdraw')}
|
||||
versions.update({name: PIL.features.version(name) for name in PIL.features.get_supported()})
|
||||
sources = [*root.joinpath('engraving').glob('*.py'), root / 'model.py', root / 'performance.py', root / 'harmony.py', root / 'theme.py', root / 'palette.json']
|
||||
sources = generator.cache.source_identity(('generator.engraving.pipeline', 'generator.printing'))
|
||||
sources['palette.json'] = hashlib.sha256((root / 'palette.json').read_bytes()).hexdigest()
|
||||
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)}}
|
||||
'sources': sources}
|
||||
|
||||
def renderer_fingerprint(fonts, style) -> str:
|
||||
return hashlib.sha256(json.dumps(renderer_identity(fonts, style), sort_keys=True).encode()).hexdigest()
|
||||
@@ -46,12 +47,9 @@ 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 has_symlink(entry) or has_symlink(destination):
|
||||
if generator.cache.has_symlink(entry) or generator.cache.has_symlink(destination):
|
||||
return None
|
||||
manifest = entry / 'pages.json'
|
||||
if manifest.is_symlink():
|
||||
@@ -76,28 +74,17 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
|
||||
return 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 = pathlib.Path(temporary)
|
||||
with generator.cache.stage_entry(entry, '.engraving_') as staged:
|
||||
for page in pages:
|
||||
shutil.copyfile(destination / page['path'], staged / page['path'])
|
||||
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
|
||||
# Identical keys have identical pages; publish complete files, then the manifest.
|
||||
for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']:
|
||||
target = entry / name
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
(staged / name).replace(target)
|
||||
generator.cache.publish_files(entry, staged, list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json'])
|
||||
|
||||
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():
|
||||
if generator.cache.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'}:
|
||||
@@ -122,19 +109,8 @@ def restore_band(entry: pathlib.Path, key: str, width: int, height: int) -> PIL.
|
||||
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)
|
||||
with generator.cache.stage_entry(entry, '.engraving_') as staged:
|
||||
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)
|
||||
generator.cache.publish_files(entry, staged, ('band.png', 'band.json'))
|
||||
|
||||
@@ -16,7 +16,7 @@ import generator.engraving.spans
|
||||
import generator.engraving.tablature
|
||||
import generator.engraving.types
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.validation
|
||||
|
||||
def lyric_text(verse: dict) -> str:
|
||||
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
|
||||
@@ -194,6 +194,8 @@ def part_header(document, fonts, style):
|
||||
raise generator.engraving.types.EngravingError('score title cannot fit the page without clipping')
|
||||
result = [item]
|
||||
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name')) if value)
|
||||
if document['metadata'].get('solos_enabled') is False:
|
||||
subtitle += ' - Improvisation'
|
||||
if not subtitle:
|
||||
return result
|
||||
credit = fonts.text(subtitle, 0, style.margin_y + size, 12, 'subtitle', ())
|
||||
@@ -250,7 +252,7 @@ def paginate(systems, document, fonts, style, *, index=None):
|
||||
|
||||
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]:
|
||||
if index is None:
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
if not index.measures:
|
||||
raise generator.engraving.types.EngravingError('score has no measures')
|
||||
@@ -276,7 +278,7 @@ def layout_systems(document: dict, style: generator.engraving.types.Style, fonts
|
||||
return tuple(systems)
|
||||
|
||||
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
style = generator.engraving.types.score_style(document, style)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
systems = layout_systems(document, style, fonts, index=index)
|
||||
|
||||
@@ -1,195 +1,17 @@
|
||||
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.notation
|
||||
import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.engraving.validation
|
||||
import generator.model
|
||||
import generator.performance
|
||||
|
||||
@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 = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
|
||||
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
|
||||
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
|
||||
music = document
|
||||
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': normal, 'playback_pages': playback}}
|
||||
validate_cached_views(document, pages)
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_cached_views(document, pages):
|
||||
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
|
||||
|
||||
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
|
||||
index = 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'))
|
||||
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
|
||||
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()
|
||||
generator.model.validate_document(document)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
|
||||
result.timings['layout'] += time.monotonic() - started
|
||||
started = time.monotonic()
|
||||
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
|
||||
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)
|
||||
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
|
||||
audited.add(placement)
|
||||
else:
|
||||
for page in pages_layout + playback_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 = []
|
||||
rendered = {}
|
||||
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
|
||||
if page.primitives in rendered:
|
||||
name, digest = rendered[page.primitives]
|
||||
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
continue
|
||||
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 / (('playback-' if is_playback else '') + f'{page.number:04}.png')
|
||||
with image:
|
||||
image.save(target, format='PNG')
|
||||
result.timings['raster'] += time.monotonic() - started
|
||||
result.renderedpages += 1
|
||||
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
rendered[page.primitives] = target.name, digest
|
||||
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
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
|
||||
import generator.engraving.worker
|
||||
import generator.validation
|
||||
|
||||
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:
|
||||
@@ -211,22 +33,22 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
||||
print(f'Engraving cache enabled: {cache_dir} ({state}; renderer {renderer})', flush=True)
|
||||
elif progress:
|
||||
print('Engraving cache disabled', flush=True)
|
||||
totals = GroupResult()
|
||||
totals = generator.engraving.worker.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)
|
||||
generator.validation.validate_document(document)
|
||||
score_key = generator.engraving.cache.score_fingerprint(document, renderer)
|
||||
destination = path.with_suffix('.pages')
|
||||
clear_pages(destination)
|
||||
generator.engraving.worker.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 not None:
|
||||
try:
|
||||
validate_cached_views(document, pages)
|
||||
generator.engraving.worker.validate_cached_views(document, pages)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pages = None
|
||||
if pages is None:
|
||||
@@ -235,7 +57,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
||||
notation = generator.engraving.cache.notation_fingerprint(document, renderer)
|
||||
groups.setdefault(notation, []).append((path, score_key))
|
||||
else:
|
||||
write_bundle(path, document, pages, fingerprint)
|
||||
generator.engraving.worker.write_bundle(path, document, pages, fingerprint)
|
||||
totals.fullscore += 1
|
||||
completed += 1
|
||||
except ValueError as error:
|
||||
@@ -272,10 +94,10 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
|
||||
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))
|
||||
finish(generator.engraving.worker.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()]
|
||||
futures = [pool.submit(generator.engraving.worker.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:
|
||||
|
||||
@@ -30,12 +30,14 @@ class OutlinePen(fontTools.pens.basePen.BasePen):
|
||||
def _endPath(self):
|
||||
pass
|
||||
|
||||
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_page(page: generator.engraving.types.Page, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
return rasterize_band(page, page.primitives, 0, page.height, fonts, ink=ink, background=background, output_scale=output_scale)
|
||||
|
||||
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 = PIL.Image.new('RGB', (page.width * scale, (bottom - top) * scale), generator.theme.rgb('background'))
|
||||
def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[generator.engraving.types.Primitive, ...], top: int, bottom: int, fonts: generator.engraving.fonts.Fonts, *, ink=None, background=None, output_scale=1) -> PIL.Image.Image:
|
||||
ink = generator.theme.rgb('ink') if ink is None else ink
|
||||
background = generator.theme.rgb('background') if background is None else background
|
||||
scale = page.supersampling * output_scale
|
||||
image = PIL.Image.new('RGB', (round(page.width * scale), round((bottom - top) * scale)), background)
|
||||
context = aggdraw.Draw(image)
|
||||
for item in primitives:
|
||||
points = [(x * scale, y * scale - top * scale) for x, y in item.points]
|
||||
@@ -56,16 +58,18 @@ def rasterize_band(page: generator.engraving.types.Page, primitives: tuple[gener
|
||||
if item.kind == 'polygon':
|
||||
path.close()
|
||||
if item.kind in ('glyph', 'polygon'):
|
||||
context.path(path, None, aggdraw.Brush(generator.theme.rgb('ink')))
|
||||
context.path(path, None, aggdraw.Brush(ink))
|
||||
else:
|
||||
context.path(path, aggdraw.Pen(generator.theme.rgb('ink'), item.thickness * scale))
|
||||
context.path(path, aggdraw.Pen(ink, item.thickness * scale))
|
||||
context.flush()
|
||||
drawing = PIL.ImageDraw.Draw(image)
|
||||
for item in primitives:
|
||||
if item.kind == 'text':
|
||||
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=generator.theme.rgb('ink'))
|
||||
return image.resize((page.width, bottom - top), PIL.Image.Resampling.LANCZOS)
|
||||
drawing.text((x * scale, y * scale - top * scale), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill=ink)
|
||||
result = image.resize((round(page.width * output_scale), round((bottom - top) * output_scale)), PIL.Image.Resampling.LANCZOS)
|
||||
image.close()
|
||||
return result
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Band:
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
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.notation
|
||||
import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.engraving.validation
|
||||
import generator.performance
|
||||
import generator.validation
|
||||
|
||||
@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 = [{k: v for k, v in {**page, 'path': path.with_suffix('.pages').name + '/' + page['path']}.items() if k != 'playback'} for page in pages]
|
||||
normal = [entry for entry, page in zip(entries, pages) if not page['playback']]
|
||||
playback = [entry for entry, page in zip(entries, pages) if page['playback']]
|
||||
music = document
|
||||
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': normal, 'playback_pages': playback}}
|
||||
validate_cached_views(document, pages)
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_cached_views(document, pages):
|
||||
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
|
||||
|
||||
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
|
||||
index = 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'))
|
||||
style = generator.engraving.types.score_style(document, generator.engraving.types.Style())
|
||||
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()
|
||||
generator.validation.validate_document(document)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
|
||||
result.timings['layout'] += time.monotonic() - started
|
||||
started = time.monotonic()
|
||||
pages_layout, playback_layout = generator.engraving.layout.page_views(systems, document, family, style, index=index)
|
||||
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)
|
||||
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
|
||||
audited.add(placement)
|
||||
else:
|
||||
for page in pages_layout + playback_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 = []
|
||||
rendered = {}
|
||||
for is_playback, page in [(False, p) for p in pages_layout] + [(True, p) for p in playback_layout]:
|
||||
if page.primitives in rendered:
|
||||
name, digest = rendered[page.primitives]
|
||||
pages.append(dict(path=name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
continue
|
||||
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 / (('playback-' if is_playback else '') + f'{page.number:04}.png')
|
||||
with image:
|
||||
image.save(target, format='PNG')
|
||||
result.timings['raster'] += time.monotonic() - started
|
||||
result.renderedpages += 1
|
||||
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
rendered[page.primitives] = target.name, digest
|
||||
pages.append(dict(path=target.name, sha256=digest, playback=is_playback, **generator.performance.page_timing(document, page)))
|
||||
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
|
||||
+89
-105
@@ -7,13 +7,13 @@ import itertools
|
||||
import logging
|
||||
import typing
|
||||
|
||||
import generator.chord_families
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.hand_position
|
||||
import generator.pitch
|
||||
import generator.chord_families
|
||||
import generator.vocabulary
|
||||
|
||||
PREFERRED_MAX_FRET = 12
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
@@ -23,36 +23,42 @@ class VoicingCost(typing.NamedTuple):
|
||||
covering_tones: int = 0
|
||||
string_shortfall: int = 0
|
||||
family_penalty: int = 0
|
||||
travel: int = 0
|
||||
peak_position: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
missing_tones: int = 0
|
||||
register: fractions.Fraction = fractions.Fraction()
|
||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
||||
return VoicingCost(*(max(a, b) if field == "peak_position" else a + b for field, a, b in zip(self._fields, self, other)))
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def moved(self, distance: int) -> VoicingCost:
|
||||
return self._replace(effort=self.effort + distance)
|
||||
def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost:
|
||||
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
|
||||
|
||||
def voicing_preference(role, center=None):
|
||||
def voicing_preference(role, center=None, *, composition=None):
|
||||
"""Immutable role settings for both selection and scored-retrieval caches."""
|
||||
settings = generator.vocabulary.role(role)
|
||||
composition = composition or generator.vocabulary.current()
|
||||
settings = composition.role(role)
|
||||
policy = settings.get('voicing', {})
|
||||
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
|
||||
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9)))
|
||||
if result[1] < 0 or result[2] < 0 or result[3] < 1:
|
||||
raise ValueError('voicing preference requires nonnegative weights and a positive fret threshold')
|
||||
if set(policy) - {'register_weight'}:
|
||||
raise ValueError('unknown voicing preference; hand position is ranked across the passage')
|
||||
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1)))
|
||||
if result[1] < 0:
|
||||
raise ValueError('voicing preference requires a nonnegative register tie weight')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def voicing_preferences(positions, preference):
|
||||
if not positions or not preference:
|
||||
return fractions.Fraction(), fractions.Fraction()
|
||||
center, register_weight, position_weight, threshold = preference
|
||||
return fractions.Fraction()
|
||||
center, register_weight = preference
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
|
||||
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold)
|
||||
return register_weight * register, position_weight * position
|
||||
return register_weight * register
|
||||
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
|
||||
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else ()
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
|
||||
return _voicing_cost(positions, profile, allowed, melody, families, preference)
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
@@ -62,16 +68,17 @@ def _voicing_cost(positions, profile, allowed, melody, families, preference):
|
||||
above = sum(p.midi > max(melody) for p in positions) if melody else 0
|
||||
span = compactness(positions)
|
||||
if profile == 'electric':
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||
else:
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
|
||||
effort = span
|
||||
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
|
||||
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
|
||||
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes))
|
||||
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes), register=voicing_preferences(positions, preference))
|
||||
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else ()
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
families = tuple(composition.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
preference = voicing_preference('electric_melody', composition=composition) if profile == 'electric' and melody else ()
|
||||
return _chord_positions(requirements, prune, profile, melody, families, preference)
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
@@ -93,9 +100,8 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
||||
|
||||
if profile:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
# A lower source melody can be the top of a three-note adaptation on
|
||||
# strings 2-4. It need not be buried beneath an added first-string tone.
|
||||
for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS:
|
||||
# Every electric chord includes string 1, even with a lower melody inside.
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
@@ -104,29 +110,13 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
||||
result.append(selected)
|
||||
else:
|
||||
visit(0, (), set())
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
# Held strings require the full candidate set instead of this reduction.
|
||||
best = {}
|
||||
for positions in result:
|
||||
center = position_center(positions)
|
||||
rank = (_voicing_cost(positions, profile, requirements.allowed, melody, families, preference), positions)
|
||||
if center not in best or rank < best[center][0]:
|
||||
best[center] = (rank, positions)
|
||||
return tuple(sorted(item[1] for item in best.values()))
|
||||
# Equal centers no longer imply equal future movement. Keep concrete grips;
|
||||
# only identical positions can be merged before contextual placement.
|
||||
return tuple(sorted(set(result)))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.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)))
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
|
||||
frets = [p.fret for p in positions] or [0]
|
||||
strings = [p.string_number for p in positions]
|
||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
||||
def movement(previous, current, old_hand, hand) -> int:
|
||||
return generator.hand_position.motion(previous, current, old_hand, hand).shift
|
||||
|
||||
def compactness(positions) -> int:
|
||||
frets = [p.fret for p in positions]
|
||||
@@ -148,38 +138,7 @@ class Placement:
|
||||
active: tuple
|
||||
previous: Placement | None
|
||||
choices: tuple
|
||||
|
||||
def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
||||
"""Exact Manhattan distance transform on the small fret/string position grid."""
|
||||
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
|
||||
grid = [[initial for _ in range(11)] for _ in range(2 * generator.fretboard.MAX_FRET + 1)]
|
||||
for index, previous in enumerate(states.values()):
|
||||
if not previous.active:
|
||||
continue
|
||||
x, y = position_center(tuple(p for _, p in previous.active))
|
||||
candidate = (previous.cost, index, previous)
|
||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
def relax(x, y, a, b):
|
||||
source = grid[a][b]
|
||||
if source is not None:
|
||||
cost, index, previous = source
|
||||
candidate = (cost.moved(1), index, previous)
|
||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
for y in range(11):
|
||||
for x in range(1, len(grid)):
|
||||
relax(x, y, x - 1, y)
|
||||
for x in range(len(grid) - 2, -1, -1):
|
||||
relax(x, y, x + 1, y)
|
||||
for x in range(len(grid)):
|
||||
for y in range(1, 11):
|
||||
relax(x, y, x, y - 1)
|
||||
for y in range(9, -1, -1):
|
||||
relax(x, y, x, y + 1)
|
||||
return grid
|
||||
hand: int = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class GuitarTone:
|
||||
@@ -211,7 +170,8 @@ def continued_sources(notes):
|
||||
def has_attack(notes: list[dict]) -> bool:
|
||||
return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes)
|
||||
|
||||
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False):
|
||||
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
pending = []
|
||||
chords = [n for n in notes if '_harmony' in n]
|
||||
chord = chords[0] if chords else None
|
||||
@@ -229,7 +189,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
requirements = generator.fretboard.chord_requirements(chord['_harmony'], respect_bass=profile != 'electric') if chord else generator.fretboard.ChordRequirements()
|
||||
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
|
||||
fresh = has_attack(notes)
|
||||
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
|
||||
candidates = chord_positions(requirements, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, composition=composition) if chord else [()]
|
||||
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
|
||||
exact_positions = dict(exact)
|
||||
for positions in candidates:
|
||||
@@ -253,7 +213,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
choices.append(EventPlacement(note, tuple((p, exact_positions[p['id']]) for p in note['pitches']), added))
|
||||
if not compatible or not playable_choices(choices):
|
||||
continue
|
||||
cost = voicing_cost(positions, profile, requirements.allowed, melody) if fresh else VoicingCost()
|
||||
cost = voicing_cost(positions, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
|
||||
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
|
||||
else:
|
||||
shape = tuple(p for _, p in exact) + positions
|
||||
@@ -263,7 +223,7 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
|
||||
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
|
||||
if not playable_choices(choices):
|
||||
continue
|
||||
cost = voicing_cost(shape, profile, requirements.allowed, melody) if fresh else VoicingCost()
|
||||
cost = voicing_cost(shape, profile, requirements.allowed, melody, composition=composition) if fresh else VoicingCost()
|
||||
yield AttackCandidate(assignment, choices, shape, cost)
|
||||
|
||||
def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
@@ -314,55 +274,78 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
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 keep_position(result, candidate, previous, fresh=True):
|
||||
old_shape = tuple(p for _, p in previous.active)
|
||||
hands = [previous.hand]
|
||||
if fresh:
|
||||
hands = generator.hand_position.regions(candidate.shape)
|
||||
for hand in hands:
|
||||
if candidate.shape and not all(hand <= p.fret <= hand + 3 for p in candidate.shape):
|
||||
continue
|
||||
motion = generator.hand_position.motion(old_shape, candidate.shape, previous.hand, hand) if fresh else generator.hand_position.Motion()
|
||||
local = candidate.cost._replace(peak_position=hand)
|
||||
cost = previous.cost.plus(local).moved(motion.shift, motion.ergonomics)
|
||||
key = (candidate.active, hand, cost.peak_position)
|
||||
value = Placement(cost, candidate.active, previous, candidate.choices, hand)
|
||||
if key not in result or cost < result[key].cost:
|
||||
result[key] = value
|
||||
|
||||
def reduce_positions(result):
|
||||
# Future effort depends on the concrete held strings and hand, not on the
|
||||
# earlier route. A strictly worse primary cost is dominated even if lower.
|
||||
best = {}
|
||||
for (active, hand, _), state in result.items():
|
||||
key = (active, hand)
|
||||
best[key] = min(best.get(key, state.cost[:7]), state.cost[:7])
|
||||
return {key: state for key, state in result.items() if state.cost[:7] == best[key[:2]]}
|
||||
|
||||
def advance_attacks(candidates, states: dict) -> dict:
|
||||
nearest = nearest_positions(states)
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
cost = cost.plus(candidate.cost)
|
||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
return result
|
||||
for previous in states.values():
|
||||
keep_position(result, candidate, previous)
|
||||
return reduce_positions(result)
|
||||
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
continued = continued_sources(notes)
|
||||
fresh = has_attack(notes)
|
||||
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
|
||||
result = {}
|
||||
|
||||
def keep(candidate, previous, cost):
|
||||
cost = cost.plus(candidate.cost)
|
||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
||||
|
||||
if not held_ids and not continued:
|
||||
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback), states)
|
||||
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
|
||||
else:
|
||||
for previous in states.values():
|
||||
active = dict(previous.active)
|
||||
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
|
||||
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback):
|
||||
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
|
||||
keep(candidate, previous, previous.cost.moved(distance))
|
||||
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
|
||||
keep_position(result, candidate, previous, fresh)
|
||||
if not result:
|
||||
if not fallback and any(n.get('_harmonize') for n in notes):
|
||||
return advance_positions(notes, onset, tones, states, preserve_strings, True)
|
||||
return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
|
||||
return result
|
||||
return reduce_positions(result)
|
||||
|
||||
def place_guitar(document: dict) -> None:
|
||||
def place_guitar(document: dict, *, composition=None) -> dict:
|
||||
"""Choose electric voicings or exact/held melody placements."""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
events, tones = guitar_events(document)
|
||||
states = {(): Placement(VoicingCost(), (), None, ())}
|
||||
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
|
||||
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 + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
|
||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||
trace = []
|
||||
final_cost = selected.cost
|
||||
skipped = []
|
||||
while selected.previous is not None:
|
||||
fresh = has_attack([c.note for c in selected.choices])
|
||||
motion = generator.hand_position.motion(tuple(p for _, p in selected.previous.active), tuple(p for _, p in selected.active), selected.previous.hand, selected.hand) if fresh else generator.hand_position.Motion()
|
||||
trace.append({'hand_position': selected.hand, 'events': [c.note['id'] for c in selected.choices],
|
||||
'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings,
|
||||
'effort': str(selected.cost.effort - selected.previous.cost.effort)})
|
||||
for choice in selected.choices:
|
||||
note = choice.note
|
||||
for tone, position in choice.exact:
|
||||
@@ -382,3 +365,4 @@ def place_guitar(document: dict) -> None:
|
||||
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
||||
document['staves'][0]['tablature'] = True
|
||||
generator.fretboard.validate_document(document)
|
||||
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in trace), 'peak_position': final_cost.peak_position, 'cost': {k: str(v) for k, v in final_cost._asdict().items()}, 'positions': list(reversed(trace))}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Four-fret hand windows shared by chord and single-note placement.
|
||||
|
||||
This describes playable regions, not an anatomical fingering solver. Chord
|
||||
and single tones may occupy any frets within the window. Only shifting the
|
||||
window is movement. Actual sounded-note limits remain in fretboard.py.
|
||||
"""
|
||||
import functools
|
||||
import typing
|
||||
|
||||
import generator.fretboard
|
||||
|
||||
VERSION = 'hand-window-2'
|
||||
|
||||
class Motion(typing.NamedTuple):
|
||||
shift: int = 0
|
||||
fingers: int = 0
|
||||
strings: int = 0
|
||||
@property
|
||||
def ergonomics(self):
|
||||
return self.fingers + self.strings
|
||||
|
||||
def base(shape):
|
||||
return min((p.fret for p in shape), default=0)
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def regions(shape):
|
||||
if not shape:
|
||||
return tuple([0])
|
||||
return tuple(range(max(1, max(p.fret for p in shape) - 3), min(base(shape), generator.fretboard.MAX_ANCHOR_FRET) + 1))
|
||||
|
||||
@functools.lru_cache(maxsize=32768)
|
||||
def motion(previous, current, old_hand, hand, acoustic=False):
|
||||
if not previous or not current:
|
||||
return Motion()
|
||||
if len(previous) == len(current) == 1:
|
||||
pairs = ((previous[0], current[0]),)
|
||||
unmatched = 0
|
||||
else:
|
||||
|
||||
def voices(shape):
|
||||
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
|
||||
|
||||
a, b = voices(previous), voices(current)
|
||||
pairs = tuple((a[s], b[s]) for s in a.keys() & b.keys())
|
||||
unmatched = len(a.keys() ^ b.keys())
|
||||
return Motion(abs(hand - old_hand),
|
||||
unmatched + sum(abs((a.fret - old_hand) - (b.fret - hand)) for a, b in pairs),
|
||||
sum(abs(a.string_number - b.string_number) for a, b in pairs))
|
||||
|
||||
def summary(shifts):
|
||||
shifts = tuple(shifts)
|
||||
return {'total_travel': sum(shifts), 'shift_count': sum(s > 0 for s in shifts), 'largest_shift': max(shifts, default=0)}
|
||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
||||
import copy
|
||||
import fractions
|
||||
import typing
|
||||
import generator.fretboard
|
||||
import generator.pitch
|
||||
|
||||
MAX_INTEGER = (1 << 63) - 1
|
||||
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
|
||||
@@ -140,157 +138,3 @@ def make_event(identifier: str, onset: fractions.Fraction, duration: fractions.F
|
||||
if 'alter' in event['pitches'][-1]:
|
||||
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
|
||||
return event
|
||||
|
||||
def validate_document(document: dict) -> None:
|
||||
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
|
||||
raise ValueError('score requires a metadata object')
|
||||
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
|
||||
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
|
||||
staves = document.get('staves', [])
|
||||
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
|
||||
raise ValueError('exactly one staff is currently supported')
|
||||
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
|
||||
raise ValueError('score requires measures')
|
||||
instruments = staves[0].get('instruments', [])
|
||||
instrument_ids = {i['id'] for i in instruments}
|
||||
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||
raise ValueError('score instruments require unique nonempty IDs')
|
||||
ids: set[str] = set()
|
||||
events: dict[str, dict] = {}
|
||||
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
|
||||
ownership: dict[str, str] = {}
|
||||
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')
|
||||
length = fraction(measure['duration'])
|
||||
if length <= 0:
|
||||
raise ValueError('measure duration must be positive')
|
||||
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']: 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')
|
||||
if not note['id'] or note['id'] in ids:
|
||||
raise ValueError(f'duplicate event ID: {note["id"]}')
|
||||
ids.add(note['id'])
|
||||
events[note['id']] = note
|
||||
ownership[note['id']] = voice['id']
|
||||
if note.get('voice', voice['id']) != voice['id']:
|
||||
raise ValueError('event voice ownership disagrees with its lane')
|
||||
onset, duration = fraction(note['onset']), fraction(note['duration'])
|
||||
if onset < 0 or duration < 0 or onset + duration > length:
|
||||
raise ValueError(f'event outside measure: {note["id"]}')
|
||||
if note['kind'] not in ('note', 'rest', 'spacer'):
|
||||
raise ValueError('invalid event kind')
|
||||
if not note.get('grace'):
|
||||
if duration == 0 or onset < previous_ends[voice['id']]:
|
||||
raise ValueError('overlapping events in one rhythmic voice')
|
||||
previous_ends[voice['id']] = onset + duration
|
||||
elif duration != 0:
|
||||
raise ValueError('grace notes must not advance metric time')
|
||||
expected = metric_length(note['written'])
|
||||
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
|
||||
raise ValueError('metric and written event durations disagree')
|
||||
if note['kind'] != 'note' and note.get('pitches'):
|
||||
raise ValueError('rests and spacers cannot contain pitches')
|
||||
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
|
||||
raise ValueError('pitched event requires at least one pitch')
|
||||
for p in note.get('pitches', []):
|
||||
if p.get('instrument') and p['instrument'] not in instrument_ids:
|
||||
raise ValueError('note refers to an unknown score instrument')
|
||||
if 'unpitched' in p:
|
||||
position = p['unpitched']
|
||||
if position['letter'] not in LETTERS or type(position['octave']) is not int:
|
||||
raise ValueError('invalid unpitched display position')
|
||||
else:
|
||||
pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
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(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.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(generator.fretboard.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:
|
||||
raise ValueError('invalid chord-tone duration or duplicate ID')
|
||||
if not note.get('grace') and pd != metric_length(p['written']):
|
||||
raise ValueError('metric and written chord-tone durations disagree')
|
||||
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
|
||||
for harmony in measure.get('harmonies', []):
|
||||
if not 0 <= fraction(harmony['onset']) < length:
|
||||
raise ValueError('harmony outside measure')
|
||||
for change in measure.get('attributes', []):
|
||||
if not 0 <= fraction(change['onset']) < length:
|
||||
raise ValueError('attribute change outside measure')
|
||||
for state in [measure, *measure.get('attributes', [])]:
|
||||
if 'meter' in state:
|
||||
validate_meter(state['meter'])
|
||||
if 'key' in state:
|
||||
if len(state['key']['alterations']) != 7:
|
||||
raise ValueError('key alterations must contain seven exact alterations')
|
||||
for value in state['key']['alterations']:
|
||||
alteration(value)
|
||||
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||
raise ValueError('invalid clef')
|
||||
absolute += length
|
||||
for identifier, (p, start, duration, voice) in tones.items():
|
||||
if target := p.get('tie_to'):
|
||||
if target not in tones:
|
||||
raise ValueError(f'dangling tie: {identifier} -> {target}')
|
||||
other, next_start, _, next_voice = tones[target]
|
||||
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
|
||||
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
|
||||
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
||||
if staves[0].get('tablature') and p['tablature'] != other['tablature']:
|
||||
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
|
||||
if source := p.get('tie_from'):
|
||||
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
||||
raise ValueError(f'dangling incoming tie: {identifier}')
|
||||
span_ids = set()
|
||||
event_order = {identifier: index for index, identifier in enumerate(events)}
|
||||
for span in staves[0].get('tuplets', []):
|
||||
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
|
||||
raise ValueError('invalid tuplet span')
|
||||
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
|
||||
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
|
||||
raise ValueError('tuplet endpoints disagree with member events')
|
||||
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
|
||||
raise ValueError('tuplet crosses independent voice lanes')
|
||||
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
|
||||
raise ValueError('invalid tuplet appearance')
|
||||
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 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', [])):
|
||||
raise ValueError('event refers to an unknown tuplet')
|
||||
if tremolo := note.get('tremolo'):
|
||||
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
|
||||
raise ValueError('invalid measured tremolo')
|
||||
if tremolo['type'] in ('start', 'stop'):
|
||||
other = events.get(tremolo.get('partner'))
|
||||
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
|
||||
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
|
||||
raise ValueError('invalid paired tremolo')
|
||||
for span in staves[0].get('octave_shifts', []):
|
||||
if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
|
||||
raise ValueError('invalid octave-shift span')
|
||||
previous_end = fractions.Fraction()
|
||||
for span in staves[0].get('scale_spans', []):
|
||||
start, end = fraction(span['start']), fraction(span['end'])
|
||||
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
|
||||
raise ValueError('invalid solo scale span')
|
||||
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
|
||||
raise ValueError('solo scale spans require unique IDs')
|
||||
ids.add(span['id'])
|
||||
previous_end = end
|
||||
|
||||
@@ -8,6 +8,7 @@ import defusedxml.ElementTree
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.validation
|
||||
|
||||
class MusicXMLError(ValueError):
|
||||
pass
|
||||
@@ -621,7 +622,7 @@ 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
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
return result
|
||||
|
||||
def load_musicxml(path: pathlib.Path) -> dict:
|
||||
|
||||
@@ -7,6 +7,7 @@ import xml.etree.ElementTree
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.validation
|
||||
|
||||
def element(parent, tag, content=None, **attributes):
|
||||
node = xml.etree.ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
|
||||
@@ -209,7 +210,7 @@ def write_barline(bar, measure, location):
|
||||
element(node, 'bar-style', measure.get('barlines', {}).get(location, 'regular'))
|
||||
|
||||
def musicxml_tree(document: dict, *, tablature=False) -> xml.etree.ElementTree.ElementTree:
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.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'])
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import fractions
|
||||
import pathlib
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
"""Page timing and supplied recordings. No note synthesis or audio transformation."""
|
||||
"""Pure page coverage and performance timing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import wave
|
||||
|
||||
import generator.model
|
||||
|
||||
@@ -117,57 +111,3 @@ def timing(document):
|
||||
return dict(song_id=document['metadata'].get('song_id', ''),
|
||||
duration=generator.model.rational(times[-1]),
|
||||
sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
|
||||
|
||||
def describe_wav(path, root, *, song_id='', bpm=0):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise ValueError(f'{path}: expected a contained regular WAV')
|
||||
try:
|
||||
with wave.open(str(path), 'rb') as audio:
|
||||
channels, rate, frames = audio.getnchannels(), audio.getframerate(), audio.getnframes()
|
||||
if audio.getcomptype() != 'NONE' or audio.getsampwidth() != 2 or channels not in (1, 2) or not 8000 <= rate <= 192000 or frames <= 0:
|
||||
raise ValueError('expected nonempty mono/stereo PCM16 WAV')
|
||||
remaining = frames
|
||||
while remaining:
|
||||
count = min(remaining, 65536)
|
||||
if len(audio.readframes(count)) != count * channels * 2:
|
||||
raise ValueError('truncated WAV data')
|
||||
remaining -= count
|
||||
except (wave.Error, EOFError) as error:
|
||||
raise ValueError(f'{path}: invalid WAV: {error}') from error
|
||||
with path.open('rb') as source:
|
||||
digest = hashlib.file_digest(source, 'sha256').hexdigest()
|
||||
return dict(path=path.relative_to(root).as_posix(), sha256=digest, channels=channels, sample_rate=rate, frames=frames, song_id=song_id, bpm=bpm)
|
||||
|
||||
def copy_audio(static, destination, song_ids):
|
||||
entries = []
|
||||
inputs = [(static / 'sounds' / name, '', 0) for name in ('beat1.wav', 'beat2.wav')]
|
||||
for path in sorted((static / 'backing').glob('*.wav')):
|
||||
match = re.fullmatch(r'([a-z0-9]+(?:_[a-z0-9]+)*)_(60|70|80|90|100|110|120)\.wav', path.name)
|
||||
if not match:
|
||||
raise ValueError(f'{path}: unsupported backing filename or tempo')
|
||||
if match[1] not in song_ids:
|
||||
continue
|
||||
inputs.append((path, match[1], int(match[2])))
|
||||
for source, song_id, bpm in inputs:
|
||||
description = describe_wav(source, static, song_id=song_id, bpm=bpm)
|
||||
target = destination / description['path']
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
entries.append(description)
|
||||
return entries
|
||||
|
||||
def validate_recording_lengths(root, entries):
|
||||
durations = {}
|
||||
for path in sorted((root / 'songs').glob('*.json')):
|
||||
document = json.loads(path.read_text())
|
||||
performance = timing(document)
|
||||
song_id = performance['song_id']
|
||||
duration = generator.model.fraction(performance['duration']) + performance['count_in_beats']
|
||||
if song_id in durations and durations[song_id] != duration:
|
||||
raise ValueError(f'{song_id}: parts disagree about performance duration')
|
||||
durations[song_id] = duration
|
||||
for entry in entries:
|
||||
if entry['song_id']:
|
||||
seconds = fractions.Fraction(entry['frames'], entry['sample_rate'])
|
||||
if seconds < durations[entry['song_id']] * 60 / entry['bpm']:
|
||||
raise ValueError(f"{entry['path']}: backing recording is shorter than count-in and score")
|
||||
|
||||
@@ -20,7 +20,7 @@ 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',
|
||||
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
|
||||
'Main Menu', 'Performance', 'Settings', 'Backing', 'Metronome', 'Solos', 'Tempo', 'Back', 'On', 'Off', 'BPM', 'Manual', 'Preparing', 'Count-in', 'Playing', 'Sustain', 'Hold to exit')
|
||||
SPRITES = LABELS + tuple('0123456789/')
|
||||
DIGIT_WIDTH = 22
|
||||
DEFAULT_FONT_ROOT = pathlib.Path(__file__).resolve().parents[1] / 'package/assets/fonts/petaluma'
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Build-only Letter PDFs from the complete arranged parts, never screen PNGs."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import shutil
|
||||
import time
|
||||
|
||||
import PIL.PdfParser
|
||||
|
||||
import generator.engraving.audit
|
||||
import generator.engraving.cache
|
||||
import generator.engraving.fonts
|
||||
import generator.engraving.layout
|
||||
import generator.engraving.notation
|
||||
import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.validation
|
||||
|
||||
# Two layout units per PDF point: guitar staff spacing is five points.
|
||||
# Raster resolution is independent of layout and the device's display theme.
|
||||
STYLE = generator.engraving.types.Style(width=1224, height=1584, margin_x=72, margin_y=72, page_clearance=28, system_gap=32, supersampling=1)
|
||||
DPI = 300
|
||||
PIXELS = (2550, 3300)
|
||||
MEDIA_BOX = [0, 0, 612, 792]
|
||||
VERSION = 'letter-parts-v1'
|
||||
|
||||
def layout_part(document, fonts):
|
||||
generator.validation.validate_document(document)
|
||||
style = dataclasses.replace(generator.engraving.types.score_style(document, STYLE), system_gap=STYLE.system_gap)
|
||||
# Measure numbers are centered over barlines and can extend left of the
|
||||
# staff. Reserve their actual overhang inside the half-inch paper margin.
|
||||
overhang = max(-fonts.text(str(m['number']), 0, 0, 12, 'measure_number', (), True).box.left for m in document['staves'][0]['measures'])
|
||||
style = dataclasses.replace(style, margin_x=style.margin_x + math.ceil(max(0, overhang)) + 4)
|
||||
index = generator.engraving.notation.ScoreIndex(document)
|
||||
systems = generator.engraving.layout.layout_systems(document, style, fonts, index=index)
|
||||
# The shared header wraps long credits; print it at twice its display size.
|
||||
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
|
||||
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
|
||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||
# Reserve the measured outline for every possible page number, just as
|
||||
# screen pagination does. A nominal font size is not its actual ascent.
|
||||
footers = tuple(fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
|
||||
for number in range(1, len(systems) + 1))
|
||||
bottom = min(p.box.top for p in footers) - style.page_clearance
|
||||
pages, placed, cursor = [], [], top
|
||||
|
||||
def finish():
|
||||
number = len(pages) + 1
|
||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
|
||||
(*header, *(p for system in placed for p in system.primitives), footers[number - 1])))
|
||||
|
||||
for system in systems:
|
||||
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
|
||||
height = system.box.bottom - system.box.top
|
||||
if height > bottom - top:
|
||||
raise generator.engraving.types.EngravingError(f'at musical offset {system.start}: system cannot fit a Letter page at readable size')
|
||||
if placed and cursor + height > bottom:
|
||||
finish()
|
||||
placed, cursor = [], top
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(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)))
|
||||
cursor += height + style.system_gap
|
||||
finish()
|
||||
result = generator.engraving.types.LayoutDocument(tuple(pages), style, fonts.fingerprint(style))
|
||||
generator.engraving.audit.audit_layout(document, result, fonts, systems_per_page=None)
|
||||
if any(p.box.left < STYLE.margin_x or p.box.right > style.width - STYLE.margin_x or
|
||||
p.box.top < STYLE.margin_y or p.box.bottom > style.height - STYLE.margin_y for page in pages for p in page.primitives):
|
||||
raise generator.engraving.types.EngravingError('print notation exceeds the half-inch printer margins')
|
||||
return result
|
||||
|
||||
def rasterize(page, fonts):
|
||||
return generator.engraving.raster.rasterize_page(page, fonts, ink=(0, 0, 0), background=(255, 255, 255), output_scale=DPI / 144)
|
||||
|
||||
def validate_pdf(path, expected_pages=None):
|
||||
"""Check the narrow PDF format written by the pinned Pillow asset dependency.
|
||||
|
||||
This adapter deliberately uses Pillow's parser, not a new general PDF stack.
|
||||
Tests also decode the embedded page images to check print color/resolution.
|
||||
"""
|
||||
try:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError('expected a regular print PDF')
|
||||
pdf = PIL.PdfParser.PdfParser(filename=str(path))
|
||||
try:
|
||||
count = len(pdf.pages)
|
||||
if not 0 < count <= 10000 or (expected_pages is not None and count != expected_pages):
|
||||
raise ValueError('invalid print PDF page count')
|
||||
for reference in pdf.pages:
|
||||
page = pdf.read_indirect(reference)
|
||||
if page[b'MediaBox'] != MEDIA_BOX:
|
||||
raise ValueError('print PDF must use portrait US Letter')
|
||||
image = pdf.read_indirect(page[b'Resources'][b'XObject'][b'image'])
|
||||
if (image.dictionary[b'Width'], image.dictionary[b'Height']) != PIXELS or image.dictionary[b'ColorSpace'] != b'DeviceGray':
|
||||
raise ValueError('print PDF must contain 300 DPI grayscale pages')
|
||||
return count
|
||||
finally:
|
||||
pdf.close()
|
||||
except (OSError, KeyError, TypeError, IndexError, PIL.PdfParser.PdfFormatError) as error:
|
||||
raise ValueError(f'{path.name}: invalid print PDF: {error}') from error
|
||||
|
||||
def write_pdf(path, document, fonts):
|
||||
layout = layout_part(document, fonts)
|
||||
# Append one page at a time so a long arrangement never keeps a chorus of
|
||||
# full-resolution raster images in memory. The enclosing build is staged.
|
||||
for page in layout.pages:
|
||||
with rasterize(page, fonts) as rgb, rgb.convert('L') as image:
|
||||
if image.size != PIXELS or image.getextrema()[0] == 255:
|
||||
raise ValueError('print renderer produced an empty or incorrectly sized page')
|
||||
image.save(path, format='PDF', append=page.number > 1, resolution=DPI, quality=95,
|
||||
title=document['metadata']['title'] + ' - ' + document['staves'][0].get('name', 'Part'),
|
||||
creator='Product Vamp', creationDate=None, modDate=None)
|
||||
validate_pdf(path, len(layout.pages))
|
||||
return len(layout.pages)
|
||||
|
||||
def renderer_key(fonts):
|
||||
identity = generator.engraving.cache.renderer_identity(fonts, STYLE)
|
||||
identity.update(print_version=VERSION, print_code=hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest(), dpi=DPI, ink='black', background='white', quality=95)
|
||||
return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
def restore(entry, key, target):
|
||||
try:
|
||||
if generator.cache.has_symlink(entry):
|
||||
return None
|
||||
pdf, manifest = entry / 'part.pdf', entry / 'print.json'
|
||||
if pdf.is_symlink() or manifest.is_symlink():
|
||||
return None
|
||||
info = json.loads(manifest.read_text())
|
||||
if info['key'] != key or info['sha256'] != hashlib.sha256(pdf.read_bytes()).hexdigest():
|
||||
return None
|
||||
pages = validate_pdf(pdf, info['pages'])
|
||||
shutil.copyfile(pdf, target)
|
||||
return pages
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
|
||||
def store(entry, key, target, pages):
|
||||
with generator.cache.stage_entry(entry, '.print_') as staged:
|
||||
shutil.copyfile(target, staged / 'part.pdf')
|
||||
(staged / 'print.json').write_text(json.dumps({'key': key, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), 'pages': pages}))
|
||||
generator.cache.publish_files(entry, staged, ('part.pdf', 'print.json'))
|
||||
|
||||
def generate_tree(root, font_path, *, cache_dir=None, progress=False):
|
||||
"""Called inside the normal build's atomic staging directory."""
|
||||
if cache_dir and (cache_dir.resolve().is_relative_to(root.resolve()) or root.resolve().is_relative_to(cache_dir.resolve())):
|
||||
raise ValueError('print cache and generated assets must not overlap')
|
||||
destination = root / 'print'
|
||||
destination.mkdir(exist_ok=True)
|
||||
paths = sorted((root / 'songs').glob('*.json'))
|
||||
fonts = generator.engraving.fonts.Fonts(font_path)
|
||||
renderer = renderer_key(fonts)
|
||||
expected, pages_total, reused = set(), 0, 0
|
||||
started = time.monotonic()
|
||||
for path in paths:
|
||||
bundle = json.loads(path.read_text())
|
||||
document = bundle.get('music', bundle)
|
||||
target = destination / path.with_suffix('.pdf').name
|
||||
expected.add(target)
|
||||
key = generator.engraving.cache.score_fingerprint(document, renderer)
|
||||
entry = cache_dir / 'print' / renderer / key if cache_dir else None
|
||||
pages = restore(entry, key, target) if entry else None
|
||||
if pages is None:
|
||||
pages = write_pdf(target, document, fonts)
|
||||
if entry:
|
||||
store(entry, key, target, pages)
|
||||
else:
|
||||
reused += 1
|
||||
pages_total += pages
|
||||
if progress:
|
||||
print(f'Print: {target.name}: {pages} pages', flush=True)
|
||||
for stale in destination.glob('*.pdf'):
|
||||
if stale not in expected:
|
||||
stale.unlink()
|
||||
if progress:
|
||||
print(f'Print complete: {len(paths)} PDFs, {pages_total} pages, {reused} cached; {time.monotonic() - started:.1f}s', flush=True)
|
||||
return len(paths)
|
||||
+25
-333
@@ -1,336 +1,23 @@
|
||||
"""Independent, reproducible role generation and non-composing assembly.
|
||||
|
||||
Only source documents and production resources enter generate_role. Completed
|
||||
roles and published assets are downstream values, never composition inputs.
|
||||
"""
|
||||
"""File discovery, process scheduling and publication of independent roles."""
|
||||
from __future__ import annotations
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import functools
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import time
|
||||
import fractions
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.assembly
|
||||
import generator.composition
|
||||
import generator.composition_types
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
import generator.solo.analysis
|
||||
import generator.solo.composer
|
||||
import generator.solo.notation
|
||||
import generator.solo.report
|
||||
import generator.solo.learned
|
||||
import generator.solo.authored
|
||||
import generator.input_audit
|
||||
import generator.solo.arrangement
|
||||
|
||||
VERSION = 'independent-roles-1'
|
||||
ROLES = ('acoustic_accompaniment', 'electric_accompaniment', 'electric_melody', 'vocal_melody', 'acoustic_solo', 'electric_solo')
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourceAnalysis:
|
||||
document_json: str
|
||||
musical_key: str
|
||||
@classmethod
|
||||
def read(cls, document):
|
||||
prepared = copy.deepcopy(document)
|
||||
staff = generator.score.source_staff(prepared)
|
||||
for i, m in enumerate(staff['measures']):
|
||||
m['id'], m['number'] = f'source_{i}', str(i + 1)
|
||||
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
|
||||
prepared['metadata'] = {k: v for k, v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')}
|
||||
prepared['metadata'].update(title='Source', composer='')
|
||||
staff.update(id='source', name='Source')
|
||||
melodic = generator.solo.analysis.analyze(prepared)
|
||||
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
|
||||
'measures': [{k: m.get(k) for k in ('duration', 'time_signature', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
|
||||
# Fraction/string spelling is canonical and never includes filesystem IDs.
|
||||
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
|
||||
return cls(json.dumps(prepared, sort_keys=True), key)
|
||||
def document(self):
|
||||
return json.loads(self.document_json)
|
||||
def accompaniment_key(self):
|
||||
document = self.document()
|
||||
document['metadata'].pop('tempo', None)
|
||||
for measure in generator.score.source_staff(document)['measures']:
|
||||
measure['tempos'] = []
|
||||
return SourceAnalysis.read(document).musical_key
|
||||
|
||||
def windows(document, part):
|
||||
source = generator.score.source_staff(document)['measures']
|
||||
sections = generator.arrange.form.full_arrangement_sections(source, part)
|
||||
result, cursor = [], fractions.Fraction()
|
||||
for section in sections:
|
||||
duration = sum((generator.model.fraction(m['duration']) for m in source[section.source_measure_index:section.source_measure_index + section.measure_count]), fractions.Fraction())
|
||||
role = {'slash': 'solo', 'vocal': 'melody'}.get(section.role, section.role)
|
||||
if section.role in ('vocal', 'melody') and generator.arrange.pickup.enters(section, source) and result:
|
||||
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
|
||||
if attacks:
|
||||
start = cursor - generator.model.fraction(source[0]['duration']) + min(attacks)
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, cursor, part + '_' + role))
|
||||
end = cursor + duration
|
||||
start = cursor
|
||||
if section.role == 'slash' and generator.score.body_start_measure_index(source) and result:
|
||||
# Allocate the source's complete pickup bar before composition. This
|
||||
# does not inspect either generated solo; a role may leave it silent.
|
||||
start = cursor - generator.model.fraction(source[0]['duration'])
|
||||
a, _, old = result[-1]
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
def dependencies(role, resources):
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
|
||||
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
|
||||
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
|
||||
**({'accompaniment': generator.vocabulary.accompaniment(role.split('_')[0], resources)} if role.endswith('_accompaniment') else {}),
|
||||
**{name: resources[name] for name in names}}
|
||||
|
||||
def _code_key(role):
|
||||
root = pathlib.Path(__file__).parent
|
||||
paths = [root / n for n in ('production.py', 'model.py', 'harmony.py', 'pitch.py', 'fretboard.py', 'parts.py', 'score.py', 'vocabulary.py')]
|
||||
paths += list((root / 'arrange').glob('*.py'))
|
||||
if role.endswith('_solo'):
|
||||
paths += list((root / 'solo').glob('*.py'))
|
||||
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'chord_families.py', 'accompaniment.py', 'guitar.py')]
|
||||
if role == 'acoustic_accompaniment':
|
||||
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
|
||||
elif role == 'electric_melody':
|
||||
paths += [root / 'guitar.py', root / 'melody.py', root / 'chord_families.py']
|
||||
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
|
||||
|
||||
def _resource_key(directory, role):
|
||||
return {'model': hashlib.sha256((directory / 'models' / 'solo.json').read_bytes()).hexdigest()} if role.endswith('_solo') else {}
|
||||
|
||||
@functools.lru_cache(maxsize=4)
|
||||
def _solo_model(directory, fingerprint):
|
||||
return generator.solo.learned.Model.load(pathlib.Path(directory) / 'models' / 'solo.json')
|
||||
|
||||
def _mask(document, allowed):
|
||||
"""Apply source-defined role exclusions, without observing another part."""
|
||||
absolute = fractions.Fraction()
|
||||
for measure in document['staves'][0]['measures']:
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
for voice in measure['voices']:
|
||||
selected = []
|
||||
for n in voice['notes']:
|
||||
start = absolute + generator.model.fraction(n['onset'])
|
||||
end = start + generator.model.fraction(n['duration'])
|
||||
intervals = [(max(start, a), min(end, b)) for a, b in allowed if a < end and b > start]
|
||||
for a, b in intervals:
|
||||
note = copy.deepcopy(n)
|
||||
if a != start:
|
||||
note['onset'] = generator.model.rational(a - absolute)
|
||||
for pitch in note['pitches']:
|
||||
pitch.pop('tie_from', None)
|
||||
generator.arrange.pickup.truncate(note, b - absolute)
|
||||
if a < b:
|
||||
selected.append(note)
|
||||
voice['notes'] = _fill(selected, length, measure['id'] + '_' + voice['id'], voice['id'])
|
||||
absolute += length
|
||||
staff = document['staves'][0]
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff.get('tuplets', []))
|
||||
|
||||
def _fill(notes, length, prefix, voice='voice_arranged'):
|
||||
notes = sorted(notes, key=lambda n: generator.model.fraction(n['onset']))
|
||||
result = []
|
||||
cursor = fractions.Fraction()
|
||||
|
||||
def rests(end):
|
||||
nonlocal cursor
|
||||
for duration, written in generator.accompaniment.written_durations(end - cursor):
|
||||
result.append(generator.model.make_event(prefix + '_rest_' + str(len(result)), cursor, duration, 'rest', written=written) | {'voice': voice})
|
||||
cursor += duration
|
||||
|
||||
for n in notes:
|
||||
start = generator.model.fraction(n['onset'])
|
||||
if start < cursor:
|
||||
raise ValueError(prefix + ': overlapping independently generated roles')
|
||||
rests(start)
|
||||
n['voice'] = voice
|
||||
result.append(n)
|
||||
cursor = start + generator.model.fraction(n['duration'])
|
||||
rests(length)
|
||||
return result
|
||||
|
||||
def generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
started = time.monotonic()
|
||||
with generator.input_audit.composition(data_dir, role, cache_dir) as reads:
|
||||
result = _generate_role(source, role, data_dir, settings, cache_dir)
|
||||
# Observed reads are diagnostics, never cache identity or musical seeds.
|
||||
result['trace'] = {**result['trace'], 'input_boundary': 'production data (including explicit authored repertoire), generator code, Python runtime, own role cache'}
|
||||
result['observed_reads'] = sorted(reads)
|
||||
result['seconds'] = time.monotonic() - started
|
||||
return result
|
||||
|
||||
def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
||||
if role not in ROLES:
|
||||
raise ValueError('Unknown generation role: ' + role)
|
||||
data_dir = pathlib.Path(data_dir)
|
||||
resources = generator.vocabulary.load(data_dir / 'composition')
|
||||
settings = settings or {}
|
||||
registered = generator.solo.authored.lookup(source, role.removesuffix('_solo'), data_dir) if role.endswith('_solo') and not settings.get('predict', False) else None
|
||||
algorithm = VERSION + '/' + generator.solo.learned.VERSION if role.endswith('_solo') else VERSION
|
||||
inputs = {'version': algorithm, 'source': source.musical_key, 'role': role, 'settings': settings,
|
||||
'vocabulary': dependencies(role, resources), 'catalogs': ({'authored': registered['sha256'], 'id': registered['id'], 'pitch_convention': registered['pitch_convention']} if registered else _resource_key(data_dir, role)), 'code': _code_key(role)}
|
||||
# Source notation/lyrics also invalidate cached score representation, never the musical seed.
|
||||
key = generator.vocabulary.fingerprint({'inputs': inputs, 'notation': source.document_json})
|
||||
path = pathlib.Path(cache_dir) / role / (key + '.json') if cache_dir else None
|
||||
if path and path.is_file() and not path.is_symlink():
|
||||
try:
|
||||
entry = json.loads(path.read_text())
|
||||
value = entry['result']
|
||||
if entry['key'] == key and entry['digest'] == generator.vocabulary.fingerprint(value):
|
||||
generator.model.validate_document(value['document'])
|
||||
generator.fretboard.validate_document(value['document'])
|
||||
return {**value, 'cache_hit': True}
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pass
|
||||
started = time.monotonic()
|
||||
document = source.document()
|
||||
part = role.split('_')[0]
|
||||
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
|
||||
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
|
||||
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
|
||||
choice_data, choice_settings = inputs['vocabulary'], settings
|
||||
if role.endswith('_accompaniment'):
|
||||
# Voicing weights invalidate the concrete cache, but must not reshuffle
|
||||
# rhythm patterns. Removing this new field preserves the pre-policy seed.
|
||||
choice_data = copy.deepcopy(choice_data)
|
||||
choice_data['roles']['role'].pop('voicing', None)
|
||||
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
|
||||
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
|
||||
details = {}
|
||||
solo_report = ''
|
||||
composition_timings = {}
|
||||
with generator.vocabulary.using(resources, role, settings | {'choice_seed': seed}):
|
||||
if role.endswith('_solo'):
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'slash'})
|
||||
si = 3 if part == 'acoustic' else 4
|
||||
first, count, offset, length = generator.solo.arrangement.regions(document)[si]
|
||||
# The template/harmony comes directly from source form, not another generated part.
|
||||
authored = generator.score.source_staff(document)['measures']
|
||||
section = generator.arrange.form.full_arrangement_sections(authored, part)[si]
|
||||
effective = generator.arrange.generate.section_source(authored, section, generator.arrange.form.full_arrangement_sections(authored, part)[si + 1])
|
||||
template = copy.deepcopy(effective[section.source_measure_index:section.source_measure_index + count])
|
||||
initial = generator.accompaniment.carried_chords(effective)[section.source_measure_index]
|
||||
start, end = next((a - offset, b - offset) for a, b, r in windows(document, part) if r == role)
|
||||
notation_first = first
|
||||
if start < 0:
|
||||
# The preceding arrangement harmony comes from the source form. Do
|
||||
# not invent harmony from a comparison score's unlabelled lead-in.
|
||||
template.insert(0, copy.deepcopy(result['staves'][0]['measures'][first - 1]))
|
||||
notation_first -= 1
|
||||
initial = generator.accompaniment.carried_chords(result['staves'][0]['measures'])[first - 1]
|
||||
if registered:
|
||||
solo = generator.solo.authored.realize(registered, generator.solo.analysis.analyze(document), template, part, start, end, initial)
|
||||
else:
|
||||
solo_model = _solo_model(str(data_dir), inputs['catalogs']['model'])
|
||||
solo = generator.solo.composer.compose(generator.solo.analysis.analyze(document), template, part, end, initial=initial, learned_model=solo_model, expressions_enabled=generator.vocabulary.role().get('expressions', True), start=start)
|
||||
composition_timings = {k: v for k, v in solo.diagnostics.items() if k.endswith('_seconds')}
|
||||
relative = tuple(dataclasses.replace(n, start=n.start - start, end=n.end - start) for n in solo.notes())
|
||||
output = generator.solo.notation.measures(result['staves'][0]['measures'][notation_first:first + count], relative, 'solo_' + part)
|
||||
generator.solo.notation.replace_region(result, notation_first, output)
|
||||
details = generator.solo.report.structure(solo)
|
||||
details['allocation'] = {'start': str(start), 'end': str(end), 'lead_in_measures': first - notation_first}
|
||||
solo_report = generator.solo.report.describe(solo, first, offset, detail=details)
|
||||
elif role.endswith('_accompaniment'):
|
||||
canonical = generator.comping.Canonical(document, part, seed)
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
|
||||
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'rhythm_source': choice_source}
|
||||
else:
|
||||
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
|
||||
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
|
||||
_mask(result, allowed)
|
||||
if role == "acoustic_accompaniment":
|
||||
generator.acoustic.validate_comping(result)
|
||||
staff = result['staves'][0]
|
||||
for measure in staff['measures']:
|
||||
measure['id'] = role + '_' + measure['id']
|
||||
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
|
||||
if part != 'vocal':
|
||||
result['staves'][0]['tablature'] = True
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
value = {'role': role, 'document': result, 'trace': trace, 'details': details, 'report': solo_report, 'seconds': time.monotonic() - started, 'composition_timings': composition_timings, 'cache_hit': False}
|
||||
# Sidecar diagnostics use the same rational-safe representation as reports.
|
||||
# Identical insertion order after cold generation and cache loading also
|
||||
# keeps serialized scores and the catalog byte-for-byte reproducible.
|
||||
value = json.loads(json.dumps(value, default=str, sort_keys=True))
|
||||
if path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {'key': key, 'digest': generator.vocabulary.fingerprint(value), 'result': value}
|
||||
with tempfile.NamedTemporaryFile('w', dir=path.parent, delete=False) as out:
|
||||
json.dump(payload, out, sort_keys=True)
|
||||
temporary = out.name
|
||||
os.replace(temporary, path)
|
||||
return value
|
||||
|
||||
def assemble(source, results):
|
||||
"""Union prevalidated role windows; no selection or pitch/fingering rewrite."""
|
||||
documents = {}
|
||||
for part in generator.parts.PART_TYPES:
|
||||
selected = [results[r]['document'] for r in ROLES if r.startswith(part + '_')]
|
||||
document = copy.deepcopy(selected[0])
|
||||
staff = document['staves'][0]
|
||||
if part == 'vocal':
|
||||
documents[part] = document
|
||||
continue
|
||||
staff['tuplets'] = []
|
||||
staff['octave_shifts'] = []
|
||||
staff['scale_spans'] = []
|
||||
for other in selected:
|
||||
for field in ('tuplets', 'octave_shifts', 'scale_spans'):
|
||||
staff[field].extend(copy.deepcopy(other['staves'][0].get(field, [])))
|
||||
for i, m in enumerate(staff['measures']):
|
||||
# Tuplet rests belong to the source/role rhythm and must survive union.
|
||||
lanes = {}
|
||||
for d in selected:
|
||||
for voice in d['staves'][0]['measures'][i]['voices']:
|
||||
lane = voice['id'] if part == 'acoustic' and voice['id'] in ('upper', 'bass') else 'voice_arranged'
|
||||
sounding = [copy.deepcopy(n) for n in voice['notes'] if n['pitches'] or n.get('tuplet_ids')]
|
||||
if sounding:
|
||||
lanes.setdefault(lane, []).extend(sounding)
|
||||
lanes = lanes or {'voice_arranged': []}
|
||||
m['voices'] = [{'id': lane, 'notes': _fill(notes, generator.model.fraction(m['duration']), m['id'] + '_' + lane, lane)} for lane, notes in lanes.items()]
|
||||
# Attribute changes belong to source melody pickups and are assembly metadata.
|
||||
for d in selected:
|
||||
other = d['staves'][0]['measures'][i]
|
||||
if other.get('attributes'):
|
||||
m['attributes'] = copy.deepcopy(other['attributes'])
|
||||
staff['tuplets'] = generator.arrange.pickup.retain_links(staff['measures'], staff['tuplets'])
|
||||
owners = {n['id']: v['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||
for span in staff['tuplets']:
|
||||
span['voice'] = owners[span['start']]
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
documents[part] = document
|
||||
return documents
|
||||
import generator.score_io
|
||||
import generator.source
|
||||
|
||||
def _job(request):
|
||||
source, role, directory, settings, cache = request
|
||||
return generate_role(source, role, directory, settings, cache)
|
||||
return generator.composition.generate_role(request.source, request.role, request.directory, json.loads(request.settings_json), request.cache)
|
||||
|
||||
def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=False, timings=None):
|
||||
if type(jobs) is not int or not 1 <= jobs <= 16:
|
||||
@@ -339,8 +26,8 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
sources = {}
|
||||
for path in generator.parts.source_paths(source_dir):
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
sources[path] = (document, SourceAnalysis.read(document))
|
||||
requests = [(path, role, (s, role, source_dir.parent, {}, cache_dir)) for path, (_, s) in sources.items() for role in ROLES]
|
||||
sources[path] = (document, generator.source.SourceAnalysis.read(document))
|
||||
requests = [(path, role, generator.composition_types.RoleRequest(s, role, source_dir.parent, cache=cache_dir)) for path, (_, s) in sources.items() for role in generator.composition_types.ROLES]
|
||||
results = {p: {} for p in sources}
|
||||
|
||||
def accept(path, role, value):
|
||||
@@ -366,18 +53,23 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
|
||||
except Exception as error:
|
||||
raise ValueError(f'{path.name}/{role}: {error}') from error
|
||||
for path, (original, source) in sources.items():
|
||||
assembled = assemble(source, results[path])
|
||||
reuse = generator.arrange.accompaniment.audit(original, assembled)
|
||||
for part, document in assembled.items():
|
||||
document['metadata'] = copy.deepcopy(original['metadata'])
|
||||
document['metadata']['song_id'] = generator.score.slugify(path.stem)
|
||||
generator.score.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + '.json'), document)
|
||||
reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True)
|
||||
for solos_enabled in (True, False):
|
||||
assembled = generator.arrange.assembly.assemble(source, results[path], solos_enabled=solos_enabled)
|
||||
generator.arrange.accompaniment.audit(original, assembled)
|
||||
for part, document in assembled.items():
|
||||
if part == 'vocal' and not solos_enabled:
|
||||
continue
|
||||
document['metadata'] = copy.deepcopy(original['metadata'])
|
||||
document['metadata'].update(song_id=generator.score.slugify(path.stem), solos_enabled=solos_enabled)
|
||||
suffix = '' if solos_enabled else '_improv'
|
||||
generator.score_io.write_json(output_dir / (generator.score.slugify(path.stem) + '_' + part + suffix + '.json'), document)
|
||||
analysis_dir = output_dir.parent / 'analysis'
|
||||
analysis_dir.mkdir(parents=True, exist_ok=True)
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in generator.composition_types.ROLES))
|
||||
# Not a score file: diagnostics are deliberately outside the songs schema.
|
||||
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
|
||||
for part, audit in reuse.items():
|
||||
stable[part + '_accompaniment']['details']['reuse_audit'] = audit
|
||||
for role, audit in reuse.items():
|
||||
stable[role]['details']['reuse_audit'] = audit
|
||||
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
|
||||
return len(sources) * 3
|
||||
return len(sources) * 5
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Role cache persistence; cache identity never supplies musical randomness."""
|
||||
import json
|
||||
|
||||
import generator.cache
|
||||
import generator.fretboard
|
||||
import generator.validation
|
||||
|
||||
ENTRYPOINTS = ('generator.composition',)
|
||||
|
||||
def code_key():
|
||||
return generator.cache.fingerprint(generator.cache.source_identity(ENTRYPOINTS))
|
||||
|
||||
def restore(path, key):
|
||||
if path is None or not path.is_file() or path.is_symlink():
|
||||
return None
|
||||
try:
|
||||
entry = json.loads(path.read_text())
|
||||
value = entry['result']
|
||||
if entry['key'] == key and entry['digest'] == generator.cache.fingerprint(value):
|
||||
generator.validation.validate_document(value['document'])
|
||||
generator.fretboard.validate_document(value['document'])
|
||||
return {**value, 'cache_hit': True}
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def store(path, key, value):
|
||||
if path is not None:
|
||||
generator.cache.write_json(path, {'key': key, 'digest': generator.cache.fingerprint(value), 'result': value})
|
||||
+1
-58
@@ -1,53 +1,8 @@
|
||||
"""Pure score navigation, cloning and identity operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
import generator.engraving.validation
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pico_artwork
|
||||
import generator.performance
|
||||
|
||||
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: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError('expected a score object')
|
||||
if 'music' in document:
|
||||
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')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||
generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
|
||||
if document.get('performance') != generator.performance.timing(document['music']):
|
||||
raise ValueError('invalid performance timing')
|
||||
for page in presentation['pages'] + presentation['playback_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 = 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')
|
||||
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
|
||||
document = document['music']
|
||||
generator.model.validate_document(document)
|
||||
return document
|
||||
|
||||
def write_json(path: pathlib.Path, document: dict) -> None:
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.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')
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r'[^a-z0-9]+', '_', value.strip().lower()).strip('_') or 'score'
|
||||
|
||||
@@ -124,15 +79,3 @@ 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: pathlib.Path) -> int:
|
||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||
|
||||
def validate_generated_tree(root: pathlib.Path, validated_pages: set[str] | None = None) -> None:
|
||||
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'))
|
||||
music = validate_json(document, path, validated_pages)
|
||||
generator.fretboard.validate_document(music)
|
||||
if 'presentation' in document:
|
||||
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
|
||||
import generator.engraving.validation
|
||||
import generator.fretboard
|
||||
import generator.performance
|
||||
import generator.pico_artwork
|
||||
import generator.validation
|
||||
|
||||
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: pathlib.Path, validated_pages: set[str] | None = None) -> dict:
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError('expected a score object')
|
||||
if 'music' in document:
|
||||
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')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||
generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
|
||||
if document.get('performance') != generator.performance.timing(document['music']):
|
||||
raise ValueError('invalid performance timing')
|
||||
for page in presentation['pages'] + presentation['playback_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 = 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')
|
||||
generator.engraving.validation.check_page(resolved, validated_pages, expected_sha256=page['sha256'])
|
||||
document = document['music']
|
||||
generator.validation.validate_document(document)
|
||||
return document
|
||||
|
||||
def write_json(path: pathlib.Path, document: dict) -> None:
|
||||
generator.validation.validate_document(document)
|
||||
generator.fretboard.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')
|
||||
|
||||
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: pathlib.Path, validated_pages: set[str] | None = None) -> None:
|
||||
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'))
|
||||
music = validate_json(document, path, validated_pages)
|
||||
generator.fretboard.validate_document(music)
|
||||
if 'presentation' in document:
|
||||
generator.pico_artwork.validate_title(root, document['presentation'].get('title_image'))
|
||||
+15
-12
@@ -6,20 +6,21 @@ policy. Unknown song/role pairs go to the learned composer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import fractions
|
||||
|
||||
import generator.harmonic_time
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.training_io
|
||||
import generator.harmonic_time
|
||||
import generator.solo.analysis
|
||||
import generator.solo.types
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.production
|
||||
import generator.solo.types
|
||||
import generator.source
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
|
||||
def sounding(evidence, offset=fractions.Fraction()):
|
||||
result = []
|
||||
@@ -32,9 +33,10 @@ def sounding(evidence, offset=fractions.Fraction()):
|
||||
result.append(generator.solo.types.Note(fractions.Fraction(event['start']) - offset, fractions.Fraction(event['end']) - offset, pitch, role='authored', event_id=f'authored_{i}'))
|
||||
return tuple(result)
|
||||
|
||||
def validate_evidence(document, evidence):
|
||||
def validate_evidence(document, evidence, *, composition=None):
|
||||
"""Training uses the same source-form harmonic context as solo playback."""
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]))
|
||||
composition = composition or generator.vocabulary.current()
|
||||
h = generator.solo.harmonic.Harmony(generator.harmonic_time.solo_source(document['staves'][0]), composition=composition)
|
||||
checked = h.evaluate(sounding(evidence), complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError('Unsupported harmonic relationship in authored solo: ' + checked.error)
|
||||
@@ -56,7 +58,7 @@ def lookup(source, role, data_dir):
|
||||
if not path.is_relative_to((root / 'songs').resolve()):
|
||||
raise ValueError('Registered sources must be in data/songs')
|
||||
doc = generator.parts.prepare_document(generator.musicxml.load_musicxml(path))
|
||||
if generator.production.SourceAnalysis.read(doc).musical_key == source.musical_key:
|
||||
if generator.source.SourceAnalysis.read(doc).musical_key == source.musical_key:
|
||||
target = (manifest.parent / entry['solo']).resolve()
|
||||
if not target.is_relative_to(manifest.parent.resolve()):
|
||||
raise ValueError('Registered solo must be inside data/training')
|
||||
@@ -67,7 +69,8 @@ def lookup(source, role, data_dir):
|
||||
raise ValueError('Multiple authored solos registered for the same source music and role')
|
||||
return matches[0] if matches else None
|
||||
|
||||
def realize(entry, source_analysis, measures, part, start, end, initial=None):
|
||||
def realize(entry, source_analysis, measures, part, start, end, initial=None, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
evidence = generator.training_io.read(entry['path'], part, pitch_convention=entry['pitch_convention'])
|
||||
if evidence['discrepancies']:
|
||||
raise ValueError(entry['id'] + ': invalid authored timing/ties: ' + str(evidence['discrepancies']))
|
||||
@@ -86,7 +89,7 @@ def realize(entry, source_analysis, measures, part, start, end, initial=None):
|
||||
if placed is None:
|
||||
raise ValueError(entry['id'] + ': no legal complete guitar placement')
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
checked = generator.solo.harmonic.Harmony(spans).evaluate(placed[1], complete=True)
|
||||
checked = generator.solo.harmonic.Harmony(spans, composition=composition).evaluate(placed[1], complete=True)
|
||||
if not checked.valid:
|
||||
raise ValueError(entry['id'] + ': ' + checked.error)
|
||||
phrase = generator.solo.types.Phrase('authored', start, end, placed[1], 'authored', 'authored', 'handoff', theme=entry['id'], expressions=checked.contracts)
|
||||
|
||||
+17
-14
@@ -1,26 +1,28 @@
|
||||
"""The sole production solo composer: frozen learned model plus music validity."""
|
||||
import dataclasses
|
||||
import time
|
||||
import fractions
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
import generator.harmonic_time
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.solo.learned
|
||||
import generator.solo.decoder
|
||||
import generator.solo.groups
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.learned
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.types
|
||||
import generator.vocabulary
|
||||
|
||||
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction()):
|
||||
def compose(analysis, measures, part, end, *, learned_model=None, initial=None, expressions_enabled=True, start=fractions.Fraction(), composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
began = time.monotonic()
|
||||
spans = tuple(dataclasses.replace(s, start=s.start + start, end=s.end + start) for s in generator.harmonic_time.read({'measures': measures}, initial=initial))
|
||||
profile = generator.solo.profiles.for_part(part, analysis)
|
||||
harmony = generator.solo.harmonic.Harmony(spans, profile)
|
||||
harmony = generator.solo.harmonic.Harmony(spans, profile, composition=composition)
|
||||
model = learned_model or generator.solo.learned.Model.load(pathlib.Path(__file__).resolve().parents[2] / 'data/models/solo.json')
|
||||
settings = generator.vocabulary.role(part + '_solo')
|
||||
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''))
|
||||
settings = composition.role(part + '_solo')
|
||||
builder = generator.solo.decoder.Decoder(analysis, harmony, model, part, start, end, expressions_enabled=expressions_enabled, attempts=settings.get('attempts', 4), seed=settings.get('choice_seed', ''), composition=composition)
|
||||
selected = builder.compose()
|
||||
decoded = time.monotonic()
|
||||
placed = generator.solo.placement.place(selected.notes, analysis.tempo, profile=profile)
|
||||
@@ -48,10 +50,11 @@ def compose(analysis, measures, part, end, *, learned_model=None, initial=None,
|
||||
'counts': dict(builder.counts), 'frontiers': builder.trace, 'completed_candidates': [
|
||||
{'rank': i + 1, 'cost': s.cost, 'events': [(str(a), str(b), p) for a, b, p in s.events]} for i, s in enumerate(builder.completed)],
|
||||
'expressions_enabled': expressions_enabled}, profile, start)
|
||||
validate(result)
|
||||
validate(result, composition=composition)
|
||||
return result
|
||||
|
||||
def validate(solo):
|
||||
def validate(solo, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
previous = None
|
||||
for n in solo.notes():
|
||||
if n.start < solo.start or n.end > solo.end or n.end <= n.start or previous and previous.end > n.start:
|
||||
@@ -59,7 +62,7 @@ def validate(solo):
|
||||
if n.position not in generator.solo.placement.positions(n.pitch.number(), solo.profile):
|
||||
raise ValueError('invalid guitar placement')
|
||||
previous = n
|
||||
checked = generator.solo.harmonic.Harmony(solo.harmony).evaluate(solo.notes(), until=solo.end, complete=True,
|
||||
checked = generator.solo.harmonic.Harmony(solo.harmony, composition=composition).evaluate(solo.notes(), until=solo.end, complete=True,
|
||||
expressions=tuple(c for p in solo.phrases for c in p.expressions))
|
||||
if not checked.valid:
|
||||
raise ValueError(checked.error)
|
||||
|
||||
+19
-17
@@ -8,17 +8,18 @@ legacy fallback. A bounded restart handles an exhausted/entirely silent sample.
|
||||
from __future__ import annotations
|
||||
import collections
|
||||
import dataclasses
|
||||
import hashlib
|
||||
import random
|
||||
import math
|
||||
import fractions
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
import generator.solo.expressions
|
||||
import generator.solo.groups
|
||||
import generator.solo.learned
|
||||
import generator.solo.types
|
||||
import generator.harmony
|
||||
import generator.vocabulary
|
||||
|
||||
class SearchExhausted(ValueError):
|
||||
pass
|
||||
@@ -35,7 +36,8 @@ class State:
|
||||
def event_key(events): return tuple((a, b, -1 if p is None else p) for a, b, p in events)
|
||||
|
||||
class Decoder:
|
||||
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed=''):
|
||||
def __init__(self, analysis, harmony, model, role, start, end, *, expressions_enabled=True, attempts=4, seed='', composition=None):
|
||||
self.composition = composition or generator.vocabulary.current()
|
||||
if type(attempts) is not int or not 1 <= attempts <= 16:
|
||||
raise ValueError('attempts must be between 1 and 16')
|
||||
self.analysis, self.harmony, self.model, self.role = analysis, harmony, model, role
|
||||
@@ -45,7 +47,7 @@ class Decoder:
|
||||
self.seed = seed or hashlib.sha256(repr((source, role, generator.solo.learned.VERSION)).encode()).hexdigest()
|
||||
self.expressions_enabled = expressions_enabled
|
||||
self.low, self.high = harmony.profile.register
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
limits = self.composition.data('roles')['difficulty']
|
||||
self.durations = tuple(d for d in model.durations if 0 < d <= 4 and d.denominator in (1, 2, 4) and
|
||||
(d.denominator != 4 or analysis.tempo <= limits['sixteenth_max_bpm']))
|
||||
self.allow_triplets = analysis.tempo <= limits['triplet_max_bpm']
|
||||
@@ -92,12 +94,12 @@ class Decoder:
|
||||
bases = []
|
||||
regions = [(None, n)]
|
||||
regions.extend((s.start, dataclasses.replace(n, start=s.start)) for s in self.harmony.spans
|
||||
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
if n.start < s.start < n.end and s.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']))
|
||||
for boundary, arrived in regions:
|
||||
exposed = [s for s, _, _ in self.harmony.segments(arrived) if n.pitch.number() % 12 not in s.roles.basic | s.roles.specified]
|
||||
if not exposed:
|
||||
continue
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
for rule in self.composition.data('harmony')['colors']:
|
||||
if arrived.end - arrived.start > fractions.Fraction(rule['max_beats']):
|
||||
continue
|
||||
if any(s.chord == 'N.C.' or not generator.solo.expressions.eligible_color(rule, s, self.harmony) or
|
||||
@@ -139,7 +141,7 @@ class Decoder:
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
for arriving in self.harmony.spans:
|
||||
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
if n.start < arriving.start < n.end and arriving.start - n.start <= fractions.Fraction(self.composition.data('harmony')['anticipation_max']):
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':anticipation', 'anticipation', 'early_chord_tone', (n,), n, arriving.chord, arriving.start)
|
||||
if self.harmony.evaluate((n,), complete=True, expressions=(c,), infer=False).valid:
|
||||
return tuple([c])
|
||||
@@ -175,9 +177,9 @@ class Decoder:
|
||||
for rule, boundary in self.color_bases(n):
|
||||
if rule.get('resolve'):
|
||||
for gap in (fractions.Fraction(), fractions.Fraction(1, 2), fractions.Fraction(1)):
|
||||
if gap <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']):
|
||||
if gap <= fractions.Fraction(self.composition.data('harmony')['resolution_gap']):
|
||||
requests.append(('color', rule, boundary, n.end + gap, (-2, -1, 1, 2)))
|
||||
delay = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
delay = self.composition.data('lead_ins')['delayed_approach']
|
||||
if n.end - n.start <= fractions.Fraction(delay['max_note']) and n.pitch.number() % 12 not in self.harmony.permitted(n.start, n.end):
|
||||
for arrival in map(fractions.Fraction, delay['arrivals']):
|
||||
if n.end <= n.start + arrival <= n.start + fractions.Fraction(delay['deadline']):
|
||||
@@ -219,7 +221,7 @@ class Decoder:
|
||||
if self.expressions_enabled and not self.harmony.ordinary(n):
|
||||
for events, ns, contracts in self.resolutions(n):
|
||||
add(events, ns, contracts)
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
rule = self.composition.data('lead_ins')['passing']
|
||||
if duration <= fractions.Fraction(rule['max_note']):
|
||||
for step in rule['steps']:
|
||||
p = number + step
|
||||
@@ -235,8 +237,8 @@ class Decoder:
|
||||
c = generator.solo.types.ExpressionContract(n.event_id + ':passing', 'passing', rule['id'], (n,), target, self.harmony.at(end).chord)
|
||||
add(((time, end, number), (end, end + hold, p)), (n, target), (c, *extra))
|
||||
if self.expressions_enabled:
|
||||
limits = generator.vocabulary.data('roles')['difficulty']
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
limits = self.composition.data('roles')['difficulty']
|
||||
for rule in self.composition.data('lead_ins')['patterns']:
|
||||
difficulty = rule.get('difficulty')
|
||||
if difficulty and self.analysis.tempo > limits[difficulty + '_max_bpm']:
|
||||
continue
|
||||
@@ -313,9 +315,9 @@ class Decoder:
|
||||
return None
|
||||
contracts = tuple(dataclasses.replace(c, preparation=state.notes[-1]) if c.kind in ('connection', 'passing') and c.preparation is None else c for c in contracts)
|
||||
if self.harmony.ordinary(state.notes[-1]):
|
||||
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony)
|
||||
_, error = generator.solo.expressions.validate((state.notes[-1], *notes), contracts, self.harmony, composition=self.composition)
|
||||
else:
|
||||
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony)
|
||||
_, error = generator.solo.expressions.validate((*state.notes, *notes), (*state.contracts, *contracts), self.harmony, composition=self.composition)
|
||||
if error:
|
||||
return None
|
||||
history = state.history
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Typed, target-driven harmonic expression against written harmony and explicit musical relationships."""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.vocabulary
|
||||
import generator.solo.types
|
||||
import generator.vocabulary
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def parsed_chord(symbol):
|
||||
@@ -90,8 +91,9 @@ def _eligible_color(rule, span, harmony):
|
||||
return False
|
||||
return True
|
||||
|
||||
def validate(notes, contracts, harmony):
|
||||
def validate(notes, contracts, harmony, *, composition=None):
|
||||
"""Return event permissions only after checking complete actual gestures."""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
actual = {n.event_id: n for n in notes}
|
||||
approved = {}
|
||||
|
||||
@@ -115,7 +117,7 @@ def validate(notes, contracts, harmony):
|
||||
if not previous or previous.chord.strip('()') == span.chord.strip('()'):
|
||||
return {}, c.id + ': anticipation requires a harmonic change'
|
||||
if c.kind in ('approach', 'enclosure', 'connection'):
|
||||
rule = next((r for r in generator.vocabulary.data('lead_ins')['patterns'] if r['id'] == c.rule and r['kind'] == c.kind), None)
|
||||
rule = next((r for r in composition.data('lead_ins')['patterns'] if r['id'] == c.rule and r['kind'] == c.kind), None)
|
||||
if not rule or len(c.events) != len(rule['semitones']):
|
||||
return {}, c.id + ': unknown approach formula'
|
||||
arrival = c.boundary if c.boundary is not None else target.start
|
||||
@@ -129,7 +131,7 @@ def validate(notes, contracts, harmony):
|
||||
return {}, c.id + ': invalid internal approach gaps'
|
||||
gap = fractions.Fraction(rule['gap'])
|
||||
cursor = target.start - gap - sum((fractions.Fraction(d) for d in rule['durations']), fractions.Fraction()) - sum(gaps, fractions.Fraction())
|
||||
if target.start - cursor > fractions.Fraction(generator.vocabulary.data('lead_ins')['max_approach']):
|
||||
if target.start - cursor > fractions.Fraction(composition.data('lead_ins')['max_approach']):
|
||||
return {}, c.id + ': approach too long'
|
||||
for i, (n, step, duration) in enumerate(zip(c.events, rule['semitones'], rule['durations'])):
|
||||
if n.start != cursor or n.end != cursor + fractions.Fraction(duration) or n.pitch.number() != target.pitch.number() + step:
|
||||
@@ -144,7 +146,7 @@ def validate(notes, contracts, harmony):
|
||||
if c.kind == 'connection' and (not c.preparation or c.preparation.end != c.events[0].start or abs(c.preparation.pitch.number() - c.events[0].pitch.number()) > 2):
|
||||
return {}, c.id + ': chromatic connection lacks its incoming motion'
|
||||
elif c.kind == 'passing':
|
||||
rule = generator.vocabulary.data('lead_ins')['passing']
|
||||
rule = composition.data('lead_ins')['passing']
|
||||
if c.rule != rule['id'] or len(c.events) != 1 or not c.preparation:
|
||||
return {}, c.id + ': unsupported passing figure'
|
||||
n, = c.events
|
||||
@@ -160,7 +162,7 @@ def validate(notes, contracts, harmony):
|
||||
if not supported(before) or not supported(target):
|
||||
return {}, c.id + ': unsupported passing endpoints'
|
||||
elif c.kind == 'delayed_approach':
|
||||
rule = generator.vocabulary.data('lead_ins').get('delayed_approach', {})
|
||||
rule = composition.data('lead_ins').get('delayed_approach', {})
|
||||
if c.rule != rule.get('id') or len(c.events) != 1:
|
||||
return {}, c.id + ': unknown delayed approach'
|
||||
n, = c.events
|
||||
@@ -171,7 +173,7 @@ def validate(notes, contracts, harmony):
|
||||
if target.pitch.number() % 12 not in harmony.members(target.start, target.end) or target.pitch.number() % 12 not in harmony.permitted(target.start, target.end):
|
||||
return {}, c.id + ': invalid delayed destination'
|
||||
elif c.kind == 'color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
rule = next((r for r in composition.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule:
|
||||
return {}, c.id + ': unknown color policy'
|
||||
colored = False
|
||||
@@ -193,10 +195,10 @@ def validate(notes, contracts, harmony):
|
||||
return {}, c.id + ': no interval expresses the declared color'
|
||||
last = c.events[-1]
|
||||
if rule.get('resolve'):
|
||||
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
|
||||
if target.event_id == last.event_id or not 0 <= target.start - last.end <= fractions.Fraction(composition.data('harmony')['resolution_gap']) or not 0 < abs(target.pitch.number() - last.pitch.number()) <= 2 or target.pitch.number() % 12 not in harmony.members(target.start, target.end):
|
||||
return {}, c.id + ': unresolved color'
|
||||
elif c.kind == 'suspension':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
|
||||
rule = next((r for r in composition.data('harmony').get('suspensions', []) if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary is None:
|
||||
return {}, c.id + ': unsupported suspension'
|
||||
held, = c.events
|
||||
@@ -211,23 +213,23 @@ def validate(notes, contracts, harmony):
|
||||
if held.pitch.number() % 12 not in harmony.permitted(held.start, held.end):
|
||||
return {}, c.id + ': suspension outside source-key/chord motion'
|
||||
elif c.kind == 'anticipated_color':
|
||||
rule = next((r for r in generator.vocabulary.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
rule = next((r for r in composition.data('harmony')['colors'] if r['id'] == c.rule), None)
|
||||
if not rule or len(c.events) != 1 or c.boundary != span.start:
|
||||
return {}, c.id + ': unsupported anticipated color'
|
||||
n, = c.events
|
||||
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
if not n.start < c.boundary < n.end or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipated color interval'
|
||||
arrived = dataclasses.replace(n, start=c.boundary)
|
||||
target = arrived if c.target == n else c.target
|
||||
inner = generator.solo.types.ExpressionContract(c.id + ':arrived', 'color', rule['id'], (arrived,), target, span.chord)
|
||||
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony)
|
||||
_, error = validate((arrived, *((target,) if target != arrived else ())), (inner,), harmony, composition=composition)
|
||||
if error:
|
||||
return {}, error
|
||||
elif c.kind == 'anticipation':
|
||||
if c.rule not in ('early_chord_tone', 'early_reattack') or len(c.events) != 1 or span.start != c.boundary:
|
||||
return {}, c.id + ': unknown anticipation or non-boundary target'
|
||||
n, = c.events
|
||||
if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']):
|
||||
if c.boundary is None or not n.start < c.boundary or c.boundary - n.start > fractions.Fraction(composition.data('harmony')['anticipation_max']):
|
||||
return {}, c.id + ': invalid anticipation interval'
|
||||
if c.rule == 'early_chord_tone' and (n != target or not c.boundary < n.end):
|
||||
return {}, c.id + ': invalid held anticipation'
|
||||
@@ -238,7 +240,7 @@ def validate(notes, contracts, harmony):
|
||||
elif c.kind == 'blues':
|
||||
if len(c.events) != 1:
|
||||
return {}, c.id + ': blues treatment requires one inflected event'
|
||||
rule = next((r for r in generator.vocabulary.data('lead_ins')['blues'] if r['id'] == c.rule), None)
|
||||
rule = next((r for r in composition.data('lead_ins')['blues'] if r['id'] == c.rule), None)
|
||||
n, = c.events
|
||||
chord = parsed_chord(span.chord)
|
||||
if not rule or n.end - n.start > fractions.Fraction(1, 2) or n.end != target.start or target.pitch.number() % 12 not in span.roles.basic:
|
||||
@@ -262,15 +264,16 @@ def validate(notes, contracts, harmony):
|
||||
approved[n.event_id] = c
|
||||
return approved, ''
|
||||
|
||||
def validate_frontier(notes, contracts, harmony, until, complete):
|
||||
def validate_frontier(notes, contracts, harmony, until, complete, *, composition=None):
|
||||
"""Validate heard prefixes and bounded future obligations separately.
|
||||
|
||||
Future contract events are used ONLY to establish rule feasibility. They do
|
||||
not count as heard resolution; the contract stays pending until its entire
|
||||
target hold is present. Missing past events and expired deadlines fail.
|
||||
"""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
if complete:
|
||||
approved, error = validate(notes, contracts, harmony)
|
||||
approved, error = validate(notes, contracts, harmony, composition=composition)
|
||||
return approved, (), error
|
||||
actual = {n.event_id: n for n in notes}
|
||||
future = dict(actual)
|
||||
@@ -290,5 +293,5 @@ def validate_frontier(notes, contracts, harmony, until, complete):
|
||||
return {}, (), c.id + ': missing/changed event before resolution deadline'
|
||||
if incomplete:
|
||||
pending.append(c)
|
||||
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony)
|
||||
approved, error = validate(tuple(sorted(future.values(), key=lambda n: (n.start, n.event_id))), contracts, harmony, composition=composition)
|
||||
return {key: c for key, c in approved.items() if key in actual}, tuple(pending), error
|
||||
|
||||
@@ -5,14 +5,16 @@ permission follows the actual sounded interval and validated resolution.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import dataclasses
|
||||
import functools
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.expressions
|
||||
import generator.solo.profiles
|
||||
import generator.solo.recognition
|
||||
import generator.solo.types
|
||||
import generator.vocabulary
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Roles:
|
||||
@@ -55,7 +57,8 @@ class Result:
|
||||
contracts: tuple = ()
|
||||
|
||||
class Harmony:
|
||||
def __init__(self, spans, profile=None):
|
||||
def __init__(self, spans, profile=None, *, composition=None):
|
||||
self.composition = composition or generator.vocabulary.current()
|
||||
self.profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||
self.source_spans = spans
|
||||
self.spans = []
|
||||
@@ -112,14 +115,14 @@ class Harmony:
|
||||
expressions = tuple(dataclasses.replace(c, events=tuple(self.identified(n) for n in c.events),
|
||||
target=self.identified(c.target), preparation=self.identified(c.preparation) if c.preparation else None) for c in expressions)
|
||||
frontier = until if until is not None else max((n.end for n in notes), default=fractions.Fraction())
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None)
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, complete or until is None, composition=self.composition)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
if infer and not pending and (complete or until is None) and any(not self.ordinary(n) and n.event_id not in permissions for n in notes):
|
||||
expressions, error = generator.solo.recognition.recognize(notes, self, expressions)
|
||||
expressions, error = generator.solo.recognition.recognize(notes, self, expressions, composition=self.composition)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True)
|
||||
permissions, pending, error = generator.solo.expressions.validate_frontier(notes, expressions, self, frontier, True, composition=self.composition)
|
||||
if error:
|
||||
return Result(False, 0, error=error)
|
||||
exposed = sounded = fractions.Fraction()
|
||||
|
||||
+69
-40
@@ -5,77 +5,106 @@ import fractions
|
||||
import functools
|
||||
|
||||
import generator.fretboard
|
||||
import generator.hand_position
|
||||
import generator.solo.types
|
||||
import generator.solo.profiles
|
||||
import generator.solo.policy
|
||||
|
||||
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
||||
if previous is None:
|
||||
return 0
|
||||
return transition(previous.position, current.position, current.start - previous.start, tempo)
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def transition(previous, current, duration, tempo):
|
||||
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
|
||||
seconds = float(duration * 60 / tempo)
|
||||
# Movement is a preference, not an unsupported claim about a player's limits.
|
||||
return round(100 * distance / max(seconds, 0.05))
|
||||
|
||||
def hand_positions(position):
|
||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
||||
return generator.hand_position.regions((position,))
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def position_rank(position):
|
||||
return generator.solo.policy.stable_rank('guitar-position', position)
|
||||
|
||||
def stable_path(path):
|
||||
return tuple(position_rank(n.position) for n in path)
|
||||
def seconds(duration, tempo):
|
||||
return max(fractions.Fraction(1, 20), duration * 60 / tempo)
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Path:
|
||||
travel: int = 0
|
||||
peak: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
notes: tuple = ()
|
||||
hands: tuple = ()
|
||||
ties: tuple = ()
|
||||
@property
|
||||
def rank(self):
|
||||
return self.travel, self.peak, self.effort, self.ties
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def paths(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED, fixed=False):
|
||||
"""Complete TAB paths: hand travel first, then maximum hand position.
|
||||
|
||||
Preserve different peaks at a common endpoint. No barline or tied fragment
|
||||
resets the hand or shortens the elapsed time from the previous attack.
|
||||
"""
|
||||
states = [(Path(peak=h), h) for h in hand_positions(previous.position)] if previous and previous.position else [(Path(), 0)]
|
||||
last_attack = previous.start if previous else None
|
||||
for note in notes:
|
||||
following = {}
|
||||
for position in positions(note.pitch.number(), profile):
|
||||
options = (note.position,) if fixed and note.position else positions(note.pitch.number(), profile)
|
||||
for position in options:
|
||||
current = dataclasses.replace(note, position=position)
|
||||
for hand in hand_positions(position):
|
||||
best = None
|
||||
for cost, path, old_hand in states:
|
||||
prior = path[-1] if path else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
||||
for path, old_hand in states:
|
||||
prior = path.notes[-1] if path.notes else previous
|
||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start or hand != old_hand):
|
||||
continue
|
||||
movement = 0
|
||||
motion = generator.hand_position.Motion()
|
||||
effort = fractions.Fraction()
|
||||
if prior and not note.continuation:
|
||||
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
|
||||
distance = abs(hand - old_hand) if old_hand is not None else 0
|
||||
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
|
||||
movement = round(100 * distance / seconds)
|
||||
candidate = (cost + movement, (*path, current), hand)
|
||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
||||
best = candidate
|
||||
if best is not None:
|
||||
following[position, hand] = best
|
||||
motion = generator.hand_position.motion((prior.position,), (position,), old_hand, hand)
|
||||
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo)
|
||||
candidate = Path(path.travel + motion.shift, max(path.peak, hand), path.effort + effort, (*path.notes, current), (*path.hands, hand), (*path.ties, (position_rank(position), hand)))
|
||||
key = (position, hand, candidate.peak)
|
||||
if key not in following or candidate.rank < following[key].rank:
|
||||
following[key] = candidate
|
||||
if not following:
|
||||
return ()
|
||||
states = list(following.values())
|
||||
# Endpoints survive so the following phrase can choose a better transition.
|
||||
# A strictly worse hand travel at the identical endpoint/hand cannot recover.
|
||||
best = {}
|
||||
for (position, hand, _), path in following.items():
|
||||
best[position, hand] = min(best.get((position, hand), path.travel), path.travel)
|
||||
states = [(path, hand) for (position, hand, _), path in following.items() if path.travel == best[position, hand]]
|
||||
if not note.continuation:
|
||||
last_attack = note.start
|
||||
return tuple(sorted((path for path, _ in states), key=lambda path: path.rank))
|
||||
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
selected, seen = [], set()
|
||||
for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
|
||||
endpoint = path[-1].position if path else None
|
||||
for path in paths(notes, tempo, previous, profile):
|
||||
endpoint = (path.notes[-1].position, path.hands[-1]) if path.notes else None
|
||||
if endpoint in seen:
|
||||
continue
|
||||
seen.add(endpoint)
|
||||
selected.append((cost, path))
|
||||
selected.append((path.travel, path.notes))
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return tuple(selected)
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
@functools.lru_cache(maxsize=512)
|
||||
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
result = alternatives(notes, tempo, previous, 1, profile)
|
||||
return result[0] if result else None
|
||||
|
||||
def describe(notes, tempo):
|
||||
candidates = paths(notes, tempo, fixed=True)
|
||||
if not candidates:
|
||||
raise ValueError('cannot describe an invalid guitar path')
|
||||
path = candidates[0]
|
||||
result, prior, old_hand, last_attack = [], None, 0, None
|
||||
for note, hand in zip(path.notes, path.hands):
|
||||
motion = generator.hand_position.motion((prior.position,), (note.position,), old_hand, hand) if prior and not note.continuation else generator.hand_position.Motion()
|
||||
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo) if prior and not note.continuation else 0
|
||||
result.append({'start': str(note.start), 'end': str(note.end), 'hand_position': hand, 'string': note.position.string_number,
|
||||
'fret': note.position.fret, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings, 'secondary_effort': str(effort)})
|
||||
if not note.continuation:
|
||||
last_attack = note.start
|
||||
prior, old_hand = note, hand
|
||||
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in result), 'secondary_effort': str(path.effort), 'peak_position': path.peak, 'positions': result}
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
|
||||
profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import functools
|
||||
import fractions
|
||||
import functools
|
||||
|
||||
import generator.fretboard
|
||||
import generator.vocabulary
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Instrument:
|
||||
|
||||
@@ -5,9 +5,10 @@ Existing expression validators prove each candidate; deterministic set-cover
|
||||
search chooses a mutually consistent explanation, never edits the notes.
|
||||
"""
|
||||
import fractions
|
||||
import generator.vocabulary
|
||||
|
||||
import generator.solo.expressions
|
||||
import generator.solo.types
|
||||
import generator.vocabulary
|
||||
|
||||
def signature(c):
|
||||
return (c.kind, c.rule, tuple(n.event_id for n in c.events), c.target.event_id,
|
||||
@@ -20,7 +21,8 @@ def merge(*groups):
|
||||
result.setdefault(signature(c), c)
|
||||
return tuple(result.values())
|
||||
|
||||
def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
def recognize(notes, harmony, provided=(), *, budget=4096, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
required = {n.event_id for n in notes if not harmony.ordinary(n)}
|
||||
fixed = {n.event_id for c in provided for n in c.events}
|
||||
if required <= fixed:
|
||||
@@ -46,7 +48,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
if not (covered & required):
|
||||
return False
|
||||
combined = merge(provided, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
|
||||
if error:
|
||||
return False
|
||||
candidates[tuple(signature(a) for a in bundle)] = (bundle, covered)
|
||||
@@ -57,11 +59,11 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
for i, n in enumerate(notes):
|
||||
if n.event_id not in required:
|
||||
continue
|
||||
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(generator.vocabulary.data('harmony')['resolution_gap'])
|
||||
future = [t for t in notes[i + 1:] if n.end <= t.start <= n.end + fractions.Fraction(composition.data('harmony')['resolution_gap'])
|
||||
and 0 < abs(t.pitch.number() - n.pitch.number()) <= 2]
|
||||
regions = [(None, harmony.at(n.start))]
|
||||
regions.extend((s.start, s) for s in harmony.spans if n.start < s.start < n.end
|
||||
and s.start - n.start <= fractions.Fraction(generator.vocabulary.data('harmony')['anticipation_max']))
|
||||
and s.start - n.start <= fractions.Fraction(composition.data('harmony')['anticipation_max']))
|
||||
for boundary, span in regions:
|
||||
if not span or span.chord == 'N.C.':
|
||||
continue
|
||||
@@ -69,7 +71,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
c = contract('anticipation', 'early_chord_tone', (n,), n, boundary)
|
||||
if add(c):
|
||||
single.setdefault(n.event_id, []).append((c,))
|
||||
for rule in generator.vocabulary.data('harmony')['colors']:
|
||||
for rule in composition.data('harmony')['colors']:
|
||||
if not generator.solo.expressions.eligible_color(rule, span, harmony) and boundary is not None:
|
||||
continue
|
||||
kind = 'anticipated_color' if boundary is not None else 'color'
|
||||
@@ -85,7 +87,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
target = notes[i + 1]
|
||||
for s in harmony.spans:
|
||||
if n.start < s.start < n.end:
|
||||
for rule in generator.vocabulary.data('harmony').get('suspensions', []):
|
||||
for rule in composition.data('harmony').get('suspensions', []):
|
||||
add(contract('suspension', rule['id'], (n,), target, s.start))
|
||||
elif s.start == n.end == target.start and n.pitch.number() == target.pitch.number():
|
||||
add(contract('anticipation', 'early_reattack', (n,), target, s.start))
|
||||
@@ -99,7 +101,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
targets.append((None, support))
|
||||
elif c.kind == 'anticipation':
|
||||
targets.append((c.boundary, support))
|
||||
for rule in generator.vocabulary.data('lead_ins')['patterns']:
|
||||
for rule in composition.data('lead_ins')['patterns']:
|
||||
size = len(rule['semitones'])
|
||||
if i < size:
|
||||
continue
|
||||
@@ -113,7 +115,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
add(contract(rule['kind'], rule['id'], ns, target, boundary, preparation), extras)
|
||||
# Delayed approaches may include real space or intervening valid music.
|
||||
# They still have an exact semitone target and a finite deadline.
|
||||
rule = generator.vocabulary.data('lead_ins')['delayed_approach']
|
||||
rule = composition.data('lead_ins')['delayed_approach']
|
||||
for n in reversed(notes[:i]):
|
||||
if target.start - n.start > fractions.Fraction(rule['deadline']):
|
||||
break
|
||||
@@ -124,8 +126,8 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
if n.event_id in required:
|
||||
for left in [(), *single.get(before.event_id, ())]:
|
||||
for right in [(), *single.get(target.event_id, ())]:
|
||||
add(contract('passing', generator.vocabulary.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
|
||||
for rule in generator.vocabulary.data('lead_ins')['blues']:
|
||||
add(contract('passing', composition.data('lead_ins')['passing']['id'], (n,), target, preparation=before), merge(left, right))
|
||||
for rule in composition.data('lead_ins')['blues']:
|
||||
add(contract('blues', rule['id'], (n,), target, preparation=before))
|
||||
options = {identifier: [] for identifier in sorted(required - fixed, key=lambda identifier: (by_id[identifier].start, identifier))}
|
||||
for bundle, covered in candidates.values():
|
||||
@@ -152,7 +154,7 @@ def recognize(notes, harmony, provided=(), *, budget=4096):
|
||||
identifier = min(remaining, key=lambda x: (len(options[x]), by_id[x].start, x))
|
||||
for bundle, added in options[identifier]:
|
||||
combined = merge(chosen, bundle)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony)
|
||||
_, error = generator.solo.expressions.validate(notes, combined, harmony, composition=composition)
|
||||
if error:
|
||||
continue
|
||||
result = solve(combined, covered | added)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Compact reports of actual selected phrases, not duplicated draft ancestry."""
|
||||
import dataclasses, fractions, json
|
||||
import generator.solo.placement
|
||||
import generator.solo.harmonic
|
||||
import collections
|
||||
import generator.fretboard
|
||||
import dataclasses, fractions, json
|
||||
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.placement
|
||||
import generator.vocabulary
|
||||
|
||||
def plain(value):
|
||||
if dataclasses.is_dataclass(value):
|
||||
@@ -16,8 +17,9 @@ def plain(value):
|
||||
return str(value)
|
||||
return value
|
||||
|
||||
def structure(solo):
|
||||
harmony = generator.solo.harmonic.Harmony(solo.harmony)
|
||||
def structure(solo, *, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
harmony = generator.solo.harmonic.Harmony(solo.harmony, composition=composition)
|
||||
contracts = tuple(c for p in solo.phrases for c in p.expressions)
|
||||
checked = harmony.evaluate(solo.notes(), until=solo.end, complete=True, expressions=contracts)
|
||||
phrases = []
|
||||
@@ -30,15 +32,16 @@ def structure(solo):
|
||||
ns = solo.notes()
|
||||
duration = solo.end - solo.start
|
||||
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo),
|
||||
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo), 'guitar_placement': generator.solo.placement.describe(solo.notes(), solo.tempo),
|
||||
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
|
||||
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
|
||||
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},
|
||||
'harmonic_roles': harmony.describe(ns, expressions=contracts), 'harmonic_policy': 'contextual (shared by authored and predicted music)', 'harmonic_valid': checked.valid, 'harmonic_error': checked.error,
|
||||
'harmonic_exposure': checked.exposure, 'unresolved_treatments': plain(checked.pending)}
|
||||
|
||||
def describe(solo, measure_offset=0, time_offset=0, *, detail=None):
|
||||
detail = detail or structure(solo)
|
||||
def describe(solo, measure_offset=0, time_offset=0, *, detail=None, composition=None):
|
||||
composition = composition or generator.vocabulary.current()
|
||||
detail = detail or structure(solo, composition=composition)
|
||||
lines = [f'{solo.diagnostics.get("construction", "solo")}: {solo.part}', 'Times are exact quarter beats from the chorus start; negative times are allocated lead-ins. Pitches sound one octave below guitar notation.',
|
||||
'Approximate fingering costs are not a biomechanical guarantee.', json.dumps(detail['phrasing'])]
|
||||
for p in solo.phrases:
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Immutable source events, meter boundaries and tempo. No solo construction."""
|
||||
from __future__ import annotations
|
||||
import dataclasses, fractions
|
||||
import generator.model, generator.score, generator.solo.types
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.solo.types
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Analysis:
|
||||
@@ -43,3 +50,32 @@ def analyze(document):
|
||||
boundaries.append(boundaries[-1] + generator.model.fraction(m['duration']))
|
||||
tempo = next((generator.model.fraction(t['bpm']) * generator.model.written_length(t['written']) for m in measures for t in m.get('tempos', []) if 'bpm' in t), fractions.Fraction(120))
|
||||
return Analysis(tuple(boundaries), tempo, attacks(document), boundaries[generator.score.body_start_measure_index(measures)])
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SourceAnalysis:
|
||||
document_json: str
|
||||
musical_key: str
|
||||
@classmethod
|
||||
def read(cls, document):
|
||||
prepared = copy.deepcopy(document)
|
||||
staff = generator.score.source_staff(prepared)
|
||||
for i, m in enumerate(staff['measures']):
|
||||
m['id'], m['number'] = f'source_{i}', str(i + 1)
|
||||
staff['tuplets'] = generator.score.reidentify(staff['measures'], staff.get('tuplets', []))
|
||||
prepared['metadata'] = {k: v for k, v in prepared['metadata'].items() if k not in ('title', 'composer', 'copyright', 'software')}
|
||||
prepared['metadata'].update(title='Source', composer='')
|
||||
staff.update(id='source', name='Source')
|
||||
melodic = analyze(prepared)
|
||||
content = {'melody': [(str(n.start), str(n.end), dataclasses.asdict(n.pitch)) for n in melodic.melody],
|
||||
'measures': [{k: m.get(k) for k in ('duration', 'time_signature', 'key', 'harmonies', 'tempos', 'section_label', 'implicit')} for m in staff['measures']]}
|
||||
# Fraction/string spelling is canonical and never includes filesystem IDs.
|
||||
key = hashlib.sha256(json.dumps(content, sort_keys=True, default=str).encode()).hexdigest()
|
||||
return cls(json.dumps(prepared, sort_keys=True), key)
|
||||
def document(self):
|
||||
return json.loads(self.document_json)
|
||||
def accompaniment_key(self):
|
||||
document = self.document()
|
||||
document['metadata'].pop('tempo', None)
|
||||
for measure in generator.score.source_staff(document)['measures']:
|
||||
measure['tempos'] = []
|
||||
return SourceAnalysis.read(document).musical_key
|
||||
@@ -6,6 +6,7 @@ import typing
|
||||
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.score_io
|
||||
|
||||
def _arranged_staff(document: dict[str, typing.Any]) -> dict[str, typing.Any]:
|
||||
staves = document.get("staves")
|
||||
@@ -61,7 +62,7 @@ def performance_document_summary(document: dict[str, typing.Any]) -> dict[str, t
|
||||
|
||||
def performance_tree_summary(root: pathlib.Path) -> dict[str, typing.Any]:
|
||||
return {
|
||||
path.name: performance_document_summary(generator.score.load_json(path))
|
||||
path.name: performance_document_summary(generator.score_io.load_json(path))
|
||||
for path in sorted(root.glob("*.json"))
|
||||
}
|
||||
|
||||
|
||||
+10
-11
@@ -3,26 +3,25 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import collections
|
||||
import dataclasses
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.harmonic_time
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.solo.authored
|
||||
import generator.solo.learned
|
||||
import generator.source
|
||||
import generator.training_io
|
||||
import generator.vocabulary
|
||||
import generator.harmonic_time
|
||||
import generator.solo.analysis
|
||||
import generator.solo.learned
|
||||
import shutil
|
||||
import generator.production
|
||||
import generator.solo.authored
|
||||
import generator.fretboard
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Span:
|
||||
@@ -57,8 +56,8 @@ def load_examples(manifest):
|
||||
if not source.is_relative_to((manifest.parent.parent / 'songs').resolve()):
|
||||
raise ValueError('Training sources must be inside data/songs')
|
||||
document = generator.parts.prepare_document(generator.musicxml.load_musicxml(source))
|
||||
a = generator.solo.analysis.analyze(document)
|
||||
pair = (generator.production.SourceAnalysis.read(document).musical_key, entry['slot'])
|
||||
a = generator.source.analyze(document)
|
||||
pair = (generator.source.SourceAnalysis.read(document).musical_key, entry['slot'])
|
||||
if pair in pairs:
|
||||
raise ValueError('Duplicate source music and solo slot')
|
||||
pairs.add(pair)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Score document validation, above pure musical values and instrument rules."""
|
||||
from __future__ import annotations
|
||||
import fractions
|
||||
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
|
||||
def validate_document(document: dict) -> None:
|
||||
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
|
||||
raise ValueError('score requires a metadata object')
|
||||
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
|
||||
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
|
||||
staves = document.get('staves', [])
|
||||
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
|
||||
raise ValueError('exactly one staff is currently supported')
|
||||
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
|
||||
raise ValueError('score requires measures')
|
||||
instruments = staves[0].get('instruments', [])
|
||||
instrument_ids = {i['id'] for i in instruments}
|
||||
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||
raise ValueError('score instruments require unique nonempty IDs')
|
||||
ids: set[str] = set()
|
||||
events: dict[str, dict] = {}
|
||||
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
|
||||
ownership: dict[str, str] = {}
|
||||
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')
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
if length <= 0:
|
||||
raise ValueError('measure duration must be positive')
|
||||
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']: 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')
|
||||
if not note['id'] or note['id'] in ids:
|
||||
raise ValueError(f'duplicate event ID: {note["id"]}')
|
||||
ids.add(note['id'])
|
||||
events[note['id']] = note
|
||||
ownership[note['id']] = voice['id']
|
||||
if note.get('voice', voice['id']) != voice['id']:
|
||||
raise ValueError('event voice ownership disagrees with its lane')
|
||||
onset, duration = generator.model.fraction(note['onset']), generator.model.fraction(note['duration'])
|
||||
if onset < 0 or duration < 0 or onset + duration > length:
|
||||
raise ValueError(f'event outside measure: {note["id"]}')
|
||||
if note['kind'] not in ('note', 'rest', 'spacer'):
|
||||
raise ValueError('invalid event kind')
|
||||
if not note.get('grace'):
|
||||
if duration == 0 or onset < previous_ends[voice['id']]:
|
||||
raise ValueError('overlapping events in one rhythmic voice')
|
||||
previous_ends[voice['id']] = onset + duration
|
||||
elif duration != 0:
|
||||
raise ValueError('grace notes must not advance metric time')
|
||||
expected = generator.model.metric_length(note['written'])
|
||||
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
|
||||
raise ValueError('metric and written event durations disagree')
|
||||
if note['kind'] != 'note' and note.get('pitches'):
|
||||
raise ValueError('rests and spacers cannot contain pitches')
|
||||
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
|
||||
raise ValueError('pitched event requires at least one pitch')
|
||||
for p in note.get('pitches', []):
|
||||
if p.get('instrument') and p['instrument'] not in instrument_ids:
|
||||
raise ValueError('note refers to an unknown score instrument')
|
||||
if 'unpitched' in p:
|
||||
position = p['unpitched']
|
||||
if position['letter'] not in generator.model.LETTERS or type(position['octave']) is not int:
|
||||
raise ValueError('invalid unpitched display position')
|
||||
else:
|
||||
generator.model.pitch_number(p)
|
||||
if staves[0].get('tablature'):
|
||||
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(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
|
||||
raise ValueError(f'{p["id"]}: invalid generated tablature position')
|
||||
state = dict(measure)
|
||||
for change in measure.get('attributes', []):
|
||||
if generator.model.fraction(change['onset']) <= onset:
|
||||
state.update(change)
|
||||
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
||||
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
|
||||
pd = generator.model.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:
|
||||
raise ValueError('invalid chord-tone duration or duplicate ID')
|
||||
if not note.get('grace') and pd != generator.model.metric_length(p['written']):
|
||||
raise ValueError('metric and written chord-tone durations disagree')
|
||||
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
|
||||
for harmony in measure.get('harmonies', []):
|
||||
if not 0 <= generator.model.fraction(harmony['onset']) < length:
|
||||
raise ValueError('harmony outside measure')
|
||||
for change in measure.get('attributes', []):
|
||||
if not 0 <= generator.model.fraction(change['onset']) < length:
|
||||
raise ValueError('attribute change outside measure')
|
||||
for state in [measure, *measure.get('attributes', [])]:
|
||||
if 'meter' in state:
|
||||
generator.model.validate_meter(state['meter'])
|
||||
if 'key' in state:
|
||||
if len(state['key']['alterations']) != 7:
|
||||
raise ValueError('key alterations must contain seven exact alterations')
|
||||
for value in state['key']['alterations']:
|
||||
generator.model.alteration(value)
|
||||
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||
raise ValueError('invalid clef')
|
||||
absolute += length
|
||||
for identifier, (p, start, duration, voice) in tones.items():
|
||||
if target := p.get('tie_to'):
|
||||
if target not in tones:
|
||||
raise ValueError(f'dangling tie: {identifier} -> {target}')
|
||||
other, next_start, _, next_voice = tones[target]
|
||||
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
|
||||
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
|
||||
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
||||
if staves[0].get('tablature') and p['tablature'] != other['tablature']:
|
||||
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
|
||||
if source := p.get('tie_from'):
|
||||
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
||||
raise ValueError(f'dangling incoming tie: {identifier}')
|
||||
span_ids = set()
|
||||
event_order = {identifier: index for index, identifier in enumerate(events)}
|
||||
for span in staves[0].get('tuplets', []):
|
||||
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
|
||||
raise ValueError('invalid tuplet span')
|
||||
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
|
||||
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
|
||||
raise ValueError('tuplet endpoints disagree with member events')
|
||||
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
|
||||
raise ValueError('tuplet crosses independent voice lanes')
|
||||
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
|
||||
raise ValueError('invalid tuplet appearance')
|
||||
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 fractions.Fraction()
|
||||
if 'extend_to' in verse and not start < generator.model.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', [])):
|
||||
raise ValueError('event refers to an unknown tuplet')
|
||||
if tremolo := note.get('tremolo'):
|
||||
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
|
||||
raise ValueError('invalid measured tremolo')
|
||||
if tremolo['type'] in ('start', 'stop'):
|
||||
other = events.get(tremolo.get('partner'))
|
||||
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
|
||||
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
|
||||
raise ValueError('invalid paired tremolo')
|
||||
for span in staves[0].get('octave_shifts', []):
|
||||
if not 0 <= generator.model.fraction(span['start']) < generator.model.fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
|
||||
raise ValueError('invalid octave-shift span')
|
||||
previous_end = fractions.Fraction()
|
||||
for span in staves[0].get('scale_spans', []):
|
||||
start, end = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
|
||||
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
|
||||
raise ValueError('invalid solo scale span')
|
||||
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
|
||||
raise ValueError('solo scale spans require unique IDs')
|
||||
ids.add(span['id'])
|
||||
previous_end = end
|
||||
+69
-22
@@ -2,12 +2,14 @@
|
||||
from __future__ import annotations
|
||||
import contextlib
|
||||
import contextvars
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import fractions
|
||||
import types
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1] / 'data' / 'composition'
|
||||
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
|
||||
@@ -16,21 +18,26 @@ def load(root=ROOT):
|
||||
# Content, not just the directory, keys memoization. Long-lived individual
|
||||
# role builds must observe edited production data without restarting Python.
|
||||
names = ('roles', 'harmony', 'lead_ins', 'accompaniment')
|
||||
return _load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names))
|
||||
return copy.deepcopy(_load(tuple((name, (pathlib.Path(root) / (name + '.json')).read_text()) for name in names)))
|
||||
|
||||
@functools.lru_cache(maxsize=16)
|
||||
def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != (4 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (6 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||||
if name == 'accompaniment':
|
||||
signatures = {}
|
||||
for part in ('electric', 'acoustic'):
|
||||
policy = value['instruments'][part]
|
||||
if part == 'acoustic' and policy.get('first_attack') != 'bass_or_combined':
|
||||
raise ValueError('acoustic patterns require a bass-or-combined first attack policy')
|
||||
if part == 'acoustic' and policy.get('first_attack') != 'bass_per_harmony':
|
||||
raise ValueError('acoustic patterns require bass at the first attack of each harmony')
|
||||
if part == 'acoustic' and policy.get('attack_assignment') != {'version': 1, 'mode': 'combined_except_eighth_pairs'}:
|
||||
raise ValueError('acoustic patterns require automatic eighth-pair assignment')
|
||||
groove = policy['groove']
|
||||
if any(type(groove[k]) is not int for k in ('cycle_patterns', 'slot_candidates', 'fill_spacing_measures')) or not 1 <= groove['cycle_patterns'] <= 4 or not 4 <= groove['slot_candidates'] <= 8 or groove['fill_spacing_measures'] < 8:
|
||||
raise ValueError('invalid bounded accompaniment groove policy')
|
||||
identifiers = set()
|
||||
signatures[part] = set()
|
||||
for pattern in policy['patterns']:
|
||||
@@ -40,8 +47,17 @@ def _load(contents):
|
||||
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
|
||||
if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
|
||||
raise ValueError('patterns require one or two positive measure lengths')
|
||||
meter = pattern.get('meter')
|
||||
grouping = tuple(pattern.get('grouping', ()))
|
||||
if meter == 'partial':
|
||||
if len(lengths) != 1 or grouping:
|
||||
raise ValueError('partial patterns require one measure and no grouping')
|
||||
elif meter not in ('3/4', '4/4', '5/4') or lengths != (fractions.Fraction(meter[0]),) * 2:
|
||||
raise ValueError('full patterns require an explicit supported meter and two complete bars')
|
||||
if (meter == '5/4' and grouping not in ((3, 2), (2, 3))) or (meter != '5/4' and grouping):
|
||||
raise ValueError('5/4 patterns require a 3+2 or 2+3 grouping')
|
||||
# Validate musical events before any measure projection or ties.
|
||||
previous = fractions.Fraction()
|
||||
previous, releases = fractions.Fraction(), {}
|
||||
onsets = []
|
||||
for row in pattern['events']:
|
||||
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
|
||||
@@ -50,19 +66,22 @@ def _load(contents):
|
||||
if onset.denominator > 2 or duration.denominator > 2:
|
||||
raise ValueError('comping vocabulary uses a quarter/eighth grid')
|
||||
if part == 'acoustic':
|
||||
if row['layer'] == 'combined':
|
||||
if duration <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes')
|
||||
if row['layer'] != 'acoustic':
|
||||
raise ValueError('acoustic pattern layers are assigned from the complete rhythm')
|
||||
elif row['layer'] != 'electric':
|
||||
raise ValueError('electric pattern must contain electric chord attacks')
|
||||
if row.get('anticipate') and row['layer'] not in ('upper', 'electric'):
|
||||
raise ValueError('only upper/electric chords can anticipate')
|
||||
previous = onset + duration
|
||||
if row.get('anticipate') and row['layer'] not in ('acoustic', 'electric'):
|
||||
raise ValueError('only acoustic/electric chords can anticipate')
|
||||
# Acoustic overlaps are checked after deterministic string-lane
|
||||
# assignment, including neighboring figures in the source form.
|
||||
lanes = () if part == 'acoustic' else (row['layer'],)
|
||||
if any(releases.get(lane, fractions.Fraction()) > onset for lane in lanes):
|
||||
raise ValueError('overlapping accompaniment attacks on the same strings')
|
||||
releases.update({lane: onset + duration for lane in lanes})
|
||||
previous = onset
|
||||
onsets.append(onset)
|
||||
if len(lengths) == 2:
|
||||
signatures[part].add((lengths, tuple(onsets)))
|
||||
signatures[part].add((meter, grouping, tuple(onsets)))
|
||||
families = policy['voicing_families']
|
||||
if not families.get('provenance'):
|
||||
raise ValueError('guitar voicing families require provenance')
|
||||
@@ -88,26 +107,54 @@ def _load(contents):
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _default():
|
||||
return {'data': load(), 'role': '', 'settings': {}}
|
||||
return CompositionContext.create(load())
|
||||
|
||||
def _freeze(value):
|
||||
if isinstance(value, dict):
|
||||
return types.MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
||||
if isinstance(value, list):
|
||||
return tuple(_freeze(item) for item in value)
|
||||
return value
|
||||
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def _frozen_json(contents):
|
||||
return _freeze(json.loads(contents))
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class CompositionContext:
|
||||
"""Owned, immutable, pickleable policy inputs with stable iteration order."""
|
||||
resources_json: str
|
||||
name: str = ''
|
||||
settings_json: str = '{}'
|
||||
@classmethod
|
||||
def create(cls, resources, name='', settings=None):
|
||||
return cls(json.dumps(resources), name, json.dumps(settings or {}))
|
||||
def data(self, name):
|
||||
return _frozen_json(self.resources_json)[name]
|
||||
def role(self, name=None):
|
||||
return {**self.data('roles')['roles'].get(name or self.name, {}), **_frozen_json(self.settings_json)}
|
||||
def accompaniment(self, part):
|
||||
value = self.data('accompaniment')
|
||||
return {**{key: item for key, item in value.items() if key != 'instruments'}, **value['instruments'][part]}
|
||||
|
||||
def current():
|
||||
return ACTIVE.get() or _default()
|
||||
|
||||
def data(name):
|
||||
return current()['data'][name]
|
||||
return current().data(name)
|
||||
|
||||
def accompaniment(part, resources=None):
|
||||
value = (resources or current()['data'])['accompaniment']
|
||||
if resources is None:
|
||||
return current().accompaniment(part)
|
||||
value = resources['accompaniment']
|
||||
return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]}
|
||||
|
||||
def role(name=None):
|
||||
active = current()
|
||||
name = name or active['role']
|
||||
return {**active['data']['roles']['roles'].get(name, {}), **active['settings']}
|
||||
return current().role(name)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def using(resources, name, settings=None):
|
||||
token = ACTIVE.set({'data': resources, 'role': name, 'settings': settings or {}})
|
||||
token = ACTIVE.set(CompositionContext.create(resources, name, settings))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
|
||||
@@ -44,6 +44,7 @@ enum class sprite : std::size_t {
|
||||
SETTINGS,
|
||||
BACKING,
|
||||
METRONOME,
|
||||
SOLOS,
|
||||
TEMPO,
|
||||
BACK,
|
||||
ON,
|
||||
|
||||
@@ -25,13 +25,14 @@ struct remote_display_state {
|
||||
std::bool_t error{};
|
||||
std::array<std::char_t, 16> phase{};
|
||||
std::bool_t metronome{};
|
||||
std::bool_t solos{true};
|
||||
std::bool_t editing{};
|
||||
std::uint32_t bpm{120U};
|
||||
std::array<std::char_t, vamp_protocol::title_id_size + 1U> title_image{};
|
||||
std::uint32_t page{};
|
||||
std::uint32_t pages{};
|
||||
auto operator==(remote_display_state const &other) const -> std::bool_t {
|
||||
return backing_available == other.backing_available && error == other.error && phase == other.phase && active == other.active && std::string_view{title} == other.title && std::string_view{part} == other.part && std::string_view{mode} == other.mode && std::string_view{focus} == other.focus && backing == other.backing && metronome == other.metronome && editing == other.editing && bpm == other.bpm && title_image == other.title_image && page == other.page && pages == other.pages;
|
||||
return backing_available == other.backing_available && error == other.error && phase == other.phase && active == other.active && std::string_view{title} == other.title && std::string_view{part} == other.part && std::string_view{mode} == other.mode && std::string_view{focus} == other.focus && backing == other.backing && metronome == other.metronome && solos == other.solos && editing == other.editing && bpm == other.bpm && title_image == other.title_image && page == other.page && pages == other.pages;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
|
||||
namespace vamp_zero {
|
||||
|
||||
namespace settings_row {
|
||||
inline constexpr std::size_t backing = 1U;
|
||||
inline constexpr std::size_t metronome = 2U;
|
||||
inline constexpr std::size_t solos = 3U;
|
||||
inline constexpr std::size_t tempo = 4U;
|
||||
inline constexpr std::size_t count = 5U;
|
||||
}
|
||||
|
||||
enum class menu_action_kind { ACTIVATE,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
namespace vamp_zero {
|
||||
|
||||
auto selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const *;
|
||||
auto visible_parts(vamp_zero::app_state const &state) -> std::vector<std::size_t>;
|
||||
auto selected_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const *;
|
||||
auto active_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const *;
|
||||
auto active_pages(vamp_zero::app_state const &state) -> std::size_t;
|
||||
|
||||
@@ -25,7 +25,8 @@ public:
|
||||
// An already-running completion may overlap stop; capture its consumer weakly.
|
||||
score_loader(boost::asio::any_io_executor executor,
|
||||
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load,
|
||||
std::function<void()> completed = {});
|
||||
std::function<void()> completed = {},
|
||||
std::optional<boost::asio::any_io_executor> worker = {});
|
||||
~score_loader();
|
||||
score_loader(score_loader const &) = delete;
|
||||
auto operator=(score_loader const &) -> score_loader & = delete;
|
||||
|
||||
@@ -71,6 +71,7 @@ struct library_part {
|
||||
std::string title_image{};
|
||||
std::string song_id{};
|
||||
std::size_t playback_pages{};
|
||||
std::bool_t solos_enabled{true};
|
||||
};
|
||||
|
||||
struct song_group {
|
||||
@@ -123,6 +124,7 @@ struct app_state {
|
||||
std::shared_ptr<vamp_zero::catalog const> catalog{vamp_zero::empty_catalog()};
|
||||
vamp_zero::app_mode mode{vamp_zero::app_mode::MAIN_MENU};
|
||||
vamp_zero::audio_state audio{};
|
||||
std::bool_t solos_enabled{true};
|
||||
std::size_t selected_main_menu_index{};
|
||||
std::size_t selected_settings_index{1U};
|
||||
std::size_t remembered_settings_index{1U};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include <boost/asio/any_io_executor.hpp>
|
||||
@@ -24,7 +25,7 @@ class score_loader;
|
||||
|
||||
class zero_ui {
|
||||
public:
|
||||
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {}, std::function<void(vamp_zero::menu_action)> menu_handler = {});
|
||||
zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler = {}, std::function<void(vamp_zero::menu_action)> menu_handler = {}, std::optional<boost::asio::any_io_executor> score_worker = {});
|
||||
~zero_ui();
|
||||
zero_ui(zero_ui const &) = delete;
|
||||
auto operator=(zero_ui const &) -> zero_ui & = delete;
|
||||
|
||||
@@ -76,13 +76,14 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
|
||||
return false;
|
||||
}
|
||||
std::copy(value.begin(), value.end(), parsed.phase.begin());
|
||||
} else if (key == "backing" || key == "metronome" || key == "editing" || key == "error" || key == "backing_available") {
|
||||
} else if (key == "backing" || key == "metronome" || key == "solos" || key == "editing" || key == "error" || key == "backing_available") {
|
||||
if (value != "0" && value != "1") {
|
||||
return false;
|
||||
}
|
||||
auto &flag = key == "backing_available" ? parsed.backing_available : key == "error" ? parsed.error
|
||||
: key == "backing" ? parsed.backing
|
||||
: key == "metronome" ? parsed.metronome
|
||||
: key == "solos" ? parsed.solos
|
||||
: parsed.editing;
|
||||
flag = value == "1";
|
||||
} else if (key == "bpm") {
|
||||
|
||||
@@ -11,8 +11,8 @@ auto blend(std::uint16_t background, vamp_style::color foreground, std::uint8_t
|
||||
}
|
||||
|
||||
auto label_sprite(std::string_view text) -> vamp_pico::sprite {
|
||||
constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Backing", "Metronome", "Tempo", "Back", "Main Menu"};
|
||||
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::BACKING, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
|
||||
constexpr auto names = std::array<std::string_view, 11>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Backing", "Metronome", "Solos", "Tempo", "Back", "Main Menu"};
|
||||
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::BACKING, vamp_pico::sprite::METRONOME, vamp_pico::sprite::SOLOS, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
|
||||
for (auto i = std::size_t{}; i < names.size(); ++i) {
|
||||
if (names[i] == text) {
|
||||
return sprites[i];
|
||||
@@ -118,11 +118,11 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
|
||||
if (state.editing && (y == 184U || y == 226U)) {
|
||||
fill(64U, 256U, vamp_style::accent);
|
||||
}
|
||||
} else if (focus == "Backing" || focus == "Metronome") {
|
||||
} else if (focus == "Backing" || focus == "Metronome" || focus == "Solos") {
|
||||
if (focus == "Backing" && !state.backing_available) {
|
||||
centered(sprite::UNAVAILABLE, 192U, vamp_style::muted);
|
||||
} else {
|
||||
centered((focus == "Backing" ? state.backing : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
|
||||
centered((focus == "Backing" ? state.backing : focus == "Metronome" ? state.metronome : state.solos) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/signal_set.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/asio/thread_pool.hpp>
|
||||
#include <boost/asio/use_future.hpp>
|
||||
#include <boost/json/object.hpp>
|
||||
#include <boost/json/serialize.hpp>
|
||||
@@ -322,6 +323,7 @@ private:
|
||||
|
||||
auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto score_worker = boost::asio::thread_pool{1};
|
||||
auto endpoint = make_server_endpoint(config);
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
@@ -335,7 +337,7 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
|
||||
if (audio != nullptr) {
|
||||
audio->sync(state.snapshot());
|
||||
}
|
||||
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); });
|
||||
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = score_worker.get_executor(), database = config.database, assets = config.runtime.assets_path](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, std::function<void()>{}, score_worker.get_executor());
|
||||
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
|
||||
auto heartbeat = write_heartbeat(config);
|
||||
if (!heartbeat) {
|
||||
@@ -354,11 +356,12 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
|
||||
|
||||
auto run_display(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
|
||||
auto io = boost::asio::io_context{};
|
||||
auto score_worker = boost::asio::thread_pool{1};
|
||||
auto endpoint = make_server_endpoint(config);
|
||||
if (!endpoint) {
|
||||
return std::unexpected(endpoint.error());
|
||||
}
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }};
|
||||
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, config.runtime.assets_path, [&state](vamp_protocol::page_command command) { static_cast<void>(state.apply(command)); }, [&state](vamp_zero::menu_action action) { static_cast<void>(state.apply(action)); }, score_worker.get_executor()};
|
||||
state.set_observer([&ui](vamp_zero::app_state app) {
|
||||
auto status = ui.publish(std::move(app));
|
||||
if (!status) {
|
||||
|
||||
@@ -95,7 +95,7 @@ auto valid_title_image(noto::sqlite_value const &value) -> std::bool_t {
|
||||
}
|
||||
|
||||
auto compatible_catalog(noto::connection &connection) -> boost::asio::awaitable<std::bool_t> {
|
||||
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages", "title_image", "part"};
|
||||
auto columns = std::vector<std::string_view>{"id", "title", "composer", "key_signature", "source_path", "source_json", "measure_count", "total_pages", "title_image", "part", "solos_enabled"};
|
||||
auto parameters = std::vector<noto::sqlite_value>{noto::sqlite_value{"songs"}};
|
||||
auto selected = co_await connection.async_execute("SELECT name, type, \"notnull\", pk FROM pragma_table_info(?1) "
|
||||
"WHERE EXISTS (SELECT 1 FROM pragma_table_list WHERE schema = 'main' AND name = ?1 AND type = 'table' AND strict = 1) ORDER BY cid",
|
||||
@@ -110,7 +110,7 @@ auto compatible_catalog(noto::connection &connection) -> boost::asio::awaitable<
|
||||
}
|
||||
auto required = std::get_if<std::int64_t>(&row[2].value);
|
||||
auto primary = std::get_if<std::int64_t>(&row[3].value);
|
||||
auto type = index == 6U || index == 7U ? "INTEGER" : "TEXT";
|
||||
auto type = index == 6U || index == 7U || index == 10U ? "INTEGER" : "TEXT";
|
||||
if (text_value(row[0]) != columns[index] || text_value(row[1]) != type || required == nullptr || *required != 1 || primary == nullptr || *primary != (index == 0U ? 1 : 0)) {
|
||||
co_return false;
|
||||
}
|
||||
@@ -138,6 +138,17 @@ auto vamp_zero::async_validate_database(boost::asio::any_io_executor executor, v
|
||||
if (status && !(co_await compatible_catalog(connection))) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "incompatible songs catalog: " + config.path + "; rebuild or reinstall Product Vamp"));
|
||||
}
|
||||
if (status) {
|
||||
auto variants = co_await connection.async_execute(
|
||||
"SELECT json_extract(source_json, '$.music.metadata.song_id'), part FROM songs "
|
||||
"WHERE json_extract(source_json, '$.music.staves[0].arranged') = 1 "
|
||||
"GROUP BY json_extract(source_json, '$.music.metadata.song_id'), part "
|
||||
"HAVING (part IN ('Acoustic', 'Electric') AND (count(*) != 2 OR sum(solos_enabled) != 1)) "
|
||||
"OR (part = 'Vocal' AND (count(*) != 1 OR sum(solos_enabled) != 1)) LIMIT 1");
|
||||
if (!variants || !variants->rows.empty()) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "missing or duplicate solo variants; rebuild or reinstall Product Vamp"));
|
||||
}
|
||||
}
|
||||
if (status && integrity) {
|
||||
auto checked = co_await connection.async_quick_check();
|
||||
if (!checked || *checked != std::vector<std::string>{"ok"}) {
|
||||
@@ -159,7 +170,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
||||
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, title_image, json_extract(source_json, '$.music.metadata.song_id'), json_array_length(source_json, '$.presentation.playback_pages') FROM "
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id'), json_array_length(source_json, '$.presentation.playback_pages'), solos_enabled FROM "
|
||||
"songs ORDER BY title, composer, part, id");
|
||||
if (!selected) {
|
||||
auto close = co_await connection.async_close();
|
||||
@@ -180,7 +191,8 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
||||
break;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() != 11U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || count_value(row[10]) == 0U || !valid_title_image(row[8])) {
|
||||
auto solos = row.size() == 12U ? std::get_if<std::int64_t>(&row[11].value) : nullptr;
|
||||
if (!solos || (*solos != 0 && *solos != 1) || count_value(row[5]) == 0U || count_value(row[7]) == 0U || count_value(row[10]) == 0U || !valid_title_image(row[8])) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata; regenerate score assets"));
|
||||
break;
|
||||
}
|
||||
@@ -201,6 +213,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
||||
.title_image = text_value(row[8]),
|
||||
.song_id = text_value(row[9]),
|
||||
.playback_pages = count_value(row[10]),
|
||||
.solos_enabled = *solos == 1,
|
||||
});
|
||||
}
|
||||
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
|
||||
|
||||
+11
-11
@@ -12,14 +12,11 @@ auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t {
|
||||
case vamp_zero::app_mode::MAIN_MENU:
|
||||
return 2U;
|
||||
case vamp_zero::app_mode::SETTINGS_MENU:
|
||||
return 4U;
|
||||
return settings_row::count;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SONG_MENU:
|
||||
return state.catalog->groups.size() + 1U;
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
if (auto group = selected_group(state)) {
|
||||
return group->parts.size() + 1U;
|
||||
}
|
||||
return 1U;
|
||||
return visible_parts(state).size() + 1U;
|
||||
case vamp_zero::app_mode::PERFORMANCE_SCORE:
|
||||
return 0U;
|
||||
}
|
||||
@@ -72,23 +69,26 @@ auto vamp_zero::menu_row(vamp_zero::app_state const &state, std::size_t row) ->
|
||||
return {"Back", ""};
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) {
|
||||
if (row == 1U) {
|
||||
if (row == settings_row::backing) {
|
||||
auto enabled = state;
|
||||
enabled.audio.backing_enabled = true;
|
||||
return {"Backing", vamp_zero::available_tempos(enabled).empty() ? "Unavailable" : state.audio.backing_enabled ? "On"
|
||||
: "Off"};
|
||||
}
|
||||
if (row == 2U) {
|
||||
if (row == settings_row::metronome) {
|
||||
return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"};
|
||||
}
|
||||
if (row == settings_row::solos) {
|
||||
return {"Solos", state.solos_enabled ? "On" : "Off"};
|
||||
}
|
||||
return {"Tempo", std::to_string(state.audio.tempo_bpm) + " BPM"};
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
|
||||
auto const &group = state.catalog->groups[row - 1U];
|
||||
return {group.title, group.composer};
|
||||
}
|
||||
if (auto group = selected_group(state)) {
|
||||
auto const &part = state.catalog->parts[group->parts[row - 1U]];
|
||||
if (auto parts = visible_parts(state); row <= parts.size()) {
|
||||
auto const &part = state.catalog->parts[parts[row - 1U]];
|
||||
return {part.part, std::to_string(part.total_pages) + " pages"};
|
||||
}
|
||||
return {};
|
||||
@@ -105,10 +105,10 @@ auto vamp_zero::apply_menu_action(vamp_zero::app_state state, vamp_zero::menu_ac
|
||||
return state;
|
||||
}
|
||||
if (action.kind == vamp_zero::menu_action_kind::TEMPO_DOWN || action.kind == vamp_zero::menu_action_kind::TEMPO_UP) {
|
||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU || action.row != 3U) {
|
||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU || action.row != settings_row::tempo) {
|
||||
return state;
|
||||
}
|
||||
state.selected_settings_index = 3U;
|
||||
state.selected_settings_index = settings_row::tempo;
|
||||
state.audio.tempo_editing = true;
|
||||
state = apply_app_command(std::move(state), action.kind == vamp_zero::menu_action_kind::TEMPO_UP ? vamp_protocol::page_command::NEXT : vamp_protocol::page_command::PREVIOUS);
|
||||
state.audio.tempo_editing = false;
|
||||
|
||||
@@ -50,7 +50,7 @@ auto vamp_zero::menu_layout(luma::rect bounds, vamp_zero::app_state const &state
|
||||
for (auto i = std::size_t{}; i < visible; ++i) {
|
||||
auto row = first + i;
|
||||
auto rectangle = luma::rect{bounds.x + 36.0F, bounds.y + 112.0F + static_cast<std::float32_t>(i) * 72.0F, bounds.width - 72.0F, 72.0F};
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && row == 3U) {
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && row == settings_row::tempo) {
|
||||
add({rectangle.x + rectangle.width - 240.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_DOWN, row);
|
||||
add({rectangle.x + rectangle.width - 64.0F, rectangle.y + 12.0F, 48.0F, 48.0F}, vamp_zero::menu_action_kind::TEMPO_UP, row);
|
||||
}
|
||||
@@ -130,9 +130,9 @@ auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_z
|
||||
draw_label(context, {row.x + 22.0F, row.y + (songs ? 4.0F : 12.0F), row.width - (songs ? 90.0F : 290.0F), songs ? 36.0F : 48.0F}, item.label, 26.0F, vamp_style::ink);
|
||||
if (songs) {
|
||||
draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted);
|
||||
} else if (settings && action.row < 3U) {
|
||||
} else if (settings && action.row != settings_row::tempo) {
|
||||
draw_label(context, {row.x + row.width - 245.0F, row.y + 12.0F, 145.0F, 48.0F}, item.detail, 20.0F, vamp_style::muted);
|
||||
draw_switch(context, row, action.row == 1U ? state.audio.backing_enabled : state.audio.metronome_enabled);
|
||||
draw_switch(context, row, action.row == settings_row::backing ? state.audio.backing_enabled : action.row == settings_row::metronome ? state.audio.metronome_enabled : state.solos_enabled);
|
||||
} else {
|
||||
draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted);
|
||||
}
|
||||
|
||||
@@ -15,16 +15,31 @@ auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::
|
||||
return &state.catalog->groups[index];
|
||||
}
|
||||
|
||||
auto vamp_zero::visible_parts(vamp_zero::app_state const &state) -> std::vector<std::size_t> {
|
||||
auto parts = std::vector<std::size_t>{};
|
||||
if (auto group = selected_group(state); group != nullptr) {
|
||||
for (auto index : group->parts) {
|
||||
if (index < state.catalog->parts.size()) {
|
||||
auto const &part = state.catalog->parts[index];
|
||||
if (part.part == "Vocal" || part.solos_enabled == state.solos_enabled) {
|
||||
parts.push_back(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
auto vamp_zero::selected_part(vamp_zero::app_state const &state) -> vamp_zero::library_part const * {
|
||||
auto const *group = vamp_zero::selected_group(state);
|
||||
if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) {
|
||||
auto parts = visible_parts(state);
|
||||
if (state.selected_part_index == 0U) {
|
||||
return nullptr;
|
||||
}
|
||||
auto index = state.selected_part_index - 1U;
|
||||
if (index >= group->parts.size()) {
|
||||
if (index >= parts.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
auto part_index = group->parts[index];
|
||||
auto part_index = parts[index];
|
||||
return part_index < state.catalog->parts.size() ? &state.catalog->parts[part_index] : nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
namespace {
|
||||
|
||||
constexpr auto main_menu_performance = std::size_t{0};
|
||||
constexpr auto settings_backing = std::size_t{1};
|
||||
constexpr auto settings_metronome = std::size_t{2};
|
||||
constexpr auto settings_tempo = std::size_t{3};
|
||||
constexpr auto main_menu_count = std::size_t{2};
|
||||
|
||||
auto wrap_next(std::size_t value, std::size_t count) -> std::size_t {
|
||||
@@ -70,15 +67,15 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
state.catalog = vamp_zero::empty_catalog();
|
||||
}
|
||||
normalize_tempo(state);
|
||||
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U);
|
||||
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U);
|
||||
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, vamp_zero::settings_row::count - 1U);
|
||||
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, vamp_zero::settings_row::backing, vamp_zero::settings_row::count - 1U);
|
||||
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) {
|
||||
state.remembered_settings_index = state.selected_settings_index;
|
||||
}
|
||||
state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U);
|
||||
state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.catalog->groups.size()) - 1U);
|
||||
if (auto const *group = selected_group(state); group != nullptr) {
|
||||
state.selected_part_index = std::min(state.selected_part_index, menu_row_count(group->parts.size()) - 1U);
|
||||
state.selected_part_index = std::min(state.selected_part_index, menu_row_count(visible_parts(state).size()) - 1U);
|
||||
} else {
|
||||
state.selected_part_index = 0U;
|
||||
}
|
||||
@@ -146,16 +143,18 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
state.audio.tempo_editing = false;
|
||||
}
|
||||
} else if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_settings_index = wrap_next(state.selected_settings_index, 4U);
|
||||
state.selected_settings_index = wrap_next(state.selected_settings_index, settings_row::count);
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U);
|
||||
state.selected_settings_index = wrap_previous(state.selected_settings_index, settings_row::count);
|
||||
} else if (state.selected_settings_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::MAIN_MENU;
|
||||
} else if (state.selected_settings_index == settings_backing) {
|
||||
} else if (state.selected_settings_index == settings_row::backing) {
|
||||
state.audio.backing_enabled = !state.audio.backing_enabled;
|
||||
} else if (state.selected_settings_index == settings_metronome) {
|
||||
} else if (state.selected_settings_index == settings_row::metronome) {
|
||||
state.audio.metronome_enabled = !state.audio.metronome_enabled;
|
||||
} else if (state.selected_settings_index == settings_tempo) {
|
||||
} else if (state.selected_settings_index == settings_row::solos) {
|
||||
state.solos_enabled = !state.solos_enabled;
|
||||
} else if (state.selected_settings_index == settings_row::tempo) {
|
||||
state.audio.tempo_editing = true;
|
||||
}
|
||||
return clamp_state(std::move(state));
|
||||
@@ -177,9 +176,9 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
|
||||
case vamp_zero::app_mode::PERFORMANCE_PART_MENU:
|
||||
if (auto const *group = selected_group(state); group != nullptr) {
|
||||
if (command == vamp_protocol::page_command::NEXT) {
|
||||
state.selected_part_index = wrap_next(state.selected_part_index, menu_row_count(group->parts.size()));
|
||||
state.selected_part_index = wrap_next(state.selected_part_index, menu_row_count(visible_parts(state).size()));
|
||||
} else if (command == vamp_protocol::page_command::PREVIOUS) {
|
||||
state.selected_part_index = wrap_previous(state.selected_part_index, menu_row_count(group->parts.size()));
|
||||
state.selected_part_index = wrap_previous(state.selected_part_index, menu_row_count(visible_parts(state).size()));
|
||||
} else if (state.selected_part_index == 0U) {
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
|
||||
state.active_part_id.clear();
|
||||
@@ -252,6 +251,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
|
||||
output << "backing_available=" << (vamp_zero::available_tempos(backing_state).empty() ? "0" : "1") << "\n";
|
||||
output << "backing=" << (state.audio.backing_enabled ? "1" : "0") << "\n";
|
||||
output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n";
|
||||
output << "solos=" << (state.solos_enabled ? "1" : "0") << "\n";
|
||||
output << "phase=" << sanitize_line_value(state.audio.phase) << "\n";
|
||||
output << "error=" << (state.audio.playback_error.empty() ? "0" : "1") << "\n";
|
||||
output << "bpm=" << state.audio.tempo_bpm << "\n";
|
||||
@@ -272,7 +272,7 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
|
||||
if (state.selected_main_menu_index >= main_menu_count) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range"));
|
||||
}
|
||||
if (state.selected_settings_index >= 4U) {
|
||||
if (state.selected_settings_index >= settings_row::count) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings index is out of range"));
|
||||
}
|
||||
auto tempos = vamp_zero::available_tempos(state);
|
||||
@@ -286,7 +286,7 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range"));
|
||||
}
|
||||
if (auto const *group = selected_group(state);
|
||||
group != nullptr && state.selected_part_index >= menu_row_count(group->parts.size())) {
|
||||
group != nullptr && state.selected_part_index >= menu_row_count(visible_parts(state).size())) {
|
||||
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance part row is out of range"));
|
||||
}
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include <boost/asio/co_spawn.hpp>
|
||||
#include <boost/asio/bind_executor.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
|
||||
#include <vamp_zero/errors.hpp>
|
||||
@@ -13,8 +14,8 @@
|
||||
|
||||
|
||||
struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
|
||||
state(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed)
|
||||
: executor{std::move(executor)}, load{std::move(load)}, completed{std::move(completed)} {}
|
||||
state(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed, std::optional<boost::asio::any_io_executor> worker)
|
||||
: executor{executor}, worker{worker.value_or(executor)}, load{std::move(load)}, completed{std::move(completed)} {}
|
||||
static auto run(std::weak_ptr<state> weak, std::uint64_t generation, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
|
||||
{
|
||||
auto self = weak.lock();
|
||||
@@ -63,15 +64,16 @@ struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
|
||||
auto start() -> void {
|
||||
in_flight = true;
|
||||
// post is required: co_spawn can enter inline on an already-running executor.
|
||||
boost::asio::post(executor, [weak = weak_from_this(), generation = generation, load = load,
|
||||
reference = *reference, executor = executor]() mutable {
|
||||
boost::asio::co_spawn(executor, run(weak, generation, std::move(load), std::move(reference)),
|
||||
[weak, generation](std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) {
|
||||
boost::asio::post(worker, [weak = weak_from_this(), generation = generation, load = load,
|
||||
reference = *reference, executor = executor, worker = worker]() mutable {
|
||||
boost::asio::co_spawn(worker, run(weak, generation, std::move(load), std::move(reference)),
|
||||
boost::asio::bind_executor(executor, [weak, generation](std::exception_ptr exception, std::expected<vamp_zero::score_document, modulus::error> loaded) {
|
||||
finish(weak, generation, exception, std::move(loaded));
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
boost::asio::any_io_executor executor;
|
||||
boost::asio::any_io_executor worker;
|
||||
std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load;
|
||||
std::function<void()> completed;
|
||||
std::mutex mutex{};
|
||||
@@ -83,8 +85,8 @@ struct vamp_zero::score_loader::state : std::enable_shared_from_this<state> {
|
||||
std::bool_t stopped{};
|
||||
};
|
||||
|
||||
vamp_zero::score_loader::score_loader(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed)
|
||||
: state_{std::make_shared<state>(std::move(executor), std::move(load), std::move(completed))} {}
|
||||
vamp_zero::score_loader::score_loader(boost::asio::any_io_executor executor, std::function<boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>>(vamp_zero::score_reference)> load, std::function<void()> completed, std::optional<boost::asio::any_io_executor> worker)
|
||||
: state_{std::make_shared<state>(std::move(executor), std::move(load), std::move(completed), std::move(worker))} {}
|
||||
|
||||
vamp_zero::score_loader::~score_loader() {
|
||||
stop();
|
||||
|
||||
@@ -184,13 +184,13 @@ private:
|
||||
|
||||
}
|
||||
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler)
|
||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::database_config database, std::string assets_path, std::function<void(vamp_protocol::page_command)> command_handler, std::function<void(vamp_zero::menu_action)> menu_handler, std::optional<boost::asio::any_io_executor> score_worker)
|
||||
: application_{std::make_shared<luma::application>(std::move(config))},
|
||||
runtime_{std::make_shared<vamp_zero::ui_runtime>(executor, std::move(command_handler), std::move(menu_handler))},
|
||||
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor, database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
|
||||
score_loader_{std::make_shared<vamp_zero::score_loader>(executor, [executor = score_worker.value_or(executor), database = std::move(database), assets = std::move(assets_path)](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference), assets); }, [weak = std::weak_ptr{application_}] {
|
||||
if (auto application = weak.lock()) {
|
||||
static_cast<void>(application->request_layout());
|
||||
} })} {}
|
||||
} }, score_worker)} {}
|
||||
|
||||
vamp_zero::zero_ui::~zero_ui() {
|
||||
stop();
|
||||
|
||||
@@ -61,6 +61,11 @@ add_test(
|
||||
# Complete deterministic phrase searches make this suite CPU-sensitive;
|
||||
# leave headroom for concurrent asset generation on development machines.
|
||||
set_tests_properties(product_vamp_generator PROPERTIES TIMEOUT 600)
|
||||
add_test(
|
||||
NAME product_vamp_assets
|
||||
COMMAND ${VAMP_PYTHON_COMMAND} -m tests.asset_contract ${CMAKE_BINARY_DIR}/generated_assets
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
add_custom_target(check
|
||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||
--test-dir ${CMAKE_BINARY_DIR}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Compare complete generated assets with the reviewed, pinned output baseline."""
|
||||
import hashlib
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
def verify(root):
|
||||
expected = json.loads(pathlib.Path(__file__).with_name('fixtures').joinpath('assets_manifest.json').read_text())
|
||||
actual = {path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in sorted(root.rglob('*')) if path.is_file() and path.relative_to(root).parts[0] != 'analysis'}
|
||||
changed = sorted(name for name in expected.keys() | actual.keys() if expected.get(name) != actual.get(name))
|
||||
if changed:
|
||||
raise AssertionError('Generated assets differ from the reviewed baseline:\n' + '\n'.join(changed))
|
||||
print(f'All {len(expected)} generated assets match the reviewed baseline.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
verify(pathlib.Path(sys.argv[1]))
|
||||
Vendored
+693
@@ -0,0 +1,693 @@
|
||||
{
|
||||
"backing/all_of_me_120.wav": "5104097be1d4ed0459dacedb61c2509effcfe0e26025b24574291a3467962f3d",
|
||||
"backing/autumn_leaves_120.wav": "990e0218f9a09161a7a8a255bd1e64daf9fd01ac41935faa273228906775d016",
|
||||
"catalog.db": "3bb66b4460363bbab987bae5a36e00ac1d6575deb9815b6b6ce07cb64cc83764",
|
||||
"fonts/petaluma/OFL.txt": "5789e74d38031e93a4fbdb0a72392d1fd58cf15c29da3efa695c5099433ad0ce",
|
||||
"fonts/petaluma/PetalumaScript.otf": "3b4be972cc9f857a7c1019ff49cd07cb083d5cc99058c0fb8563897fc75c32c8",
|
||||
"pico/titles/42076b2eaa61a6fb408eeaec133b130967cf2c32981a9444dde6903ed7771fc8.mask": "42076b2eaa61a6fb408eeaec133b130967cf2c32981a9444dde6903ed7771fc8",
|
||||
"pico/titles/8ed7df6668e9e4a24c148ba0fe9ac57209e504ca6a7e8a9b29179c097076ecae.mask": "8ed7df6668e9e4a24c148ba0fe9ac57209e504ca6a7e8a9b29179c097076ecae",
|
||||
"print/all_of_me_acoustic.pdf": "210000b746f411e68b38c2be58dd8807d5da263799084708972a5a11ac9ba724",
|
||||
"print/all_of_me_acoustic_improv.pdf": "882c2c5a855cf411d2d443de4661452edb0ec6e624c0ddc17b49f4fc07a3cd4a",
|
||||
"print/all_of_me_electric.pdf": "83cd1c2780b64cc6194d42baf95e85c39ab68ba8789572f266310724ce16c403",
|
||||
"print/all_of_me_electric_improv.pdf": "4642f8a81052502979ac867d40fb3248a7674f587939f5ecc9adaff3bae30fa4",
|
||||
"print/all_of_me_vocal.pdf": "0ff78cb9d315dff93e7a88209610e959fa804c480cf63a8c4fb018a17c46ff66",
|
||||
"print/autumn_leaves_acoustic.pdf": "f31537d21ea65ba410f0423fe98798f85e0a82600460c41eecc577f427617125",
|
||||
"print/autumn_leaves_acoustic_improv.pdf": "d15456a77bdb4dfb5e78b0f3045b7a1d10ada056f03ee3994e6c98e310186419",
|
||||
"print/autumn_leaves_electric.pdf": "11532f29ba9a7543fbd2c64917137fe4acf838a0fed801dedb09b0a3abd81aeb",
|
||||
"print/autumn_leaves_electric_improv.pdf": "a0099729711f40367ff54a8554fcafd5aa2c3d529b4ccac882d8cb81026da830",
|
||||
"print/autumn_leaves_vocal.pdf": "78cc66dd27c7088e196f917eb752266989de13c51361692195b6bb9765210001",
|
||||
"songs/all_of_me_acoustic.json": "6ea2b9f38a902cd011e851c9e3c9db4ead9247b8f1809b4731865bf6e0398a73",
|
||||
"songs/all_of_me_acoustic.pages/0001.png": "6948328c81efda868613241c6c87d620bb88a9d72c3503ca8741d8c4e2d3ab6e",
|
||||
"songs/all_of_me_acoustic.pages/0002.png": "ec2a3460bf961c08a297d480eab25e08fb7cbd8070bb286dc0e6dbf44b63336e",
|
||||
"songs/all_of_me_acoustic.pages/0003.png": "ea3cc201d6d524295313cfeefca133f09216729f8fb43b1f8af08fbc0fd9a0d5",
|
||||
"songs/all_of_me_acoustic.pages/0004.png": "9973275f27d61dcca851b7f5c82549ffbb6098349f54daaa7849c8794fb0577a",
|
||||
"songs/all_of_me_acoustic.pages/0005.png": "1e6822e6a69de4397fb4135a072ef4b13926d026ca82758933087aba5b84f95d",
|
||||
"songs/all_of_me_acoustic.pages/0006.png": "4a4622622165b22df36bad0d9d68a57f2691809791fec92dcd4a96a2cb7ca504",
|
||||
"songs/all_of_me_acoustic.pages/0007.png": "9455f6730e8ba0a423d90d42ab9ebbc7c152f178402d323f2e4d1c4b67bd920c",
|
||||
"songs/all_of_me_acoustic.pages/0008.png": "5e677e76625a518f315a7231242cf264f847f2e4a912ac9e4d8519b095749d62",
|
||||
"songs/all_of_me_acoustic.pages/0009.png": "547776c3ab3746361b1e86162631071e41b2ec5eb358cfa80fa94c94a9a73545",
|
||||
"songs/all_of_me_acoustic.pages/0010.png": "ea4a777b604af4f7245071494c488bf015be41f39fa80b47615f76abf33610f2",
|
||||
"songs/all_of_me_acoustic.pages/0011.png": "8e537c7d8f8adee6cbb841d12144e47bdd842d6a4d7483c650564abd4ee0d419",
|
||||
"songs/all_of_me_acoustic.pages/0012.png": "ac8ad03632f908d4b3b3b353ac1e84678398e74fc9a776520fb73115cb290de5",
|
||||
"songs/all_of_me_acoustic.pages/0013.png": "cad6681e2b17a48f17749077f7ed34064783e21fbf973b22d9603b5a45cf2c40",
|
||||
"songs/all_of_me_acoustic.pages/0014.png": "b5ccd0b027437716e718d221b13ad2f5c8a78abb9c2b2dc290df43fe2f3e2928",
|
||||
"songs/all_of_me_acoustic.pages/0015.png": "0025f0ee929c11e0cf8bf59746f37d57e191458650dd302839c9b0b78e5a8b9e",
|
||||
"songs/all_of_me_acoustic.pages/0016.png": "c738908f9de0b2281e4c625e31efe3952cf8061f61e26127ecf9a50bfa1847b1",
|
||||
"songs/all_of_me_acoustic.pages/0017.png": "f93aba261ea619c318388a24a216ccc8d3d520e30ed287cb335432fb66495ed8",
|
||||
"songs/all_of_me_acoustic.pages/0018.png": "5fa1034c023b489f404385202666ab9ef9e91166ef31f5620eead3ae17dc05e7",
|
||||
"songs/all_of_me_acoustic.pages/0019.png": "fabbb1223ea9f17c9d183169194b3000bf30aa9a69a29fc981ccdcbb61b9890f",
|
||||
"songs/all_of_me_acoustic.pages/0020.png": "5a079a94509cd5f4dc3cd50af508f1c7228e0dc8f8e0f0756267a95a5619fce9",
|
||||
"songs/all_of_me_acoustic.pages/0021.png": "9caa8383805c07dffce2fd778c024c5985ab13defaa14e52fbf2225ac4a44003",
|
||||
"songs/all_of_me_acoustic.pages/0022.png": "5d2b3ad95807a0a32a60ad5c91cfe82e5e626e5d6eb5fea671e34bd558bd52f2",
|
||||
"songs/all_of_me_acoustic.pages/0023.png": "2bd55dd9012c516acddbab0890de58108af1d9422e1682fbfd3cd2224487cf34",
|
||||
"songs/all_of_me_acoustic.pages/0024.png": "aeb5d2adb983511335a0f0a23cf42e5ab6934d935c7416271da6d0471425d1cd",
|
||||
"songs/all_of_me_acoustic.pages/0025.png": "d657e45864e01bcd4a757fdc1bd43fefca6276b549a45f0a733681466c57a448",
|
||||
"songs/all_of_me_acoustic.pages/0026.png": "7b80fb28fa47446e2ec18be64271a8082c0a7c92d123c841bace99ea0ab02dfa",
|
||||
"songs/all_of_me_acoustic.pages/0027.png": "05551515bf93f660e371c6f26798f8ab0560c7e84c404c11b5432575e53992cd",
|
||||
"songs/all_of_me_acoustic.pages/0028.png": "700e865258c21b92353c9e764a3395adfc91c9045695586934e6766a543b88ff",
|
||||
"songs/all_of_me_acoustic.pages/playback-0002.png": "0c7a810b3550add99456e5dbee8ed520f664d0a294d4e3672b2a68c898d4184c",
|
||||
"songs/all_of_me_acoustic.pages/playback-0003.png": "36f47fe4c0fe860ac178ce2b12145b0eee52469655358798e4d7e3104594a212",
|
||||
"songs/all_of_me_acoustic.pages/playback-0004.png": "0d38c62684a33cac380dda87782e806edd0b5931bffed72f2da069fecadfe75a",
|
||||
"songs/all_of_me_acoustic.pages/playback-0005.png": "c42feaa1db61e48245a0577bbd4ab6fe7a80582e47dbac5e9fb804614767b779",
|
||||
"songs/all_of_me_acoustic.pages/playback-0006.png": "6e604209db5d889f86c5395f22a69e649e37823c679b9e70d5d12a2168c64a87",
|
||||
"songs/all_of_me_acoustic.pages/playback-0007.png": "e4d8fafd269de531e9c2a618ee58bfb5a5c505c710979f3c991249f601a65128",
|
||||
"songs/all_of_me_acoustic.pages/playback-0008.png": "9d0a3fb48b0418140fc6f28503113e5b81b7485b6ffde675b5388a5b9b97757d",
|
||||
"songs/all_of_me_acoustic.pages/playback-0009.png": "4d55c12934f9c9dcb611a2ae749d6cf7a7a65e000ffb21d38cb4341db0c90694",
|
||||
"songs/all_of_me_acoustic.pages/playback-0010.png": "b774c61de2f7a49672cf3a912436914a5d0f3376c9941876ae7a376a26eae4c5",
|
||||
"songs/all_of_me_acoustic.pages/playback-0011.png": "9f7f4ee1540252f8a066df53d46fd4e8ce0de6baafd55caaaec3fd52ddf46180",
|
||||
"songs/all_of_me_acoustic.pages/playback-0012.png": "6318a460b8706400524f7b897c30f3e705b257fc93fb1ade7f947a963e4308c3",
|
||||
"songs/all_of_me_acoustic.pages/playback-0013.png": "8705102e6d873d89a86532e9b6936c5fc9d1b8b6b40fd10e00eb741a872f8500",
|
||||
"songs/all_of_me_acoustic.pages/playback-0014.png": "78b7a97b2c92872671150dfa0e9806e2bfba7eb88e56e1845b705612c88edae3",
|
||||
"songs/all_of_me_acoustic.pages/playback-0015.png": "fb9bcdbad5c364f22270bfcc6439ccf090601791eabcc0eda7bb825d5b9133b5",
|
||||
"songs/all_of_me_acoustic.pages/playback-0016.png": "f9bf1dfc3f01d8c16619ec5949f4ce8bd63b0a93f9b665cebb8bcb1587b6efa9",
|
||||
"songs/all_of_me_acoustic.pages/playback-0017.png": "c1600567151bb1c9c55578a106a98981704937ba53bbe40707bf602a488f26bd",
|
||||
"songs/all_of_me_acoustic.pages/playback-0018.png": "65451cc2753bc6ee09ff6f6c4c854e57000b69d5061fb0da6675b80299081ec3",
|
||||
"songs/all_of_me_acoustic.pages/playback-0019.png": "d0af98812bc7a58eeb294886cf14e99a66d024c90cbe184c945ba3311d6ae4f4",
|
||||
"songs/all_of_me_acoustic.pages/playback-0020.png": "5fa5e50c6700bdb7b4421e8990290d96d80bac66c10dac4eb2de90b60d485f37",
|
||||
"songs/all_of_me_acoustic.pages/playback-0021.png": "cf34bbfb65e50ada490446fa0f3d716f12441456b90401f6651a69e64e1e73a9",
|
||||
"songs/all_of_me_acoustic.pages/playback-0022.png": "9f602c971da1a3e83acaf586175d74ab7478dbf1e2cc40d43a1564d43c5e2a31",
|
||||
"songs/all_of_me_acoustic.pages/playback-0023.png": "c96a191ca680d797010aaf5cc308e07b16a2e530eb6f33d77bf3afff074136d6",
|
||||
"songs/all_of_me_acoustic.pages/playback-0024.png": "febe6a1cf13751148d0dc13654b871930ff3d8f01485e8710a86bed3b8891996",
|
||||
"songs/all_of_me_acoustic.pages/playback-0025.png": "9214956d4c421ee6d2fec2bcf79e60765403cec91553cc090fd6009055e10a52",
|
||||
"songs/all_of_me_acoustic.pages/playback-0026.png": "895353db4a1753e173777f2e25d193f3f7f9d45f48996a787f79a52a74cb100a",
|
||||
"songs/all_of_me_acoustic.pages/playback-0027.png": "ecf1e302e95d53d4c4d3b788662d5f4bb93514ff2137e050dacd2145c2a65b2b",
|
||||
"songs/all_of_me_acoustic.pages/playback-0028.png": "f8b60cf8bbc47057ddd1afa2cf192ef6b2b3cdcd2f00de8f2ffe30534f9de120",
|
||||
"songs/all_of_me_acoustic.pages/playback-0029.png": "20ea66c6e6f0498fc41689068b984540667fe2aeaecf879ca808bcb8b7935222",
|
||||
"songs/all_of_me_acoustic.pages/playback-0030.png": "c71c58b7dbeba20cf9416383dee295f4cc9a6f5faded58c209a0ff0d7c2a3ae5",
|
||||
"songs/all_of_me_acoustic.pages/playback-0031.png": "63e3042cfc12f43311fa4a0dcddc06d42e48be2a1ee2e6a0d24deff80beb84cb",
|
||||
"songs/all_of_me_acoustic.pages/playback-0032.png": "6224a1efd4c9a869b2b966dda98586f6d9fb4c3a198f13b30a24e612c848b998",
|
||||
"songs/all_of_me_acoustic.pages/playback-0033.png": "a834362ba19691b432a97b4dd2176fca7b38799f9e53e9d6e535929d5de9b922",
|
||||
"songs/all_of_me_acoustic.pages/playback-0034.png": "63038a745f7a1700383c43b90a1aabc67773c63fecb130788c8f5872f75722c2",
|
||||
"songs/all_of_me_acoustic.pages/playback-0035.png": "47c8a0c7dbe5499e9e68ced5c05e09693a68f781dbf0c3d236a6dc1813b32780",
|
||||
"songs/all_of_me_acoustic.pages/playback-0036.png": "e4c3f7c2cf5e49a612cbd508d0e90d63c3d3b9bbb5f91ee8b7be1229c1c6cc7a",
|
||||
"songs/all_of_me_acoustic.pages/playback-0037.png": "0a6167c4e5d56da7ad37fcfb2fa47f0aa3b8b44b075b57dc720c7969a1a42113",
|
||||
"songs/all_of_me_acoustic.pages/playback-0038.png": "ab65dcd046d6a5ad070551bdbdad3be2f6b526b529ea0d71807182890a0bd14b",
|
||||
"songs/all_of_me_acoustic.pages/playback-0039.png": "4cac5dcecc44f5b2b5f49d445c76869ad6664742c1b2c4389b343075791c77ea",
|
||||
"songs/all_of_me_acoustic.pages/playback-0040.png": "e5ba773de82e1062efc109626c6a744f7a8edb00d86625f67f8d6f4ad54de597",
|
||||
"songs/all_of_me_acoustic.pages/playback-0041.png": "d0ddfa3fd259f8a091fa18746e83c475b3f64c25c9f2240f75e5fac1f27c4b92",
|
||||
"songs/all_of_me_acoustic.pages/playback-0042.png": "76a8127d4d1b87bd3d62162559617951314379eaa75141ecc8ee8852b375d68f",
|
||||
"songs/all_of_me_acoustic.pages/playback-0043.png": "a0959998579d3f2c24b96bcdd31c8fb69852b4810cd4d00fd3c65af10740dfcb",
|
||||
"songs/all_of_me_acoustic.pages/playback-0044.png": "9f51830d3e1a16a85d8b66e19e6fcbc63d0eb07e29494b46576e7ecd3cacf7b4",
|
||||
"songs/all_of_me_acoustic.pages/playback-0045.png": "eba577283878f5b0c100aa0c817005ca829ef01157027eaa5c11b9ccc2b302f3",
|
||||
"songs/all_of_me_acoustic.pages/playback-0046.png": "e73079cf481a613ec1ef151382751ca995eaaa9300107915dbf86f2d7415b25d",
|
||||
"songs/all_of_me_acoustic.pages/playback-0047.png": "ff53312206eca688db65d6f6b98cc07775219a8ade385df3388d9c5d665afeb8",
|
||||
"songs/all_of_me_acoustic.pages/playback-0048.png": "18354efb13a24a3f9ca5742a4499febc6a245f6f4b459e6f7afef09dda15b514",
|
||||
"songs/all_of_me_acoustic.pages/playback-0049.png": "105140cf22f8ded503d789c7ddb19583c331174562a511be833d78385eb8b27e",
|
||||
"songs/all_of_me_acoustic.pages/playback-0050.png": "684ea0f0c8bc5d369f582e600ac96741b40f64b9caaffffd1c3facb5e35fe7a5",
|
||||
"songs/all_of_me_acoustic.pages/playback-0051.png": "f40161362e8cee056b7873e5446078f2a45226c7792492310d893487ce36acf1",
|
||||
"songs/all_of_me_acoustic.pages/playback-0052.png": "d64917e194675315569d598fe204286fe3925cb20eae7937e4a7d6532f2b8597",
|
||||
"songs/all_of_me_acoustic.pages/playback-0053.png": "16958512a479d8f994193a2364ffad66f8c80a6b3e99fd22c90a4ef691421e23",
|
||||
"songs/all_of_me_acoustic.pages/playback-0054.png": "ef2906bc9aefe132e5dc2295f49bef7d40513b8eda31f3a190e0f69650c39bfb",
|
||||
"songs/all_of_me_acoustic.pages/playback-0055.png": "6167a172045169a8a17c302b8ff4fb0e88559a2bfd92805b4785af0e86200d23",
|
||||
"songs/all_of_me_acoustic_improv.json": "75f4a689f6597000a4ebf64fdbc06ac482656a946e17eb30694eb3a52fa06663",
|
||||
"songs/all_of_me_acoustic_improv.pages/0001.png": "c7c49c4a8bfb2121dfe20c4f2247bc9755fe9c32cfdd7604801d7f20138453b9",
|
||||
"songs/all_of_me_acoustic_improv.pages/0002.png": "a17f7a1dd15222f91975e96b04db8e2637ba7179ec24ddbe84ee96a94691208e",
|
||||
"songs/all_of_me_acoustic_improv.pages/0003.png": "964f31d3cc749bd4e1e4b97ad13e01fdb4b3d0e323c34a5c9702a723e33c29c6",
|
||||
"songs/all_of_me_acoustic_improv.pages/0004.png": "0e176df36603fe9942f85912310e71aa5a4096cf66fd6cf605c5bea8d642c097",
|
||||
"songs/all_of_me_acoustic_improv.pages/0005.png": "34eeb871dee2e999aeac440efe45cb22d57082bc072189927c027743d4638ff2",
|
||||
"songs/all_of_me_acoustic_improv.pages/0006.png": "3c43857f7b81157a05fb56b379e734d6242fa2c089852ca2067e7cbb2de4e046",
|
||||
"songs/all_of_me_acoustic_improv.pages/0007.png": "b33f37f12c71729e187a3ec136c855deacefbfba76b76958a69b69ed8ecbbf05",
|
||||
"songs/all_of_me_acoustic_improv.pages/0008.png": "102e25d5120aa16d5d65d22fbfac6cc7de736d839f3170c82e169a45a554fda1",
|
||||
"songs/all_of_me_acoustic_improv.pages/0009.png": "57aca2d015ab96464e6505c3dca2fe81e0089643fad02b83a56c48cc4f41b02d",
|
||||
"songs/all_of_me_acoustic_improv.pages/0010.png": "1b1557405acdaa34af757b1fee11732444183171d618aef5bd16d27ed4ccb18d",
|
||||
"songs/all_of_me_acoustic_improv.pages/0011.png": "ed1091121402a4a4969e77266ee1773b239dd6150a851f870131ec10c6b724ce",
|
||||
"songs/all_of_me_acoustic_improv.pages/0012.png": "5334063c1cef8ec1174a15bd5674cff945b3c44a53d5affc99ca88c5c498db97",
|
||||
"songs/all_of_me_acoustic_improv.pages/0013.png": "f7f4fda42b00536adc368dfeacbeefaaa91d12a5ca355d1eae11bae32820cd03",
|
||||
"songs/all_of_me_acoustic_improv.pages/0014.png": "0663583f36229c054401b627b605718a311ba50feafcccc22e16fd2be45e8970",
|
||||
"songs/all_of_me_acoustic_improv.pages/0015.png": "7436537b05d351d79c0f4d3d08a148f3308b2473dcbc6ede287070a31977f13e",
|
||||
"songs/all_of_me_acoustic_improv.pages/0016.png": "3bf7576ba2c76e7b18a0c816ad9d8d4f1b1c526098ffe7b372764bf9e72f37a9",
|
||||
"songs/all_of_me_acoustic_improv.pages/0017.png": "83fcd6e315ca2352d7a87b6f8568a2600c83e6a8dc5066552e246fab84aff4c7",
|
||||
"songs/all_of_me_acoustic_improv.pages/0018.png": "73e241ab4f46390b1330c73ae5a9ee381424ebc90e6a6850a22a9a510a788cf9",
|
||||
"songs/all_of_me_acoustic_improv.pages/0019.png": "7ca41c2f0cf291094903f9498ebcfd7be5b0dd6791eb5055b848eb8d824ae6f1",
|
||||
"songs/all_of_me_acoustic_improv.pages/0020.png": "1f8e510e36d6e0c2f04ce8b645146508466e37f33fa67900c0075d4e6b91b953",
|
||||
"songs/all_of_me_acoustic_improv.pages/0021.png": "9b916bd70b484d74d23e541c18c26edee7c6b22e863ce0476fd219fa70337711",
|
||||
"songs/all_of_me_acoustic_improv.pages/0022.png": "15b2905bf32a8664145563814a35cd1e25b972258af627264473af59b4a630c4",
|
||||
"songs/all_of_me_acoustic_improv.pages/0023.png": "1a06b5a190343bc34492751d759acd2ac1bfe4b1d3624eba27f502a0dc314f94",
|
||||
"songs/all_of_me_acoustic_improv.pages/0024.png": "6f88afd2ab872dc5e63768b00e615391468fe8743a7d6cf026cf7c2098f8304d",
|
||||
"songs/all_of_me_acoustic_improv.pages/0025.png": "3d07e915635f3b2379648c5f237260aaa79f0a52b04eb26866358f1ddc1c2977",
|
||||
"songs/all_of_me_acoustic_improv.pages/0026.png": "783b05d3446fad225b0c0437769d3d97c75269ebe5c8cc44abb04cdeab886b7c",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0002.png": "f27560250db7468b7b84ac4807f9a1749cf4ed1f83d84ba38f872dae283f0ea0",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0003.png": "0c207781bfd393d5e6662a3b2bb41dab72d646df35f9c42ce25a877345e0f8d2",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0004.png": "3b7ea3048dfee3325a279c3870070d8a4cf67865e83dbe86b3fb94fc1c14850f",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0005.png": "5c1e1299f1a1c4cec7c6ff57ad3eb08ec542b4277beb7f74eb44b36ed580c8a2",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0006.png": "15cf982c4ce785cf8bb3cc55688e77aaa96ac36e974082687ba3a890adc0ad0e",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0007.png": "901da3d0710ce18ede6f86b5cf71210b5fc506030c16bf3f7688e78450c33d24",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0008.png": "faf91b35e258128a3950a6aa6c13673f92392c26a40d20b4d5b3d228f85a34b4",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0009.png": "bd1a9566c6b8ddcf1af70535b469641ab23fc3cb5b01c6123d3fef36656bdc84",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0010.png": "2e969b19d7d4f0d8fc3c59fa6120a06c7aec6d285563d6b5669fa81a6602b348",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0011.png": "12a5d0cdf4f95a3fd5fdf350f0778c818687ecc3de1f4d4f736ebe92f45e8e7b",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0012.png": "b0baba61fa2de21d458a028fa8c2cfecf1600f351a9b64d0b0466527ca5b09f4",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0013.png": "3caf610c9ddde72fa6a4b8c9e0bd579c725ff335f50936f2a0a93c981278edf1",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0014.png": "9199ee111c83c4c8b189148198343d1b60d7db7c1830741f57897a965dcb65a6",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0015.png": "a107b24904a78315d495fd468447adecfe155841f46d2d75ef652eb58992923c",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0016.png": "2dd3b1ab25a5f556e1514235b31be11d5bf0ab78a02353338c7c2e32cc9ad318",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0017.png": "26ba7c99fb7a33a9830cdd69396e2070b9d6b44ceca1e9ab373b24e3ccac5975",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0018.png": "c853af1449f47727ab075254e972e80f169ba91a8315b54b9acd151cced8d2aa",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0019.png": "2946ce5ee949fbfda621ecaf05bb85d86cce925c1523fe7bb9dd76e624e13e37",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0020.png": "cc373ffb93a9b7d2e18771636a6fe7937857b5c6e599be920447f0b4098408dc",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0021.png": "70644d156b969e9e6976d73a1ea19209b5d9ac68437df062156f1e3b85c2e985",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0022.png": "736e46edeab2ef667d3a0450719d1dd21ef68699b3fbd83bcfebfbe0dfede3a4",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0023.png": "b0cef1f6f70518ea6f05ea945a6a5f756647720595664009e2971bc6ebc8e9e9",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0024.png": "c345408406935c17f79be4f34ee1c68916d7d2047807a8b5f0c38889b84d6273",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0025.png": "1d009dec40021103bb3e0030908ab5c7ea14a660adf08939d8b8ce3bd8cd0a7f",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0026.png": "6e6dd0a31555f29496f7e4fc79b1f6b1d3ea107f493eacf7e4dd3e0090f22fbd",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0027.png": "39225282a6ffb530ae2a318d1112b906678d93ba9af7f8695c679267734090c4",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0028.png": "d50de4bce20b8cdeccdb9c694737c07cf4fe53874dce327f99dd717e54710453",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0029.png": "bb5d0f7b58046ee04ec14dfc127a3844b10322884fe8bb57c98e55e0b264ab21",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0030.png": "bbc16aaf034bf7743ebe6c4b8e7edd22ce1f7b4438513f94c3da653237f43545",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0031.png": "e7a3251349199da78744b72f51f044339ea0612fd569f19afe39b3dbc0c4832e",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0032.png": "ccbfe703cac72e634bab9d9be858df1579000e3aa03ee4aee3a894a463da7996",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0033.png": "513d82651223e0f5ca8027bc01a4a501aae14f10e178cef32f5512fce29d58d1",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0034.png": "bf0eb13dbf18b71a6bd0aaee8a2c227b6e607009dd3d630f7d7578a9206d2566",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0035.png": "d227f3fc1c6e10a65b577703d8c276e68305b04a8166b4d92b50299062043c9d",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0036.png": "19efab6757d036574e91cce3fc15030d3621e7aefffc6e9edf82eaff76443703",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0037.png": "773cfa95f23d05e3fe683a41af075bd1cc99ed139cf1f5ce691e678510563b28",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0038.png": "929c01e75c49f13dcf8857ba5509c8c0eac2f1d3b44ff2d7669d9028b54e7871",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0039.png": "8934ed0092867acdb7c08ff26eee743e05cb413e82b02f711443f8d44ed4d3bf",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0040.png": "f1d0e28048580ca2868eb49c6c5b217a358bb2679c7ab6c97d3883ff286c9659",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0041.png": "cc31eb3f579da316a3b78880dc820e06caf90945cc1f6b00889fc548d97d3bed",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0042.png": "25f0fc66d9a0386dffe3a293dd3f1fe11fd47d5dbb0f8039c650a376664f365b",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0043.png": "075014acd3f962a9b909076c813cdb9401dfcafca7d66024d9f5ca261df2370e",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0044.png": "04a74d576ea5a6b182829da6bf026dd5e1cb4bbdefcb6b5078a7e9839eccc4f9",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0045.png": "a625417a048e01fc861eb2077ff0af81e2298f8e3865a44ea0f75d8f7568c9cf",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0046.png": "b0065192d5536aab450561ca5a050646b3afef7f415750adede962d78721c4b7",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0047.png": "b584bc5df9b970429386ac9a4a23416eb4f9dd83559f033b406023ee7eb4c90d",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0048.png": "0b64db7e5b9841f7b5abd2974e6914e730e6a8a426f478d136085f472386d8f4",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0049.png": "c03651fb0bcc16b75576dcd45cc85c6658e893cc4058ab446a2532d227d3e4bd",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0050.png": "d24a1dc68cae5a26edadf42d74d620d70dd16952e939cf3328452802c8672e53",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0051.png": "127fa02816b447a5bdf521d7359259ab9859673aeb5dff61430e4253238a0cab",
|
||||
"songs/all_of_me_acoustic_improv.pages/playback-0052.png": "a328cfae5ec1c913699de23712b198332c0744d80a77cb9bd6cbf679fd1e5e12",
|
||||
"songs/all_of_me_electric.json": "89a7d561a036afd88cea9a01fe684e72ea5747d572c9fe6f25f4a82bcce642e4",
|
||||
"songs/all_of_me_electric.pages/0001.png": "0ae7c59a905837a9fa374c381366e98933a1927238b99f0b21ca05f11e70671b",
|
||||
"songs/all_of_me_electric.pages/0002.png": "96dbcc9e16014523b677921fa5d4996aac5dcb95cde220b6351fe2951ea28cc8",
|
||||
"songs/all_of_me_electric.pages/0003.png": "7d5fb460c7ca8daeeb2a3456d60290c152d3579064cac25b61ad3a48c85626fb",
|
||||
"songs/all_of_me_electric.pages/0004.png": "bcbfb4894d90fd9180706169bccebe2b03dea791c2d6c2b33609316163cb2dd5",
|
||||
"songs/all_of_me_electric.pages/0005.png": "fb3bb43d1a9bd697b48a05358c29243399bbf2e5959567ca05e62bb9857631ec",
|
||||
"songs/all_of_me_electric.pages/0006.png": "aecd14c4af630bdd6a222797737a85688257592c030d284b1bb22b182c3020e1",
|
||||
"songs/all_of_me_electric.pages/0007.png": "f51dbe9fcc6c48944a01cb4cb3a7d39c0588d334486b5cf25e512118557af915",
|
||||
"songs/all_of_me_electric.pages/0008.png": "7229055aa0b4d09e9d3a40ec8b1f74762f123a696136e5038a2511651e422166",
|
||||
"songs/all_of_me_electric.pages/0009.png": "b7295f85e20ca762cd6dd6255574a09b7b8bb4c840c408c9782d8557cbf2df65",
|
||||
"songs/all_of_me_electric.pages/0010.png": "c28b8d11c2bec34d7e7663c6ea2e69564d625e8d62d95ab5dc39d26d3228166a",
|
||||
"songs/all_of_me_electric.pages/0011.png": "46f2e302beda292b2fd1dd90b21ee9b92ea49b470c85017cbc157e571657fa23",
|
||||
"songs/all_of_me_electric.pages/0012.png": "2992e61b6e94042bf5417e520c96c6bb02311496a826126ef2d88d4da5e192a2",
|
||||
"songs/all_of_me_electric.pages/0013.png": "168ade5a595abb0b75fe9046d24126772b4a901fd8ef5df1a1148dd4913166cb",
|
||||
"songs/all_of_me_electric.pages/0014.png": "bb84c4eaf3e135693576e465533f4d0f01b8ed351fd81c07896045bc67e0e033",
|
||||
"songs/all_of_me_electric.pages/0015.png": "3305bec3aa92cbb329e4014907fa8991d5007aaea1a80f5232ce8146628c593e",
|
||||
"songs/all_of_me_electric.pages/0016.png": "e69f42716800aeb0155b26562898fa3c76454f2bca8a0b9353d845f8126e5c21",
|
||||
"songs/all_of_me_electric.pages/0017.png": "9bf101895d2ee0137899fe8e8e925501b1a031007fda39cc0158f2abe170275c",
|
||||
"songs/all_of_me_electric.pages/0018.png": "b203036635c6549522095b819e4c637de336620df966baab02b9e624b5982da1",
|
||||
"songs/all_of_me_electric.pages/0019.png": "29d3ad6169b9f6f234cb9275602166678347f743f5f5b50f3990cd4bd8771bdf",
|
||||
"songs/all_of_me_electric.pages/0020.png": "33923f139e31394d4e90c39362b8ff59193b975bd1f8196406dec8b1b3f57520",
|
||||
"songs/all_of_me_electric.pages/0021.png": "cd1d09d884491b886b886f0af4e067397305258aae172f375cf366ad5d78133c",
|
||||
"songs/all_of_me_electric.pages/0022.png": "7bb58dba686f58655bccba33162282f58043436e2d7945687d7345ce0a4d7288",
|
||||
"songs/all_of_me_electric.pages/0023.png": "f14d7afa19d737b4c155640e27dfcae0da04fa6e7042668e11e8c35ed995c846",
|
||||
"songs/all_of_me_electric.pages/playback-0002.png": "0571463f99b6828e503093c97de4475e8d07bc9445e591902355c8c17df9a642",
|
||||
"songs/all_of_me_electric.pages/playback-0003.png": "cff0aac588d3f48c3990144c81137175ca3edcc1ba83c024b5da93ef9b9f3a3c",
|
||||
"songs/all_of_me_electric.pages/playback-0004.png": "1b55b57f88aaf8bdd4ab37480b6b40f199298fc0b5a85bfab1a910c53aeabd1e",
|
||||
"songs/all_of_me_electric.pages/playback-0005.png": "4d3da50a81fc1de48d13481ae046ecd7e4bb5fbae1f70729b9d09d35fd7e65fe",
|
||||
"songs/all_of_me_electric.pages/playback-0006.png": "57da8bdc7dc21856527d9a050cc704969fa413b9eb9cd3f0229f6abe04d5c4d5",
|
||||
"songs/all_of_me_electric.pages/playback-0007.png": "1af6916b5586c1efd197b33b79f5ee109074feb2939b2a7495e45a345c46c226",
|
||||
"songs/all_of_me_electric.pages/playback-0008.png": "6f0c2f611e21592b90dd85d2ca7239ce48c2062a986d94859add9d3363f5de7a",
|
||||
"songs/all_of_me_electric.pages/playback-0009.png": "2ceed3a3dd0c685c4cfdecd05e9b46d311fd371e10062b05101ffbf024a40621",
|
||||
"songs/all_of_me_electric.pages/playback-0010.png": "eb8420fa5a47810931e5fea8ad8fd318bb4e5052802a4a2cdd82935c05154dc9",
|
||||
"songs/all_of_me_electric.pages/playback-0011.png": "81c2e5e63db37e20e4fe6108831c62fd8b9d9261903d290abcca7042a86a2094",
|
||||
"songs/all_of_me_electric.pages/playback-0012.png": "0f9e9e004de5224ac80ccd3eeff0196baabdc21d96ea58a6e3c453adaa46deac",
|
||||
"songs/all_of_me_electric.pages/playback-0013.png": "1ed1bd390980f211f0b32a3a8803d66bf3d42ce60e7ac79241bb6b2ad2de0320",
|
||||
"songs/all_of_me_electric.pages/playback-0014.png": "ea5db6c615f5bb477b5aaf3ab809103b43a4cd7f371664a47d79a53830fb134f",
|
||||
"songs/all_of_me_electric.pages/playback-0015.png": "0486c9f8b6bf59da12b06854d7a6dc70b600fa4150370b540c20638e4ba4ca74",
|
||||
"songs/all_of_me_electric.pages/playback-0016.png": "17665bacc2c021cac24da31525df4554c70005a6ff8b5f430917e2363b56605d",
|
||||
"songs/all_of_me_electric.pages/playback-0017.png": "9854a78b18fc09b25d8e8218ce9b24eab9a227dbaeb82f48051bf9e39271a041",
|
||||
"songs/all_of_me_electric.pages/playback-0018.png": "33c78c362b98e14b0c54caeac26a683ba8de28f39303476e1fd0a26443452277",
|
||||
"songs/all_of_me_electric.pages/playback-0019.png": "529bedb15f3149d4e861d7f674b7155e7afaf11f6b4e430401da54015ef8bc49",
|
||||
"songs/all_of_me_electric.pages/playback-0020.png": "c9499fb45efa55956bc60523efc44babac117ff7ab8aacb809d44ed2a947e5b9",
|
||||
"songs/all_of_me_electric.pages/playback-0021.png": "0d0ca4378249adf58cc5afc4559713480e7eb633957126892f98a696edf4010f",
|
||||
"songs/all_of_me_electric.pages/playback-0022.png": "14b63e52f24292bc4ccf99f446b15377aeb4bb0e54f092c3f5e8e6bf209b4cd0",
|
||||
"songs/all_of_me_electric.pages/playback-0023.png": "9e2929dd2c9345c4fe73f18cda0eb8360e21e23ac687b6a07411922a32a2c5e9",
|
||||
"songs/all_of_me_electric.pages/playback-0024.png": "cceb9cf59e79b1e773793da75036e315dbf1d675aef192537669f0db2250825d",
|
||||
"songs/all_of_me_electric.pages/playback-0025.png": "28bb99dd21eba56d754f3d832d81213600e316a26c5ed51f09b98b119ddef4e5",
|
||||
"songs/all_of_me_electric.pages/playback-0026.png": "51eb318f5a56b6b13d4c135abefb4c349124a09d3fd2ff5ba6878c76d989722b",
|
||||
"songs/all_of_me_electric.pages/playback-0027.png": "c5e81c6df91e9d3ef2f088f2c0ece6bfb1aefa15a1a25463958f62778a2d7c54",
|
||||
"songs/all_of_me_electric.pages/playback-0028.png": "1d3a113142e204567a87fc788da4b0467842e6ed8a3ceab763ca57dc0364cff9",
|
||||
"songs/all_of_me_electric.pages/playback-0029.png": "3def7dcafe036b23443df159994db9329460ba4132c08533bd4b94abcaa4320b",
|
||||
"songs/all_of_me_electric.pages/playback-0030.png": "e00305051391a58669a5c788e292371cf5d93bbdcde7dbbfdc1198eefbd8cba6",
|
||||
"songs/all_of_me_electric.pages/playback-0031.png": "e0d4e72241ab0f3fd31633bc67627fa8637f54c33a213ca63823f508657b0005",
|
||||
"songs/all_of_me_electric.pages/playback-0032.png": "a547016e4eff0898c224532490f4e83a4a84c87efb77e471c2b12e21a03a0809",
|
||||
"songs/all_of_me_electric.pages/playback-0033.png": "707d0c281befeb8aa2a16e17575e1593bfd602ab723cf5ba3027bbb83d125653",
|
||||
"songs/all_of_me_electric.pages/playback-0034.png": "ecbd44ae3f090dc915732d1706910612b5f8ccce10789eb7cc97465c6989a66d",
|
||||
"songs/all_of_me_electric.pages/playback-0035.png": "dbc45491cb7c544273c38f427fa2a373ea27f067cc4395566685e1bdbac226d4",
|
||||
"songs/all_of_me_electric.pages/playback-0036.png": "f239d52cbb9ebfa771cebe9adfa2a226f78910704294ed8dd6526c8af756410f",
|
||||
"songs/all_of_me_electric.pages/playback-0037.png": "74677eb40b2953a482e4436d83d872b7f3c1e9d763a8f90b5aff63aa3f77ab98",
|
||||
"songs/all_of_me_electric.pages/playback-0038.png": "08191dae0867e938afa09ade672bad6d6ad2f315974fa8fa31cc6047a9ade114",
|
||||
"songs/all_of_me_electric.pages/playback-0039.png": "7f96060add92f5cc1def9eef2c024950309a33ab4c921edc196716698b7fb1b0",
|
||||
"songs/all_of_me_electric.pages/playback-0040.png": "e9b6a19824218fd4beca0692079b418074377b325785e8a01e6716204f704f98",
|
||||
"songs/all_of_me_electric.pages/playback-0041.png": "218b10759f29b917c541025802890025b2f301222c0d7c56555e1b1620f0d30d",
|
||||
"songs/all_of_me_electric.pages/playback-0042.png": "d780c544f2131cc267120022657a7e7aa82666d508da28512869aa8d0997e2e3",
|
||||
"songs/all_of_me_electric.pages/playback-0043.png": "b154b245a6143a9e803f7044d7bd8d0ff7728f50750f2bff97066067bcfada36",
|
||||
"songs/all_of_me_electric.pages/playback-0044.png": "49609362f3f21c9777b8aefe8f8ad624069754fb88c804db038555fb95b7c384",
|
||||
"songs/all_of_me_electric.pages/playback-0045.png": "de369edc2b4df96f39cb49f9441302bdbaea5e5dfac4dcbecc09fee437e81f8a",
|
||||
"songs/all_of_me_electric_improv.json": "d3c571e1634e112179fefc23e2881c8d67b321357e48e3ffecfa74d844731edc",
|
||||
"songs/all_of_me_electric_improv.pages/0001.png": "9ae7caf030d3f4694caab85bd7ae8eeff0ef72ea5aaad8f2cc183ab7b04d1bfe",
|
||||
"songs/all_of_me_electric_improv.pages/0002.png": "1db713c064d04e69ae2721d8b22fb213707c31073c54de09a40a27bca5630797",
|
||||
"songs/all_of_me_electric_improv.pages/0003.png": "2fed91a78a32d3c8510c8f4b3677d85094567b4e6d7824f11c8253697bfd3788",
|
||||
"songs/all_of_me_electric_improv.pages/0004.png": "9e86cda45554b91d09356a82bc61194eca8e2e4bbab9b8f30d07d4813660ee23",
|
||||
"songs/all_of_me_electric_improv.pages/0005.png": "8edeaf1afb9881cb6bb63703625ea8b140670ecf4adfe489df801719a36bc880",
|
||||
"songs/all_of_me_electric_improv.pages/0006.png": "50f80d60a706fdf98e91c384535a5cbecf8ccf01911d81805863b82d5bb0a273",
|
||||
"songs/all_of_me_electric_improv.pages/0007.png": "f1fb9c96a3ad22c61f83e0cc1fab090f0d92fc3925291be4d68625af96559d48",
|
||||
"songs/all_of_me_electric_improv.pages/0008.png": "b0c51b473f67aa99ae4c52c94646537a48f66bff9973ae174672393e7f460be4",
|
||||
"songs/all_of_me_electric_improv.pages/0009.png": "5af8c7d702f746467afc4631e1152ba5262100f6ddd433be4a3db68856d20913",
|
||||
"songs/all_of_me_electric_improv.pages/0010.png": "4d0bf0b6aee6547752f7b900c7a85639720c3a3a7a36f1843d9b713304a2dc36",
|
||||
"songs/all_of_me_electric_improv.pages/0011.png": "b3b4cf499dff0e50198a3561e84405f3cd96b3ec3c4583c44718edf7f2477ad4",
|
||||
"songs/all_of_me_electric_improv.pages/0012.png": "c19ce7feb29ccb539d1714e4c9f3e9ced083f507c6e4963767be526512657e57",
|
||||
"songs/all_of_me_electric_improv.pages/0013.png": "14fe1de03afb67a12e6c8952fea5f7298114f7859975e9371d64e9718d53f92c",
|
||||
"songs/all_of_me_electric_improv.pages/0014.png": "96bd624b1460be30156ea0b6c38950fd790712c6da296ad873c987a11c9467c1",
|
||||
"songs/all_of_me_electric_improv.pages/0015.png": "a2e04e9a32c05d036b1230131588f2888837f7eb972877d189c9acbdaa0568ae",
|
||||
"songs/all_of_me_electric_improv.pages/0016.png": "12a521d04b7eed2173e1d0b4a1a302490e0b088c8c233854a73146c65008a21a",
|
||||
"songs/all_of_me_electric_improv.pages/0017.png": "6d5c6b38ea5e0bf6c717ac041f2e0242010c0a4128838c125a243350422af44c",
|
||||
"songs/all_of_me_electric_improv.pages/0018.png": "3c7113ee216704ab1294a0b1bcc6e2e2f781b7e2fa97211cad96bcf4128de0bc",
|
||||
"songs/all_of_me_electric_improv.pages/0019.png": "078b008dc60f3b179a97967a67f811ab14a0b39d502c2ceab6473ecffd62776c",
|
||||
"songs/all_of_me_electric_improv.pages/0020.png": "4704de6aabba302f204098fe952699d696419110fc12931fdcfd01581de1cb62",
|
||||
"songs/all_of_me_electric_improv.pages/0021.png": "dce38beeffb026938a9fb97282a554bd43837a24ec0ae912458b504b18d96a98",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0002.png": "56cc0e04629e57b0ae89128b78cb55066f75f871575f08b3a6f04555fa31fb04",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0003.png": "264745b420fc459b1ef660a854b77bc201eadaaabae2260112e5826a4a2d1746",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0004.png": "6f3f5f4c4defdf42b4e159727217937668afe9b8c54d1834d633d77ba2bb78d1",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0005.png": "85a22f27df97fac5fe68ebbcb8eef3597b3fc4a6a3329da629b0355f7446359f",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0006.png": "a23a29e0fb69dc09671e05f47b5b915d2b8b48847059b4c48f4f971a8f2aaeb5",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0007.png": "fe9054ea77c6747e34a11022281ba2b2adc3989fbfe0fcd429c15738ac05502c",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0008.png": "db053bb59ccb5a4b301c68e008badbdbbd355c9d5d627e6b216a8f8b3bec1111",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0009.png": "b14fe42721dfd1db4959923b725b3af009d7a36e80aa285d427efd6d59478e18",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0010.png": "eaf5ee79cf71f5f94cf0a3f0caae0b77d079518ec53356a582e2a9e6f8bc34ce",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0011.png": "e84a47819a386df8e9649c88b35fc87d8505b466b354ec3aa93cebfda42e9ba2",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0012.png": "e0929ecf808e09dec70ecbcabc346050705b5ea024bcf43a0a04a297ebc4d5c7",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0013.png": "26f90ab441182907004f440a2c0e0a6c98b6fc4d59831aee67ddd72e57f1e83e",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0014.png": "9e3a85ec831d6e6c04c1587532454d600457f09df511bd6099c2760eacfb0947",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0015.png": "c9d2ae3d248cbf25a8677013e81c8e956a3dd6e0cd46428124a558345fa65a7e",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0016.png": "30d60c7a143742aa55ea0d6d6d9b2885fe66384f4b0f9b674fa1d08cacfdd51c",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0017.png": "669e85cc777fe281abe359e751eae6e51234a0a9cc84881bc161841bf9f2762d",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0018.png": "0528f5d62dc1c201b6c983a11ad4960e08daa3b36209f822813433412350bc69",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0019.png": "20410f0ea715f92f9463b9c9887330dd74185084e1fae21f79e4fe4e2c8720ca",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0020.png": "7e79616233f7b45237b9e4dfc378f764815b501d3c04e5df3925ff6db1299584",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0021.png": "309781a5de4cc2c964deb9dc5f4107fc36407e4d0174b94822bd92ebb3a8f214",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0022.png": "107f0253b24014d03a492be5c132e127a3679a8e2c0be884cd86a47d64921f61",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0023.png": "44159205003df9ea39dba9f53692f923cb92f74b36205aa2162cc1fde6d3caf7",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0024.png": "5675a342d78d7705a609dc53666c356fc829f4fc7d3598a84ba67dd586f8ea56",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0025.png": "bd3c1c8f123e1487568019ff17335c87fea6fb73e941469f4d13d5ee53aca9fd",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0026.png": "af3f08ee67aa2bf7afdfc06e98112997bf301b680ba00cf83ca0e2f9827442a8",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0027.png": "d4fef97de6a608516d46cf141cb35dfead1820270a83d62a76152f75117cb87b",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0028.png": "85ecb164aa2441c6b918a5e200e1a71ffaafa7ba1109176a2036509e685a175e",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0029.png": "06ca3710809f08c24584498768876458eb5b6259c9df40779c9172c2658a40ad",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0030.png": "437f429e6e44c20eb7831acb11e2965b67c306bfa340d83d1efbaadb17ab53c2",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0031.png": "8b042e7ae21ed38afbb5556cfaac8da266ab819a4e622a4fb078a463974338e8",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0032.png": "b81f08291112980a3d51560d5ae4dbc7550d0819271d48a2f623e59b80b6a85e",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0033.png": "6c64311516a7c70fd60dd91c9d59cd90c97de63d5fe78bd86d40b4dd45913b59",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0034.png": "e4009814e660f5c2baba72f887a0ede182abf57388a0ce5f0de077d72cac24dd",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0035.png": "14a7ec9942ee08e347284290b1a72655f346c1d4a140b3816867faade058374d",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0036.png": "5edaa8c587b4588c2609a5cd658de7a060f22a5fe9fcb3432bf0575daccdce78",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0037.png": "f2211592e1d074be423d4687c6589fe1d5dcbb8ea35d147aa091ebc1a732c993",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0038.png": "87039af6fca4f6be05a038a48d16ca416975a3165ca79049e36cadb76e42f302",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0039.png": "87cfdf7ddfc88f886d7fe52e9adb49f8eba011042da06a936294a7ef7828dad8",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0040.png": "ed59c8d9c4a3308bde22144de89ec6c572078d926681f24c96a30c020bf8d525",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0041.png": "4b7e52c00a843e7d597701928c8270d5c76f3ed5631691cae273fea606a27543",
|
||||
"songs/all_of_me_electric_improv.pages/playback-0042.png": "a63fe816186fc5e118f96bd0bff5fd67be723e679e737ccb944ac94029970de4",
|
||||
"songs/all_of_me_vocal.json": "589af69e505c0d1fe8341dde7daf36700408469f133d793aa051134ba668d8d7",
|
||||
"songs/all_of_me_vocal.pages/0001.png": "5ff59ef10db0937c0dc132effc4f1ab761c6a4cdd9898aed1cbfc79c3ee126ad",
|
||||
"songs/all_of_me_vocal.pages/0002.png": "8c737499c4e537b779160b86bf779de9b0a6771ece8f594af64ccd66e896f0d6",
|
||||
"songs/all_of_me_vocal.pages/0003.png": "1ae152662e101117056704e7ccc92e7fadf546e4b0b68b11a463b8a778a2449e",
|
||||
"songs/all_of_me_vocal.pages/0004.png": "d921899d08554455d72c71709d1110d808c5227de5cfc0c9aff68a168c7d4ecb",
|
||||
"songs/all_of_me_vocal.pages/0005.png": "4c9fb6f70a887dceb7aca582122c7ba3d06a38431a37247b422e11a81f917e5e",
|
||||
"songs/all_of_me_vocal.pages/0006.png": "a2792f1dc70d11d7f1b26f4c51686c8c6739844021e237c0d30f3604a2bc6306",
|
||||
"songs/all_of_me_vocal.pages/0007.png": "ecdad2ac3c0a38bb0a553986b68fd64b6bce4dae7e2e654c5a152bc83abc61b9",
|
||||
"songs/all_of_me_vocal.pages/0008.png": "0e619566374434cae278bd66344d94c80507e7477f678172da1f1cdf24bb65b9",
|
||||
"songs/all_of_me_vocal.pages/0009.png": "8ef33bc47271fe389b3cc1c03be11e8689e8f5d1e3638a8af127adecbe90cde2",
|
||||
"songs/all_of_me_vocal.pages/0010.png": "5f40610e4d3b143d3260b574c28c633b3a18e6e298fb3afe8adac95878bde3f7",
|
||||
"songs/all_of_me_vocal.pages/0011.png": "32c857d8cdac61f34eb97031e27dce2f3b7d85f354e002c76c6947c07a49fb48",
|
||||
"songs/all_of_me_vocal.pages/0012.png": "f6674afe2c6f4ead074491509ff12fc572656a73d894b170a7ef78ea0f83adce",
|
||||
"songs/all_of_me_vocal.pages/0013.png": "886cee78fa1a4f5868a6adba5205d0843a6b9de0af42ea80e047c514b6ba0bc4",
|
||||
"songs/all_of_me_vocal.pages/0014.png": "0e7d208b5da11cf6fd25b499ee138d865e63e85e2d9392ea9363623145b5677b",
|
||||
"songs/all_of_me_vocal.pages/0015.png": "ba792e980325d694638ff90c7e9b87713de6b313c8ca2667e2889cd278581c42",
|
||||
"songs/all_of_me_vocal.pages/playback-0002.png": "ad7fc6426e39e204109e3122bfa142d203abe60ba76a59b4df507e61745d923a",
|
||||
"songs/all_of_me_vocal.pages/playback-0003.png": "4980352dee0600cad30f54ffba81577f4285248c7987df23aaa84f28544d02ed",
|
||||
"songs/all_of_me_vocal.pages/playback-0004.png": "ad23b22d0fb931f098b88124fb6e48d2a1b03437f12944d8dbfbab9e5a6639e1",
|
||||
"songs/all_of_me_vocal.pages/playback-0005.png": "065262ff6b758419107fd41f7b3956d996c325d6552a72f62281288c57ed29c1",
|
||||
"songs/all_of_me_vocal.pages/playback-0006.png": "bd6cff39e52a41aab31473740a77357c47b2bb2b3dff42cfa794d3fe7888d7c1",
|
||||
"songs/all_of_me_vocal.pages/playback-0007.png": "7cf0ebec216da49ab7385514a10e466656770d6668975840c8967f132414d2ce",
|
||||
"songs/all_of_me_vocal.pages/playback-0008.png": "b4d59c19612d34d766cf2d76db71c6d6238d9afd6ce88f51dfebd117e6b1c5e1",
|
||||
"songs/all_of_me_vocal.pages/playback-0009.png": "3d30c0180874fee2163821c4ef160cc5e134e5c5069f6e77ba542e40f5174bde",
|
||||
"songs/all_of_me_vocal.pages/playback-0010.png": "5464485dc6c19973ac39dd3e573b9d7884cae22a6ef632f3c31e0e863635c7d6",
|
||||
"songs/all_of_me_vocal.pages/playback-0011.png": "761aa1f8a8096046dabef1c1a30875205aec3309030cd60ae27938f51247f154",
|
||||
"songs/all_of_me_vocal.pages/playback-0012.png": "0285ff0feffa0a61ec1a26b3eea54ca9156cba357847c74b1cfeac2084d454f4",
|
||||
"songs/all_of_me_vocal.pages/playback-0013.png": "6177b132b398724081c814a65006ec205df52ea90403ed0c543e6ac9bdfdf2e8",
|
||||
"songs/all_of_me_vocal.pages/playback-0014.png": "5fb997b60b54f808490b9fa0c6fedc396540f3b407c33b4562b54290daca4afe",
|
||||
"songs/all_of_me_vocal.pages/playback-0015.png": "2a4a6345c63a9e6548519a3303d369bff76896eb1c4bfb40da42dc29fe08f710",
|
||||
"songs/all_of_me_vocal.pages/playback-0016.png": "2ce1db668951fa36a075f119a423b1f50a1c4f346a29b1077cd55ecb31a5fa7b",
|
||||
"songs/all_of_me_vocal.pages/playback-0017.png": "9f2677349e9d54f8de873e12399fb1f30a24d18e5f06c990aea2190cc41737e3",
|
||||
"songs/all_of_me_vocal.pages/playback-0018.png": "25e7ab38426b3ce2c970c0c474aac7758f0f8c6a816fd0472a1a98ae4e37df9f",
|
||||
"songs/all_of_me_vocal.pages/playback-0019.png": "f580eb1801c2ffad826c48a5a3c0b35c7513a8e4949723bba3f8f4522d03534e",
|
||||
"songs/all_of_me_vocal.pages/playback-0020.png": "e6f23793d569cab0069560e88b1bc38e8fe44b1a11843d3a1a7639a33a6ab2fd",
|
||||
"songs/all_of_me_vocal.pages/playback-0021.png": "a3cd07885f5ec333ba7ab3bf61807cb66263b7a4da4f1cc53e36717388a0cc13",
|
||||
"songs/all_of_me_vocal.pages/playback-0022.png": "82529db7c06aa1ebe3bf5608287d2f4a6822337a5e1261d4687a6a9665478efc",
|
||||
"songs/all_of_me_vocal.pages/playback-0023.png": "0b2c34c2524e5d437187d4bbf5cda395c55149081cc1478405a338d29af02ee3",
|
||||
"songs/all_of_me_vocal.pages/playback-0024.png": "08d783cfb2d4bc09e83f3647a86455394f044dcce723ab9c5fa55063b222e844",
|
||||
"songs/all_of_me_vocal.pages/playback-0025.png": "9c65d79d14f04bf05bee9de69c6c37a871e50aab635de797a91eb62fd81aac31",
|
||||
"songs/all_of_me_vocal.pages/playback-0026.png": "79e87e2185d99c32aa753ac54068fabb698f81b16d6017ee7285f3b7c4d20054",
|
||||
"songs/all_of_me_vocal.pages/playback-0027.png": "015cd105d6e5d2130393ced1268413b4929ff77231c30501b3a2748be6783b45",
|
||||
"songs/all_of_me_vocal.pages/playback-0028.png": "7f3f16ced98698ef1034101976c05a1fb33b7c513057066b160b42eefacdbe64",
|
||||
"songs/all_of_me_vocal.pages/playback-0029.png": "dfe0f2ea02945997653b0fac1b6d1355cd1c9a2cb5307f38ee59d51a54d4ec53",
|
||||
"songs/all_of_me_vocal.pages/playback-0030.png": "98e2a6862eff212591ffc51d55c48ab65267e670bdb56d6c7ca6fb00ad750086",
|
||||
"songs/autumn_leaves_acoustic.json": "424355c83269f205e784ac632d6dcedc3eb2c7ce784931f4176c12f63e48ef41",
|
||||
"songs/autumn_leaves_acoustic.pages/0001.png": "754c7e2cd65dfe2b6a8d3ce3fc1612d12fe44dc86e38a857922a4a228b631d9c",
|
||||
"songs/autumn_leaves_acoustic.pages/0002.png": "d29366df7962f098d633aae5ff729c0b6c72791929ced191f1faaed12177d361",
|
||||
"songs/autumn_leaves_acoustic.pages/0003.png": "a8fb76be9e0d13e15798156a8995d71f86f7d700ac3f8e1ef38750ea23483ee3",
|
||||
"songs/autumn_leaves_acoustic.pages/0004.png": "3fa3a9a415308448ad8036b2d50b45540dc6a5b5a083bf86342514077e9aecdf",
|
||||
"songs/autumn_leaves_acoustic.pages/0005.png": "c374b7a4a99a1874e6265708e11677a99483f3fe65fb3ec6e27fbccde94e1416",
|
||||
"songs/autumn_leaves_acoustic.pages/0006.png": "602365dadc0841c8afa7bcb61540cdaa74dfbfa41cce92c565feacea6b1a0708",
|
||||
"songs/autumn_leaves_acoustic.pages/0007.png": "d060737953a4334f7e02774567e3329bf3c21638e84fc656366a00b28416ff43",
|
||||
"songs/autumn_leaves_acoustic.pages/0008.png": "90467ef58fdd554143322b425cb16184f124451d0b4960300ae9560b11143072",
|
||||
"songs/autumn_leaves_acoustic.pages/0009.png": "98d7404f052d8818f0aef807de2ab33f093de213f5b6d49cbbdd0e2323af77f0",
|
||||
"songs/autumn_leaves_acoustic.pages/0010.png": "42fc2eda84a51df4bd7d6b718c9e1bf9ce6d0af15ff7da5c47ebc2bbe27a89a3",
|
||||
"songs/autumn_leaves_acoustic.pages/0011.png": "613c1317b9c23e5e116ba540ab1100ba13c93b24967214b7391bd2f03c203a86",
|
||||
"songs/autumn_leaves_acoustic.pages/0012.png": "41db70c7359e98f676755ecaa474056c10903ea95059e65baf472ac1eb9acdc4",
|
||||
"songs/autumn_leaves_acoustic.pages/0013.png": "d96bc686639c9ee0426494e181b57380e5f2047625d11675ca6f92af160b297f",
|
||||
"songs/autumn_leaves_acoustic.pages/0014.png": "c264b6642fd212d8adc626575f4654b749bdc7d3c1d1dd56334b38d123c1825f",
|
||||
"songs/autumn_leaves_acoustic.pages/0015.png": "d2d4ed1be0e1e8727a70a39b0e399ed564cb035f77c7381fef804f0a0c6c158a",
|
||||
"songs/autumn_leaves_acoustic.pages/0016.png": "cdc24eb58fc6b88043faf47196f7b73aeb79b2ac2d09a1a789616b6410d48500",
|
||||
"songs/autumn_leaves_acoustic.pages/0017.png": "25bf2148154a3263a2b91d91bd7a57df7a0c043b56bedb220292e9151e28f411",
|
||||
"songs/autumn_leaves_acoustic.pages/0018.png": "eca1c6d920f3a197aea1e6f132f0b8321b2fdad2d0f8b1a002f1ddca37a36e19",
|
||||
"songs/autumn_leaves_acoustic.pages/0019.png": "ffeb3a3762731c8358e9c883dfaaac7b52c7f23dab70cd65f3cebc528073bd19",
|
||||
"songs/autumn_leaves_acoustic.pages/0020.png": "fcc30ef29c1ec7ed77d7f09ed3ea80fd942d348d75c7e1f2e00dd053d936bd9d",
|
||||
"songs/autumn_leaves_acoustic.pages/0021.png": "020fbb4263821a62d7071e753d3f0a169a9803056654044204bcbf354b787757",
|
||||
"songs/autumn_leaves_acoustic.pages/0022.png": "dd384bc58ae72d4169b6c7705d62925292542411610f6d3419c183396da87183",
|
||||
"songs/autumn_leaves_acoustic.pages/0023.png": "d7a90f05d1f6d24c9fdc1fe9e42b666eaafdc4d20252b24adb461e6f324450f2",
|
||||
"songs/autumn_leaves_acoustic.pages/0024.png": "902970515962f0805fdfe17cc172808c82745aace0351397ad8936a5b1edefda",
|
||||
"songs/autumn_leaves_acoustic.pages/0025.png": "f46b2b544d649b94367223a1a56d51312cd06870e7e5d14b76a141b30165da5f",
|
||||
"songs/autumn_leaves_acoustic.pages/0026.png": "1a29bb232d414bcb77232fda368143a08a53178418f990704de4d7f3c8402967",
|
||||
"songs/autumn_leaves_acoustic.pages/0027.png": "90cded4192545e2b8d9d022761838b81bd85c3ea46afc6b5ced5e52259e4de73",
|
||||
"songs/autumn_leaves_acoustic.pages/0028.png": "91155a03c1040bc9965a23fb88aa53d278280c398e6912ea21d12cb491a0097a",
|
||||
"songs/autumn_leaves_acoustic.pages/0029.png": "25d3344fec51974219e61433f1512e28fb1ccac1ba7cfcda07e302f6bae2f2de",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0002.png": "bffdb1d0ca3ba782c4565f4ec972d53ab615960efbb978893a6f19b8b5c8753a",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0003.png": "25f0f7fef9bff4d09130ba370f0dab0d96a74725a6af827804a38b6bf83d052e",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0004.png": "09213305f712a25b2385e4809740d6c93dc3cf0516ce8f5613b097f6e384907b",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0005.png": "da532299863e568041d9f4bce0454c5184e299d91faebe688279e2e957c8e70d",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0006.png": "f7b73337b22763729001c9b14ae6b27040834a01467590d48eeeaae15f046a48",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0007.png": "3e4292779a089c41cc74ed3f1470ac08c158e456be03a951f954ddbb4de6d2b7",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0008.png": "03b2a528ed962672527b8fa298b66b76bfcdd0bf36c8b15fa515f8d12de61a08",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0009.png": "de92a6cec2f58dfe1024388b6f4905e1f660af9019b780e58a8ca8642f1d0b0d",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0010.png": "0e78d180cdc309e61b9e393d7522d40944cf0c5aeebf4e6bf6334b95b6962b13",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0011.png": "df441f2d55c3c32616841bcf3995d97488a02cff602475c66ccaf370c604475a",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0012.png": "9e343ff6b1d95923a3c4860aa32b98fe8a48be11b5fb21c4435a6435c5438932",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0013.png": "c2b1b1c578f3fdae9175905b853633089bad0147dbfcc289cdef74e52e0b35f8",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0014.png": "905c8ae0e609924cda4e78497ffe4a46fec0cb44363bb651de34b5aca16451ad",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0015.png": "07fe45e2bdfec334026fee44f5821b3d69e1bb6a0e61ff7b166b12e0f5957e4d",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0016.png": "28a1af1c818424a1ee32c00ab8c45561dce64eec59c63cd5a1eaf9a4fd55eb9a",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0017.png": "0eca5f3fd5872c8980a1f1ac4098121ba3f2547bf9abef6f2535abf50107de08",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0018.png": "459f07bfd907fd6362c3dca0659cdb95003df9dcea2295ddf9545c7c7721fc27",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0019.png": "bfe63b5f93c18a6d6bb5a6f8ba86b370a98f56d4a6f6dd3dff6660eb31582234",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0020.png": "37b035a6f150a55ee9f35a3b2010d67e46ef3a90a7671397c5a6bfab8253f224",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0021.png": "88b954365fa56359cba4d730d6412d735001d430d036a4adb682195b207ac359",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0022.png": "8d6052a868b6d54695e6b7fa9f6f85adc353aaca9cd2de925f2519d53fbbc62b",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0023.png": "219749a6941a81728b7ce39077abc0c7b0794f739665548f82031636b2732894",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0024.png": "9a7a71da600bf9b6f231d0d56dc80b7d0b15e9c4bbe5bb6ff8123753ef071a71",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0025.png": "c0ad2a3df93c738a5cdce3fa28319cc4fd1135b51b376360b73ff2e9b2eaa0c2",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0026.png": "6235f12598bd6436690668ee5d1a48e86efaf4c244d1365e0733def8d97310c4",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0027.png": "2cdcb30b41d6434a84255fbf65d91e95221feb63c8eca1760a782153b4857493",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0028.png": "ba0574a66af2bfcbcdaf4dec8c1db0838b5c02004924ed821ef79d46b49d7319",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0029.png": "27df7f3ff5e63442c7b3e863234ca942ed543f25af8e63d57c46687159338407",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0030.png": "e1b00c1bb79842378c75ca38c35a9649e5c6ad6d7c59634c599fe972646bdffe",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0031.png": "2a3d6dc83778cbee4a030e8f31950601053e4e38374594fef99d217117af9eff",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0032.png": "fc58891c4bcd5fba3f2779168f74f1b42ee00f398e70464439a854c81dcc2bb5",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0033.png": "b39f14f4e578f5e1f70e33b08893c16c55e6135a8673a03b49d2a0d48036f796",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0034.png": "e6de7b9f74dad8238677bf80b6feb79152cd7099fc1a3cdc5cb6ad18aca17a18",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0035.png": "4d680115e003c1f185373219e2e2304f3a08b90580d08d4fba0b8fc2434b6015",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0036.png": "89031463dac17a1e7fdbb441f0f38691fe92191972a9f703cd31354b5bcb1a5a",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0037.png": "dd8daaba5115059a825716751244ba01a241875279c2869de547ac57abbf49ac",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0038.png": "fc7d19006d9669516acc7ccc9031c0d04832606f83eeaf98e9af85646b208349",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0039.png": "eebc63368ffbdb3260300afcb40a5168ee819717debd597bc495943307d0b7c8",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0040.png": "c87d70fec0ac36ae8928a740463d5c8cac0d442195cee1f0d168781ece3d88ea",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0041.png": "dce765fa661c3a54efd6862afa9c3c8283b8b2e736e10663af2a49883b73135f",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0042.png": "9b401790ef6e565356cd46ce4aa1de9ab17718007e1e0c9a4541fbb49e8e20c1",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0043.png": "6c5e6435f14f0834ddb2ae5be27dc61b7e7bd700c49cb106ac85c7bf7e880c1d",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0044.png": "b1ce382ffc6fee9d00f3b4c67053e903410188383e33d7c917a7cbf3c90cd814",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0045.png": "b0253732b05f2be0819dbd95bbcca9bff30ff20c33f1164b6a15eaf7ec7108b2",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0046.png": "04ea04828174e1bc6c0455229dc93cf4c35f01557b81be1c9fc4e8645d6e407a",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0047.png": "f355eda7e49bcc7732ea0858a0c08fe157dd3033cc2b6fe8471baf60928f6717",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0048.png": "e4f847bbff2fa32a46613c9d3586aa0e3daca5712ed595239cc9113157d477c9",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0049.png": "f63191d92ecf372174c08a00ea67f02ec5f6bf21d40671b917610155c37d55b5",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0050.png": "da0b31851ba8c79a653f6a091f53037023b05d36042119770b9c90c0409916fc",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0051.png": "4f4107df9c96c2af56d69feee9371bd53a802915c379b2bd1f4afcda389c8c27",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0052.png": "f0e3d7865a03d32929334896ce4558ab82bc0911a20b4b567f18f90825c6d16e",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0053.png": "30e05e50f63cecc3de67c8e351757549597af4b57947bd80bb13fa7e6ac376d1",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0054.png": "e0dec601e352093703a8cbeac2be53c6f72cf5d74cabcb8b14c4f5e9a23b8d14",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0055.png": "75bf2c21a0295babac403648fc78b14419ad099435fc25aaf06b74cf3383c4bd",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0056.png": "817d04b52972c066cd648fc239f2bdd15d1fc0379b7c81b088e3e398e2490eb9",
|
||||
"songs/autumn_leaves_acoustic.pages/playback-0057.png": "0a09863b96dddbaafa342e2a739fb990861e0cefba0feabc2bb5634a05207de5",
|
||||
"songs/autumn_leaves_acoustic_improv.json": "31c28b9ae250efaa298951f6407d1df17bed03a9e2b9f198625ffdf33931914e",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0001.png": "87b0b0d1d3ebdd0fac08bcf8ab0073d32fd0826015b0280d1de7a9902f32f84d",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0002.png": "138de5c3a953813fdfb8b996f77a9a85019172c6c4c71c6227a0a3e87cf340fb",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0003.png": "4c1a9bcecfdb9ac5fed64fb29158ddc186cf56b9a1ff90b7b4c705153c8e97a2",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0004.png": "7d44cb1a80fa749d14c5ef26b8d90b77e6fc99ce4913daa854709a301b076bcb",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0005.png": "3acc64066c2443fbeca185dbe52bc2c69331e215ed28ddeb78546a7ee085028b",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0006.png": "c7f0b7eb425bc4f38651ee0c21191ed2842d7ff015344a14314a7e467648e108",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0007.png": "221c35123f16f8aaf10ee9d447b8937a0a99b16dff621583de683aa96f1d5b76",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0008.png": "bea591fcfe2814c213862a01ded8121d9d7ad4902cfd4ce54e46f52ef41bb09c",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0009.png": "3f7bda3f88a779af96b69830a8f6c68ada047cf62d145afc7018f92b0c8f0f6b",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0010.png": "59c00b13e3e9615e44d64f0f0b044e6108f30fc96a4bc9317a2a3d145cc8119e",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0011.png": "0f5d4bfb0f879175c68160298cdc2da0aa2d9b3302f838c7d9af0a1629493d45",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0012.png": "3f59448da5f395125bbd5e9b68d38351331cb92d03874ddc1cea2f3858167dac",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0013.png": "a8cfbae920a6541da7fc7e2db812760f9d0cf9be35f74b2a97d3a5706b8526f9",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0014.png": "4e908300033bdef7980f79f86feca3ec7ec0dfd541f4e4529a8431eaa97ffbeb",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0015.png": "999f6d92b313664b691833235e0a7ab11eb6747edd183e4c6260b32574b7f9ed",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0016.png": "cacfb981f78b5c4fa10b544f2dac065bd10186529d0317e8a5233799ebc7d37a",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0017.png": "4602af131a8c816092044d9fb3a5ce7208097d2fe6e7ead754a449a74ff2bcdf",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0018.png": "b8df0ba54c10fdb8a7e901ced58e07b11095474931572f5f3764e51cbe27abcf",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0019.png": "0be07dec199fecae49ff19d87470cb7249f6f360bd94f85fde74517d7cd0c8da",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0020.png": "65e988c5a73a25e1be2ed5d34f1658a69205afd9f6715eadd16cfb2e8457a792",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0021.png": "d56a1fb42e862f9fa7ddf42be4e9ca67d48e50e13dafef5809c77555f00b4b9b",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0022.png": "ec9905238977294f2d7cb34eabe733060f4aaf5c94c898d9152c8093ec2419c6",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0023.png": "f8a61346c1fc3485a5171c228b16dc65cd2da26e7453c56e8fcfd85817058d87",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0024.png": "1aef4ecd707a99e2c5d6fe9dc85408a6621e7533fe7062e8fc77b9001302fab3",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0025.png": "8020c158485bc90479fbd5a82e3ef6b1ee26386f9e127d55926c3cf61d0a73ce",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0026.png": "0ad46eee5f2d397ff9849aa4a45927b581ce3cf9f7645ee8d8a1739de4e95b0c",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/0027.png": "13abf4d226948667faef5090a7a306571e1537aaaca2e8b12bf12fc7b9cb1007",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0002.png": "ae4b38302388cbfe1d3fe73a13457dc713f2f02eb63fc07dd5965bf008823c22",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0003.png": "b634687a9603b43a6f63b136cb525e7f603d5757205a14498ae3065c00226b7c",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0004.png": "ea9f82f3f32eed034a3a322b95677e0a9d0de0ab659fce3744d147133595f2d0",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0005.png": "31c5c14ac8dd9e321ba8b555f4470284f51877bfad79dd1baa5f97f4ae881296",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0006.png": "d158fc52078ca1a0c96d86a608c53680eaf19a0ce75b0cc986a7c821323702d6",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0007.png": "b6ed13e25a8bad742d6ea3aa799f2a9d843e002c1ba30d2129e565b81191d969",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0008.png": "5d59ce11151b3438d9e6a4598dd6b741dbcfb25885946e4d8a4d566317d938a0",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0009.png": "d4a26acbda393efb01dbf4d689545aac0327f74b7045c10f4560f28192b0e0a5",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0010.png": "fe3ab8cc68e76cdc952b4c76dec90fbf5f718c1be3e29f34355e5e40f8ec5645",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0011.png": "bba0e55fa5ed0e57311efd1c32a86a52415865c1d64b6b48b12ead289b4dd49f",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0012.png": "a349f59498f04e3659a43879135f657cae09a97e76e6da31ff2f3cb5bd30088f",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0013.png": "df4b16903b5e39a9c96f1ce02610759b21ed26dfbda9efe768cf4d08d85a8c19",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0014.png": "4954ad48cc53b247c8ae0d96645eba44fc8512db2dd7fe0859fb1c4eb8cb2865",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0015.png": "55dcc02d878e22accec50bc0b0011d1c0f54e7a9007759c95140aa4a7f8fd94b",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0016.png": "bedb8963df24aa609b672012d49ea187ba91b5416a30d7b8f2c9ffe4751cefb6",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0017.png": "c1a351c54518b05ea17b03f389ab76193281ffcf1c9307cd2a451595803947e5",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0018.png": "a3720fc3021c82a8ee18fc29555512925334644e2c89d100ddb0d7b670c97aba",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0019.png": "806af0cf1e7ac6101d7f193227646614cff8017a1fc121e1aee111cfa04abd42",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0020.png": "991bf9c780b397ad91dc032238022dd33dca8e529f5ff949caf842a0d693f6d8",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0021.png": "9ce8190ee7543cdf340833757b216a23c35f8197ec55bdf557a27fa214fb26c8",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0022.png": "720ac8b8a074ffc2fdd143543594727eb87ce134a835536b53e2c2265cb3fccd",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0023.png": "c9fd1fbf2fa389db102a06c2fcf828980c80e0143113ec939e8cb5264aea92e4",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0024.png": "85300b820036636a5ac8d6e454fb5e2def0a44ef3762a6fdc0fbc2a56f50f9b0",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0025.png": "56383bd216899208b0b364e562bff71da7f29a6f5f44bae9a77b9a2e1dc80d50",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0026.png": "2913c4515047cb85bdd9a053284cadecef78aaf580c7bb61370cdb8f325e4c84",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0027.png": "b4a3d90a750326b6107caa99c83733f4a188994194d31b403f47b957c8597a6d",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0028.png": "173ca5ce02aea70520a2e3fb88f5feaeed8fd13b0e4063ef0b94ea2ff31281ac",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0029.png": "89e9e2e171fdce100d153d6bfb89336397231386ef9a603c7f7d9d4fb4a2fb59",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0030.png": "983388a18b4663a29a21b47e3f27fa0c15510273e2ca214bbb7509f9de084c10",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0031.png": "8d0a21f5a13eea2781e7a56506943a286ff0b759380dad6e213dc1478e02e038",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0032.png": "5eeb34fc520619934cf733c4dfc66ada4418cc7b134077fc03ac8379a0adbf12",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0033.png": "f17d82f411572e4f82468194551eba137ef131fd62848c996fe3fcd3a17c89f6",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0034.png": "f2057312a52f4ad6329beb7a4b3609ebba0a3beb3f85f7afaf164313a667ce73",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0035.png": "73767b8da428fa4cb553e67d62d87dcc6dbc59ced022731dd259879b774a0757",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0036.png": "f9a0f96a824c8c4cc6fd9182c119c3a13815b123254edfae52f4c63a678745c1",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0037.png": "176ea351ff4c6f4fcd970e4a2aafd3c2132427dc191907961635b98e15ceee04",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0038.png": "c0d9110a9a00f98011d92893424019b4e578a5c37d471c2b653e3f28305ff514",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0039.png": "9c64d547101a1091165d051444e6b0312a0f74f011854a9cc5983d7be7a7bdc9",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0040.png": "4eb92187e8db1b2008d410f0cc74db08bbe5aa4293c6c20124e2f15aeee32457",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0041.png": "6da52f2d0987c971a2b7b695ce1bc7f9d5f31c35538480d35ea82efdb78edb9d",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0042.png": "6a8592aa10076cb6c56262bc5ec487fd5904a047ebfa4bf7ff3e00ac11e35bb4",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0043.png": "3ff7c1bb46fff73cb387698fcfe741e10a1e1c8489689cca0f2c134b36882acf",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0044.png": "5e4afcf151d85ac4aeb89b3aac5ac0cc82f5e04435e29042b7ce9c0c76787cec",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0045.png": "6675bada6afd62a38101215c5067c30534b8fb02990e2015da286e1a49eaa8f4",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0046.png": "6ed3a55eb4d36f45826b27d12119b306418d7cffa6581e50e02124a9fec02c82",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0047.png": "e723e467aab30080d23f1150ac97b04a9596b00c8a97dd50103d24128c902cdd",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0048.png": "b39123e53afa05c92854c9326ec3b2aab4ae5757ff316138ee01dc0f9d750e1b",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0049.png": "7de3be69940d820c61ef27594376a5387661153af01dfd1681a2680ef22d93d0",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0050.png": "f9e3a19122d992a107ef910c03f766272b289a24e52892f0d969432297387120",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0051.png": "10f7479b7b50c0511ba0e4a4c499afe5a2a10f5ab9e4417f5becfa1e21ad3f55",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0052.png": "dae45537d30d61232815b270d1085c4f4eb5d3b1c7560e0652fd813eea276f10",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0053.png": "5e1bafa880130f2170fa5604ad579b3fdc27ddf07dd875857221d4c9a45d4f13",
|
||||
"songs/autumn_leaves_acoustic_improv.pages/playback-0054.png": "09bb49370c8dab41b78d62ca7c687ac89f2aa4bbcec748ef2162ce4a7da05c18",
|
||||
"songs/autumn_leaves_electric.json": "4e7c6ce71981ad46b63ef3908c9ef1d89454d4ef9e2c3abfcdf0017e71e162e7",
|
||||
"songs/autumn_leaves_electric.pages/0001.png": "fef5ff5b73c0e03116b280182755a8133e3ca6e647af81dca984b71d33ed772c",
|
||||
"songs/autumn_leaves_electric.pages/0002.png": "637ca3009beeaa5eadb587039bcec08cfe66442dba683e3b18443415a1d34491",
|
||||
"songs/autumn_leaves_electric.pages/0003.png": "26e73ccda608f5baebec6e9c1beceb5586b13b5d138f0d29d4c13f58a6924715",
|
||||
"songs/autumn_leaves_electric.pages/0004.png": "d7d47739c2c32a676198d091d546e027e218530dbe76147a71ad7156dad96f8c",
|
||||
"songs/autumn_leaves_electric.pages/0005.png": "bfdba8e3bd988d7c71769254b779ad58b9b2641e1fb4a0990346616b90df11ca",
|
||||
"songs/autumn_leaves_electric.pages/0006.png": "08f6c8007bf814d9e3e113eaa34064a9448b9831bf810a05f45a7bda57a166d2",
|
||||
"songs/autumn_leaves_electric.pages/0007.png": "342b85bd00874966710187e93020d89edb570d259db2c13e95343c020d11ac76",
|
||||
"songs/autumn_leaves_electric.pages/0008.png": "818ee9b542eab4811bbad3e3f3ae0316e9d4bbe24c83ebd6abf492df5c9ff919",
|
||||
"songs/autumn_leaves_electric.pages/0009.png": "880bb9261e311a307b9f78f4c65a8c622357f640310b7a62ba2e6bc75bc09162",
|
||||
"songs/autumn_leaves_electric.pages/0010.png": "4b70d68d3018504acbf891cb450b21da1878d7b78b9def1a157f1c5ab5cbf313",
|
||||
"songs/autumn_leaves_electric.pages/0011.png": "ceafe100023db75a8d34ea08e8c66155ba76aa12e8a24c9b83294d2a01779ca9",
|
||||
"songs/autumn_leaves_electric.pages/0012.png": "26ba3b04b7e14ceb4a2420f9cdac5aa7c4be2a1134726ffd291efea72c701a77",
|
||||
"songs/autumn_leaves_electric.pages/0013.png": "f15aeb9cae1d24acedd8bcf34bf03dcfd51aa1d06cfb9f2d6bebc7513d1b8b7e",
|
||||
"songs/autumn_leaves_electric.pages/0014.png": "7f04cd38a79073c0d3ccbeabecb0118acb60c1584d63bb700df5d4a9c0b920a5",
|
||||
"songs/autumn_leaves_electric.pages/0015.png": "51d51b7f44cbd817f8c4e99dcbefb975d50232743cd2f2aa2039a39721da503b",
|
||||
"songs/autumn_leaves_electric.pages/0016.png": "a39b11be87dc80d21838e4b2ba7b9e9a35672e67e089d8744238587ed8a3762c",
|
||||
"songs/autumn_leaves_electric.pages/0017.png": "28fd47c4a918eb62f3a94e13c4e036f5abf66635c7e9f2e40f6cec17fa3840b7",
|
||||
"songs/autumn_leaves_electric.pages/0018.png": "576b94e72e221b89e12d9383ffa554101cb2e5ed4e7b00204fc0654eb7912427",
|
||||
"songs/autumn_leaves_electric.pages/0019.png": "71672fc8613539914aa4069a4668751363e0be3d2e9822cf078755fa6b9116f3",
|
||||
"songs/autumn_leaves_electric.pages/0020.png": "86747ada8e9ff0b1c05824d1a1ec9328f01cf33a65c3ae13c64a1e075c0a52ba",
|
||||
"songs/autumn_leaves_electric.pages/0021.png": "c981fe628c1621c6f5add8635e33aaacae112fe954b2b43f15b3f02604c6c9e9",
|
||||
"songs/autumn_leaves_electric.pages/playback-0002.png": "3c96277900a9810bb5e42c261b4a7576affea141d6976babe13c16309b22f54f",
|
||||
"songs/autumn_leaves_electric.pages/playback-0003.png": "49f47d61b78f7a9301a9cdc5a108a28d28440388865d8fdcba884c3ad69c3ee2",
|
||||
"songs/autumn_leaves_electric.pages/playback-0004.png": "221682c6ce84ae99c33fd8dcffc1356965e2b9fa6b068f02570335231d881278",
|
||||
"songs/autumn_leaves_electric.pages/playback-0005.png": "568f242912b71a2a0e6b438fabaeaf043a62087d1b3018d61838093e6812c392",
|
||||
"songs/autumn_leaves_electric.pages/playback-0006.png": "dc5b754b3d11d136a89c82b3160b3cb631572065505b2770f529c312c8beb0cc",
|
||||
"songs/autumn_leaves_electric.pages/playback-0007.png": "13d877cc54210a860f0e08006c5e386a6a3e5c339e2d334df11ad37afd8b8fec",
|
||||
"songs/autumn_leaves_electric.pages/playback-0008.png": "e453d7c206320f7bd7020d6d53a912a4e4c47ad4909d5c5a0848acfce06f6081",
|
||||
"songs/autumn_leaves_electric.pages/playback-0009.png": "2929de6efff1ddccfd9a5a173310e78b16fdd22e0d646d798b10df29b7798f39",
|
||||
"songs/autumn_leaves_electric.pages/playback-0010.png": "07d5c2b2ae31ebd88d4fb4708caefac36aca5d436048c1ffad55feffa6d87925",
|
||||
"songs/autumn_leaves_electric.pages/playback-0011.png": "02cb49878815115e99a0343bb99e9874e018cd763e2b362d1a87815d54eb441b",
|
||||
"songs/autumn_leaves_electric.pages/playback-0012.png": "5493a523d68e125a46317b9b225abaf1c2b327057a40db8b5ae56f289a71de81",
|
||||
"songs/autumn_leaves_electric.pages/playback-0013.png": "bcbcf06bc8587542974e53309ec1221b4a74ff060d5c5690313400a689f6945e",
|
||||
"songs/autumn_leaves_electric.pages/playback-0014.png": "ff2886e25c24329eebf526cd9b10dd56266e3a5ec78c18d2e63ae98e107c4761",
|
||||
"songs/autumn_leaves_electric.pages/playback-0015.png": "2a2684b1c7b0c0ca5cac6461c2c1178bce0303150884fa21b29517086ef6642f",
|
||||
"songs/autumn_leaves_electric.pages/playback-0016.png": "bcd67894d53d043526686e56cc4b2058944c1f9a1f99eb14074e2e90dda7937c",
|
||||
"songs/autumn_leaves_electric.pages/playback-0017.png": "6356a7db80fda5ed10256ca3a4e37bb7a0115c339d405ffeb48854b2e49eb96b",
|
||||
"songs/autumn_leaves_electric.pages/playback-0018.png": "18ec2f04c2d552ec6fe341ef796d785fd7a86a9762f6827f8457712510cf99f9",
|
||||
"songs/autumn_leaves_electric.pages/playback-0019.png": "640adf4f8be4957d984d393aeb62f953625b5a165769ba41830b1e9900694aa7",
|
||||
"songs/autumn_leaves_electric.pages/playback-0020.png": "95a7d3e90b7a7cf435ce87c1d83366822f38f53857d993cb9c242afce3f1ac2a",
|
||||
"songs/autumn_leaves_electric.pages/playback-0021.png": "f983e3bccc183efa8fbbe9f83a7629b056820d15129bc84108f02934a4b52ae3",
|
||||
"songs/autumn_leaves_electric.pages/playback-0022.png": "614a51979fa8c9229503018645533118f05f496ffee4be4003e18a2281015844",
|
||||
"songs/autumn_leaves_electric.pages/playback-0023.png": "62a9183db3b0b22143e914bb282bd51dd6c2ae93ea314251df8812e8915fce6f",
|
||||
"songs/autumn_leaves_electric.pages/playback-0024.png": "0817b0426ac6535d814c03c73c55524ea2fdeb7df2f792e0f946e8fb7ec74415",
|
||||
"songs/autumn_leaves_electric.pages/playback-0025.png": "1f662d387e317ba06d7b66a684425cf97550c2272f6c720e9390e5151dff0afc",
|
||||
"songs/autumn_leaves_electric.pages/playback-0026.png": "c47f0ef9f4b336e0c22f5df2070d6bcdfbf17011318917d68df676506a3ecec9",
|
||||
"songs/autumn_leaves_electric.pages/playback-0027.png": "a4f09eb130425c83f6c8454b330afc060d1eaed310c4a2e3b313891f53282f68",
|
||||
"songs/autumn_leaves_electric.pages/playback-0028.png": "0fab01c2efe841ef6751a19b2e792ae11aeaa15638cbb41072eef8a9ccf17a92",
|
||||
"songs/autumn_leaves_electric.pages/playback-0029.png": "29d124f64604bdcc7415ec262be7efc09038f733b75c3c075170f3a4a1638f56",
|
||||
"songs/autumn_leaves_electric.pages/playback-0030.png": "ac453524be3748a357b873761512b07ca370661ac23196e57333108bcc2fbba9",
|
||||
"songs/autumn_leaves_electric.pages/playback-0031.png": "3890e566a3b136f86ef84aac7dafa4505c6a455111cee4faf1f3f82aa372ea08",
|
||||
"songs/autumn_leaves_electric.pages/playback-0032.png": "403e51bac8d0c1ffffebd8c0253c62dd2ee3e354cffdd36c846b9fab88507e93",
|
||||
"songs/autumn_leaves_electric.pages/playback-0033.png": "a2a8f2a5dec60d1fd539ee2c9fb8b310789c3ef68c0fce928319fb1a800f8808",
|
||||
"songs/autumn_leaves_electric.pages/playback-0034.png": "4d3c0283b624fae38ec67128596f33d0cffa573c99fc4b31d0e31f4a38fd2dbc",
|
||||
"songs/autumn_leaves_electric.pages/playback-0035.png": "b39b815b867647c3997c80a88397de12b66b218d1b2be4dc240b0beda9052685",
|
||||
"songs/autumn_leaves_electric.pages/playback-0036.png": "814c805dc6aa0e6e4dae791b9af5743ad4dd71207ae00f8d5442715d787b4d82",
|
||||
"songs/autumn_leaves_electric.pages/playback-0037.png": "d3e999968b05cb703fba44bcb1f3b8cc1b0934ca7513f9e952db41a27d9dbccd",
|
||||
"songs/autumn_leaves_electric.pages/playback-0038.png": "43773db6f940cdd5457053e9d1cebb1f715d7a9d069fef8f29dd280bc664383f",
|
||||
"songs/autumn_leaves_electric.pages/playback-0039.png": "da683c3e3d6198a49464d6e9463f9bed2ad9595c6c69ac09adaeeeeddb626003",
|
||||
"songs/autumn_leaves_electric.pages/playback-0040.png": "1e1fe40450357ed40ab4a688906e62c69306dc706ec53e4c23de94d7228dffc3",
|
||||
"songs/autumn_leaves_electric.pages/playback-0041.png": "f7c3ab577b12be529b9c73b44521852db930681e6dba1410aae569e526fc4a29",
|
||||
"songs/autumn_leaves_electric.pages/playback-0042.png": "a827c32cf60d5bafb3b042d36f14883bee389e677091bfc5033771356c164254",
|
||||
"songs/autumn_leaves_electric_improv.json": "e71fa13f0178fa8b7df0260efbe42189ca72457d439d6a71ed8a6df3f2ccfbf7",
|
||||
"songs/autumn_leaves_electric_improv.pages/0001.png": "ea87300c5d8e0bcb706f2475c884678e850531b7ddf453b8566ba45c612b889e",
|
||||
"songs/autumn_leaves_electric_improv.pages/0002.png": "051112d56df6754e84ed3417dfb9549a9351c217ae31d34bfa459b6fbc8e08aa",
|
||||
"songs/autumn_leaves_electric_improv.pages/0003.png": "f5c72081b94bdd88934c10e9ae56738d5b461f761985693e96e72113e98e7679",
|
||||
"songs/autumn_leaves_electric_improv.pages/0004.png": "ebc3e3861e883ffda6868aa55900dbfa4eac5d3b495c5ac8dff5fec312445102",
|
||||
"songs/autumn_leaves_electric_improv.pages/0005.png": "680eff0386feb5cf91f662987610a8edb6fbbee9db67bb1701cfa108ca223c00",
|
||||
"songs/autumn_leaves_electric_improv.pages/0006.png": "309224e6d3147cdd43b22256550cb3935a4dc1ed282052c78518be3c3fefb695",
|
||||
"songs/autumn_leaves_electric_improv.pages/0007.png": "550d260e0c0e18e32459e0f9be85ba4f123b225ef79ef7813f4ce9f3d50fac7f",
|
||||
"songs/autumn_leaves_electric_improv.pages/0008.png": "783442fd6a94f595709170e35431f7ad39454ed5a526eaab58617b11bdd9bed1",
|
||||
"songs/autumn_leaves_electric_improv.pages/0009.png": "a937b80c08fcf163021a15013a317f797f1fbb4fc4c4b2959752a3703de0938e",
|
||||
"songs/autumn_leaves_electric_improv.pages/0010.png": "e9d3d3a55e2629e5c529901c3fc22c120e809966d721a84a285418ed312575f3",
|
||||
"songs/autumn_leaves_electric_improv.pages/0011.png": "ab9d4498690ff12d47a9b0b73301eebf481e87215173a7744bb9073a5169abbc",
|
||||
"songs/autumn_leaves_electric_improv.pages/0012.png": "3b2c3d1db73b73bb9b07fae7eea73702b0308c5d2e21e340546ed0267fa7f1f3",
|
||||
"songs/autumn_leaves_electric_improv.pages/0013.png": "43a4714dfd26490fa7d839529e5e53f3183ecc82a2a7e60c72851e032d91a53d",
|
||||
"songs/autumn_leaves_electric_improv.pages/0014.png": "ea9dd92ddfc7f152f9bbe84390fa4b68029e7ddb5a5aabfccd19da3a70eeb876",
|
||||
"songs/autumn_leaves_electric_improv.pages/0015.png": "425675e1e9c470b3d404fa439f4aeb3015f69db438c8a0fd1c1340959f50c20b",
|
||||
"songs/autumn_leaves_electric_improv.pages/0016.png": "ab3dfc09ae234286915f85112ff393706490ead8513045c004267894f4993837",
|
||||
"songs/autumn_leaves_electric_improv.pages/0017.png": "a934f1c8b8b0b1c2ef5e8491efc1395e7097353a48e11b2ae0d43d848cfd2672",
|
||||
"songs/autumn_leaves_electric_improv.pages/0018.png": "7d4397525fd07c9646277059870e12b323cc39482ad148bf3f780eeb6168df65",
|
||||
"songs/autumn_leaves_electric_improv.pages/0019.png": "167db0cc5ae709ae31bf51e25e8873010bcdd2e03859944400ec66096102e7ac",
|
||||
"songs/autumn_leaves_electric_improv.pages/0020.png": "e0a3fcd34364177ba8fe68c2f5112193a2994af4fe7b8b6c624d804b27e37ff7",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0002.png": "073c0218f2e1a43f5d01eafe623a637f990f59ea74d8b3b07dd09d53ca21912a",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0003.png": "bfc2642b4e266165bd160fa2010d50f86e4c31c85373be691336a6dd9cbfb0ec",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0004.png": "86bfdce07b4da9f5a17a2ef5adcf9c54f15b9c40bff14b45921cf048a5b002df",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0005.png": "a62f8e15f45311ba8cf8e8082e3aa783e4ce198adba7e57b95f676b4857f922c",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0006.png": "81fe69c2e45c03affe092750cb098ed2a2054c39fe0d31135a478592f8db53e0",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0007.png": "fc9d82afdb05db20b055d6d019c74d5a3850dff823de16f3eb6fa50ea3c6f126",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0008.png": "dcd7808b47ead0f16a51a08b21781e79d1c5dc0e7b0465f88ebb46f2b57d37db",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0009.png": "aa8a820c75c3f9c1c0a679c9ae85fb1cde3f8c85559afd24c91bb6de460af7f2",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0010.png": "96b113c3bfdc9fe04c5c22eac967fb12f4cc1be0bbb9c689673cd3d146ab1e20",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0011.png": "0f4d547396506cbdcf1ab83bfcf586f04edcc991607f13636f719b6405db9bea",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0012.png": "be7a617464ba40a46194b1b1cd78c5c18ed6cc6ddb48a796406ee9d0c969ae00",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0013.png": "78f06a0ef42d36174f766646de81e2cb810e7a4c88a8302254cbee8790c634d0",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0014.png": "842b748a992f221f8d5836f2e4fc90d547b3d91421c29318298070c8f432263c",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0015.png": "8840485be64bd1411da40aa26f89521b8b0f017c226410196025851980f09a9e",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0016.png": "6c3b974dcb16be0539551469da6a8e616756a7438fa7389b54e2a09a1098695e",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0017.png": "bf04cc2321a764a6ee755b49f4dc59ab5adb7ab0625f036d367123bfa6bf44fe",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0018.png": "16b4faf44c794adf34158c999c8d10eec7bbd05393a802f005365ce2403b7455",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0019.png": "3806622991cf8eba1f4b74b287866252af9fd3aa87e7360ce4edab50edff0ca3",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0020.png": "eddf1ce73b2367c5a00d0429977f0a5bb4b38048aaca3627f0d0ac5d559eec13",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0021.png": "74416b6187127c07b7f8f0fdb16f98ffb235e61a8d661e4fba294d294346f0b8",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0022.png": "21dc73ac8e46538fc3d1df1b1fd7691f30395934460b043c51ee64a63e3d1cf6",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0023.png": "e16a4a547541b41597108297951790fc642a5e91028028b6e32ce18e03b5d0d7",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0024.png": "ccb1e63b301882d5c2eed9a8ce1e4978e25ad790a32ffd56c433c1d8964cbc44",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0025.png": "23aba3a66b1631d92275fdc3124c456504917ba9da239c81c000abb9d7af090a",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0026.png": "7bc2cf14428c6d9a137840bf423de648b2f0b5505ce1c7a8b61791e167d66d56",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0027.png": "e0ab8c074bd0286744106a5e6fff11b9b24a4aae4a4822414ae38bcb84b0f845",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0028.png": "1099b56128019d5b637f23e04336553c9a0af6b686bf9c8a41244b81891cd01c",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0029.png": "a574680a3659a3a0f2a461f6d296a5d4e138579ac9cedc3f2423426519da75b5",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0030.png": "36df93cd562a2452f1035c1243eaecdadd8853318ee5694d31780900564243a5",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0031.png": "bfe81e7c69459d19bf5da1b44ca270a9f284e58250d3a7a440daaf9b145e5bfd",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0032.png": "ea49ab07e94cc0477ef29b804048458532e1a4f42c545f862cf2b9ade40fd098",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0033.png": "766abf245d2c92659df00fbb17e3e2392fbfa4d7064bdbc1ca5d0c71073bbff4",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0034.png": "f2e90f61126a6e1ae61d834aa8bd4b0463fb4981ffe2ac3fc214b8638417d36f",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0035.png": "78f87a74619a50bd40c7bad4fa2522cb74d815fe44a9c7e2987e790284fa8631",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0036.png": "6a0edfc9448247de68b9700f20889f75cbf3f0ca553a06d975e672679fe59dca",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0037.png": "855d996c7fb4616ca240698c1ab9f90c02971bf26279af38f2ff594e7d2ddf40",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0038.png": "c01d3874dbb703e33e3f1e4ca8d5edb01e08c53314499813d9e04ba16054c74e",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0039.png": "158c0ca1c6df28d4e0842a63b51258b351935a0a5e0bfd7eb201b4791b1e1f80",
|
||||
"songs/autumn_leaves_electric_improv.pages/playback-0040.png": "3b4331d2b0d31583b800405506e188695a2e14aaf8a1dd2409e0eeb2b2651d91",
|
||||
"songs/autumn_leaves_vocal.json": "b0e22bf88d75521bdc4bc8acbbd31440aae79e38b6d84c4048dd729fce82ed5f",
|
||||
"songs/autumn_leaves_vocal.pages/0001.png": "4b9402563b8ae88a34f25482d5db3d5279643bed60731c7baac69a030b3ce2d9",
|
||||
"songs/autumn_leaves_vocal.pages/0002.png": "b961fb05e5539ce4335b561df9fc9558b81574872a35956da0beb192a8db258e",
|
||||
"songs/autumn_leaves_vocal.pages/0003.png": "a3e5423f083d8159e52dd4f1fa1532b982a4290dc3db75921a120fa3befb5c7d",
|
||||
"songs/autumn_leaves_vocal.pages/0004.png": "9aecc0d1c84bdebdf214c99469ef2fbc7a8e625471b8b06e26c3267dea1dfc42",
|
||||
"songs/autumn_leaves_vocal.pages/0005.png": "80f3956a196ef56b01647c7daa0d58989c12f04b2ddd8c166cedaf846c1d0f18",
|
||||
"songs/autumn_leaves_vocal.pages/0006.png": "56e0fcb5faf8c5bd514efd6568284d6548708b4fed7d470c138d9478db34eb77",
|
||||
"songs/autumn_leaves_vocal.pages/0007.png": "81b80d0a70883a69e10b8f06fd2c2f904670dc3a1cae13b980d239ae4a882e46",
|
||||
"songs/autumn_leaves_vocal.pages/0008.png": "d55633751f78440bd94f1dbbed846c151cf79bebc5fa4b0f338add0c4b6d2e99",
|
||||
"songs/autumn_leaves_vocal.pages/0009.png": "2da18b1db70f1cff719f697c39ecda329dbc60d3fe0d605351fcae8583e6901a",
|
||||
"songs/autumn_leaves_vocal.pages/0010.png": "321d18c54e138248b31a00df6e3a95b9af62588a227a83e56db7475acb923d7a",
|
||||
"songs/autumn_leaves_vocal.pages/0011.png": "c02c41a2ff2b14ec5bde44f22bec03fd71edf2d9e272d2ef0a8434b843a26b6b",
|
||||
"songs/autumn_leaves_vocal.pages/0012.png": "ac4bf6573e196d5afbb0049bcc54836a7acac6a1e9e8cce48dedad203f4b43e0",
|
||||
"songs/autumn_leaves_vocal.pages/0013.png": "6c22d2dde54e4e7d6cd4cef815ff4e4a9738cd48707f344e90e75737939f68ec",
|
||||
"songs/autumn_leaves_vocal.pages/0014.png": "3b602afdbb2705281dc2dc5120427105649c5df1692155540c9ad9adae3621c8",
|
||||
"songs/autumn_leaves_vocal.pages/0015.png": "59339718670a3c8d97b6efb7acc3e7234cb26b11e135da9f502689c77824be8a",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0002.png": "c95138bff07d8dae52535a96bb33cc3a31c6509d7da49c60c35a55f2a1072c4c",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0003.png": "fb030bc5b0aa831b534223b63b07cbadd7ab76ce9376c3bde8965f09e73d41f8",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0004.png": "250262570aac9adaff71ddde21a61c65550afb46849016dcd5162cfcedb522da",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0005.png": "4832b81482f73003ec7e2990f1558ac8d4cc62883ad1c01d8e78837a37a6de6c",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0006.png": "0b753c25706437aa8b716f9a254e5dfc03c0c441dd2dfc4f038c3fba7528dd19",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0007.png": "9f9cfa20a8dfa15cd5ac9e0c47f5d6c0632177702229d5f5c7566c315aa99459",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0008.png": "d525a0af7688927b7cb5f14302ab7151b9e2d0dd83d04d4b53ac7414f8c928e4",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0009.png": "f6dbfeca5e20dcf475f8da0285632b317759e1d2d4d69a54a4232d853db68004",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0010.png": "b54d909532c99f359dded5fc2cb11f93309bd8a158b500ee3be524338e471f48",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0011.png": "619b4621c5ade46fd29e5e70f08c8d421e1916e0578fa2979bcb4a8204dd99d7",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0012.png": "4472606d66ca6d1a6d490e89f127327e121331eba14c89b1f20e108ed63cb2af",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0013.png": "525be592c83a6a119c1b2bc8f0eba24de0bac7ac66c56899f10921e959ae7290",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0014.png": "8f792dd41bad096867edf9e51fcbe5e6773db1256e849cc32b9402aed59ca991",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0015.png": "2767f2d9979040258f3a619274e6f11fc8e750b7dc3f0d34adbf373455c08200",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0016.png": "8a61095e966c67c35df99da5fe32d0bb710c9e0443f135585593100a3b76a8bd",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0017.png": "234f9045d2cc31fc73f1c11bed69641acc5887ac794d051760ba041df5279b8f",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0018.png": "6d3156bc58fbcd88abc9c5b18e41ab62a7fece8996655c6723a52501869a60a3",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0019.png": "9851262495f7b0916982a8f46421948068d4208af9ec1d7ff22fd38a80860d24",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0020.png": "03026a3ab967ac2c963336c475f9ecb3033c799ae28d9e832a2e9d3ac5367c99",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0021.png": "d72cd7ed7c58cd4fd44372f6dfcef7e91b84b3c74f747bf1fc84a2c81c44da36",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0022.png": "46ff7fd7202c1378a9598982379d4d7322f5de934280a78eb5cd76a14f6fad35",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0023.png": "406830c4e11ecc52774bf67351a2d6c3410f2606df206c43cafa44c5beed42cf",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0024.png": "072dda94b8a251c775663f81b4b264edac9bdb64c67d115fd87ae469eb7b812f",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0025.png": "b98a8b5879620dfe0bccd063f8d6122fc6826b86b11d07da3aa7abb8a07f265d",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0026.png": "476255d5814bba2f838f9addc852f080408c24fa3d1c2b549c93da3d59bc11bb",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0027.png": "0678a2cf026519a06e62c8ef3599a429290092968785e71431584634bba26375",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0028.png": "d89db0cd8a2435a978a2e11b070c7aaacb3f791542aed8da4b3e17af888ceee5",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0029.png": "b8ae37ad031080e1a70f8113123e6706455798e51e8dae87913381f846661e08",
|
||||
"songs/autumn_leaves_vocal.pages/playback-0030.png": "c04d59f0f791b9d813ba659fdb4bd616b649afca3a79b9ed437781f2301f04d8",
|
||||
"sounds/beat1.wav": "634e73055b4ae05857de54c0cff0bee2ac4476dcde874b474b192512ba113a39",
|
||||
"sounds/beat2.wav": "9bc510822228479fe4d4fd85be0d3b2f9eac009449f7fd6f916dd54661124c34"
|
||||
}
|
||||
+7728
File diff suppressed because it is too large
Load Diff
Vendored
+16725
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
"""Independent exhaustive oracle for small grip/hand placement examples."""
|
||||
import itertools
|
||||
|
||||
def regions(shape):
|
||||
return tuple(h for h in range(1, 13) if all(h <= p.fret <= h + 3 for p in shape)) if shape else tuple([0])
|
||||
|
||||
def movement(a, b, ha, hb, acoustic=False):
|
||||
if not a or not b:
|
||||
return 0, 0
|
||||
if len(a) == len(b) == 1:
|
||||
pairs, unmatched = [(a[0], b[0])], 0
|
||||
else:
|
||||
|
||||
def voices(shape):
|
||||
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
|
||||
|
||||
x, y = voices(a), voices(b)
|
||||
pairs = [(x[s], y[s]) for s in x.keys() & y.keys()]
|
||||
unmatched = len(x.keys() ^ y.keys())
|
||||
return abs(ha - hb), unmatched + sum(abs((p.fret - ha) - (q.fret - hb)) + abs(p.string_number - q.string_number) for p, q in pairs)
|
||||
|
||||
def cost(path, costs, indices, *, acoustic=False, left=(), right=(), close=False, hands=None, left_hand=None, right_hand=None):
|
||||
travel, peak, effort = indices
|
||||
base = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(len(costs[0][path[0]]))]
|
||||
results = []
|
||||
for positions in [hands] if hands is not None else itertools.product(*(regions(v) for v in path)):
|
||||
for lh in [left_hand] if left_hand is not None else regions(left):
|
||||
for rh in [positions[0]] if close else [right_hand] if right_hand is not None else regions(right):
|
||||
points = [(left, lh), *zip(path, positions), (path[0] if close else right, rh)]
|
||||
total = base.copy()
|
||||
total[peak] = max(h for _, h in points)
|
||||
for (a, ha), (b, hb) in zip(points, points[1:]):
|
||||
shift, ergonomics = movement(a, b, ha, hb, acoustic)
|
||||
total[travel] += shift
|
||||
total[effort] += ergonomics
|
||||
results.append(tuple(total))
|
||||
return min(results)
|
||||
@@ -11,7 +11,6 @@ import generator.harmonic_time
|
||||
import generator.solo.types
|
||||
import generator.solo.placement
|
||||
import generator.solo.profiles
|
||||
import generator.solo.decoder
|
||||
import generator.solo.learned
|
||||
import generator.solo.harmonic
|
||||
import dataclasses
|
||||
@@ -45,6 +44,6 @@ def install(test):
|
||||
test.enterContext(unittest.mock.patch('generator.production.generate_tree', side_effect=serial))
|
||||
test.enterContext(unittest.mock.patch('generator.vocabulary.load', return_value=resources))
|
||||
test.enterContext(unittest.mock.patch('generator.solo.authored.lookup', return_value=None))
|
||||
test.enterContext(unittest.mock.patch('generator.production._solo_model', return_value=predictor))
|
||||
test.enterContext(unittest.mock.patch('generator.production._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
|
||||
test.enterContext(unittest.mock.patch('generator.composition._solo_model', return_value=predictor))
|
||||
test.enterContext(unittest.mock.patch('generator.composition._resource_key', return_value={'model': 'synthetic infrastructure fixture'}))
|
||||
test.enterContext(unittest.mock.patch('generator.solo.composer.compose', side_effect=compose))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import copy, fractions
|
||||
import generator.model, generator.parts, generator.harmony
|
||||
import generator.solo.analysis, generator.solo.composer, generator.solo.types
|
||||
import generator.source, generator.solo.composer, generator.solo.types
|
||||
import tests.test_musicxml
|
||||
import generator.harmonic_time
|
||||
|
||||
@@ -20,7 +20,7 @@ def song(meter=4, count=6, chords=('Dm7', 'G7', 'CM7')):
|
||||
return generator.parts.prepare_document(document)
|
||||
|
||||
def compose(document, part='acoustic', cutoff=None):
|
||||
analysis = generator.solo.analysis.analyze(document)
|
||||
analysis = generator.source.analyze(document)
|
||||
measures = document['staves'][0]['measures']
|
||||
end = sum(generator.model.fraction(m['duration']) for m in measures) if cutoff is None else cutoff
|
||||
return generator.solo.composer.compose(analysis, measures, part, end)
|
||||
|
||||
+9
-3
@@ -1,3 +1,4 @@
|
||||
#include <vamp_zero/menu.hpp>
|
||||
#include <test.hpp>
|
||||
#include "catalog_fixture.hpp"
|
||||
|
||||
@@ -157,7 +158,12 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
BOOST_TEST(state.audio.metronome_enabled);
|
||||
BOOST_TEST(static_cast<std::int32_t>(state.mode) == static_cast<std::int32_t>(vamp_zero::app_mode::SETTINGS_MENU));
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_settings_index == 3U);
|
||||
BOOST_TEST(state.selected_settings_index == vamp_zero::settings_row::solos);
|
||||
BOOST_TEST(state.solos_enabled);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(!state.solos_enabled);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
BOOST_TEST(state.selected_settings_index == vamp_zero::settings_row::tempo);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT);
|
||||
BOOST_TEST(state.audio.tempo_editing);
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
@@ -179,7 +185,7 @@ BOOST_AUTO_TEST_CASE(settings_menu_toggles_audio_settings_and_edits_tempo) {
|
||||
BOOST_AUTO_TEST_CASE(remote_display_reports_settings_without_a_catalog_dump) {
|
||||
auto state = navigation_state();
|
||||
state.mode = vamp_zero::app_mode::SETTINGS_MENU;
|
||||
state.selected_settings_index = 3U;
|
||||
state.selected_settings_index = vamp_zero::settings_row::tempo;
|
||||
state.audio.backing_enabled = true;
|
||||
state.audio.metronome_enabled = true;
|
||||
state.audio.tempo_bpm = 110;
|
||||
@@ -291,7 +297,7 @@ BOOST_AUTO_TEST_CASE(database_reload_preserves_all_generated_song_parts) {
|
||||
auto expected = std::size_t{};
|
||||
for (auto const &entry : std::filesystem::directory_iterator{std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "data/songs"}) {
|
||||
if (entry.is_regular_file() && entry.path().extension() == ".xml") {
|
||||
expected += 3U;
|
||||
expected += 5U;
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(expected > 0U);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.generate
|
||||
import generator.engraving.fonts
|
||||
@@ -41,19 +41,19 @@ def acoustic_document(bars):
|
||||
for note in notes:
|
||||
note.update(voice=lane, stem='down' if lane == 'bass' else 'up')
|
||||
measure['voices'].append({'id': lane, 'notes': notes})
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
return document
|
||||
|
||||
class AcousticTests(unittest.TestCase):
|
||||
def combine(self, document):
|
||||
generator.acoustic.prepare(document)
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
prepared = copy.deepcopy(document)
|
||||
generator.acoustic.prepare(document)
|
||||
self.assertEqual(document, prepared)
|
||||
before = sounding(document)
|
||||
generator.acoustic.combine(document)
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
self.assertEqual(sounding(document), before)
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
|
||||
for measure in document['staves'][0]['measures']:
|
||||
@@ -130,7 +130,7 @@ class AcousticTests(unittest.TestCase):
|
||||
separate = builder(source, 'acoustic')
|
||||
combined = builder(source, 'acoustic')
|
||||
self.assertEqual(sounding(separate), sounding(combined))
|
||||
generator.model.validate_document(combined)
|
||||
generator.validation.validate_document(combined)
|
||||
for measure in combined['staves'][0]['measures']:
|
||||
self.assertIn(len(measure['voices']), (1, 2))
|
||||
if len(measure['voices']) == 2:
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Acoustic attack assignment across musical, rather than storage, boundaries."""
|
||||
import generator.validation
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import unittest
|
||||
|
||||
import generator.acoustic
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
class AcousticPairs(unittest.TestCase):
|
||||
def assign(self, onsets, durations=None, harmony=None, breaks=()):
|
||||
events = tuple(generator.comping_rhythm.Attack('acoustic', F(a), F(a) + F(d)) for a, d in zip(onsets, durations or ['1/2'] * len(onsets)))
|
||||
harmony = harmony or [(F(0), F(40), generator.harmony.parse_symbol('C7'))]
|
||||
return generator.comping_rhythm.assign_acoustic(events, harmony, breaks)
|
||||
def test_unpaired_and_nonoverlapping_pairs(self):
|
||||
for onsets, lanes in (
|
||||
(('0', '1', '2'), ('combined',) * 3),
|
||||
(('0', '1/2'), ('bass', 'upper')),
|
||||
(('0', '1/2', '1'), ('bass', 'upper', 'combined')),
|
||||
(('0', '1/2', '1', '3/2'), ('bass', 'upper', 'bass', 'upper')),
|
||||
(('0', '1/2', '1', '3/2', '2'), ('bass', 'upper', 'bass', 'upper', 'combined')),
|
||||
(('0', '1', '3/2', '3'), ('combined', 'bass', 'upper', 'combined'))):
|
||||
with self.subTest(onsets=onsets):
|
||||
events = self.assign(onsets)
|
||||
self.assertEqual(tuple(e.lane for e in events), lanes)
|
||||
self.assertEqual(tuple(e.start for e in events), tuple(map(F, onsets)))
|
||||
def test_pairing_uses_attack_spacing_not_written_duration(self):
|
||||
events = self.assign(('0', '1/2'), ('1', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(3, 2))])
|
||||
self.assertEqual(events[0].end, events[1].start)
|
||||
def test_each_attack_releases_previous_sound_in_both_directions(self):
|
||||
events = self.assign(('0', '1/2', '1'), ('2', '2', '1'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('combined', F(2))])
|
||||
events = self.assign(('0', '1/2', '1', '3/2'), ('2', '2', '2', '2'))
|
||||
self.assertEqual([(e.lane, e.end) for e in events], [('bass', F(1, 2)), ('upper', F(1)), ('bass', F(3, 2)), ('upper', F(7, 2))])
|
||||
events = self.assign(('0', '1/2'), ('1', '3'))
|
||||
self.assertEqual(events[-1].end, F(7, 2))
|
||||
def test_chord_change_breaks_pair_but_repeated_label_does_not(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', 'C7'))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [{'onset': a, 'duration': '1/2', 'layer': 'acoustic'} for a in ('7/2', '4')]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
flat['harmonies'][-1].update(generator.harmony.parse_symbol('FM7'))
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([e.lane for e in events], ['combined', 'combined'])
|
||||
def test_pairing_at_barline_in_each_supported_meter(self):
|
||||
for beats, grouping in ((3, ()), (4, ()), (5, (3, 2)), (5, (2, 3))):
|
||||
with self.subTest(beats=beats, grouping=grouping):
|
||||
source = tests.solo_fixtures.song(count=2, meter=beats, chords=('C7',))
|
||||
measures = source['staves'][0]['measures']
|
||||
if grouping:
|
||||
for m in measures:
|
||||
m['meter']['groups'][0]['beats'] = list(grouping)
|
||||
m['time_signature'] = generator.model.meter_label(m['meter'])
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(measures)))
|
||||
pattern = {'lengths': [str(beats)] * 2, 'events': [
|
||||
{'onset': str(t), 'duration': '1', 'layer': 'acoustic'} for t in (F(beats) - F(1, 2), F(beats))]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([e.lane for e in events], ['bass', 'upper'])
|
||||
self.assertEqual(events[0].end, F(beats))
|
||||
def test_release_rebinds_expired_anticipation_to_direct_harmony(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '7/2', 'duration': '1', 'layer': 'acoustic', 'anticipate': True},
|
||||
{'onset': '4', 'duration': '1', 'layer': 'acoustic'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual([e.lane for e in events], ['combined', 'combined'])
|
||||
self.assertIsNone(events[0].harmonic_time)
|
||||
self.assertEqual(events[0].end, 4)
|
||||
# A participation break prevents pairing; an expired anticipation must
|
||||
# revert to the current harmony rather than keep an unresolved label.
|
||||
raw, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True, assign=False)
|
||||
broken = generator.comping_rhythm.assign_acoustic(raw, generator.comping_rhythm.spans(flat, None), (F(4),))
|
||||
self.assertEqual([e.lane for e in broken], ['combined', 'combined'])
|
||||
self.assertIsNone(broken[0].harmonic_time)
|
||||
self.assertEqual(broken[0].end, 4)
|
||||
# With no intervening attack the bounded anticipated hold stays intact.
|
||||
pattern['events'].pop()
|
||||
held, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual(held[0].harmonic_time, 4)
|
||||
self.assertEqual(held[0].end, F(9, 2))
|
||||
def resources(self):
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': 'test.cross_block', 'family': 'quarters', 'lengths': ['4', '4'],
|
||||
'events': [{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '1/2', '2', '15/2')]}]
|
||||
return resources
|
||||
def test_pattern_boundary_carries_pair_and_odd_leftover(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'cross-block')
|
||||
self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'bass')
|
||||
self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['upper', 'combined'])
|
||||
measures = source['staves'][0]['measures']
|
||||
rendered = [canonical(m, None, i) for i, m in enumerate(measures)]
|
||||
source['staves'][0]['measures'] = rendered
|
||||
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
|
||||
generator.acoustic.combine(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
generator.acoustic.validate_comping(source)
|
||||
attacks = [n for n in generator.score.measure_notes(rendered[2]) if n['pitches']]
|
||||
strings = [{p['tablature']['string'] for p in n['pitches']} for n in attacks[:2]]
|
||||
self.assertEqual(strings[0], {2, 3, 4})
|
||||
self.assertIn(strings[1], ({2, 3, 4, 5}, {2, 3, 4, 6}))
|
||||
def test_entry_and_handoff_break_pairs_without_restarting_pattern(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'entry')
|
||||
measures = source['staves'][0]['measures']
|
||||
entry = canonical.acoustic_realizations(measures, (2, 8))
|
||||
self.assertEqual(entry[0].events[-1].lane, 'combined')
|
||||
self.assertEqual([e.lane for e in entry[2].events[:2]], ['bass', 'upper'])
|
||||
stop = canonical.acoustic_realizations(measures, stop=(0, F(1, 2)))
|
||||
self.assertEqual([(e.start, e.lane) for e in stop[0].events], [(F(0), 'combined')])
|
||||
repeated = canonical.acoustic_realizations(measures)
|
||||
self.assertEqual(repeated[2].events, canonical.block_rhythms[2].events)
|
||||
def test_source_defined_partial_entry_is_consistent_in_every_iteration(self):
|
||||
source = tests.solo_fixtures.song(count=10, chords=('C7',))
|
||||
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'partial')
|
||||
# The source-defined eight-bar introduction starts at measure index two.
|
||||
self.assertEqual(canonical.block_rhythms[0].events[-1].lane, 'combined')
|
||||
self.assertEqual([e.lane for e in canonical.block_rhythms[2].events[:2]], ['bass', 'upper'])
|
||||
measures = source['staves'][0]['measures']
|
||||
self.assertEqual(canonical(measures[2], None, 2), canonical(measures[2], None, 2, bounds=(2, 10)))
|
||||
def test_neighbor_harmony_change_invalidates_cached_pair(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
with generator.vocabulary.using(self.resources(), 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'neighbor')
|
||||
measures = source['staves'][0]['measures']
|
||||
before = canonical(measures[1], None, 1)
|
||||
changed = copy.deepcopy(measures)
|
||||
changed[2]['harmonies'][0].update(generator.harmony.parse_symbol('FM7'))
|
||||
after = canonical(changed[1], None, 1, sources=changed)
|
||||
def last_lanes(m):
|
||||
return {v['id'] for v in m['voices'] for n in v['notes'] if n['pitches'] and generator.model.fraction(n['onset']) == F(7, 2)}
|
||||
self.assertEqual(last_lanes(before), {'bass'})
|
||||
self.assertEqual(last_lanes(after), {'bass', 'upper'})
|
||||
def test_assignment_is_independent_of_computational_partition(self):
|
||||
onsets = tuple(map(F, ('0', '1/2', '7/2', '4', '9/2', '15/2', '8')))
|
||||
full = self.assign(onsets)
|
||||
events = [generator.comping_rhythm.Attack('acoustic', a, a + F(1, 2)) for a in onsets]
|
||||
harmony = ((F(0), F(20), generator.harmony.parse_symbol('C7')),)
|
||||
for cut in (F(4), F(8)):
|
||||
parts = [[e for e in events if e.start < cut], [dataclasses.replace(e, start=e.start - cut, end=e.end - cut) for e in events if e.start >= cut]]
|
||||
restored = (*parts[0], *(dataclasses.replace(e, start=e.start + cut, end=e.end + cut) for e in parts[1]))
|
||||
self.assertEqual(full, generator.comping_rhythm.assign_acoustic(restored, harmony))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,3 +1,5 @@
|
||||
import generator.hand_position
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
@@ -7,13 +9,12 @@ import unittest
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import tests.test_acoustic
|
||||
import tests.test_accompaniment
|
||||
import tests.position_fixtures
|
||||
|
||||
def planned_document(harmonies, attacks=None):
|
||||
if attacks is None:
|
||||
@@ -79,7 +80,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
self.assertIn(strings, ([5], [6]))
|
||||
self.assertFalse(any(k.startswith('_') for k in note))
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in note['pitches']))
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
return spans
|
||||
def test_independent_attacks_keep_one_complete_voicing(self):
|
||||
document = planned_document([[(0, 'FM7')]])
|
||||
@@ -89,7 +90,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
before = tests.test_acoustic.sounding(document)
|
||||
generator.acoustic.combine(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
def test_four_strings_allow_all_defined_bass_inversions(self):
|
||||
for symbol in ('CM', 'Dm', 'Caug', 'Cdim', 'C7', 'Cdim7', 'C7no5', 'C7b9', 'C7no1', 'C6', 'C13', 'Csus', 'Csus2'):
|
||||
with self.subTest(chord=symbol):
|
||||
@@ -174,19 +175,15 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
|
||||
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
|
||||
self.assertEqual(*selected)
|
||||
def test_bass_switch_counts_both_string_and_fret_travel(self):
|
||||
def test_bass_switch_and_finger_changes_do_not_move_the_hand(self):
|
||||
previous = voicing((7, 8, 7, 8))
|
||||
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
|
||||
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
|
||||
for current in (voicing((7, 8, 7, 8), 6), voicing((8, 8, 7, 8), 6), voicing((9, 8, 7, 8), 6), voicing((6, 8, 7, 8))):
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, current, 6, 6), 0)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(previous, previous, 5, 7), 2)
|
||||
def test_each_upper_string_is_tracked_as_secondary_finger_effort(self):
|
||||
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
|
||||
for index in (1, 2, 3):
|
||||
frets = [5, 7, 5, 7]
|
||||
frets[index] += 1
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, after, 5, 5), 0)
|
||||
self.assertEqual(generator.hand_position.motion(before, after, 5, 5, True).fingers, 8)
|
||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
with self.subTest(frets=frets):
|
||||
@@ -206,7 +203,8 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
||||
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
|
||||
self.assertTrue(all(p.cost.travel == 0 for p in result))
|
||||
self.assertEqual({(p.voicing, p.hand, p.cost.peak_position) for p in result}, {(v, h, h) for v in options for h in tests.position_fixtures.regions(v)})
|
||||
def test_optional_tones_break_equal_movement_costs_only(self):
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
@@ -218,13 +216,16 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
|
||||
harmonies = ['FM7', 'Gm7', 'C7']
|
||||
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
|
||||
options = [candidates(s) for s in harmonies]
|
||||
options = [candidates(s)[:4] for s in harmonies]
|
||||
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
||||
costs = [{v: generator.acoustic_voicing.Cost(extended_grips=generator.fretboard.span_penalty(v), missing_tones=len(set(r.allowed) - {p.midi % 12 for p in v})) for v in layer} for r, layer in zip(requirements, options)]
|
||||
def cost(path):
|
||||
return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
|
||||
selected = generator.acoustic_voicing.select(spans)
|
||||
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
|
||||
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
|
||||
return tests.position_fixtures.cost(path, costs, (1, 2, 3), acoustic=True)
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
for layer, r in zip(options, requirements):
|
||||
states = generator.acoustic_voicing.advance(layer, r, states)
|
||||
self.assertEqual(min(s.cost for s in states), min(cost(path) for path in itertools.product(*options)))
|
||||
self.assertEqual(generator.acoustic_voicing.select(spans), generator.acoustic_voicing.select(spans))
|
||||
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
|
||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
|
||||
with self.subTest(chord=symbol):
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""The held recording tail has exact score time and no vocal attacks."""
|
||||
import generator.arrange.form
|
||||
import generator.composition
|
||||
import generator.source
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.acoustic
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.generate
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
@@ -13,7 +16,6 @@ import generator.model
|
||||
import generator.musicxml
|
||||
import generator.parts
|
||||
import generator.performance
|
||||
import generator.production
|
||||
import generator.score
|
||||
import tests.solo_fixtures
|
||||
|
||||
@@ -45,7 +47,7 @@ class ArrangementEnding(unittest.TestCase):
|
||||
self.assertEqual(generator.model.fraction(timing['sustain_start']), 672)
|
||||
self.assertEqual(generator.model.fraction(timing['duration']), 684)
|
||||
self.assertEqual(len(timing['clicks']), 672)
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
self.assertEqual(source, before)
|
||||
def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self):
|
||||
@@ -54,10 +56,10 @@ class ArrangementEnding(unittest.TestCase):
|
||||
prepared = generator.parts.prepare_document(source)
|
||||
for part in ('acoustic', 'electric'):
|
||||
role = part + '_accompaniment'
|
||||
result = generator.production.generate_role(generator.production.SourceAnalysis.read(prepared), role, pathlib.Path('data'))
|
||||
result = generator.composition.generate_role(generator.source.SourceAnalysis.read(prepared), role, pathlib.Path('data'))
|
||||
tail = result['document']['staves'][0]['measures'][-3:]
|
||||
self.assertEqual([generator.model.fraction(m['duration']) for m in tail], [meter] * 3)
|
||||
windows = generator.production.windows(prepared, part)
|
||||
windows = generator.arrange.form.windows(prepared, part)
|
||||
self.assertEqual(windows[-1][2], role)
|
||||
self.assertEqual(windows[-1][1] - windows[-1][0], 3 * meter)
|
||||
self.assertEqual(sum(b - a for a, b, r in windows if r == part + '_solo'), 2 * meter)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
import generator.model
|
||||
@@ -29,7 +29,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
for part in generator.parts.PART_TYPES:
|
||||
with self.subTest(song=path.stem, part=part):
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 171)
|
||||
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)])
|
||||
@@ -57,7 +57,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
|
||||
with self.subTest(part=part):
|
||||
result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'})
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 9)
|
||||
for boundary in boundaries:
|
||||
@@ -84,13 +84,13 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
self.assertEqual(notes[1]['lyrics'][0]['syllables'][0]['text'], 'doo')
|
||||
self.assertEqual([generator.model.fraction(n['onset']) for n in notes], [0, 1])
|
||||
document['staves'][0]['measures'] = [destination]
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
def test_pickup_harmonies_are_planned_for_every_part_at_the_same_offset(self):
|
||||
document = source(tests.test_parts.xml_harmony('F') + tests.test_musicxml.xml_note('F', 1, 'quarter'))
|
||||
prepared = generator.parts.prepare_document(document)
|
||||
for part in generator.parts.PART_TYPES:
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
for index in (0, 1, 4):
|
||||
chords = result['staves'][0]['measures'][index]['harmonies']
|
||||
self.assertEqual([(generator.model.fraction(h['onset']), h['root']['letter']) for h in chords], [(0, 'c'), (3, 'f')])
|
||||
@@ -102,7 +102,7 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
document = source(notes, divisions=3)
|
||||
for part in ('vocal', 'electric'):
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
spans = result['staves'][0]['tuplets']
|
||||
self.assertEqual(len(spans), 2 if part == 'vocal' else 1)
|
||||
self.assertEqual(len({s['id'] for s in spans}), len(spans))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import generator.production
|
||||
import tests.production_fixtures
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import generator.arrange.generate
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
@@ -16,8 +16,8 @@ class CatalogTests(unittest.TestCase):
|
||||
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
|
||||
source.mkdir()
|
||||
(source / 'song.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
|
||||
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
|
||||
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
|
||||
self.assertEqual(generator.production.generate_tree(source, output), 5)
|
||||
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_acoustic_improv.json', 'song_electric.json', 'song_electric_improv.json', 'song_vocal.json'})
|
||||
for path in output.iterdir():
|
||||
metadata = json.loads(path.read_text())['metadata']
|
||||
self.assertEqual(metadata['key_signature'], 'Eb')
|
||||
|
||||
@@ -111,6 +111,8 @@ class CatalogTests(unittest.TestCase):
|
||||
write_score(self.root, 'songs/tune.json')
|
||||
generator.catalog.build_catalog(self.root)
|
||||
(self.root / 'songs/tune.png').write_bytes(b'page')
|
||||
(self.root / 'print').mkdir()
|
||||
(self.root / 'print/tune.pdf').write_bytes(b'build-only PDF')
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
destination = pathlib.Path(temporary)
|
||||
subprocess.run(['cmake', f'-DSOURCE={self.root}', f'-DDESTINATION={destination}', '-P', 'cmake/runtime_assets.cmake'], check=True, capture_output=True)
|
||||
@@ -120,6 +122,8 @@ class CatalogTests(unittest.TestCase):
|
||||
self.assertEqual(pathlib.Path(config['database']['path']).name, 'catalog.db')
|
||||
self.assertTrue((destination / 'songs/tune.png').exists())
|
||||
self.assertFalse(list(destination.rglob('*.json')))
|
||||
self.assertFalse((destination / 'print').exists())
|
||||
self.assertIn('PATTERN "print" EXCLUDE', pathlib.Path('cmake/zero.cmake').read_text())
|
||||
def test_menu_query_uses_index_and_preserves_binary_identity_order(self):
|
||||
for name, title, composer, part in (('z', 'Same', 'Bea', 'Acoustic'), ('b', 'Same', 'Ada', 'Vocal'), ('a', 'Same', 'Ada', 'Acoustic'), ('c', 'same', 'Ada', 'Electric'), ('d', 'Same', 'Ada', 'Acoustic')):
|
||||
document = bundle(title=title, part=part)
|
||||
@@ -141,3 +145,28 @@ class CatalogTests(unittest.TestCase):
|
||||
self.assertFalse(connection.in_transaction)
|
||||
self.assertEqual(connection.execute("SELECT name FROM sqlite_schema").fetchall(), [])
|
||||
self.assertEqual(connection.execute('PRAGMA application_id').fetchone()[0], 0)
|
||||
|
||||
def test_solo_variant_metadata_and_required_pairs(self):
|
||||
for enabled in (True, False):
|
||||
document = bundle(part='Acoustic')
|
||||
document['music']['metadata'].update(song_id='tune', solos_enabled=enabled)
|
||||
document['music']['staves'][0]['arranged'] = True
|
||||
write_score(self.root, 'songs/tune_acoustic' + ('' if enabled else '_improv') + '.json', document)
|
||||
generator.catalog.build_catalog(self.root)
|
||||
self.assertEqual([row['solos_enabled'] for row in self.rows('songs')], [1, 0])
|
||||
saved = (self.root / 'catalog.db').read_bytes()
|
||||
alternate = self.root / 'songs/tune_acoustic_improv.json'
|
||||
document = json.loads(alternate.read_text())
|
||||
for value in ('false', 0, None):
|
||||
document['music']['metadata']['solos_enabled'] = value
|
||||
alternate.write_text(json.dumps(document))
|
||||
with self.assertRaisesRegex(ValueError, 'boolean'):
|
||||
generator.catalog.build_catalog(self.root)
|
||||
document['music']['metadata']['solos_enabled'] = True
|
||||
alternate.write_text(json.dumps(document))
|
||||
with self.assertRaisesRegex(ValueError, 'solo variants'):
|
||||
generator.catalog.build_catalog(self.root)
|
||||
alternate.unlink()
|
||||
with self.assertRaisesRegex(ValueError, 'solo variants'):
|
||||
generator.catalog.build_catalog(self.root)
|
||||
self.assertEqual((self.root / 'catalog.db').read_bytes(), saved)
|
||||
|
||||
@@ -284,8 +284,8 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
|
||||
BOOST_REQUIRE(db.build());
|
||||
// Complete cached metadata must not require parsing or retaining the score body.
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
||||
"key_signature, measure_count, total_pages, title_image) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{\"presentation\":{\"playback_pages\":[{},{},{}]}}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
|
||||
"key_signature, measure_count, total_pages, title_image, solos_enabled) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{\"presentation\":{\"playback_pages\":[{},{},{}]}}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000', 1)");
|
||||
auto songs = db.rows("songs");
|
||||
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
|
||||
auto performance = db.performance();
|
||||
@@ -303,6 +303,31 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
|
||||
BOOST_TEST(song.error().code() == static_cast<std::int32_t>(vamp_zero::error_code::INVALID_ARGUMENT));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalog_loads_solo_variants_and_rejects_incomplete_pairs) {
|
||||
auto db = catalog_database{};
|
||||
for (auto enabled : {true, false}) {
|
||||
auto value = boost::json::parse(catalog_bundle("Variants", "C", "Acoustic"));
|
||||
value.at("music").at("metadata").as_object()["solos_enabled"] = enabled;
|
||||
value.at("music").at("staves").as_array().front().as_object()["arranged"] = true;
|
||||
db.write(enabled ? "songs/acoustic.json" : "songs/acoustic_improv.json", boost::json::serialize(value));
|
||||
}
|
||||
BOOST_REQUIRE(db.build());
|
||||
BOOST_REQUIRE(run_catalog_operation(vamp_zero::async_validate_database, db.config, true));
|
||||
auto loaded = db.performance();
|
||||
BOOST_REQUIRE(loaded);
|
||||
BOOST_REQUIRE_EQUAL(loaded->parts.size(), 2U);
|
||||
BOOST_TEST(loaded->parts[0].solos_enabled);
|
||||
BOOST_TEST(!loaded->parts[1].solos_enabled);
|
||||
auto score = db.score("acoustic_improv");
|
||||
BOOST_REQUIRE(score);
|
||||
BOOST_TEST(!boost::json::parse(score->source_json).at("music").at("metadata").at("solos_enabled").as_bool());
|
||||
db.query("DELETE FROM songs WHERE solos_enabled = 0");
|
||||
BOOST_TEST(!run_catalog_operation(vamp_zero::async_validate_database, db.config, true));
|
||||
BOOST_REQUIRE(db.build());
|
||||
db.query("ALTER TABLE songs DROP COLUMN solos_enabled");
|
||||
BOOST_TEST(!run_catalog_operation(vamp_zero::async_validate_database, db.config, false));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(catalog_validation_rejects_missing_incompatible_and_corrupt_files_without_repair) {
|
||||
auto db = catalog_database{};
|
||||
auto validate = [&] { return run_catalog_operation(vamp_zero::async_validate_database, db.config, true); };
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""General chord families and one-voice acoustic rhythm, not song overrides."""
|
||||
import generator.composition
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import unittest
|
||||
|
||||
import generator.acoustic
|
||||
import generator.accompaniment
|
||||
import generator.chord_families
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic
|
||||
@@ -79,18 +78,20 @@ class ChordFamilies(unittest.TestCase):
|
||||
return min(p.string_number for p in v if p.midi == midi)
|
||||
tier = generator.fretboard.span_penalty(choices[0])
|
||||
self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier))
|
||||
self.assertEqual(max(p.midi for p in choices[0]), midi)
|
||||
self.assertTrue(all(tuple(p.string_number for p in v) in ((3, 2, 1), (4, 3, 2, 1)) for v in choices))
|
||||
self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v))
|
||||
self.assertTrue(all(midi in {p.midi for p in v} for v in choices))
|
||||
low = tests.test_voicing.ranked('C7', (64,))[0]
|
||||
self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2))
|
||||
self.assertIn(1, {p.string_number for p in low})
|
||||
self.assertIn(64, {p.midi for p in low})
|
||||
self.assertGreater(max(p.midi for p in low), 64)
|
||||
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
|
||||
resources = generator.vocabulary.load()
|
||||
changed = copy.deepcopy(resources)
|
||||
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2'
|
||||
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
|
||||
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed))
|
||||
self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed))
|
||||
self.assertEqual(generator.composition.dependencies(role, resources), generator.composition.dependencies(role, changed))
|
||||
self.assertNotEqual(generator.composition.dependencies('acoustic_accompaniment', resources), generator.composition.dependencies('acoustic_accompaniment', changed))
|
||||
|
||||
class CombinedAcoustic(unittest.TestCase):
|
||||
def document(self, bars):
|
||||
@@ -107,7 +108,7 @@ class CombinedAcoustic(unittest.TestCase):
|
||||
def convert(self, document):
|
||||
before = tests.test_acoustic.sounding(document)
|
||||
generator.acoustic.combine(document)
|
||||
generator.model.validate_document(document)
|
||||
generator.validation.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Recurring source-position grooves, fills and chord-first bass behavior."""
|
||||
import generator.composition_types
|
||||
import generator.composition
|
||||
import generator.source
|
||||
import generator.validation
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
import multiprocessing
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.form
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
def groups(source):
|
||||
measures = source['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
return [tuple((m, carried[i], i) for i, m in enumerate(measures[a:a + 2], a)) for a in range(0, len(measures), 2)]
|
||||
|
||||
def signature(measure):
|
||||
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
|
||||
[(generator.model.pitch_number(p), p.get('tablature'), bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches']])
|
||||
for n in generator.score.measure_notes(measure)]
|
||||
|
||||
class RecurringGrooves(unittest.TestCase):
|
||||
def test_cycles_repeat_distinct_patterns_for_arbitrary_lengths(self):
|
||||
for count in (9, 18, 32):
|
||||
source = tests.solo_fixtures.song(count=count, chords=('C7',))
|
||||
for part, width in (('acoustic', 2), ('electric', 4)):
|
||||
canonical = generator.comping.Canonical(source, part, 'cycle')
|
||||
self.assertEqual(canonical.groove_report[0]['cycle_patterns'], width)
|
||||
patterns = [p for p, _ in canonical.patterns.values()]
|
||||
identities = [generator.comping_rhythm.identity(p) for p in patterns[:width]]
|
||||
self.assertEqual(len(set(identities)), width)
|
||||
for i, p in enumerate(patterns):
|
||||
self.assertEqual(generator.comping_rhythm.identity(p), identities[i % width])
|
||||
# The odd final measure is the first half of its cycle's figure.
|
||||
if count % 2:
|
||||
self.assertEqual(canonical.block_rhythms[count - 1].events,
|
||||
tuple(e for e in canonical.block_rhythms[(len(patterns) - 1) % width * 2].events if e.start < 4))
|
||||
def test_whole_form_evaluation_can_reject_locally_best_pattern(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.quarters.statement')]
|
||||
realize = generator.comping_rhythm.realize
|
||||
def evaluated(pattern, inputs, *args, **kwargs):
|
||||
candidate = realize(pattern, inputs, *args, **kwargs)
|
||||
# Charleston wins the first two bars, but loses the full eight-bar span.
|
||||
cost = (-1 if inputs[0][2] == 0 else 2) if 'charleston' in pattern['id'] else 0
|
||||
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
|
||||
selected, _, report = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'coverage')
|
||||
self.assertTrue(all(p['id'] == 'electric.quarters.statement' for p, _, _ in selected))
|
||||
self.assertEqual(report[0]['cost'], 0)
|
||||
def test_acoustic_cycle_is_ranked_after_cross_block_assignment(self):
|
||||
source = tests.solo_fixtures.song(count=4, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1', 'layer': 'acoustic'} for t in times]}
|
||||
for name, times in (('crossing', ('0', '1/2', '15/2')), ('local', ('0', '2', '6')))]
|
||||
def evaluated(events, *args):
|
||||
# Isolated retrieval prefers 'local' (0) over 'crossing' (1).
|
||||
# The complete stream permits a prepared upper entry in block two.
|
||||
cost = -3 if events[0].lane == 'upper' else 1 if events[1].start == fractions.Fraction(1, 2) else 0
|
||||
return [('test_entry', cost)]
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
selected, _, reports = generator.comping_rhythm.choose_cycles(groups(source), 'acoustic', generator.comping_rhythm.melody_contexts(source), 'complete')
|
||||
self.assertEqual([p['id'] for p, _, _ in selected], ['crossing', 'crossing'])
|
||||
self.assertEqual([r.events[0].lane for _, _, r in selected], ['bass', 'upper'])
|
||||
self.assertEqual(reports[0]['retrieval_cost'], 4)
|
||||
self.assertEqual(reports[0]['cost'], -4)
|
||||
self.assertEqual(reports[0]['cost'], sum(r.cost * 2 for _, _, r in selected))
|
||||
self.assertEqual(reports[0]['cost'], reports[0]['final_cost'])
|
||||
for _, _, rhythm in selected:
|
||||
generator.comping_rhythm.validate_events(rhythm.events, 'acoustic')
|
||||
def test_acoustic_fill_cannot_reassign_neighboring_attacks(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
source['staves'][0]['measures'][2]['section_label'] = 'Source boundary'
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [{'id': name, 'family': 'quarters', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': t, 'duration': '1/2', 'layer': 'acoustic'} for t in ('0', '2', last)]}
|
||||
for name, last in (('base', '5'), ('unsafe', '15/2'), ('safe', '7'))]
|
||||
source_groups = groups(source)
|
||||
melody = generator.comping_rhythm.melody_contexts(source)
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, melody)
|
||||
base = policy['patterns'][0]
|
||||
selected = [(base, False, generator.comping_rhythm.realize(base, g, 'acoustic', melody, assign=False)) for g in source_groups]
|
||||
locations = [{'fill': None} for _ in selected]
|
||||
original = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
def evaluated(events, part, melody, *args):
|
||||
# Only the first block has this source entrance. The preferred local
|
||||
# fill pairs with the next block's first attack, so it is ineligible.
|
||||
eligible = melody[0][0] == 0
|
||||
cost = (-2 if events[-1].start == fractions.Fraction(15, 2) else -1 if events[-1].start == 7 else 0) if eligible else 0
|
||||
return [('test_fill', cost)]
|
||||
# Distinguish source contexts without relying on the pattern or part ID.
|
||||
contexts = tuple(m if i < 2 else tuple((a + fractions.Fraction(1, 4), b) for a, b in m) for i, m in enumerate(melody))
|
||||
context = generator.comping_rhythm.acoustic_context(source_groups, contexts)
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'), unittest.mock.patch('generator.comping_rhythm.evaluate', side_effect=evaluated):
|
||||
generator.comping_rhythm.add_fills(source_groups, selected, locations, 'acoustic', contexts, 'fill', context=context)
|
||||
self.assertEqual(locations[0]['fill']['pattern'], 'base.fill.safe')
|
||||
final = generator.comping_rhythm.realize_acoustic_form([r for _, _, r in selected], context)
|
||||
self.assertEqual([r.events for r in original[1:]], [r.events for r in final[1:]])
|
||||
def test_melody_and_progression_influence_selection_with_fixed_seed(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7', 'FM7'))
|
||||
sparse = copy.deepcopy(source)
|
||||
for m in sparse['staves'][0]['measures']:
|
||||
m['voices'][0]['notes'] = [generator.model.make_event(m['id'] + '_held', fractions.Fraction(), fractions.Fraction(1, 2), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
|
||||
choices = [generator.comping_rhythm.choose_cycles(groups(s), 'electric', generator.comping_rhythm.melody_contexts(s), 'fixed')[2] for s in (source, sparse)]
|
||||
self.assertNotEqual(choices[0][0]['patterns'], choices[1][0]['patterns'])
|
||||
changed = copy.deepcopy(source)
|
||||
for m in changed['staves'][0]['measures']:
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(3, 2))})
|
||||
other = generator.comping_rhythm.choose_cycles(groups(changed), 'electric', generator.comping_rhythm.melody_contexts(changed), 'fixed')[2]
|
||||
self.assertNotEqual(choices[0][0]['cost'], other[0]['cost'])
|
||||
def test_sparse_fill_replaces_only_second_bar_and_does_not_change_phase(self):
|
||||
source = tests.solo_fixtures.song(count=32, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] in ('electric.charleston.statement', 'electric.charleston.answer')]
|
||||
# Section-ending evidence is source data. Deliberately put it at nearby
|
||||
# positions to exercise spacing, including the cyclic end/start join.
|
||||
for i in (2, 4, 12, 20, 30):
|
||||
source['staves'][0]['measures'][i]['section_label'] = 'Source section'
|
||||
realize = generator.comping_rhythm.realize
|
||||
def evaluated(pattern, inputs, *args, **kwargs):
|
||||
candidate = realize(pattern, inputs, *args, **kwargs)
|
||||
cost = -1 if '.fill.' in pattern['id'] and pattern['id'].endswith('.answer') else 0 if pattern['id'].endswith('.statement') else 1
|
||||
return dataclasses.replace(candidate, contributions=(('test_context', cost),))
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'), unittest.mock.patch('generator.comping_rhythm.realize', side_effect=evaluated):
|
||||
selected, locations, _ = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'fills')
|
||||
fills = [v['fill']['source_index'] for v in locations if v['fill']]
|
||||
self.assertGreater(len(fills), 0)
|
||||
self.assertLessEqual(len(fills), 4)
|
||||
for i, a in enumerate(fills):
|
||||
for b in fills[i + 1:]:
|
||||
self.assertGreaterEqual(min(abs(a - b), 32 - abs(a - b)), 8)
|
||||
original = policy['patterns'][0]
|
||||
for pattern, _, _ in selected:
|
||||
self.assertEqual([r for r in pattern['events'] if fractions.Fraction(r['onset']) < 4], [r for r in original['events'] if fractions.Fraction(r['onset']) < 4])
|
||||
self.assertTrue(all(v['cycle_slot'] == 0 for v in locations))
|
||||
def test_real_source_fit_can_select_a_fill_without_mocked_scores(self):
|
||||
source = tests.solo_fixtures.song(count=16, chords=('C7',))
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['electric']
|
||||
policy['groove']['cycle_patterns'] = 1
|
||||
policy['patterns'] = [{'id': 'test.' + name, 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1/2', 'duration': '1/2', 'layer': 'electric'}, {'onset': onset, 'duration': '1/2', 'layer': 'electric'}]}
|
||||
for name, onset in (('base', '11/2'), ('answer', '5'))]
|
||||
# Most of the form favors the offbeat answer. At the final boundary the
|
||||
# source enters on beat two after a breath, so the related on-beat answer
|
||||
# supports that entrance. All contributions come from the real evaluator.
|
||||
source['staves'][0]['measures'][-1]['voices'][0]['notes'].pop(0)
|
||||
with generator.vocabulary.using(resources, 'electric_accompaniment'):
|
||||
_, locations, reports = generator.comping_rhythm.choose_cycles(groups(source), 'electric', generator.comping_rhythm.melody_contexts(source), 'real-fill')
|
||||
self.assertEqual(reports[0]['patterns'], ['test.base'])
|
||||
self.assertEqual([r['fill']['source_index'] for r in locations if r['fill']], [15])
|
||||
def test_meter_change_restarts_compatible_cycle_and_reports_reduction(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
shorter = tests.solo_fixtures.song(meter=3, count=4, chords=('C7',))
|
||||
source['staves'][0]['measures'][4:] = shorter['staves'][0]['measures']
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'meter')
|
||||
self.assertEqual([r['meter'] for r in canonical.groove_report], ['4/4', '3/4'])
|
||||
self.assertTrue(all(r['reduction'] for r in canonical.groove_report))
|
||||
self.assertEqual(canonical.locations[4]['cycle_slot'], 0)
|
||||
def test_partial_entry_uses_second_half_instead_of_restarting(self):
|
||||
source = tests.solo_fixtures.song(count=10, chords=('C7',))
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'entry')
|
||||
measures = source['staves'][0]['measures']
|
||||
full = canonical(measures[3], measures[2]['harmonies'][0], 3)
|
||||
sliced = canonical(measures[3], measures[2]['harmonies'][0], 3, bounds=(3, 10))
|
||||
# The entry may attack a formerly carried chord; actual relative times
|
||||
# and fret positions remain from the same second half of the figure.
|
||||
def music(m):
|
||||
return [(generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for n in generator.score.measure_notes(m)]
|
||||
self.assertEqual(music(full), music(sliced))
|
||||
def test_final_harmony_change_keeps_unaffected_rhythm_and_grips(self):
|
||||
source = tests.solo_fixtures.song(count=8, chords=('C7',))
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'final')
|
||||
original = source['staves'][0]['measures']
|
||||
changed = copy.deepcopy(original)
|
||||
changed[-1]['harmonies'] = [{**generator.harmony.parse_symbol('FM7'), 'id': 'final', 'onset': generator.model.rational(0)}]
|
||||
before = canonical(original[6], original[5]['harmonies'][0], 6)
|
||||
after = canonical(changed[6], changed[5]['harmonies'][0], 6, sources=changed)
|
||||
self.assertEqual(signature(before), signature(after))
|
||||
self.assertEqual(canonical.rhythm_report[-1]['base_pattern'], canonical.locations[6]['base_pattern'])
|
||||
def test_bass_eighth_then_upper_quarter_survives_single_voice_merge(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('Dm7',))
|
||||
source['staves'][0]['measures'][0]['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(2)})
|
||||
resources = copy.deepcopy(generator.vocabulary.load())
|
||||
policy = resources['accompaniment']['instruments']['acoustic']
|
||||
policy['patterns'] = [p for p in policy['patterns'] if p['id'] == 'acoustic.quarters.overlapping']
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'overlap')
|
||||
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(source['staves'][0]['measures'])]
|
||||
source['staves'][0].update(arrangement_part_type='acoustic', tablature=True)
|
||||
generator.acoustic.validate_comping(source)
|
||||
generator.acoustic.combine(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
generator.acoustic.validate_comping(source)
|
||||
first = source['staves'][0]['measures'][0]
|
||||
self.assertEqual(len(first['voices']), 1)
|
||||
notes = [n for n in generator.score.measure_notes(first) if n['pitches']]
|
||||
self.assertEqual([(generator.model.fraction(n['onset']), generator.model.fraction(n['duration'])) for n in notes],
|
||||
[(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), 1), (2, 1)])
|
||||
strings = [{p['tablature']['string'] for p in n['pitches']} for n in notes]
|
||||
self.assertIn(strings[0], ({5}, {6}))
|
||||
self.assertEqual(strings[1], {2, 3, 4})
|
||||
self.assertIn(strings[2], ({2, 3, 4, 5}, {2, 3, 4, 6}))
|
||||
self.assertFalse(any(p.get('tie_from') or p.get('tie_to') for n in notes for p in n['pitches']))
|
||||
def test_mid_measure_chord_change_bass_without_forced_barline_attack(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
m = source['staves'][0]['measures'][0]
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'middle', 'onset': generator.model.rational(2)})
|
||||
pattern = {'id': 'test', 'family': 'quarters', 'lengths': ['4'], 'events': [
|
||||
{'onset': '1/2', 'duration': '1', 'layer': 'upper'}, {'onset': '5/2', 'duration': '1/2', 'layer': 'upper'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.lane) for e in events], [(fractions.Fraction(1, 2), 'combined'), (fractions.Fraction(5, 2), 'combined')])
|
||||
# Unpaired attacks include bass even when the harmony is unchanged.
|
||||
inputs = ((source['staves'][0]['measures'][1], source['staves'][0]['measures'][1]['harmonies'][0], 1),)
|
||||
r = generator.comping_rhythm.realize(pattern, inputs, 'acoustic', generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertTrue(all(e.lane == 'combined' for e in r.events))
|
||||
def test_clean_cached_parallel_and_other_role_settings_keep_groove(self):
|
||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=10, chords=('C7', 'FM7')))
|
||||
root = pathlib.Path('data').resolve()
|
||||
roles = ('acoustic_accompaniment', 'electric_accompaniment')
|
||||
clean = {r: generator.composition.generate_role(source, r, root) for r in roles}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
for r in roles:
|
||||
cold = generator.composition.generate_role(source, r, root, cache_dir=directory)
|
||||
warm = generator.composition.generate_role(source, r, root, cache_dir=directory)
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(clean[r]['document'], cold['document'])
|
||||
self.assertEqual(clean[r]['document'], warm['document'])
|
||||
generator.composition.generate_role(source, roles[1], root, {'seed': 'different'}, directory)
|
||||
self.assertEqual(clean[roles[0]]['document'], generator.composition.generate_role(source, roles[0], root, cache_dir=directory)['document'])
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
futures = {r: pool.submit(generator.production._job, generator.composition_types.RoleRequest(source, r, root)) for r in reversed(roles)}
|
||||
for r, future in futures.items():
|
||||
self.assertEqual(clean[r]['document'], future.result()['document'])
|
||||
def test_reuse_audit_checks_rests_and_all_six_roles(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
for role in generator.composition_types.ROLES:
|
||||
part = role.split('_')[0]
|
||||
wanted = 'slash' if role.endswith('_solo') else 'vocal' if part == 'vocal' else 'melody' if role.endswith('_melody') else 'accompaniment'
|
||||
section = generator.arrange.form.ArrangementSection('Repeated role', 0, 2, 1, wanted)
|
||||
doc = copy.deepcopy(source)
|
||||
doc['staves'][0]['measures'] *= 2
|
||||
doc = copy.deepcopy(doc)
|
||||
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section, section]):
|
||||
result = generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
|
||||
self.assertEqual(result[role]['reused_bars'], 2)
|
||||
# Remove music only in the second occurrence; reuse must fail.
|
||||
doc['staves'][0]['measures'][2] = copy.deepcopy(doc['staves'][0]['measures'][2])
|
||||
n = doc['staves'][0]['measures'][2]['voices'][0]['notes'][0]
|
||||
n.update(kind='rest', pitches=[])
|
||||
with self.assertRaisesRegex(ValueError, 'canonical'):
|
||||
generator.arrange.accompaniment.audit(source, {role: doc}, role_documents=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Meter-specific vocabulary and source-derived quintuple grouping."""
|
||||
import generator.composition
|
||||
import generator.harmony
|
||||
import generator.source
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import fractions
|
||||
import json
|
||||
import multiprocessing
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
import tests.test_comping_cycles
|
||||
|
||||
F = fractions.Fraction
|
||||
|
||||
class CompingMeters(unittest.TestCase):
|
||||
def source(self, beats=5, count=10, explicit=()):
|
||||
source = tests.solo_fixtures.song(meter=beats, count=count, chords=('C7', 'FM7'))
|
||||
if explicit:
|
||||
for m in source['staves'][0]['measures']:
|
||||
m['meter']['groups'][0]['beats'] = list(explicit)
|
||||
m['time_signature'] = generator.model.meter_label(m['meter'])
|
||||
source['metadata']['time_signature'] = source['staves'][0]['measures'][0]['time_signature']
|
||||
return source
|
||||
def test_balanced_pools_and_all_families_reach_joint_evaluation(self):
|
||||
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||
source = self.source(beats, 2, grouping)
|
||||
inputs = tests.test_comping_cycles.groups(source)[0]
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
for role in ('acoustic', 'electric'):
|
||||
with self.subTest(beats=beats, grouping=grouping, role=role):
|
||||
policy = generator.vocabulary.accompaniment(role)
|
||||
pool = generator.comping_rhythm.patterns_for(policy, flat, (F(beats),) * 2, grouping)
|
||||
self.assertEqual(len(pool), 24)
|
||||
self.assertEqual(len({generator.comping_rhythm.identity(p) for p in pool}), 24)
|
||||
self.assertEqual(len({p['family'] for p in pool}), 11 if beats == 4 else 12)
|
||||
candidates = generator.comping_rhythm.candidates(flat, None, role, (), 'coverage', lengths=(F(beats),) * 2, grouping=grouping)
|
||||
self.assertEqual({p['family'] for p in pool}, {dict(c.families)['pattern'] for c in candidates})
|
||||
for p in pool:
|
||||
fitted = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertIsNotNone(fitted, p['id'])
|
||||
generator.comping_rhythm.validate_events(fitted.events, role)
|
||||
for start in (0, beats):
|
||||
self.assertLessEqual(len({e.start for e in fitted.events if start <= e.start < start + beats}), 3 if role == 'acoustic' else 2)
|
||||
if role == 'electric':
|
||||
self.assertTrue(any(F(e['onset']) < beats < F(e['onset']) + F(e['duration']) for p in pool for e in p['events']))
|
||||
def test_explicit_grouping_overrides_harmony_and_melody(self):
|
||||
source = self.source(explicit=(2, 3))
|
||||
for m in source['staves'][0]['measures']:
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(3)})
|
||||
grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertEqual(grouping, (2, 3))
|
||||
self.assertEqual(evidence['reason'], 'written')
|
||||
def test_harmonic_boundary_then_melodic_entrances_then_attacks(self):
|
||||
for boundary in (2, 3):
|
||||
source = self.source()
|
||||
for m in source['staves'][0]['measures']:
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'secondary', 'onset': generator.model.rational(boundary)})
|
||||
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||
source = self.source()
|
||||
# Competing secondary pulses have equal attack counts. Only one follows
|
||||
# an actual rest; an uninterrupted reattack is weaker grouping evidence.
|
||||
other = 5 - boundary
|
||||
contexts = tuple(((F(other) - F(1, 2), F(other)), (F(other), F(other) + F(1, 2)), (F(boundary), F(boundary) + F(1, 2))) for _ in range(10))
|
||||
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts)
|
||||
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||
contexts = tuple(((F(boundary), F(boundary + 1)),) for _ in range(10))
|
||||
grouping, _ = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), contexts)
|
||||
self.assertEqual(grouping, (boundary, 5 - boundary))
|
||||
def test_same_chord_marking_is_not_a_harmonic_change(self):
|
||||
source = self.source()
|
||||
for m in source['staves'][0]['measures']:
|
||||
m['harmonies'].append({**m['harmonies'][0], 'onset': generator.model.rational(2)})
|
||||
grouping, evidence = generator.comping_rhythm.grouping_for(tests.test_comping_cycles.groups(source), generator.comping_rhythm.melody_contexts(source))
|
||||
self.assertEqual(grouping, (3, 2))
|
||||
self.assertEqual(evidence['reason'], 'default_3+2')
|
||||
def test_not_just_equal_measure_length(self):
|
||||
source = self.source(3)
|
||||
m = source['staves'][0]['measures'][0]
|
||||
m.update(meter={'groups': [{'beats': [6], 'unit': 8}]}, time_signature='6/8')
|
||||
with self.assertRaisesRegex(ValueError, 'no comping vocabulary for 6/8'):
|
||||
generator.comping.Canonical(source, 'electric', 'compound')
|
||||
def test_schema_rejects_wrong_meter_lengths_and_missing_grouping(self):
|
||||
root = pathlib.Path('data/composition')
|
||||
contents = {p.stem: p.read_text() for p in root.glob('*.json')}
|
||||
for change in ({'meter': '3/4'}, {'grouping': []}, {'grouping': [4, 1]}):
|
||||
value = json.loads(contents['accompaniment'])
|
||||
pattern = next(p for p in value['instruments']['electric']['patterns'] if p['meter'] == '5/4')
|
||||
pattern.update(change)
|
||||
with self.assertRaises(ValueError):
|
||||
generator.vocabulary._load(tuple((name, json.dumps(value) if name == 'accompaniment' else text) for name, text in contents.items()))
|
||||
def test_full_cycles_odd_forms_and_grouping_consistency(self):
|
||||
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||
source = self.source(beats, 9, grouping)
|
||||
reports = []
|
||||
for role, width in (('acoustic', 2), ('electric', 4)):
|
||||
canonical = generator.comping.Canonical(source, role, 'cycles')
|
||||
report = canonical.groove_report[0]
|
||||
reports.append(report)
|
||||
self.assertEqual(report['cycle_patterns'], width)
|
||||
self.assertEqual(report['eligible_patterns'], 24)
|
||||
self.assertIsNone(report['reduction'])
|
||||
for location in canonical.locations.values():
|
||||
slot = location['cycle_slot']
|
||||
self.assertEqual(location['base_pattern'], report['patterns'][slot])
|
||||
last = canonical.block_rhythms[8]
|
||||
self.assertTrue(all(0 <= e.start < e.end <= beats for e in last.events))
|
||||
self.assertEqual(reports[0]['grouping'], reports[1]['grouping'])
|
||||
def test_grouping_changes_restart_and_fills_cannot_cross_groups(self):
|
||||
source = self.source(count=18, explicit=(3, 2))
|
||||
for m in source['staves'][0]['measures'][9:]:
|
||||
m['meter']['groups'][0]['beats'] = [2, 3]
|
||||
m['time_signature'] = '2+3/4'
|
||||
for role in ('acoustic', 'electric'):
|
||||
canonical = generator.comping.Canonical(source, role, 'change')
|
||||
self.assertEqual([r['grouping'] for r in canonical.groove_report], [[3, 2], [2, 3]])
|
||||
self.assertEqual(canonical.locations[9]['cycle_slot'], 0)
|
||||
for pattern, _ in canonical.patterns.values():
|
||||
suffix = '.' + '+'.join(map(str, pattern['grouping'])) + '.'
|
||||
if '.fill.' in pattern['id']:
|
||||
self.assertTrue(all(suffix in p for p in pattern['id'].split('.fill.')))
|
||||
def test_every_fill_candidate_uses_its_base_grouping(self):
|
||||
source = self.source(count=16, explicit=(2, 3))
|
||||
checked = []
|
||||
realize = generator.comping_rhythm.realize
|
||||
def inspect(pattern, *args, **kwargs):
|
||||
if '.fill.' in pattern['id']:
|
||||
checked.append(pattern['id'])
|
||||
self.assertTrue(all('.2+3.' in p for p in pattern['id'].split('.fill.')))
|
||||
return realize(pattern, *args, **kwargs)
|
||||
with unittest.mock.patch.object(generator.comping_rhythm, 'realize', side_effect=inspect):
|
||||
generator.comping.Canonical(source, 'electric', 'fill-group')
|
||||
self.assertTrue(checked)
|
||||
def test_partial_pickup_and_mid_pattern_entry_keep_meter_and_phase(self):
|
||||
for beats in (3, 5):
|
||||
source = self.source(beats)
|
||||
m = copy.deepcopy(source['staves'][0]['measures'][0])
|
||||
m['duration'] = generator.model.rational(F(3, 2))
|
||||
candidates = generator.comping_rhythm.candidates(m, None, 'electric', (), 'pickup')
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(0 <= e.start < e.end <= F(3, 2) for c in candidates for e in c.events))
|
||||
for role in ('acoustic', 'electric'):
|
||||
canonical = generator.comping.Canonical(source, role, 'entry')
|
||||
measures = source['staves'][0]['measures']
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
full = canonical(measures[3], carried[3], 3)
|
||||
full_position = canonical.last_position
|
||||
partial = canonical(measures[3], carried[3], 3, bounds=(3, 10))
|
||||
self.assertEqual(canonical.last_position, full_position)
|
||||
# Rest engraving can split differently at a sliced boundary. Sounded
|
||||
# onsets, releases, voicings and tie relationships must remain equal.
|
||||
self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]])
|
||||
def test_unused_meter_and_other_role_data_do_not_reseed(self):
|
||||
resources = generator.vocabulary.load()
|
||||
measures = self.source(4)['staves'][0]['measures']
|
||||
baseline = generator.composition.dependencies('acoustic_accompaniment', resources, measures)
|
||||
changed = copy.deepcopy(resources)
|
||||
for role, policy in changed['accompaniment']['instruments'].items():
|
||||
for p in policy['patterns']:
|
||||
if role == 'electric' or p['meter'] in ('3/4', '5/4'):
|
||||
p['events'] = []
|
||||
self.assertEqual(baseline, generator.composition.dependencies('acoustic_accompaniment', changed, measures))
|
||||
changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'] = []
|
||||
self.assertNotEqual(baseline, generator.composition.dependencies('acoustic_accompaniment', changed, measures))
|
||||
def test_chord_changes_anticipations_and_cross_bar_ties(self):
|
||||
for beats, grouping in ((4, ()), (3, ()), (5, (3, 2)), (5, (2, 3))):
|
||||
source = self.source(beats, 2, grouping)
|
||||
for m in source['staves'][0]['measures']:
|
||||
m['harmonies'].append({**generator.harmony.parse_symbol('G7'), 'id': 'middle', 'onset': generator.model.rational(beats - 1)})
|
||||
inputs = tests.test_comping_cycles.groups(source)[0]
|
||||
for role in ('acoustic', 'electric'):
|
||||
pool = generator.comping_rhythm.patterns_for(generator.vocabulary.accompaniment(role), inputs[0][0], (F(beats),) * 2, grouping)
|
||||
anticipated = False
|
||||
for p in pool:
|
||||
result = generator.comping_rhythm.realize(p, inputs, role, generator.comping_rhythm.melody_contexts(source), anticipate=True)
|
||||
self.assertIsNotNone(result, p['id'])
|
||||
generator.comping_rhythm.validate_events(result.events, role)
|
||||
for e in result.events:
|
||||
if e.harmonic_time is not None:
|
||||
anticipated = True
|
||||
self.assertEqual(e.harmonic_time - e.start, F(1, 2))
|
||||
self.assertTrue(anticipated)
|
||||
def test_roles_are_deterministic_alone_cached_and_parallel(self):
|
||||
for meter in (3, 5):
|
||||
source = generator.source.SourceAnalysis.read(self.source(meter, 9))
|
||||
roles = ('acoustic_accompaniment', 'electric_accompaniment')
|
||||
with tempfile.TemporaryDirectory() as cache:
|
||||
clean = {r: generator.composition.generate_role(source, r, pathlib.Path('data')) for r in roles}
|
||||
for role in reversed(roles):
|
||||
cold = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
|
||||
warm = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=cache)
|
||||
self.assertEqual(clean[role]['document'], cold['document'])
|
||||
self.assertEqual(cold['document'], warm['document'])
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2, mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
jobs = {r: pool.submit(generator.production._job, generator.composition_types.RoleRequest(source, r, pathlib.Path('data'))) for r in reversed(roles)}
|
||||
for role, job in jobs.items():
|
||||
self.assertEqual(clean[role]['document'], job.result()['document'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,4 +1,8 @@
|
||||
"""Independent rhythm contracts, including real overlapping guitar voices."""
|
||||
"""Independent rhythm contracts and their complete sounding durations."""
|
||||
import generator.composition_types
|
||||
import generator.arrange.assembly
|
||||
import generator.composition
|
||||
import generator.validation
|
||||
import copy
|
||||
import dataclasses
|
||||
import fractions
|
||||
@@ -14,9 +18,8 @@ import generator.fretboard
|
||||
import generator.harmonic_time
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.solo.analysis
|
||||
import generator.source
|
||||
import generator.solo.decoder
|
||||
import generator.solo.harmonic
|
||||
import generator.solo.learned
|
||||
@@ -53,15 +56,16 @@ class LayeredComping(unittest.TestCase):
|
||||
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
|
||||
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
|
||||
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
|
||||
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
|
||||
def test_separate_attacks_cannot_overlap_and_combined_attacks_are_valid(self):
|
||||
F = fractions.Fraction
|
||||
attack = generator.comping_rhythm.Attack
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(1)),), 'acoustic')
|
||||
for events in ((attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(3, 2))), (attack('combined', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(1)), attack('bass', F(1, 2), F(1))), (attack('bass', F(0), F(1, 3)),)):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.comping_rhythm.validate_events(events, 'acoustic')
|
||||
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
|
||||
def test_bass_first_keeps_pattern_timing_and_does_not_reset_at_barlines(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
|
||||
@@ -70,11 +74,15 @@ class LayeredComping(unittest.TestCase):
|
||||
if pattern['lengths'] != ['4', '4']:
|
||||
continue
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual([(e.start, e.end - e.start) for e in events], [(fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events']])
|
||||
for start in (0, 4):
|
||||
local = [e for e in events if start <= e.start < start + 4]
|
||||
if local:
|
||||
self.assertIn(local[0].lane, ('bass', 'combined'))
|
||||
self.assertEqual([e.start for e in events], [fractions.Fraction(r['onset']) for r in pattern['events']])
|
||||
for i, (e, r) in enumerate(zip(events, pattern['events'])):
|
||||
intended = e.start + fractions.Fraction(r['duration'])
|
||||
self.assertEqual(e.end, min(intended, events[i + 1].start) if i + 1 < len(events) else intended)
|
||||
if events:
|
||||
self.assertIn(events[0].lane, ('bass', 'combined'))
|
||||
pattern = {'lengths': ['4', '4'], 'events': [{'onset': '0', 'duration': '1', 'layer': 'bass'}, {'onset': '4', 'duration': '1', 'layer': 'upper'}]}
|
||||
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'acoustic')
|
||||
self.assertEqual(events[-1].lane, 'combined')
|
||||
self.assertEqual(before, patterns)
|
||||
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
|
||||
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
|
||||
@@ -86,24 +94,22 @@ class LayeredComping(unittest.TestCase):
|
||||
{'onset': '3/2', 'duration': '1', 'layer': 'upper', 'anticipate': True},
|
||||
{'onset': '7/2', 'duration': '1/2', 'layer': 'bass'}]}
|
||||
events, changes = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True)
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('bass', fractions.Fraction(3, 2), 2, None), ('upper', fractions.Fraction(7, 2), 4, None)])
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in events], [('combined', fractions.Fraction(3, 2), fractions.Fraction(5, 2), 2), ('combined', fractions.Fraction(7, 2), 4, None)])
|
||||
self.assertIn('layer_assignment', {kind for kind, _ in changes})
|
||||
limited, _ = generator.comping_rhythm.fit(pattern, m, None, 'acoustic', anticipate=True, limit=fractions.Fraction(2))
|
||||
self.assertEqual(limited, (events[0],))
|
||||
self.assertEqual([(e.lane, e.start, e.end, e.harmonic_time) for e in limited], [('combined', fractions.Fraction(3, 2), 2, None)])
|
||||
def test_combined_first_attack_and_carried_hold_do_not_require_reattacks(self):
|
||||
attack, F = generator.comping_rhythm.Attack, fractions.Fraction
|
||||
valid = (attack('combined', F(3), F(5)), attack('bass', F(6), F(7)))
|
||||
generator.comping_rhythm.validate_events(valid, 'acoustic', (4, 4))
|
||||
generator.comping_rhythm.validate_events(valid[:1], 'acoustic', (4, 4))
|
||||
invalid = (valid[0], attack('upper', F(6), F(7)))
|
||||
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
|
||||
generator.comping_rhythm.validate_events(invalid, 'acoustic', (4, 4))
|
||||
generator.comping_rhythm.validate_events((valid[0], attack('upper', F(6), F(7))), 'acoustic', (4, 4))
|
||||
def test_completed_score_rejects_upper_first_even_after_a_rest(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
pattern = {'id': 'test.delayed', 'family': 'call_response', 'lengths': ['4', '4'], 'events': [
|
||||
{'onset': '1', 'duration': '1/2', 'layer': 'upper'}, {'onset': '2', 'duration': '1', 'layer': 'bass'},
|
||||
{'onset': '5', 'duration': '1', 'layer': 'combined'}]}
|
||||
# The final combined event must be longer than one beat.
|
||||
# A long combined event remains available alongside short attacks.
|
||||
pattern['events'][-1]['duration'] = '2'
|
||||
resources = copy.deepcopy(self.resources)
|
||||
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
|
||||
@@ -116,6 +122,21 @@ class LayeredComping(unittest.TestCase):
|
||||
first['voices'] = [v for v in first['voices'] if v['id'] != 'bass']
|
||||
with self.assertRaisesRegex(ValueError, 'first acoustic attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_completed_score_rejects_bass_under_upper_before_and_after_merge(self):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
source['staves'][0]['arrangement_part_type'] = 'acoustic'
|
||||
source['staves'][0]['measures'][0]['voices'] = [
|
||||
{'id': 'bass', 'notes': [generator.model.make_event('bass', fractions.Fraction(), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'c', 'alter': 0, 'octave': 3, 'tablature': {'string': 5, 'fret': 3}}])]},
|
||||
{'id': 'upper', 'notes': [generator.model.make_event('upper', fractions.Fraction(1, 2), fractions.Fraction(1), 'note', [
|
||||
{'letter': 'e', 'alter': 0, 'octave': 3, 'tablature': {'string': 4, 'fret': 2}}])]}]
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
generator.acoustic.combine(source)
|
||||
# The generic merger preserves imported overlaps faithfully. Generated
|
||||
# accompaniment must fail even when the overlap is encoded using ties.
|
||||
with self.assertRaisesRegex(ValueError, 'release before the next attack'):
|
||||
generator.acoustic.validate_comping(source)
|
||||
def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
|
||||
source = copy.deepcopy(self.source)
|
||||
for m in source['staves'][0]['measures'][:2]:
|
||||
@@ -135,7 +156,7 @@ class LayeredComping(unittest.TestCase):
|
||||
for c in anticipated:
|
||||
for e in c.events:
|
||||
if e.harmonic_time is not None:
|
||||
self.assertIn(e.lane, ('upper', 'electric'))
|
||||
self.assertIn(e.lane, ('bass', 'combined', 'upper', 'electric'))
|
||||
self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2))
|
||||
self.assertLess(e.harmonic_time, e.end)
|
||||
self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates))
|
||||
@@ -169,17 +190,17 @@ class LayeredComping(unittest.TestCase):
|
||||
changed = copy.deepcopy(self.source)
|
||||
changed['metadata']['tempo'] = 240
|
||||
changed['staves'][0]['measures'][0]['tempos'] = [{'onset': generator.model.rational(0), 'bpm': generator.model.rational(240), 'written': {'type': 'quarter', 'dots': 0}}]
|
||||
a, b = map(generator.production.SourceAnalysis.read, (self.source, changed))
|
||||
a, b = map(generator.source.SourceAnalysis.read, (self.source, changed))
|
||||
self.assertEqual(a.accompaniment_key(), b.accompaniment_key())
|
||||
for role in ('acoustic_accompaniment', 'electric_accompaniment'):
|
||||
first = generator.production.generate_role(a, role, pathlib.Path('data'))
|
||||
second = generator.production.generate_role(b, role, pathlib.Path('data'))
|
||||
first = generator.composition.generate_role(a, role, pathlib.Path('data'))
|
||||
second = generator.composition.generate_role(b, role, pathlib.Path('data'))
|
||||
self.assertEqual(sounding(first['document']), sounding(second['document']))
|
||||
self.assertEqual(first['details'], second['details'])
|
||||
self.assertNotEqual(first['trace']['cache_key'], second['trace']['cache_key'])
|
||||
def test_assembly_keeps_overlaps_and_canonical_reuse(self):
|
||||
source = generator.production.SourceAnalysis.read(self.source)
|
||||
results = {role: generator.production.generate_role(source, role, pathlib.Path('data')) for role in generator.production.ROLES if not role.endswith('_solo')}
|
||||
def test_assembly_keeps_releases_and_canonical_reuse(self):
|
||||
source = generator.source.SourceAnalysis.read(self.source)
|
||||
results = {role: generator.composition.generate_role(source, role, pathlib.Path('data')) for role in generator.composition_types.ROLES if not role.endswith('_solo')}
|
||||
# Silence in source-defined solo slots is sufficient to test union;
|
||||
# no generated solo participates in these accompaniment decisions.
|
||||
for part in ('acoustic', 'electric'):
|
||||
@@ -189,12 +210,12 @@ class LayeredComping(unittest.TestCase):
|
||||
for n in v['notes']:
|
||||
n.update(kind='rest', pitches=[])
|
||||
results[part + '_solo'] = empty
|
||||
assembled = generator.production.assemble(source, results)
|
||||
assembled = generator.arrange.assembly.assemble(source, results)
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures']))
|
||||
audit = generator.arrange.accompaniment.audit(self.source, assembled)
|
||||
self.assertGreater(audit['acoustic']['reused_bars'], 0)
|
||||
for doc in assembled.values():
|
||||
generator.model.validate_document(doc)
|
||||
generator.validation.validate_document(doc)
|
||||
generator.fretboard.validate_document(doc)
|
||||
def test_combined_voices_reject_conflicting_strings_and_fret_spans(self):
|
||||
for string, fret in ((5, 3), (2, 10)):
|
||||
@@ -248,7 +269,7 @@ class SoloRhythmConditioning(unittest.TestCase):
|
||||
if held:
|
||||
m = source['staves'][0]['measures'][0]
|
||||
m['voices'][0]['notes'] = [generator.model.make_event('held', fractions.Fraction(), fractions.Fraction(4), 'note', [{'letter': 'c', 'alter': 0, 'octave': 4}])]
|
||||
analysis = generator.solo.analysis.analyze(source)
|
||||
analysis = generator.source.analyze(source)
|
||||
harmony = generator.solo.harmonic.Harmony(generator.harmonic_time.read(source['staves'][0]))
|
||||
examples.append({'analysis': analysis, 'harmony': harmony, 'events': [(fractions.Fraction(), fractions.Fraction(duration), 64), (fractions.Fraction(duration), fractions.Fraction(4), 67)]})
|
||||
model = generator.solo.learned.Model(generator.solo.learned.fit(examples, {'purpose': 'independently authored conditional-rhythm test'}))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Accompaniment geometry and whole-form movement, using authored small cases."""
|
||||
import generator.composition
|
||||
import generator.source
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
@@ -13,22 +15,20 @@ import generator.comping
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.hand_position
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic_voicing
|
||||
import tests.test_voicing
|
||||
import tests.position_fixtures
|
||||
|
||||
def options(symbol, part):
|
||||
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
|
||||
|
||||
def path_cost(path, costs, part, left=(), right=(), close=False):
|
||||
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
|
||||
total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
|
||||
total[2] += generator.comping.movement(part, path[-1], path[0] if close else right)
|
||||
return tuple(total)
|
||||
def path_cost(path, costs, part, left=(), right=(), close=False, **kwargs):
|
||||
return tests.position_fixtures.cost(path, costs, (2, 3, 4), acoustic=part == 'acoustic', left=left, right=right, close=close, **kwargs)
|
||||
|
||||
class CompingVoicings(unittest.TestCase):
|
||||
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
|
||||
@@ -38,23 +38,23 @@ class CompingVoicings(unittest.TestCase):
|
||||
costs = {v: generator.comping.local_cost('electric', key, v, 60) for v in (low, high)}
|
||||
self.assertEqual(costs[low][:2], costs[high][:2])
|
||||
self.assertLess(costs[low], costs[high])
|
||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
|
||||
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
|
||||
self.assertEqual(path, (low,))
|
||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
|
||||
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
|
||||
self.assertEqual(path, (high,))
|
||||
def test_electric_voicing_policy_does_not_reseed_rhythm(self):
|
||||
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
|
||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
|
||||
root = pathlib.Path('data')
|
||||
current = generator.production.generate_role(source, 'electric_accompaniment', root)
|
||||
current = generator.composition.generate_role(source, 'electric_accompaniment', root)
|
||||
with tempfile.TemporaryDirectory() as cache:
|
||||
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}}
|
||||
other = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||
warm = generator.production.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||
settings = {'voicing': {'register_weight': 0}}
|
||||
other = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||
warm = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(other['document'], warm['document'])
|
||||
self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key'])
|
||||
self.assertEqual(current['details']['accompaniment_rhythm'], other['details']['accompaniment_rhythm'])
|
||||
def test_lower_progression_can_outweigh_extra_boundary_travel(self):
|
||||
def test_connected_progression_uses_actual_windows_and_neighboring_grips(self):
|
||||
symbols = ('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')
|
||||
low = tuple(tests.test_acoustic_voicing.voicing(f) for f in ((7, 8, 7, 8), (7, 8, 6, 8), (5, 7, 5, 6), (5, 5, 4, 6), (3, 5, 3, 4), (3, 3, 2, 4)))
|
||||
high = tuple(tests.test_acoustic_voicing.voicing(f, 6) for f in ((12, 12, 12, 11), (12, 11, 12, 11), (10, 10, 10, 10), (10, 9, 10, 8), (8, 8, 8, 8), (8, 7, 8, 6)))
|
||||
@@ -65,12 +65,11 @@ class CompingVoicings(unittest.TestCase):
|
||||
self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer))
|
||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol(symbol))
|
||||
costs.append({v: generator.comping.local_cost('acoustic', key, v, 53) for v in layer})
|
||||
def travel(path):
|
||||
return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right)))
|
||||
self.assertEqual((travel(low), travel(high)), (43, 35))
|
||||
self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right))
|
||||
path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
|
||||
self.assertEqual(path, low)
|
||||
# The high path travels less from these particular neighbors; a lower
|
||||
# sounding register cannot purchase extra hand movement.
|
||||
self.assertGreater(path_cost(low, costs, 'acoustic', left, right)[2], path_cost(high, costs, 'acoustic', left, right)[2])
|
||||
path, cost, hands = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
|
||||
self.assertEqual(cost, path_cost(path, costs, 'acoustic', left, right, hands=hands))
|
||||
self.assertEqual(cost, min(path_cost(p, costs, 'acoustic', left, right) for p in itertools.product(*layers)))
|
||||
def test_large_round_trip_can_retain_a_high_position(self):
|
||||
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
|
||||
@@ -78,28 +77,26 @@ class CompingVoicings(unittest.TestCase):
|
||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
||||
costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, high)}
|
||||
self.assertLess(costs[low], costs[high])
|
||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
|
||||
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
|
||||
self.assertEqual(path, (high,))
|
||||
def test_acoustic_preferences_are_exact_and_weights_are_operational(self):
|
||||
def test_register_weight_is_an_exact_subordinate_tiebreak(self):
|
||||
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
|
||||
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
|
||||
resources = generator.vocabulary.load()
|
||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
||||
self.assertEqual(generator.comping.voicing_preferences('acoustic', high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
|
||||
self.assertEqual(generator.comping.voicing_preferences('acoustic', low, 53)[1], 0)
|
||||
baseline = generator.comping.local_cost('acoustic', key, high, 53)
|
||||
for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)):
|
||||
policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing'])
|
||||
policy[field] += 1
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}):
|
||||
cost = generator.comping.local_cost('acoustic', key, high, 53)
|
||||
self.assertEqual(cost.effort - baseline.effort, expected, field)
|
||||
self.assertEqual(cost.extended_grips, baseline.extended_grips)
|
||||
self.assertEqual(cost.family, baseline.family)
|
||||
self.assertEqual(baseline.register, fractions.Fraction(39, 4))
|
||||
self.assertEqual(baseline.effort, 0)
|
||||
self.assertEqual(baseline.peak_position, 0)
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': 2}}):
|
||||
cost = generator.comping.local_cost('acoustic', key, high, 53)
|
||||
self.assertEqual(cost.register, 2 * baseline.register)
|
||||
self.assertEqual(cost[:-1], baseline[:-1])
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
|
||||
generator.comping.local_cost('acoustic', key, high, 53)
|
||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'high_position_weight': 1}}), self.assertRaisesRegex(ValueError, 'unknown voicing'):
|
||||
generator.comping.local_cost('acoustic', key, high, 53)
|
||||
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
|
||||
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
|
||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
|
||||
def rhythm(result):
|
||||
return [[(n['onset'], n['duration'], n['written'], n['kind'], len(n['pitches']), tuple((bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches'])) for n in generator.score.measure_notes(m)] for m in result['document']['staves'][0]['measures']]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -109,31 +106,34 @@ class CompingVoicings(unittest.TestCase):
|
||||
data = json.loads(role_path.read_text())
|
||||
policy = data['roles']['acoustic_accompaniment'].pop('voicing')
|
||||
role_path.write_text(json.dumps(data))
|
||||
legacy = generator.production.generate_role(source, 'acoustic_accompaniment', root)
|
||||
electric = generator.production.generate_role(source, 'electric_accompaniment', root)
|
||||
legacy = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
|
||||
electric = generator.composition.generate_role(source, 'electric_accompaniment', root)
|
||||
data['roles']['acoustic_accompaniment']['voicing'] = policy
|
||||
role_path.write_text(json.dumps(data))
|
||||
current = generator.production.generate_role(source, 'acoustic_accompaniment', root)
|
||||
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
|
||||
old = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||
warm = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||
current = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
|
||||
settings = {'voicing': {'register_weight': 0}}
|
||||
old = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||
warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(old['document'], warm['document'])
|
||||
self.assertNotEqual(current['document'], old['document'])
|
||||
# A tie weight is not required to change a winner with better movement.
|
||||
self.assertEqual(current['details']['guitar_placement']['cost']['effort'], old['details']['guitar_placement']['cost']['effort'])
|
||||
self.assertNotEqual(current['trace']['cache_key'], old['trace']['cache_key'])
|
||||
self.assertNotEqual(legacy['trace']['cache_key'], current['trace']['cache_key'])
|
||||
self.assertEqual(legacy['document'], current['document'])
|
||||
self.assertEqual(rhythm(current), rhythm(old))
|
||||
self.assertEqual(current['details']['accompaniment_rhythm'], old['details']['accompaniment_rhythm'])
|
||||
self.assertEqual(electric['document'], generator.production.generate_role(source, 'electric_accompaniment', root)['document'])
|
||||
self.assertEqual(electric['document'], generator.composition.generate_role(source, 'electric_accompaniment', root)['document'])
|
||||
def test_report_balanced_cost_matches_selected_path(self):
|
||||
canonical = generator.comping.Canonical(tests.solo_fixtures.song(count=4, chords=('Em7b5', 'A7b9', 'Dm7', 'G7')), 'acoustic', 'report')
|
||||
rows = [r for r in canonical.report if r['tones']]
|
||||
for row in rows:
|
||||
cost = row['voicing_cost']
|
||||
self.assertEqual(cost['movement'], row['movement'])
|
||||
self.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position')))
|
||||
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) for r in rows))
|
||||
self.assertGreaterEqual(fractions.Fraction(cost['secondary_effort']), fractions.Fraction(cost['grip_effort']))
|
||||
self.assertEqual(canonical.path_cost.peak_position, max(r['hand_position'] for r in rows))
|
||||
self.assertEqual(canonical.path_cost.travel, sum(r['movement'] for r in rows))
|
||||
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['secondary_effort']) for r in rows))
|
||||
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
|
||||
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
|
||||
with self.subTest(chord=symbol):
|
||||
@@ -186,21 +186,21 @@ class CompingVoicings(unittest.TestCase):
|
||||
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C')
|
||||
layers = tuple(options(s, part) for s in chords)
|
||||
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
|
||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test')
|
||||
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test')
|
||||
greedy = []
|
||||
for i, layer in enumerate(layers):
|
||||
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
|
||||
self.assertLess(cost, path_cost(greedy, costs, part))
|
||||
self.assertLessEqual(cost, path_cost(greedy, costs, part))
|
||||
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
|
||||
self.assertEqual(cost, path_cost(path, costs, part))
|
||||
self.assertEqual((path, cost), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
|
||||
self.assertEqual((path, cost, hands), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
|
||||
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
|
||||
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers)
|
||||
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v), peak_position=generator.hand_position.base(v)) for v in layer} for layer in layers)
|
||||
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
|
||||
with self.subTest(part=part, endpoints=endpoints):
|
||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
||||
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
||||
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
|
||||
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
|
||||
def test_rest_does_not_reset_the_previous_hand_position(self):
|
||||
@@ -209,7 +209,7 @@ class CompingVoicings(unittest.TestCase):
|
||||
for part in ('acoustic', 'electric'):
|
||||
canonical = generator.comping.Canonical(source, part, 'rest')
|
||||
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
|
||||
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2])
|
||||
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2], *canonical.hand_order[:2])
|
||||
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
|
||||
def test_harmony_variants_are_independent_of_request_order(self):
|
||||
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))
|
||||
@@ -242,13 +242,13 @@ class CompingVoicings(unittest.TestCase):
|
||||
high = generator.comping.Canonical(source, part, 'fixed-rhythm')
|
||||
self.assertEqual(rhythm(low), rhythm(high))
|
||||
def test_accompaniment_cache_matches_clean_generation(self):
|
||||
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
|
||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=3))
|
||||
for part in ('acoustic', 'electric'):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
role = part + '_accompaniment'
|
||||
cold = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
warm = generator.production.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
clean = generator.production.generate_role(source, role, pathlib.Path('data'))
|
||||
cold = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
warm = generator.composition.generate_role(source, role, pathlib.Path('data'), cache_dir=directory)
|
||||
clean = generator.composition.generate_role(source, role, pathlib.Path('data'))
|
||||
self.assertTrue(warm['cache_hit'])
|
||||
self.assertEqual(cold['document'], warm['document'])
|
||||
self.assertEqual(cold['document'], clean['document'])
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.electric
|
||||
import generator.fretboard
|
||||
@@ -110,6 +110,6 @@ class ElectricVoicingTests(unittest.TestCase):
|
||||
before = copy.deepcopy(source)
|
||||
with unittest.mock.patch('generator.electric.reuse_voicings', side_effect=AssertionError('cross-part grip dependency')) as reuse:
|
||||
result = generator.arrange.generate.arrange_document(source, 'electric')
|
||||
generator.model.validate_document(result)
|
||||
generator.validation.validate_document(result)
|
||||
reuse.assert_not_called()
|
||||
self.assertEqual(source, before)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import generator.score_io
|
||||
import tests.production_fixtures
|
||||
import argparse
|
||||
import contextlib
|
||||
@@ -19,11 +20,11 @@ import generator.engraving.cache
|
||||
import generator.engraving.fonts
|
||||
import generator.engraving.layout
|
||||
import generator.engraving.pipeline
|
||||
import generator.engraving.worker
|
||||
import generator.engraving.raster
|
||||
import generator.engraving.types
|
||||
import generator.engraving.validation
|
||||
import generator.pico_artwork
|
||||
import generator.score
|
||||
import tests.solo_fixtures
|
||||
import tests.test_musicxml
|
||||
|
||||
@@ -173,8 +174,8 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
style = generator.engraving.types.Style()
|
||||
documents = [self.document, tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('D')))]
|
||||
pages = [generator.engraving.layout.layout_score(document, style, family).pages[0] for document in documents]
|
||||
result = generator.engraving.pipeline.GroupResult()
|
||||
bands = generator.engraving.pipeline.BodyBands(self.root / 'spool', None, result, limit=1)
|
||||
result = generator.engraving.worker.GroupResult()
|
||||
bands = generator.engraving.worker.BodyBands(self.root / 'spool', None, result, limit=1)
|
||||
self.addCleanup(bands.close)
|
||||
original = None
|
||||
for index in (0, 1, 0):
|
||||
@@ -338,7 +339,7 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
reads.append(path)
|
||||
return read_text(path, *args, **kwargs)
|
||||
with unittest.mock.patch.object(pathlib.Path, 'read_text', read):
|
||||
generator.score.validate_generated_tree(root)
|
||||
generator.score_io.validate_generated_tree(root)
|
||||
self.assertCountEqual(reads, sorted(root.rglob('*.json')))
|
||||
def test_invalid_worker_count_and_overlapping_cache_are_rejected(self):
|
||||
root = self.root / 'assets'
|
||||
@@ -360,10 +361,16 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
(output / 'solo_xml').mkdir()
|
||||
(output / 'solo_xml/stale.xml').write_text('obsolete solo export')
|
||||
(output / 'solo_xml/manifest.solo').write_text('{}')
|
||||
(output / 'print/stale.pdf').write_bytes(b'obsolete print part')
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
self.assertFalse((output / 'solo_xml').exists())
|
||||
self.assertFalse(list(output.rglob('*.xml')))
|
||||
self.assertEqual(len(list(output.glob('print/*.pdf'))), 5)
|
||||
with unittest.mock.patch('generator.printing.generate_tree', side_effect=ValueError('print rendering failed')):
|
||||
with self.assertRaisesRegex(ValueError, 'print rendering failed'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
|
||||
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
|
||||
generator.__main__.generate_assets(args)
|
||||
|
||||
+13
-13
@@ -1,3 +1,5 @@
|
||||
import generator.score_io
|
||||
import generator.validation
|
||||
import copy
|
||||
import fractions
|
||||
import pathlib
|
||||
@@ -14,9 +16,6 @@ import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.score
|
||||
import generator.solo.analysis
|
||||
import generator.solo.placement
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic_voicing
|
||||
import tests.test_musicxml
|
||||
@@ -57,7 +56,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)}
|
||||
# Movement/family wins cannot purchase entry into the four-fret tier.
|
||||
costs[primary] = costs[primary]._replace(effort=100000)
|
||||
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
||||
path, cost, hands = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
||||
self.assertEqual(path, (primary,))
|
||||
self.assertEqual(cost.extended_grips, 0)
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
|
||||
@@ -69,7 +68,8 @@ class FretPolicyTests(unittest.TestCase):
|
||||
best = choices[0]
|
||||
self.assertEqual(generator.fretboard.span_penalty(best), 0)
|
||||
self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2)
|
||||
self.assertEqual(max(p.midi for p in best), 65)
|
||||
self.assertIn(65, {p.midi for p in best})
|
||||
self.assertIn(1, {p.string_number for p in best})
|
||||
self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices))
|
||||
primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000)
|
||||
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
|
||||
@@ -97,7 +97,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6))))
|
||||
before = tests.score_events.timeline(source)
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
self.assertEqual(tests.score_events.timeline(source), before)
|
||||
self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}])
|
||||
@@ -114,7 +114,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True)
|
||||
self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1)
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']])
|
||||
# Removing the low anchor early still leaves an illegal high single tail.
|
||||
@@ -151,7 +151,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
|
||||
if valid:
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
else:
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -162,7 +162,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
source = document((position(2, 12), position(1, 14)))
|
||||
anchor = tests.test_musicxml.notes(source)[0]['pitches'][0]
|
||||
anchor.update(duration=generator.model.rational(2), written=generator.model.written_for(fractions.Fraction(2)))
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
||||
generator.fretboard.validate_document(source)
|
||||
with self.assertRaisesRegex(ValueError, 'no guitar placement'):
|
||||
@@ -175,12 +175,12 @@ class FretPolicyTests(unittest.TestCase):
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
source['staves'][0]['measures'][0]['voices'][0]['notes'] = split
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
self.assertEqual([p['tablature'] for p in split[0]['pitches']], [p['tablature'] for p in split[1]['pitches']])
|
||||
split[0]['pitches'][0].pop('tie_to')
|
||||
split[1]['pitches'].pop(0)
|
||||
generator.model.validate_document(source)
|
||||
generator.validation.validate_document(source)
|
||||
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
||||
generator.fretboard.validate_document(source)
|
||||
def test_another_voice_cannot_supply_a_single_notes_anchor(self):
|
||||
@@ -221,9 +221,9 @@ class FretPolicyTests(unittest.TestCase):
|
||||
target = document(tuple(position(s, 12) for s in (3, 2, 1)))
|
||||
reference, note = tests.test_musicxml.notes(source)[0], tests.test_musicxml.notes(target)[0]
|
||||
generator.electric.reuse_voicings([generator.electric.VoicingLink(note, (reference,))])
|
||||
generator.model.validate_document(target)
|
||||
generator.validation.validate_document(target)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = pathlib.Path(directory) / 'score.json'
|
||||
with self.assertRaisesRegex(ValueError, 'each sounding chord requires an anchor'):
|
||||
generator.score.write_json(output, target)
|
||||
generator.score_io.write_json(output, target)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Dependency direction, cache coverage and explicit composition inputs."""
|
||||
import ast
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import generator.cache
|
||||
import generator.composition
|
||||
import generator.role_cache
|
||||
import generator.source
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
|
||||
class GeneratorBoundaries(unittest.TestCase):
|
||||
def test_generator_imports_are_acyclic(self):
|
||||
files = {'.'.join(path.relative_to(ROOT).with_suffix('').parts): path for path in (ROOT / 'generator').rglob('*.py')}
|
||||
graph = {name: set(generator.cache.imports(path.read_bytes())) & files.keys() for name, path in files.items()}
|
||||
visited, active = set(), []
|
||||
def visit(name):
|
||||
self.assertNotIn(name, active, ' -> '.join([*active, name]))
|
||||
if name in visited:
|
||||
return
|
||||
active.append(name)
|
||||
for dependency in graph[name]:
|
||||
visit(dependency)
|
||||
active.pop()
|
||||
visited.add(name)
|
||||
for name in graph:
|
||||
visit(name)
|
||||
def test_composition_import_does_not_load_rendering_or_publication(self):
|
||||
code = "import generator.composition, sys, json; print(json.dumps(sorted(sys.modules)))"
|
||||
result = subprocess.run([sys.executable, '-c', code], cwd=ROOT, capture_output=True, text=True, check=True)
|
||||
forbidden = ('PIL', 'fontTools', 'generator.engraving', 'generator.production', 'generator.score_io')
|
||||
self.assertFalse([name for name in json.loads(result.stdout) if name.startswith(forbidden)])
|
||||
def test_all_previously_missing_role_dependencies_change_the_fingerprint(self):
|
||||
baseline = generator.role_cache.code_key()
|
||||
read = pathlib.Path.read_bytes
|
||||
for name in ('solo/policy.py', 'source.py', 'accompaniment.py'):
|
||||
changed = ROOT / 'generator' / name
|
||||
with self.subTest(dependency=name):
|
||||
def contents(path):
|
||||
return read(path) + (b'\n# changed implementation\n' if path == changed else b'')
|
||||
with unittest.mock.patch.object(pathlib.Path, 'read_bytes', contents):
|
||||
self.assertNotEqual(generator.role_cache.code_key(), baseline)
|
||||
def test_context_owns_immutable_resources_and_survives_caller_mutation(self):
|
||||
resources = generator.vocabulary.load()
|
||||
context = generator.vocabulary.CompositionContext.create(resources, 'electric_accompaniment')
|
||||
expected = context.role()['center']
|
||||
resources['roles']['roles']['electric_accompaniment']['center'] = 999
|
||||
self.assertEqual(context.role()['center'], expected)
|
||||
with self.assertRaises(TypeError):
|
||||
context.data('roles')['roles']['electric_accompaniment']['center'] = 999
|
||||
self.assertNotEqual(generator.vocabulary.load()['roles']['roles']['electric_accompaniment']['center'], 999)
|
||||
def test_role_generation_does_not_consult_ambient_policy(self):
|
||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=1, chords=('CM7',)))
|
||||
with unittest.mock.patch('generator.vocabulary.current', side_effect=AssertionError('implicit policy dependency')):
|
||||
for role in generator.composition_types.ROLES:
|
||||
with self.subTest(role=role):
|
||||
generator.composition.generate_role(source, role, ROOT / 'data', {'predict': True})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user