Compare commits

..

4 Commits

Author SHA1 Message Date
khodowany d2f493c17b Playback Mode Pages
ci / checks (push) Successful in 50m29s
2026-10-06 23:11:25 -05:00
khodowany 125aaa1637 Comping Gen 2026-10-06 22:13:50 -05:00
khodowany 2f21ec1f16 Acoustic Comping 2026-10-06 21:50:59 -05:00
khodowany c7f6a30a13 Comping 2026-10-06 21:28:42 -05:00
59 changed files with 3886 additions and 636 deletions
+10 -6
View File
@@ -102,7 +102,11 @@ 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. 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 rhythm is light at every tempo: normally 0–2 electric chord attacks, 1–2 acoustic bass attacks, and 1–3 acoustic upper-chord attacks per full measure. Bass attacks beat one wherever harmony is available. Acoustic attacks last a quarter or eighth note; electric chords mix stabs, sustains and space. Both consider the original melody and harmonic rhythm. Acoustic bass and upper notes appear in one rhythmic voice on the same staff/TAB, with ties preserving any overlapping durations. Combined attacks are evaluated for crowding. An extra attack is permitted only for harmonic changes and reported. Compatible electric holds can tie across barlines. The selected accompaniment repeats at corresponding form positions, with source-defined pickup/handoff exceptions, and does not change when only tempo changes. 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.
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.
**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. **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.
@@ -134,25 +138,25 @@ Add a source and its authored solo, then retrain the pooled model, without chang
To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations. To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations.
The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six main performance sections plus the held Ending as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure.
All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded. All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged. There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product. Normal browsing uses two music systems per page, with a single system only when the score ends on an odd system. Playback uses overlapping pairs (1–2, 2–3, 3–4): each new active line moves to the top, revealing the next below. The final line moves to the top with an empty lower half. Measure numbers sit above their starting barlines in a clear lane; playback outlines the active measure number in the accent color. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The six-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass.
Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there. Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there.
A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. There is no separate Ending section. The current 32-bar songs contain 168 numbered bars. Optional turnaround chords are omitted from the final vocal pass. A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. A final Ending section adds one guitar chord attack sustained across three full measures, with vocal rests. The current 32-bar songs contain 171 numbered bars. The ending holds the final performed harmony (GM6 for All of Me, Dm for Autumn Leaves). Optional turnaround chords are omitted from the final vocal pass.
Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/<song_id>_<bpm>.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings. Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/<song_id>_<bpm>.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings.
Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them. Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them.
Backing recordings include one bar of count-in, the complete score, and the held ending with decay. The recorded count-in plays alone. When both toggles are on, metronome clicks start at the score and stop at its end, leaving the last three held bars and decay unclicked. Metronome-only mode supplies its own count-in and stops at score end. Pages follow the rendered audio clock, using each engraved page's actual measure range; the final page stays visible through the backing tail. Backing recordings include one bar of count-in, the complete arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. Playback line turns and measure highlighting follow the rendered audio clock through all 171 measures, including rests and ties; the final line stays visible through the recording decay. Preparation and count-in have no highlighted measure.
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
+2 -2
View File
@@ -59,6 +59,6 @@ Controls:
- Double tap: previous menu item, or previous page when both audio toggles are off. - Double tap: previous menu item, or previous page when both audio toggles are off.
- Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it. - Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it.
Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks accompany the score but stop before the three-bar held tail. Hold exits at any time. Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks stop at the written three-bar Ending section; page timing continues through the hold. Hold exits at any time.
Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `<normalized_song_id>_<bpm>.wav`. Supply separate files at 60, 70, 80, 90, 100, 110 and 120 BPM, each with one bar of count-in, the complete eight-bar intro plus five body passes, and its held ending. Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs. Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `<normalized_song_id>_<bpm>.wav`. Supply separate files at 60, 70, 80, 90, 100, 110 and 120 BPM, each with one bar of count-in, the complete eight-bar intro plus five body passes, and its three-bar held ending plus decay (171 written bars for the current songs). Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs.
+32 -10
View File
@@ -97,11 +97,11 @@ Parenthesized chord symbols mark optional turnarounds, including Soundslice's te
### Linear Reading Order ### Linear Reading Order
There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal) explicitly onto successive pages; neither display nor playback jumps backward. There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal), followed by three held ending measures, explicitly onto successive pages; neither display nor playback jumps backward.
Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports.
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 168 arrangement bars, including the eight-bar intro. 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.
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. 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.
@@ -127,29 +127,51 @@ Electric accompaniment shares the electric melody's `guitar.chord_positions(...,
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. 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 grip effort plus hand-center travel, missing optional tones and register preference. Acoustic prefers declared degree-order families on either bass string, then travel of the bass and all three upper voices, optional-tone coverage and register preference. 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, 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.
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. 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` constructs whole-measure rhythms from version 3 of `composition/accompaniment.json`, with instrument-specific duration and voicing families. Normal full-measure ranges are 0–2 electric chord attacks, 1–2 acoustic bass attacks and 1–3 acoustic upper-chord attacks. Bass starts on beat one whenever harmony is available there. An absent harmony or N.C. remains silent. Acoustic attacks last exactly a quarter or eighth note, with same-layer attacks at least an eighth apart. Electric retains short stabs, quarters, longer holds and alternating durations. Exact chord offsets constrain sounding intervals; short partial bars have bounded gestures. A required acoustic attack with less than an eighth of valid harmony fails explicitly rather than creating a sixteenth or silently quantizing the change. Rapid harmonic changes can authorize one extra attack per layer, recorded as an exception, without assigning a fresh density budget to every chord. `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.
The original head supplies entrances, moving-note intervals and space; the actual progression supplies harmonic support requirements and release boundaries. Candidates retain distinct attack counts and articulation families before the cutoff. Acoustic bass/upper candidates are paired and scored for combined crowding, counting coincident attacks as one action. A bounded dynamic program selects the form's complete rhythm path using source-context costs and preferences against immediate whole-bar repetition. This chooses from retained candidates, not every possible rhythm. Compatible electric sustained neighbors with identical harmony and grip can join across a barline without reattack; acoustic automatic hold joining is disabled. Changing only tempo leaves these musical decisions unchanged. Tempo remains in the concrete asset cache key so score metadata refreshes correctly. 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.
Acoustic bass and upper events are planned as two layers, 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 per-measure families, attack counts, overlaps, ties and exceptions. Their `selection_cost` records local rhythm-candidate contributions before cross-bar joining; the form selector also applies the 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. 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.
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.
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.
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.
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. 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.
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.
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.
With no concurrent build, isolated cold role generation measured 0.56–0.57 seconds for acoustic accompaniment, 0.35–0.37 for electric accompaniment, and 0.26–0.29 for electric melody; cache hits took 0.02–0.04 seconds and matched clean regeneration for both songs. The normal two-worker native asset stage took about 14 seconds (4 composing, 9 engraving, 1 other); ARM reused the caches in about 4 seconds. Application compilation is separate from these asset-stage timings.
Lower-position verification on October 6, 2026: all 400 Python tests and 124 native test cases passed. Native and ARM builds passed asset checks; all 139 score/page files matched byte for byte. Against the saved production output, acoustic rhythms and all other five role documents were identical for both songs. Mean fret across canonical active acoustic grips changed from 8.90 to 4.50 in Autumn Leaves and 5.19 to 4.29 in All of Me; movement costs rose from 144 to 190 and 128 to 140 respectively. In the requested six-chord passage, five suggested grips won; Em7b5 instead chose its valid fifth-bass shape on strings 6/4/3/2 at frets 6/5/7/5. Tests also verify that the complete suggested sequence beats the previous higher sequence with fixed neighbors, and that sufficiently costly travel can still retain a higher grip. These are score/selection facts, not an ear-based audition.
Isolated uncached acoustic role generation took 0.61–0.62 seconds per song, versus 0.56–0.58 seconds before this change; cache hits took about 0.048 seconds and matched clean output. With two composition and engraving workers, the native asset rebuild took approximately 11 seconds (4 composing, 6 engraving, 1 other); ARM reused caches and took approximately 6 seconds. These are individual local measurements, not a statistical benchmark. Supplied recordings remain byte-identical.
### Recording Playback and Page Timing ### Recording Playback and Page Timing
Vamp uses only supplied PCM16 WAVs. `package/assets/sounds/beat1.wav` and `beat2.wav` provide measure accents and ordinary beats. Backing files are `package/assets/backing/<normalized_source_id>_<bpm>.wav`. `generator/performance.py` checks containment, format, decoded length and checksums, then copies the recordings byte for byte into the staged output. A catalog `resources` row stores their paths, checksums, sample rates, channels, frame counts, song IDs and tempos. No note-event audio compiler, pitch synthesizer, release envelope or generated audition audio remains. Pitch mathematics remains for notation, TAB, composition and exact MusicXML comparisons. Vamp uses only supplied PCM16 WAVs. `package/assets/sounds/beat1.wav` and `beat2.wav` provide measure accents and ordinary beats. Backing files are `package/assets/backing/<normalized_source_id>_<bpm>.wav`. `generator/performance.py` checks containment, format, decoded length and checksums, then copies the recordings byte for byte into the staged output. A catalog `resources` row stores their paths, checksums, sample rates, channels, frame counts, song IDs and tempos. No note-event audio compiler, pitch synthesizer, release envelope or generated audition audio remains. Pitch mathematics remains for notation, TAB, composition and exact MusicXML comparisons.
Settings exposes `audio.backing_enabled`, `audio.metronome_enabled` and `audio.tempo_bpm`; both toggles default off and tempo defaults to 120. The fixed tempo set is 60 through 120 in steps of 10. Backing restricts it to the intersection available for all catalog songs. Turning it on snaps to the nearest available tempo, with ties going slower; an empty intersection makes it unavailable. Obsolete playback and configurable tempo-range settings are rejected. Recordings are never stretched or trimmed. Settings exposes `audio.backing_enabled`, `audio.metronome_enabled` and `audio.tempo_bpm`; both toggles default off and tempo defaults to 120. The fixed tempo set is 60 through 120 in steps of 10. Backing restricts it to the intersection available for all catalog songs. Turning it on snaps to the nearest available tempo, with ties going slower; an empty intersection makes it unavailable. Obsolete playback and configurable tempo-range settings are rejected. Recordings are never stretched or trimmed.
The existing recordings contain one bar of count-in, 168 score bars, then three held bars and decay. Backing-only plays the complete file. Both toggles play the recorded count-in alone, then backing plus sample clicks during the score; no clicks play over the held ending. Metronome-only supplies its own one-bar count-in and completes at score end. Both off enables manual page navigation. Opening a part with either toggle on starts automatic mode. Next/previous are ignored during automatic mode; hold exits, and natural completion returns to the selected part menu. The final page remains displayed through a recording tail. The existing recordings contain one bar of count-in, 168 main-form bars, three held bars and decay. `arrange/ending.py` now writes that existing tail as measures 169–171, without extending or transforming the recordings. Both accompaniment roles choose a valid closing voicing using their own outgoing position and hold it with ties; melody/solo roles are silent there and the vocal has three full rests. The chord is the final effective harmony after omitting optional turnarounds, including carried harmony. Missing/N.C. final harmony fails accompaniment generation explicitly. The original final bar is untouched.
Preparation runs on a worker: checksums and samples are loaded before publishing a Sonus recording transport. Sonus streams backing audio through miniaudio’s background resource manager and mixes preloaded stereo clicks on the same rendered-frame clock. Callback work uses preallocated buffers, and resource retirement occurs on control threads. Stream failures stop progress and surface an error. Absolute rational beat positions are rounded to frames at 48 kHz once per position, avoiding accumulated rounding drift. The UI polls that audio clock for page boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress. Score timing includes `sustain_start`, an exact measure boundary, as well as the total written duration. Ending measures carry `ending_sustain`; Python and C++ validate a contiguous tail and consistent timing. Older scores without the new timing field retain their previous full-duration click behavior. Both toggles play the recorded count-in alone, then backing plus sample clicks until `sustain_start`. No clicks sound over the ending. Metronome-only supplies its own count-in and completes after the three silent held bars. Backing-only plays the complete file and decay. Both off enables manual navigation; hold exits and natural completion returns to the part menu. The final page remains visible through decay.
Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. The UI prepares the current and next page before playback starts and retains a two-texture cache. Session and catalog identities reject stale preparation, page updates and completion after cancellation. Missing or corrupt pages/audio leave a visible error and permit hold/back. `--once` validates packaged recording checksums and all score pages without opening audio/display hardware. Preparation runs on a worker: checksums and samples are loaded before publishing a Sonus recording transport. Sonus streams backing audio through miniaudio’s background resource manager and mixes preloaded stereo clicks on the same rendered-frame clock. Callback work uses preallocated buffers, and resource retirement occurs on control threads. Stream failures stop progress and surface an error. Absolute rational beat positions are rounded to frames at 48 kHz once per position, avoiding accumulated rounding drift. The UI polls that audio clock for system and measure boundaries and Count-in/Playing/Sustain status; wall-clock timers never determine musical progress.
Page metadata comes from actual system pagination and is preserved and revalidated in engraving caches. `presentation.pages` partitions the music into normal two-system pages. `presentation.playback_pages` holds one view per system: that system and its successor, or a final single top system. Both reuse the same system breaks, horizontal geometry, and part-wide scale, fitted against every adjacent pair. Each descriptor carries system measure ranges, exact beat boundaries, and page-coordinate rectangles for its measure labels. Normal systems partition the score; overlapping playback views must match that canonical partition. Bundles missing playback metadata require regeneration.
Measure numbers are centered over their starting barlines (the staff’s left edge at system start), in a reserved lane above notation and below harmony/section labels. The display outlines the current label using the page texture’s letterbox transform; count-in, preparation, exit, and playback error clear it. Measure updates publish even when the line stays unchanged. Normal catalog page counts remain unchanged; the loader reads the separate playback count from bundle metadata. During automatic playback, navigation bounds, the header’s Line counter, and Pico’s page/pages fields refer to overlapping views. Manual browsing uses the normal sequence. The UI prepares the current and next page before playback starts and retains a two-texture cache. Session and catalog identities reject stale preparation, page updates and completion after cancellation. Missing or corrupt pages/audio leave a visible error and permit hold/back. `--once` validates packaged recording checksums and both normal and playback score pages without opening audio/display hardware.
### Authored Repertoire and Learned Solos ### Authored Repertoire and Learned Solos
@@ -243,7 +265,7 @@ Voices retain separate stems and collision offsets; chord tones retain their own
Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment. Generated guitar tones carry `tablature: {string, fret}`; their staff is marked `tablature: true`. `engraving/tablature.py` draws a six-line TAB view below standard notation, sharing onset columns, barlines, and page breaks. Both views belong to one system and never duplicate the semantic timeline or playback events. Standard notation carries rhythm, rests, and tuplets; TAB carries positions, solo slashes, and tied continuations without rest glyphs. Strings and frets are validated against sounding pitch, and the layout audit checks each visible TAB numeral's position and alignment.
Every part uses two music systems per page; only an odd final system occupies a row alone. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest pair. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures. Normal pages use two music systems; only an odd final system occupies a row alone. Playback pages use overlapping adjacent pairs and always finish with the last system alone on top. A compact title/composer/part header reserves the same height on every page, wrapping credits if necessary. Pagination fits the two systems using their individual heights and distributes extra whitespace around them while retaining minimum system and header/footer clearances. One uniform scale applies throughout the entire part, reducing the ordinary notation size only when required by the tallest adjacent pair, including transition pages. Notation, TAB, text, and anchors scale together without changing musical content. Guitar layout starts with 10-pixel notation spacing, 10.5-pixel TAB spacing, 12-pixel fret labels, and a 14-pixel minimum gap between systems. The unscaled TAB spacing accommodates Petaluma numerals up to 9.75 pixels tall. Tempo markings consume neither ink nor spacing; their semantic data is preserved. Existing `.pages/*.png` preview files are the exact packaged page textures.
Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage. Line breaks occur only at measure boundaries. Each line fits as many complete measures as readable spacing allows; it never fills remaining space with a partial measure. A complete measure exceeding the available width or height fails with source and measure context instead of being split or squeezed to fit. Authored ties, beams, tuplets, and lyric spans can continue across whole-measure line breaks. Column measurement and fit trials reuse a score index and bounded column cache. Page placement then re-spaces columns for the final height-fitting scale, keeping every notation/TAB staff at the same physical left and right margins without stretching glyphs. Non-final lines are justified; a short final line retains natural note spacing while staff lines extend to the same right margin. Layout audits enforce these margins as well as musical coverage.
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -8,15 +8,31 @@
"acoustic_accompaniment": { "acoustic_accompaniment": {
"seed": "acoustic_comp", "seed": "acoustic_comp",
"bass": "primary", "bass": "primary",
"center": 53 "center": 53,
"voicing": {
"register_weight": 1,
"high_position_weight": 1,
"comfortable_max_fret": 9
}
}, },
"electric_accompaniment": { "electric_accompaniment": {
"seed": "electric_comp", "seed": "electric_comp",
"bass": "essential", "bass": "essential",
"center": 60 "center": 60,
"voicing": {
"register_weight": 1,
"high_position_weight": 1,
"comfortable_max_fret": 9
}
}, },
"electric_melody": { "electric_melody": {
"seed": "head" "seed": "head",
"center": 60,
"voicing": {
"register_weight": 1,
"high_position_weight": 1,
"comfortable_max_fret": 9
}
}, },
"vocal_melody": { "vocal_melody": {
"seed": "voice" "seed": "voice"
+46
View File
@@ -6,6 +6,7 @@ import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
import generator.score
@dataclasses.dataclass @dataclasses.dataclass
class HarmonySpan: class HarmonySpan:
@@ -107,3 +108,48 @@ def combine(document: dict) -> None:
a = fragments[start][-1] if start in fragments else originals[start] a = fragments[start][-1] if start in fragments else originals[start]
b = fragments[end][0] if end in fragments else originals[end] b = fragments[end][0] if end in fragments else originals[end]
a['tie_to'], b['tie_from'] = b['id'], a['id'] 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."""
tones, starts, cursor = {}, {}, fractions.Fraction()
for measure in document['staves'][0]['measures']:
for note in generator.score.measure_notes(measure):
for pitch in note['pitches']:
tones[pitch['id']] = pitch
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
cursor += generator.model.fraction(measure['duration'])
groups = {}
for identifier, pitch in tones.items():
if pitch.get('tie_from') in tones:
continue
start, tail = starts[identifier], pitch
seen = {identifier}
while tail.get('tie_to') in tones:
target = tail['tie_to']
if target in seen:
raise ValueError('cyclic acoustic sustain')
seen.add(target)
tail = tones[target]
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')
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')
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
+10 -2
View File
@@ -50,7 +50,15 @@ def audit(source, documents):
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'}) exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
continue continue
position = section.source_measure_index + j position = section.source_measure_index + j
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j]))) # A complete figure depends on both measures' effective harmony and
# source-defined participation. Changed final turnarounds/pickups are
# explicit contexts, not incidental changes between iterations.
first = generator.score.body_start_measure_index(authored)
block_start = first + 2 * ((position - first) // 2)
neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2))
context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors)
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']), 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'])) 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']) for v in m['voices'] for n in v['notes'] if n['pitches'])
@@ -60,7 +68,7 @@ def audit(source, documents):
reused += 1 reused += 1
else: else:
if any(k[0] == key[0] for k in seen): if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'}) exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
seen[key] = signature seen[key] = signature
offset += section.measure_count offset += section.measure_count
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions} result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
+80
View File
@@ -0,0 +1,80 @@
"""The recorded three-bar hold, expressed once as source-derived score time."""
import copy
import fractions
import generator.accompaniment
import generator.acoustic_voicing
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()
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):
if not source or not output:
return
model = 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)
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):
for p in n['pitches']:
if 'tablature' in p:
tab = p['tablature']
recent.setdefault(tab['string'], generator.fretboard.TabPosition(tab['string'], tab['fret'], int(generator.model.pitch_number(p)) - 12))
if len(recent) >= 4:
break
previous = tuple(recent[s] for s in sorted(recent, reverse=True))
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))
length = generator.model.fraction(model['duration'])
previous = None
for index in range(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:
measure['harmonies'] = [{**copy.deepcopy(harmony), 'onset': generator.model.rational(0)}]
measure['harmonies'][0].pop('chord_optional', None)
if grip:
notes, cursor = [], fractions.Fraction()
for duration, written in generator.accompaniment.written_durations(length):
n = generator.model.make_event(f'ending_{part}_{index}_{len(notes)}', cursor, duration, 'note', generator.fretboard.chord_pitches(harmony, grip), written)
n['voice'] = 'voice_arranged'
if previous:
for a, b in zip(previous['pitches'], n['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes.append(n)
previous = n
cursor += duration
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
output.append(measure)
generator.score.reidentify(output[-BARS:], [])
+17 -1
View File
@@ -8,6 +8,7 @@ import generator.accompaniment
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.acoustic import generator.acoustic
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.ending
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup import generator.arrange.pickup
import generator.electric import generator.electric
@@ -61,10 +62,20 @@ 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) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures) previous = generator.accompaniment.carried_chords(section_measures)
segment = [] segment = []
stop = None
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): for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si] m = section_measures[si]
if role == 'accompaniment' and accompaniment is not None: if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si) result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
result['id'] = f'measure_{len(output) + len(segment) + 1}' result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else '' result['section_label'] = section.label if not segment else ''
else: else:
@@ -97,6 +108,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)}) octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
output.extend(segment) output.extend(segment)
absolute += segment_length absolute += segment_length
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))
tuplets = generator.arrange.pickup.retain_links(output, tuplets) 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 = 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) result['metadata'].setdefault('tempo', 124)
@@ -104,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
if accompaniment is None and (owned is None or 'accompaniment' in owned): if accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result) generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result) generator.acoustic_voicing.place(result)
generator.acoustic.validate_comping(result)
generator.acoustic.combine(result) generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned): elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result) generator.melody.harmonize(result)
+173 -116
View File
@@ -67,7 +67,7 @@ def movement(part, previous, current):
class VoicingCost(typing.NamedTuple): class VoicingCost(typing.NamedTuple):
extended_grips: int = 0 extended_grips: int = 0
family: int = 0 family: int = 0
effort: int = 0 effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0 missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction() register: fractions.Fraction = fractions.Fraction()
def plus(self, other): def plus(self, other):
@@ -75,21 +75,27 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance): def moved(self, distance):
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
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):
required = requirements(json.loads(key), part) required = requirements(json.loads(key), part)
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center) register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
preference = sum(voicing_preferences(part, grip, center))
if part == 'electric': if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed) value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register) 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 whole-form movement. # 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) family = generator.chord_families.acoustic(json.loads(key), grip)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register) return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False): def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
"""Exact small-state dynamic programming; never prune by hand center. """Exact small-state dynamic programming; never prune by hand center.
Electric effort includes its melody grip cost and hand-center travel. Both roles balance register/position with travel. Electric includes its
Acoustic effort follows all four voices, including a bass-string change. 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. Fixing the first grip permits an exact repeat-join cost without a beam.
""" """
if not layers: if not layers:
@@ -135,91 +141,157 @@ class HarmonyChoice:
def key(self): def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events) return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical: class Canonical:
def __init__(self, document, part, seed): def __init__(self, document, part, seed):
self.part, self.seed = part, seed self.part, self.seed = part, seed
self.policy = generator.vocabulary.role(part + '_accompaniment') self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {} self.cache, self._reports, self._rhythm_reports = {}, {}, {}
measures = generator.score.source_staff(document)['measures'] self.measures = generator.score.source_staff(document)['measures']
self.melody = generator.comping_rhythm.melody_contexts(document) self.melody = generator.comping_rhythm.melody_contexts(document)
first_index = generator.score.body_start_measure_index(measures) self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(measures, part) sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures) self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:])) and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(measures) carried = generator.accompaniment.carried_chords(self.measures)
inputs = [] self.groups = []
for index in range(first_index, len(measures)): index = self.first
source = copy.deepcopy(measures[index]) while index < len(self.measures):
for chord in source['harmonies']: size = 1
chord.pop('chord_optional', None) if index + 1 < len(self.measures):
inputs.append((source, carried[index], index)) lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs) if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
rhythms = generator.comping_rhythm.select(layers, seed) size = 2
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)} self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)] index += size
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events) layers = [self.candidates(group) for group in self.groups]
selected = generator.comping_rhythm.select(layers, seed)
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) path, self.path_cost = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path)) self.base = dict(zip((s.key for s in active), path))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path)) 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] if self.closed and path else ()
for source, prior, index, spans in prepared: for group, rhythm, spans in zip(self.groups, selected, prepared):
selected = tuple(self.base[s.key] for s in spans if s.events) grips = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False) self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
if selected: cursor = fractions.Fraction()
previous = selected[-1] for m, _, i in group:
if generator.vocabulary.accompaniment(part)['connect_holds']: length = generator.model.fraction(m['duration'])
self.connect_holds(inputs) self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property @property
def report(self): def report(self):
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]] return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property @property
def rhythm_report(self): def rhythm_report(self):
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))] return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod @staticmethod
def key(source, prior, index): def key(inputs, limit=None):
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)), return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
source['duration'].__repr__()) def candidates(self, inputs, limit=None):
def prepare(self, source, prior, index, rhythm): flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
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)
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = [] spans = []
for start, end, chord in generator.comping_rhythm.spans(source, prior): offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord: if not chord:
continue continue
events = tuple(e for e in rhythm.events if start <= e.start < end) 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: try:
options = voicings(semantic_key(chord), self.part) options = voicings(semantic_key(chord), self.part)
except ValueError as error: except ValueError as error:
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from 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)) spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
self._rhythm_reports[self.key(source, prior, index)] = {
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
return tuple(spans) return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False, fixed=None): def select(self, spans, *, left=(), right=(), close=False):
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans) 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} 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))
for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close) return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index): def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
key = self.key(source, prior, index) group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
sources = sources or self.measures
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)
limit = None
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)
if key not in self.cache: if key not in self.cache:
# Effective pickup/turnaround harmony may differ from the main form. choices = self.candidates(group, limit)
# Solve that measure between fixed canonical neighbors. Request order start, end = group[0][2], group[-1][2]
# and previously generated arrangement sections cannot change it. # Preserve the canonical whole figure where still legal under changed
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index)) # effective harmony. Recompute only genuinely different source context.
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0] original = self.block_rhythms.get(start)
spans = self.prepare(source, prior, index, rhythm) matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
before = [v for i, _, v in self.order if i < index] 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]
after = [v for i, _, v in self.order if i > index] 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 () 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 () 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, fixed=self.base) selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(source, prior, index, spans, selected, left, True) self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
return copy.deepcopy(self.cache[key]) return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, source, prior, index, spans, selected, previous, variant): def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
length = generator.model.fraction(source['duration']) flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], [] attacks, rows = [], []
choices = iter(selected) choices = iter(selected)
for span in spans: for span in spans:
@@ -227,8 +299,8 @@ class Canonical:
bass = grip[0] if grip and self.part == 'acoustic' else None bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord) degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter'])) root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, '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': (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_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
'fret_span': generator.fretboard.fret_span(grip), 'fret_span': generator.fretboard.fret_span(grip),
@@ -238,67 +310,52 @@ class Canonical:
'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_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, 'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)}) 'movement': movement(self.part, previous, grip), '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)}
for event in span.events: for event in span.events:
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
attacks.append((event.lane, event.start, event.end, positions, span.chord)) 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: if grip:
previous = grip previous = grip
self._reports[self.key(source, prior, index)] = rows key = self.key(inputs, limit)
self._reports[key] = rows
voices = [] voices = []
notes, cursor, lane = [], fractions.Fraction(), ''
def emit(a, b, positions=(), chord=None):
nonlocal cursor
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
if self.part == 'acoustic':
n['stem'] = 'down' if lane == 'bass' else 'up'
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
cursor = b
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']: for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
notes, cursor = [], fractions.Fraction() 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
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]): for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start: if cursor < start:
emit(cursor, start) emit(cursor, start)
emit(start, end, positions, chord) emit(start, end, positions, chord)
cursor = end
if cursor < length: if cursor < length:
emit(cursor, length) emit(cursor, length)
voices.append({'id': lane, 'notes': notes}) voices.append({'id': lane, 'notes': notes})
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior) result = split_measures({'voices': voices}, inputs, self.part)
result['voices'] = voices 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,
'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
def connect_holds(self, inputs):
"""Only explicit sustained neighbors can share an attack across a barline.
These are canonical internal joins, never links across role boundaries.
The direct reattack remains when a grip or required attack count differs.
"""
for left, right in zip(inputs, inputs[1:]):
before = self.cache[self.key(*left)]
after = self.cache[self.key(*right)]
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
if not generator.accompaniment.same_chord(previous_chord, next_chord):
continue
for a, b in zip(before['voices'], after['voices']):
if a['id'] == 'bass':
continue
old, new = a['notes'][-1], b['notes'][0]
minimum = 1 if a['id'] == 'upper' else 0
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
continue
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
continue
for p, q in zip(old['pitches'], new['pitches']):
p['tie_to'], q['tie_from'] = q['id'], p['id']
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
row['attacks'] -= 1
row['tied_continuations'] += 1
+143 -134
View File
@@ -1,7 +1,6 @@
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly.""" """Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses import dataclasses
import fractions import fractions
import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
@@ -14,6 +13,7 @@ class Attack:
lane: str lane: str
start: fractions.Fraction start: fractions.Fraction
end: fractions.Fraction end: fractions.Fraction
harmonic_time: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Rhythm: class Rhythm:
@@ -21,12 +21,13 @@ class Rhythm:
families: tuple families: tuple
contributions: tuple contributions: tuple
exceptions: tuple = () exceptions: tuple = ()
pattern_id: str = ''
@property @property
def cost(self): def cost(self):
return sum(value for _, value in self.contributions) return sum(value for _, value in self.contributions)
@property @property
def signature(self): def signature(self):
return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events) 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): def melody_contexts(document):
melody = generator.solo.analysis.attacks(document) melody = generator.solo.analysis.attacks(document)
@@ -49,151 +50,159 @@ def spans(measure, prior):
result.append((a, b, chord)) result.append((a, b, chord))
return tuple(result) return tuple(result)
def lane_score(events, lane, melody, harmony, length): def flatten(inputs):
"""Named source-context preferences, independent of the deterministic seed.""" """Harmony/timing adapter only; never concatenate separately chosen rhythms."""
attacks = [a for a, b in melody if a >= 0] first = inputs[0][0]
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction()) result = {**first, 'harmonies': []}
space = max(0, length - occupied) cursor = fractions.Fraction()
busy = len(attacks) >= length for m, prior, _ in inputs:
if lane == 'bass': chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony)) initial = chord or {'kind': 'none'}
elif lane == 'upper': result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
target = 2.5 if space >= length / 2 else 1.5 if busy else 2 result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset']))
else: cursor += generator.model.fraction(m['duration'])
target = .5 if busy else 1.5 if space >= length / 2 else 1 result['duration'] = generator.model.rational(cursor)
# A half-attack tolerance makes neighboring densities genuine alternatives. return result
# The objective does not always fill up to a layer's permitted maximum.
activity = .35 * max(0, abs(len(events) - target) - .5) def validate_events(events, part, lengths=None):
support, articulation = 0., 0. ordered = sorted(events, key=lambda e: e.start)
for e in events: for e in ordered:
# Supporting an entrance is different from doubling every moving note. duration = e.end - e.start
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody)) if duration <= 0:
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody) raise ValueError('comping event must have positive sounding duration')
gap = not any(a <= e.start < b for a, b in melody) if part == 'acoustic':
if lane != 'bass': if e.lane == 'combined':
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0 if duration <= 1:
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0 raise ValueError('combined acoustic sustain must exceed one beat')
elif e.start and e.start.denominator != 1: elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
support += .15 raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
divisor = max(1, len(events)) elif e.lane != 'electric':
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor)) 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
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
"""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)
harmony = spans(measure, prior)
events, exceptions, available = [], [], []
for row in pattern['events']:
start = fractions.Fraction(row['onset'])
end = min(start + fractions.Fraction(row['duration']), limit)
if start >= limit:
continue
active = next(((a, b, c) for a, b, c in harmony if a <= start < b), None)
if active is None or active[2] is None:
exceptions.append(('space', 'no written harmony at ' + str(start)))
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'):
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))
try:
validate_events(events, part, lengths)
except ValueError:
return None
return tuple(events), tuple(exceptions)
def evaluate(events, part, melody, harmony, lengths):
activity, support, coverage, articulation = 0., 0., 0., 0.
cursor = fractions.Fraction()
for length in lengths:
local = [e for e in events if cursor <= e.start < cursor + length]
attacks = [a for a, b in melody if cursor <= a < cursor + length]
busy = len(attacks) >= length
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:
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
articulation += .08 if busy and e.end - e.start > 2 else 0
cursor += length
for a, b, chord in harmony:
if chord and b - a >= 1 and not any(e.start < b and e.end > a for e in events):
coverage += .18 # A preference, never a chord-attack quota.
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):
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part)
result = []
for pattern in policy['patterns']:
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
continue
for anticipate in (False, True):
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
if fitted is None:
continue
events, exceptions = fitted
if anticipate and not any(e.harmonic_time is not None for e in events):
continue
contributions = evaluate(events, part, melody, spans(measure, prior), lengths)
result.append(Rhythm(events, (('pattern', pattern['family']),), contributions, exceptions, pattern['id'] + ('.anticipated' if anticipate else '')))
if not result:
raise ValueError(f'measure {measure["number"]}: no {part} pattern for lengths {lengths} and written harmonic/release constraints')
return representatives(result, policy['pattern_candidates'], seed)
def representatives(candidates, limit, seed): def representatives(candidates, limit, seed):
"""Keep counts, placements and duration families before the final cutoff."""
unique = {} unique = {}
for candidate in candidates: for c in candidates:
key = candidate.signature rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key)) if c.signature not in unique or rank < unique[c.signature][0]:
if key not in unique or rank < unique[key][0]: unique[c.signature] = rank, c
unique[key] = rank, candidate ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])] retained, families = [], set()
groups = {}
shapes = set()
for c in ordered: for c in ordered:
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families) if c.families not in families:
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events)) retained.append(c)
# Round-robin attack counts first. Otherwise many cosmetic variants of families.add(c.families)
# the cheapest density can exclude every one- or three-attack idea. retained.extend(c for c in ordered if c not in retained)
if shape not in shapes: return tuple(retained[:limit])
groups.setdefault(counts, []).append(c)
shapes.add(shape)
result = []
while groups and len(result) < limit:
for key in list(groups):
result.append(groups[key].pop(0))
if not groups[key]:
del groups[key]
if len(result) == limit:
break
return tuple(result)
def lane_candidates(measure, prior, lane, melody, policy, seed):
length = generator.model.fraction(measure['duration'])
harmony = spans(measure, prior)
active = [(a, b, c) for a, b, c in harmony if c]
if not active:
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
low, high = policy['layers'][lane]['attacks']
# Genuinely different harmony gets a gesture, not its own density budget.
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
required.insert(0, active[0][:2])
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
required = []
# With very fast harmonic rhythm, establish the edges and longest spans.
# Short intervening chords may be silent; no incompatible sustain is kept.
if len(required) > high + 1:
edges = [required[0], required[-1]]
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
extra = int(len(required) > high)
grid = fractions.Fraction(policy['grid'])
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
positions.update(a for a, _, _ in active)
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
result = []
for count in range(low, high + extra + 1):
for onsets in itertools.combinations(positions, count):
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
continue
if any(not any(a <= t < b for t in onsets) for a, b in required):
continue
if any(b - a < fractions.Fraction(policy['attack_spacing']) for a, b in zip(onsets, onsets[1:])):
continue
for family in policy['layers'][lane]['families']:
durations = policy['families'][family]
events = []
for i, a in enumerate(onsets):
end = next(b for start, b, c in active if start <= a < b)
following = onsets[i + 1] if i + 1 < len(onsets) else length
duration = fractions.Fraction(durations[i % len(durations)])
events.append(Attack(lane, a, min(a + duration, following, end)))
if policy.get('allowed_durations') and any(e.end - e.start not in tuple(map(fractions.Fraction, policy['allowed_durations'])) for e in events):
continue
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
if not result:
# A short partial bar can have fewer gestures, not invented subdivisions.
if length < 1 and not policy.get('allowed_durations'):
a, b, _ = active[0]
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
def crowding(events):
times = sorted({e.start for e in events})
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
# Three successive half-beat changes are a busy figure even though each
# layer is sparse. Coincident attacks count as one hand action.
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
def candidates(measure, prior, part, melody, seed):
policy = generator.vocabulary.accompaniment(part)
if part == 'electric':
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
result = []
for a, b in itertools.product(bass, upper):
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
return representatives(result, policy['measure_candidates'], seed)
def transition(previous, current): def transition(previous, current):
if previous is None: if previous is None:
return 0. return 0.
# Recurrence is allowed; penalize repeating an entire bar more than sharing return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
# a duration family. This stays separate from the source-context objective.
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
def select(layers, seed, left=None, right=None): def select(layers, seed, left=None, right=None):
"""One state per concrete bar rhythm; compare complete equal-span paths.""" """Compare complete two-bar paths, not independently chosen bar fragments."""
states = [(0., (), (), left)] states = [(0., (), (), left)]
for i, layer in enumerate(layers): for i, layer in enumerate(layers):
following = [] following = []
for c in layer: for c in layer:
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states] 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])) following.append(min(options, key=lambda item: item[:2]))
states = following states = following
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2] return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
+29
View File
@@ -79,6 +79,9 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct # Staff, ledger, stem, beam and tie contacts are intentional. Distinct
# noteheads, accidentals and text in the same lane must remain separate. # noteheads, accidentals and text in the same lane must remain separate.
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')] checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric', 'scale', 'section')]
for label in (p for p in system.primitives if p.role == 'measure_number'):
if any(label.box.overlaps(p.box, 3 * system.scale) for p in system.primitives if p is not label):
raise generator.engraving.types.EngravingError(f'{label.sources}: measure number overlaps notation')
for i, item in enumerate(checked): for i, item in enumerate(checked):
for other in checked[i + 1:]: for other in checked[i + 1:]:
if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'): if set(item.sources) & set(other.sources) and (item.role != 'tab' or other.role != 'tab'):
@@ -141,3 +144,29 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
for span in index.staff.get('octave_shifts', []): for span in index.staff.get('octave_shifts', []):
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])): if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted') raise generator.engraving.types.EngravingError(f'{span["id"]}: octave shift was omitted')
def audit_playback(pages, playback, style, fonts):
canonical = [system for page in pages for system in page.systems]
if len(playback) != len(canonical):
raise generator.engraving.types.EngravingError('playback must have one view per system')
for i, page in enumerate(playback):
audit_primitives(page.primitives, page.width, page.height, fonts)
expected = canonical[i:i + 2]
if len(page.systems) != len(expected):
raise generator.engraving.types.EngravingError('invalid playback system pairing')
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'])
if page.systems[0].box.top < header.bottom + style.page_clearance - 0.01 or page.systems[-1].box.bottom > footer.top - style.page_clearance + 0.01:
raise generator.engraving.types.EngravingError('playback overlaps header or footer')
for a, b in zip(page.systems, page.systems[1:]):
if a.box.bottom + style.system_gap > b.box.top + 0.01:
raise generator.engraving.types.EngravingError('playback systems overlap')
for source, actual in zip(expected, page.systems):
if (source.start, source.end, source.scale, source.fragments) != (actual.start, actual.end, actual.scale, actual.fragments):
raise generator.engraving.types.EngravingError('playback changes system content')
dy = actual.box.top - source.box.top
if len(source.primitives) != len(actual.primitives):
raise generator.engraving.types.EngravingError('playback changes primitives')
for a, b in zip(source.primitives, actual.primitives):
if (a.kind, a.role, a.sources, a.value, a.size) != (b.kind, b.role, b.sources, b.value, b.size) or len(a.points) != len(b.points) or any(abs(x - u) > .001 or abs(y + dy - v) > .001 for (x, y), (u, v) in zip(a.points, b.points)):
raise generator.engraving.types.EngravingError('playback changes notation geometry')
+2 -2
View File
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
return None return None
destination.mkdir(parents=True, exist_ok=True) destination.mkdir(parents=True, exist_ok=True)
for index, page in enumerate(pages, 1): for index, page in enumerate(pages, 1):
if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat'} or page['path'] != f'{index:04}.png' or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']): if not isinstance(page, dict) or set(page) != {'path', 'sha256', 'first_measure', 'measure_count', 'start_beat', 'end_beat', 'systems', 'playback'} or type(page['playback']) is not bool or not isinstance(page['path'], str) or not re.fullmatch(r'(?:playback-)?[0-9]{4,5}\.png', page['path']) or not isinstance(page['sha256'], str) or not re.fullmatch('[0-9a-f]{64}', page['sha256']):
return None return None
path = entry / page['path'] path = entry / page['path']
if path.is_symlink() or not path.is_file(): if path.is_symlink() or not path.is_file():
@@ -88,7 +88,7 @@ def store_pages(entry: pathlib.Path, destination: pathlib.Path, pages: list[dict
shutil.copyfile(destination / page['path'], staged / page['path']) shutil.copyfile(destination / page['path'], staged / page['path'])
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8') (staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
# Identical keys have identical pages; publish complete files, then the manifest. # Identical keys have identical pages; publish complete files, then the manifest.
for name in [page['path'] for page in pages] + ['pages.json']: for name in list(dict.fromkeys(page['path'] for page in pages)) + ['pages.json']:
target = entry / name target = entry / name
if target.is_dir() and not target.is_symlink(): if target.is_dir() and not target.is_symlink():
shutil.rmtree(target, ignore_errors=True) shutil.rmtree(target, ignore_errors=True)
+28 -19
View File
@@ -57,7 +57,6 @@ def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fract
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],))) items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 18 if index.staff.get('tablature') else 21, 'harmony', (item['id'],)))
items.extend(generator.engraving.scales.column(index, time, start, fonts)) items.extend(generator.engraving.scales.column(index, time, start, fonts))
if time == measure_start: if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure_number', (measure['id'],)))
if label := measure.get('section_label'): if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',))) items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments return items, anchors, fragments
@@ -103,6 +102,10 @@ def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions
for owner, ci, change in index.attributes_by_onset.get(time, []): for owner, ci, change in index.attributes_by_onset.get(time, []):
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change)) extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra) leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if time == measure_start:
# Anchor labels to the barline, not to the first note's time column.
bar_x = cursor - generator.engraving.types.bounds(leading).left if leading else 0
items.append(fonts.text(str(m['number']), bar_x, 0, 12, 'measure_number', (m['id'],), True))
if leading: if leading:
width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14 width = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading) items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
@@ -162,7 +165,7 @@ def fit_part_systems(systems, index, available_height, fonts, style):
def fitting_scale(items): def fitting_scale(items):
scale = 1 scale = 1
for first in range(0, len(items), 2): for first in range(len(items)):
pair = items[first:first + 2] pair = items[first:first + 2]
height = sum(s.box.bottom - s.box.top for s in pair) height = sum(s.box.bottom - s.box.top for s in pair)
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height) scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
@@ -214,30 +217,36 @@ def part_header(document, fonts, style):
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ())) result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
return result return result
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[generator.engraving.types.Page, ...]: def page_views(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, *, index=None) -> tuple[tuple[generator.engraving.types.Page, ...], tuple[generator.engraving.types.Page, ...]]:
pages = []
index = index or generator.engraving.notation.ScoreIndex(document) index = index or generator.engraving.notation.ScoreIndex(document)
header = part_header(document, fonts, style) header = part_header(document, fonts, style)
top = generator.engraving.types.bounds(header).bottom + style.page_clearance top = generator.engraving.types.bounds(header).bottom + style.page_clearance
footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, (len(systems) + 1) // 2 + 1)] footers = [fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page_number', (), True) for number in range(1, len(systems) + 1)]
bottom = min(p.box.top for p in footers) - style.page_clearance bottom = min(p.box.top for p in footers) - style.page_clearance
if bottom - top <= style.system_gap: if bottom - top <= style.system_gap:
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems') raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
fitted = fit_part_systems(systems, index, bottom - top, fonts, style) fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
for first in range(0, len(systems), 2):
number = len(pages) + 1 def place(stride):
pair = fitted[first:first + 2] pages = []
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1)) for first in range(0, len(fitted), stride):
spacing = slack / 3 if len(pair) == 2 else 0 number = len(pages) + 1
cursor = top + spacing pair = fitted[first:first + 2]
placed = [] slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
for system in pair: spacing = slack / 3 if len(pair) == 2 else 0
dx, dy = style.margin_x + 1, cursor - system.box.top cursor = top + spacing
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))) placed = []
cursor = placed[-1].box.bottom + style.system_gap + spacing for system in pair:
footer = footers[first // 2] dx, dy = style.margin_x + 1, cursor - system.box.top
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer]))) placed.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)))
return tuple(pages) cursor = placed[-1].box.bottom + style.system_gap + spacing
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footers[number - 1]])))
return tuple(pages)
return place(2), place(1)
def paginate(systems, document, fonts, style, *, index=None):
return page_views(systems, document, fonts, style, index=index)[0]
def layout_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts, *, index=None) -> tuple[generator.engraving.types.System, ...]: 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: if index is None:
+22 -9
View File
@@ -101,14 +101,19 @@ def clear_pages(destination):
shutil.rmtree(destination) shutil.rmtree(destination)
def write_bundle(path, document, pages, fingerprint): def write_bundle(path, document, pages, fingerprint):
entries = [{**page, 'path': path.with_suffix('.pages').name + '/' + page['path']} for page in pages] 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 music = document
bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint, bundle = {'music': music, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'style_sha256': fingerprint,
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': entries}} 'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
generator.performance.validate_pages(document, pages) validate_cached_views(document, pages)
bundle['performance'] = generator.performance.timing(document) bundle['performance'] = generator.performance.timing(document)
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') 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): def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
family = generator.engraving.fonts.Fonts(font_path) family = generator.engraving.fonts.Fonts(font_path)
style = generator.engraving.types.Style() style = generator.engraving.types.Style()
@@ -140,19 +145,25 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
systems = generator.engraving.layout.layout_systems(document, style, family, index=index) systems = generator.engraving.layout.layout_systems(document, style, family, index=index)
result.timings['layout'] += time.monotonic() - started result.timings['layout'] += time.monotonic() - started
started = time.monotonic() started = time.monotonic()
pages_layout = generator.engraving.layout.paginate(systems, document, family, style, index=index) 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) 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: if placement not in audited:
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint) layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
generator.engraving.audit.audit_layout(document, layout, family) generator.engraving.audit.audit_layout(document, layout, family)
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
audited.add(placement) audited.add(placement)
else: else:
for page in pages_layout: for page in pages_layout + playback_layout:
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number')) 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) generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
result.timings['pagination'] += time.monotonic() - started result.timings['pagination'] += time.monotonic() - started
pages = [] pages = []
for page in pages_layout: 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) bands = generator.engraving.raster.page_bands(page)
if bands is None: if bands is None:
started = time.monotonic() started = time.monotonic()
@@ -161,12 +172,14 @@ def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, prog
body = bodies.get(notation, page, bands[1], family) body = bodies.get(notation, page, bands[1], family)
started = time.monotonic() started = time.monotonic()
image = generator.engraving.raster.compose_page(page, family, bands, body) image = generator.engraving.raster.compose_page(page, family, bands, body)
target = destination / f'{page.number:04}.png' target = destination / (('playback-' if is_playback else '') + f'{page.number:04}.png')
with image: with image:
image.save(target, format='PNG') image.save(target, format='PNG')
result.timings['raster'] += time.monotonic() - started result.timings['raster'] += time.monotonic() - started
result.renderedpages += 1 result.renderedpages += 1
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **generator.performance.page_timing(document, page)}) 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) scores[score_key] = (destination, pages)
write_bundle(path, document, pages, fingerprint) write_bundle(path, document, pages, fingerprint)
result.scores.append((path, score_key, pages)) result.scores.append((path, score_key, pages))
@@ -213,7 +226,7 @@ def engrave_tree(root: pathlib.Path, fonts: pathlib.Path, *, cache_dir: pathlib.
pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None pages = generator.engraving.cache.restore_pages(entry, destination, validated_pages) if entry else None
if pages is not None: if pages is not None:
try: try:
generator.performance.validate_pages(document, pages) validate_cached_views(document, pages)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
pages = None pages = None
if pages is None: if pages is None:
+3 -3
View File
@@ -123,7 +123,7 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
y = anchor.y - anchor.direction * 8 y = anchor.y - anchor.direction * 8
if anchor.x - 2 > left: if anchor.x - 2 > left:
result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6)) result.append(generator.engraving.types.curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo')), default=0)) top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'tempo', 'measure_number')), default=0))
tuplet_lanes = [] tuplet_lanes = []
for span in index.staff.get('tuplets', []): for span in index.staff.get('tuplets', []):
a, b = index.by_id[span['start']], index.by_id[span['end']] a, b = index.by_id[span['start']], index.by_id[span['end']]
@@ -203,11 +203,11 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
return items + result return items + result
def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]: def place_text_lanes(items: list[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale')] music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'measure_number')]
box = generator.engraving.types.bounds(music) box = generator.engraving.types.bounds(music)
result = list(music) result = list(music)
top = box.top - 12 top = box.top - 12
for role in ('harmony', 'scale', 'section'): for role in ('measure_number', 'harmony', 'scale', 'section'):
lane = [p for p in items if p.role == role] lane = [p for p in items if p.role == role]
if lane: if lane:
extent = generator.engraving.types.bounds(lane) extent = generator.engraving.types.bounds(lane)
+30 -9
View File
@@ -23,7 +23,7 @@ class VoicingCost(typing.NamedTuple):
covering_tones: int = 0 covering_tones: int = 0
string_shortfall: int = 0 string_shortfall: int = 0
family_penalty: int = 0 family_penalty: int = 0
effort: int = 0 effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0 missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost: def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other))) return VoicingCost(*(a + b for a, b in zip(self, other)))
@@ -31,18 +31,38 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance: int) -> VoicingCost: def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost: def voicing_preference(role, center=None):
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else () """Immutable role settings for both selection and scored-retrieval caches."""
return _voicing_cost(positions, profile, allowed, melody, families) settings = generator.vocabulary.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')
return result
@functools.lru_cache(maxsize=8192) @functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families): def voicing_preferences(positions, preference):
if not positions or not preference:
return fractions.Fraction(), fractions.Fraction()
center, register_weight, position_weight, threshold = 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
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 ()
return _voicing_cost(positions, profile, allowed, melody, families, preference)
@functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families, preference):
frets = {p.fret for p in positions} frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions} classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0 above = sum(p.midi > max(melody) for p in positions) if melody else 0
span = compactness(positions) span = compactness(positions)
if profile == 'electric': if profile == 'electric':
effort = 2 * max(0, len(frets) - 1) + 3 * span effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
else: else:
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0 family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
@@ -51,10 +71,11 @@ def _voicing_cost(positions, profile, allowed, melody, families):
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]: 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 () families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
return _chord_positions(requirements, prune, profile, melody, families) preference = voicing_preference('electric_melody') if profile == 'electric' and melody else ()
return _chord_positions(requirements, prune, profile, melody, families, preference)
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def _chord_positions(requirements, prune, profile, melody, families): def _chord_positions(requirements, prune, profile, melody, families, preference):
result = [] result = []
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING] options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
@@ -90,7 +111,7 @@ def _chord_positions(requirements, prune, profile, melody, families):
best = {} best = {}
for positions in result: for positions in result:
center = position_center(positions) center = position_center(positions)
rank = (voicing_cost(positions, profile, requirements.allowed, melody), positions) rank = (_voicing_cost(positions, profile, requirements.allowed, melody, families, preference), positions)
if center not in best or rank < best[center][0]: if center not in best or rank < best[center][0]:
best[center] = (rank, positions) best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values())) return tuple(sorted(item[1] for item in best.values()))
+66 -3
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import fractions import fractions
import hashlib import hashlib
import json import json
import math
import pathlib import pathlib
import re import re
import shutil import shutil
@@ -23,7 +24,63 @@ def page_timing(document, page):
times = boundaries(document) times = boundaries(document)
start, end = page.systems[0].start, page.systems[-1].end start, end = page.systems[0].start, page.systems[-1].end
first, last = times.index(start), times.index(end) first, last = times.index(start), times.index(end)
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end)) measures = document['staves'][0]['measures']
systems = []
for system in page.systems:
a, b = times.index(system.start), times.index(system.end)
labels = {p.sources[0]: p for p in system.primitives if p.role == 'measure_number'}
boxes = []
for i in range(a, b):
box = labels[measures[i]['id']].box
boxes.append(dict(measure=i + 1, bounds=[box.left, box.top, box.right, box.bottom]))
systems.append(dict(first_measure=a + 1, measure_count=b - a, start_beat=generator.model.rational(system.start), end_beat=generator.model.rational(system.end), labels=boxes))
return dict(first_measure=first + 1, measure_count=last - first, start_beat=generator.model.rational(start), end_beat=generator.model.rational(end), systems=systems)
def validate_views(document, pages, playback_pages):
validate_pages(document, pages)
times = boundaries(document)
canonical = []
def system_range(system):
a, count = system['first_measure'], system['measure_count']
if type(a) is not int or type(count) is not int or a < 1 or count < 1 or a + count > len(times):
raise ValueError('invalid system measure coverage')
if generator.model.fraction(system['start_beat']) != times[a - 1] or generator.model.fraction(system['end_beat']) != times[a + count - 1]:
raise ValueError('invalid system timing')
if [label['measure'] for label in system['labels']] != list(range(a, a + count)):
raise ValueError('invalid measure label coverage')
for label in system['labels']:
box = label['bounds']
if not isinstance(box, list) or len(box) != 4 or any(type(v) not in (int, float) or not math.isfinite(v) for v in box) or not (4 <= box[0] < box[2] <= 972 and 4 <= box[1] < box[3] <= 480):
raise ValueError('invalid measure label bounds')
return a, count
for page in pages:
systems = page.get('systems', [])
if len(systems) != 2 and (page is not pages[-1] or len(systems) != 1):
raise ValueError('invalid page systems')
cursor = page['first_measure']
for system in systems:
a, count = system_range(system)
if a != cursor:
raise ValueError('system gap or overlap')
cursor += count
canonical.append(system)
if cursor != page['first_measure'] + page['measure_count']:
raise ValueError('systems disagree with page coverage')
if not isinstance(playback_pages, list) or len(playback_pages) != len(canonical):
raise ValueError('missing playback pages; regenerate score assets')
for i, page in enumerate(playback_pages):
expected = canonical[i:i + 2]
systems = page.get('systems', [])
if len(systems) != len(expected):
raise ValueError('invalid playback systems')
for actual, source in zip(systems, expected):
system_range(actual)
if any(actual[k] != source[k] for k in ('first_measure', 'measure_count', 'start_beat', 'end_beat')):
raise ValueError('playback changes system boundaries')
if any(page[k] != expected[0][k] for k in ('first_measure', 'start_beat')) or page['end_beat'] != expected[-1]['end_beat'] or page['measure_count'] != sum(s['measure_count'] for s in expected):
raise ValueError('invalid playback page timing')
def validate_pages(document, pages): def validate_pages(document, pages):
times = boundaries(document) times = boundaries(document)
@@ -43,9 +100,15 @@ def validate_pages(document, pages):
def timing(document): def timing(document):
times = boundaries(document) times = boundaries(document)
measures = document['staves'][0]['measures'] measures = document['staves'][0]['measures']
# Metronome accents follow each actual measure, including meter changes. ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')]
if ending and ending != list(range(ending[0], len(measures))):
raise ValueError('held ending must be a contiguous terminal section')
sustain_start = times[ending[0]] if ending else times[-1]
# The written held ending participates in page time, not metronome time.
clicks = [] clicks = []
for start, measure in zip(times, measures): for start, measure in zip(times, measures):
if start >= sustain_start:
break
duration = generator.model.fraction(measure['duration']) duration = generator.model.fraction(measure['duration'])
beat = fractions.Fraction() beat = fractions.Fraction()
while beat < duration: while beat < duration:
@@ -53,7 +116,7 @@ def timing(document):
beat += 1 beat += 1
return dict(song_id=document['metadata'].get('song_id', ''), return dict(song_id=document['metadata'].get('song_id', ''),
duration=generator.model.rational(times[-1]), duration=generator.model.rational(times[-1]),
count_in_beats=4, clicks=clicks) sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
def describe_wav(path, root, *, song_id='', bpm=0): 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()): if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
+12 -1
View File
@@ -26,6 +26,7 @@ import generator.parts
import generator.score import generator.score
import generator.vocabulary import generator.vocabulary
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.arrange.ending
import generator.arrange.form import generator.arrange.form
import generator.arrange.generate import generator.arrange.generate
import generator.arrange.pickup import generator.arrange.pickup
@@ -94,6 +95,7 @@ def windows(document, part):
result[-1] = (a, start, old) result[-1] = (a, start, old)
result.append((start, end, part + '_' + role)) result.append((start, end, part + '_' + role))
cursor = end cursor = end
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result) return tuple(result)
def dependencies(role, resources): def dependencies(role, resources):
@@ -211,7 +213,14 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'], 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} '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_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm}) 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 = {} details = {}
solo_report = '' solo_report = ''
composition_timings = {} composition_timings = {}
@@ -254,6 +263,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'}) 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) allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
_mask(result, allowed) _mask(result, allowed)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0] staff = result['staves'][0]
for measure in staff['measures']: for measure in staff['measures']:
measure['id'] = role + '_' + measure['id'] measure['id'] = role + '_' + measure['id']
+2 -2
View File
@@ -24,10 +24,10 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
raise ValueError('invalid score bundle; regenerate score assets') 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): 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') raise ValueError('unsupported score presentation; regenerate score assets')
generator.performance.validate_pages(document['music'], presentation['pages']) generator.performance.validate_views(document['music'], presentation['pages'], presentation.get('playback_pages'))
if document.get('performance') != generator.performance.timing(document['music']): if document.get('performance') != generator.performance.timing(document['music']):
raise ValueError('invalid performance timing') raise ValueError('invalid performance timing')
for page in presentation['pages']: 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): 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') raise ValueError('invalid score page manifest')
name = page['path'] name = page['path']
+41 -20
View File
@@ -23,25 +23,46 @@ def _load(contents):
result = {} result = {}
for name, text in contents: for name, text in contents:
value = json.loads(text) value = json.loads(text)
if value.get('version') != (3 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'): if value.get('version') != (4 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary') raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
if name == 'accompaniment': if name == 'accompaniment':
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2): signatures = {}
raise ValueError('accompaniment requires an eighth-note placement grid') for part in ('electric', 'acoustic'):
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
policy = value['instruments'][part] policy = value['instruments'][part]
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2): if part == 'acoustic' and policy.get('first_attack') != 'bass_or_combined':
raise ValueError('accompaniment attacks must be at least an eighth apart') raise ValueError('acoustic patterns require a bass-or-combined first attack policy')
for lane in lanes: identifiers = set()
layer = policy['layers'][lane] signatures[part] = set()
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']: for pattern in policy['patterns']:
raise ValueError('invalid accompaniment attack range or duration families') if pattern['id'] in identifiers or not pattern['family']:
for family in layer['families']: raise ValueError('duplicate or unnamed accompaniment pattern')
durations = policy['families'][family] identifiers.add(pattern['id'])
if not durations or any(fractions.Fraction(d) <= 0 for d in durations): lengths = tuple(map(fractions.Fraction, pattern['lengths']))
raise ValueError('accompaniment durations must be positive') if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations): raise ValueError('patterns require one or two positive measure lengths')
raise ValueError('acoustic rhythm uses quarter and eighth notes') # Validate musical events before any measure projection or ties.
previous = fractions.Fraction()
onsets = []
for row in pattern['events']:
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
if onset < previous or duration <= 0 or onset + duration > sum(lengths):
raise ValueError('overlapping or out-of-bounds accompaniment pattern')
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')
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
onsets.append(onset)
if len(lengths) == 2:
signatures[part].add((lengths, tuple(onsets)))
families = policy['voicing_families'] families = policy['voicing_families']
if not families.get('provenance'): if not families.get('provenance'):
raise ValueError('guitar voicing families require provenance') raise ValueError('guitar voicing families require provenance')
@@ -49,16 +70,16 @@ def _load(contents):
if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()): if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()):
raise ValueError('invalid electric family ranking') raise ValueError('invalid electric family ranking')
else: else:
if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']:
raise ValueError('acoustic rhythm must retain short quarter/eighth attacks')
for kind in ('triad', 'tetrad', 'extended'): for kind in ('triad', 'tetrad', 'extended'):
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}: if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
raise ValueError('acoustic families require root/fifth bass on both bass strings') raise ValueError('acoustic families require root/fifth bass on both bass strings')
for orders in families[kind].values(): for orders in families[kind].values():
if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders): if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders):
raise ValueError('invalid acoustic upper chord order') raise ValueError('invalid acoustic upper chord order')
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64: if signatures['acoustic'] & signatures['electric']:
raise ValueError('accompaniment candidate budgets must be 1–64') raise ValueError('acoustic/electric two-bar onset patterns must be distinct')
if not 1 <= value['pattern_candidates'] <= 64:
raise ValueError('accompaniment pattern budget must be 1–64')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', [])))) entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
if len({v['id'] for v in entries}) != len(entries): if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID') raise ValueError(f'{name}: duplicate vocabulary ID')
+1 -1
View File
@@ -25,7 +25,7 @@ public:
auto refresh_connection() -> std::optional<vamp_zero::app_state>; auto refresh_connection() -> std::optional<vamp_zero::app_state>;
auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void; auto set_observer(std::function<void(vamp_zero::app_state)> observer) -> void;
auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void; auto finish_playback(std::uint64_t session, std::string error = {}, std::shared_ptr<vamp_zero::catalog const> catalog = {}) -> void;
auto update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void; auto update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<std::size_t> measure = {}) -> void;
private: private:
auto pico_connected_locked() const -> std::bool_t; auto pico_connected_locked() const -> std::bool_t;
auto snapshot_locked() const -> vamp_zero::app_state; auto snapshot_locked() const -> vamp_zero::app_state;
+1
View File
@@ -19,6 +19,7 @@ auto prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_
struct performance_position { struct performance_position {
std::size_t page{}; std::size_t page{};
std::string phase{}; std::string phase{};
std::optional<std::size_t> measure{};
}; };
auto performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position; auto performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position;
+5 -3
View File
@@ -28,8 +28,10 @@ private:
std::vector<entry> entries_{}; std::vector<entry> entries_{};
}; };
auto score_page_count(vamp_zero::score_document const &score) -> std::size_t; auto score_page_count(vamp_zero::score_document const &score, bool playback = false) -> std::size_t;
auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error>; auto validated_page_path(vamp_zero::score_document const &score, std::size_t index, bool playback = false) -> std::expected<std::string, modulus::error>;
auto validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<void, modulus::error>; auto validate_score_page(vamp_zero::score_document const &score, std::size_t index, bool playback = false) -> std::expected<void, modulus::error>;
auto page_rectangle(luma::rect bounds) -> luma::rect;
auto measure_highlight(vamp_zero::score_page const &page, std::optional<std::size_t> measure, luma::rect image) -> std::optional<luma::rect>;
} }
+19
View File
@@ -3,6 +3,8 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <memory> #include <memory>
#include <optional>
#include <array>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -23,6 +25,18 @@ struct score_page {
std::size_t measure_count{}; std::size_t measure_count{};
vamp_zero::beat_position start_beat{}; vamp_zero::beat_position start_beat{};
vamp_zero::beat_position end_beat{}; vamp_zero::beat_position end_beat{};
struct measure_label {
std::size_t measure{};
std::array<double, 4> bounds{};
};
struct system {
std::size_t first_measure{};
std::size_t measure_count{};
vamp_zero::beat_position start_beat{};
vamp_zero::beat_position end_beat{};
std::vector<measure_label> labels{};
};
std::vector<system> systems{};
}; };
struct score_document { struct score_document {
@@ -39,7 +53,10 @@ struct score_document {
std::vector<vamp_zero::score_page> pages{}; std::vector<vamp_zero::score_page> pages{};
std::string song_id{}; std::string song_id{};
vamp_zero::beat_position duration{}; vamp_zero::beat_position duration{};
vamp_zero::beat_position sustain_start{};
std::uint32_t count_in_beats{4}; std::uint32_t count_in_beats{4};
std::vector<vamp_zero::score_page> playback_pages{};
std::vector<vamp_zero::beat_position> measure_boundaries{};
}; };
struct library_part { struct library_part {
@@ -53,6 +70,7 @@ struct library_part {
std::size_t total_pages{}; std::size_t total_pages{};
std::string title_image{}; std::string title_image{};
std::string song_id{}; std::string song_id{};
std::size_t playback_pages{};
}; };
struct song_group { struct song_group {
@@ -115,6 +133,7 @@ struct app_state {
std::uint64_t score_session{}; std::uint64_t score_session{};
std::uint64_t input_revision{}; std::uint64_t input_revision{};
std::bool_t pico_connected{false}; std::bool_t pico_connected{false};
std::optional<std::size_t> active_measure{};
}; };
auto to_string(vamp_zero::app_mode value) -> std::string; auto to_string(vamp_zero::app_mode value) -> std::string;
+7 -5
View File
@@ -180,10 +180,12 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
if (!score) { if (!score) {
co_return std::unexpected(score.error()); co_return std::unexpected(score.error());
} }
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) { for (auto playback : {false, true}) {
auto valid = vamp_zero::validate_score_page(*score, page); for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score, playback); ++page) {
if (!valid) { auto valid = vamp_zero::validate_score_page(*score, page, playback);
co_return std::unexpected(valid.error()); if (!valid) {
co_return std::unexpected(valid.error());
}
} }
} }
co_return std::expected<void, modulus::error>{}; co_return std::expected<void, modulus::error>{};
@@ -301,7 +303,7 @@ private:
} }
if (loaded.document) { if (loaded.document) {
if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) { if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) {
state_.update_performance(app.score_session, position->page, position->phase, app.catalog); state_.update_performance(app.score_session, position->page, position->phase, app.catalog, position->measure);
} }
} }
} else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) { } else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
+4 -2
View File
@@ -227,6 +227,7 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
} else { } else {
state_.audio.playback_error = std::move(error); state_.audio.playback_error = std::move(error);
state_.audio.phase = "Error"; state_.audio.phase = "Error";
state_.active_measure.reset();
} }
updated = snapshot_locked(); updated = snapshot_locked();
observer = observer_; observer = observer_;
@@ -280,7 +281,7 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
return router; return router;
} }
auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void { auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog, std::optional<std::size_t> measure) -> void {
std::function<void(vamp_zero::app_state)> observer; std::function<void(vamp_zero::app_state)> observer;
vamp_zero::app_state updated; vamp_zero::app_state updated;
{ {
@@ -288,11 +289,12 @@ auto vamp_zero::control_state::update_performance(std::uint64_t session, std::si
if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) { if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) {
return; return;
} }
if (state_.page_index == page && state_.audio.phase == phase) { if (state_.page_index == page && state_.audio.phase == phase && state_.active_measure == measure) {
return; return;
} }
state_.page_index = page; state_.page_index = page;
state_.audio.phase = std::move(phase); state_.audio.phase = std::move(phase);
state_.active_measure = measure;
++state_.input_revision; ++state_.input_revision;
updated = snapshot_locked(); updated = snapshot_locked();
observer = observer_; observer = observer_;
+4 -3
View File
@@ -159,7 +159,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
co_return std::unexpected(connected.error()); co_return std::unexpected(connected.error());
} }
auto selected = 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') 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') FROM "
"songs ORDER BY title, composer, part, id"); "songs ORDER BY title, composer, part, id");
if (!selected) { if (!selected) {
auto close = co_await connection.async_close(); auto close = co_await connection.async_close();
@@ -180,8 +180,8 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
break; break;
} }
auto const &row = **next; auto const &row = **next;
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) { if (row.size() != 11U || 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")); status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata; regenerate score assets"));
break; break;
} }
auto source = resolve_source_path(assets_path, text_value(row[4])); auto source = resolve_source_path(assets_path, text_value(row[4]));
@@ -200,6 +200,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
.total_pages = count_value(row[7]), .total_pages = count_value(row[7]),
.title_image = text_value(row[8]), .title_image = text_value(row[8]),
.song_id = text_value(row[9]), .song_id = text_value(row[9]),
.playback_pages = count_value(row[10]),
}); });
} }
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{}; auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
+1 -1
View File
@@ -92,7 +92,7 @@ auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule)); context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink); draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) { if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + std::to_string(state.page_index + 1U) + "/" + std::to_string(part->total_pages); auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + (vamp_zero::automatic(state.audio) ? "Line " : "Page ") + std::to_string(state.page_index + 1U) + "/" + std::to_string(vamp_zero::active_pages(state));
draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted); draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted);
draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger); draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
} }
+2 -1
View File
@@ -1,6 +1,7 @@
#include <algorithm> #include <algorithm>
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/performance.hpp>
auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * { auto vamp_zero::selected_group(vamp_zero::app_state const &state) -> vamp_zero::song_group const * {
@@ -40,7 +41,7 @@ auto vamp_zero::active_part(vamp_zero::app_state const &state) -> vamp_zero::lib
auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t { auto vamp_zero::active_pages(vamp_zero::app_state const &state) -> std::size_t {
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
auto const *part = vamp_zero::active_part(state); auto const *part = vamp_zero::active_part(state);
return part == nullptr ? 0U : part->total_pages; return part == nullptr ? 0U : (automatic(state.audio) ? part->playback_pages : part->total_pages);
} }
return 0U; return 0U;
} }
+123 -32
View File
@@ -1,5 +1,6 @@
#include <algorithm> #include <algorithm>
#include <cstdint> #include <cstdint>
#include <cmath>
#include <expected> #include <expected>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
@@ -109,9 +110,21 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
return invalid("invalid performance duration or count-in"); return invalid("invalid performance duration or count-in");
} }
document.duration = *duration; document.duration = *duration;
auto sustain = performance.if_contains("sustain_start") ? vamp_zero::parse_beat(performance.at("sustain_start")) : duration;
if (!sustain || std::find(times.begin(), times.end(), rational(*sustain)) == times.end()) {
return invalid("invalid held-ending boundary");
}
document.sustain_start = *sustain;
for (auto i = std::size_t{}; i < measures->size(); ++i) {
auto marker = (*measures)[i].as_object().if_contains("ending_sustain");
auto ending = marker && marker->is_bool() && marker->as_bool();
if (ending != (times[i] >= rational(*sustain))) {
return invalid("held-ending measures disagree with performance timing");
}
}
auto const &clicks = performance.at("clicks").as_array(); auto const &clicks = performance.at("clicks").as_array();
auto click = std::size_t{}; auto click = std::size_t{};
for (auto measure = std::size_t{}; measure + 1 < times.size(); ++measure) { for (auto measure = std::size_t{}; measure + 1 < times.size() && times[measure] < rational(*sustain); ++measure) {
for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) { for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) {
if (click >= clicks.size()) { if (click >= clicks.size()) {
return invalid("missing metronome beat"); return invalid("missing metronome beat");
@@ -129,42 +142,120 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
} catch (...) { } catch (...) {
return invalid("missing performance timing; regenerate score assets"); return invalid("missing performance timing; regenerate score assets");
} }
auto cursor = std::size_t{}; try {
for (auto const &item : *pages) { for (auto const &time : times) {
if (!item.is_object()) { document.measure_boundaries.push_back({numerator(time).convert_to<std::uint64_t>(), denominator(time).convert_to<std::uint64_t>()});
return invalid("page must be an object");
} }
auto path = string_field(item.as_object(), "path"); auto const *playback = array_field(*presentation, "playback_pages");
auto hash = string_field(item.as_object(), "sha256"); if (!playback || playback->empty() || playback->size() > 10000) {
if (!path || !hash) { return invalid("missing playback pages; regenerate score assets");
return invalid("page requires path and sha256 strings");
} }
auto relative = std::filesystem::path{*path}; auto parse_page = [&](boost::json::value const &item) -> score_page {
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos || if (!item.is_object()) {
relative.is_absolute() || relative.extension() != ".png" || throw std::invalid_argument("page must be an object");
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) { }
return invalid("page paths must be safe relative PNG paths"); auto path = string_field(item.as_object(), "path");
} auto hash = string_field(item.as_object(), "sha256");
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) { if (!path || !hash) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'); throw std::invalid_argument("page requires path and sha256 strings");
})) { }
return invalid("page sha256 must be 64 lowercase hexadecimal digits"); auto relative = std::filesystem::path{*path};
} if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
try { relative.is_absolute() || relative.extension() != ".png" ||
auto first = item.at("first_measure").to_number<std::size_t>(); std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
auto count = item.at("measure_count").to_number<std::size_t>(); throw std::invalid_argument("page paths must be safe relative PNG paths");
auto start = vamp_zero::parse_beat(item.at("start_beat")), end = vamp_zero::parse_beat(item.at("end_beat")); }
if (first != cursor + 1 || count == 0 || count > measures->size() - cursor || !start || !end || rational(*start) != times[cursor] || rational(*end) != times[cursor + count]) { if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
})) {
throw std::invalid_argument("page sha256 must be 64 lowercase hexadecimal digits");
}
auto parse_range = [&](boost::json::value const &value, auto &range) {
range.first_measure = value.at("first_measure").to_number<std::size_t>();
range.measure_count = value.at("measure_count").to_number<std::size_t>();
auto a = range.first_measure, count = range.measure_count;
auto start = parse_beat(value.at("start_beat")), end = parse_beat(value.at("end_beat"));
if (!a || a > measures->size() || !count || count > measures->size() - (a - 1) || !start || !end || rational(*start) != times[a - 1] || rational(*end) != times[a + count - 1]) {
throw std::invalid_argument("invalid page/system timing or coverage");
}
range.start_beat = *start;
range.end_beat = *end;
};
auto result = score_page{};
result.path = std::move(*path);
result.sha256 = std::move(*hash);
parse_range(item, result);
auto const &systems = item.at("systems").as_array();
if (systems.empty() || systems.size() > 2) {
throw std::invalid_argument("invalid page systems");
}
auto cursor = result.first_measure;
for (auto const &value : systems) {
auto system = score_page::system{};
parse_range(value, system);
if (system.first_measure != cursor) {
throw std::invalid_argument("system gap or overlap");
}
auto const &labels = value.at("labels").as_array();
if (labels.size() != system.measure_count) {
throw std::invalid_argument("invalid measure label coverage");
}
for (auto const &label : labels) {
auto parsed = score_page::measure_label{};
parsed.measure = label.at("measure").to_number<std::size_t>();
auto const &bounds = label.at("bounds").as_array();
if (parsed.measure != cursor++ || bounds.size() != 4) {
throw std::invalid_argument("invalid measure label");
}
for (std::size_t k = 0; k < 4; ++k) {
parsed.bounds[k] = bounds[k].to_number<double>();
if (!std::isfinite(parsed.bounds[k])) {
throw std::invalid_argument("non-finite label bounds");
}
}
auto const &r = parsed.bounds;
if (!(4 <= r[0] && r[0] < r[2] && r[2] <= 972 && 4 <= r[1] && r[1] < r[3] && r[3] <= 480)) {
throw std::invalid_argument("invalid label bounds");
}
system.labels.push_back(parsed);
}
result.systems.push_back(std::move(system));
}
if (cursor != result.first_measure + result.measure_count) {
throw std::invalid_argument("systems disagree with page coverage");
}
return result;
};
auto cursor = std::size_t{1};
auto canonical = std::vector<score_page::system>{};
for (auto const &item : *pages) {
auto page = parse_page(item);
if (page.first_measure != cursor || (page.systems.size() != 2 && &item != &pages->back())) {
return invalid("invalid page measure coverage"); return invalid("invalid page measure coverage");
} }
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end}); cursor += page.measure_count;
cursor += count; canonical.insert(canonical.end(), page.systems.begin(), page.systems.end());
} catch (...) { document.pages.push_back(std::move(page));
return invalid("missing page timing; regenerate score assets");
} }
} if (cursor != measures->size() + 1 || playback->size() != canonical.size()) {
if (cursor != measures->size()) { return invalid("incomplete normal or playback coverage");
return invalid("incomplete page measure coverage"); }
for (std::size_t i = 0; i < playback->size(); ++i) {
auto page = parse_page((*playback)[i]);
if (page.systems.size() != std::min(std::size_t{2}, canonical.size() - i)) {
return invalid("invalid playback system pairing");
}
for (std::size_t j = 0; j < page.systems.size(); ++j) {
auto const &a = canonical[i + j];
auto const &b = page.systems[j];
if (a.first_measure != b.first_measure || a.measure_count != b.measure_count) {
return invalid("playback changes system boundaries");
}
}
document.playback_pages.push_back(std::move(page));
}
} catch (std::exception const &error) {
return invalid(std::string{"invalid playback presentation: "} + error.what() + "; regenerate score assets");
} }
document.id = std::filesystem::path{source_path}.stem().string(); document.id = std::filesystem::path{source_path}.stem().string();
document.source_path = std::move(source_path); document.source_path = std::move(source_path);
+10 -2
View File
@@ -90,6 +90,7 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
: vamp_zero::app_mode::PERFORMANCE_PART_MENU; : vamp_zero::app_mode::PERFORMANCE_PART_MENU;
state.active_part_id.clear(); state.active_part_id.clear();
state.page_index = 0; state.page_index = 0;
state.active_measure.reset();
} }
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) { if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
state.audio.tempo_editing = false; state.audio.tempo_editing = false;
@@ -98,6 +99,10 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
state.page_index = std::min(state.page_index, pages - 1U); state.page_index = std::min(state.page_index, pages - 1U);
} else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) { } else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
state.page_index = 0; state.page_index = 0;
state.active_measure.reset();
}
if (!automatic(state.audio)) {
state.active_measure.reset();
} }
return state; return state;
} }
@@ -179,9 +184,11 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU; state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU;
state.active_part_id.clear(); state.active_part_id.clear();
state.page_index = 0; state.page_index = 0;
state.active_measure.reset();
} else if (auto const *part = selected_part(state); part != nullptr) { } else if (auto const *part = selected_part(state); part != nullptr) {
state.active_part_id = part->id; state.active_part_id = part->id;
state.page_index = 0; state.page_index = 0;
state.active_measure.reset();
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE; state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session; ++state.score_session;
state.audio.playback_error.clear(); state.audio.playback_error.clear();
@@ -202,6 +209,7 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU; state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
state.active_part_id.clear(); state.active_part_id.clear();
state.page_index = 0; state.page_index = 0;
state.active_measure.reset();
} }
} }
return clamp_state(std::move(state)); return clamp_state(std::move(state));
@@ -223,7 +231,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
title_image = selected->title_image; title_image = selected->title_image;
part = selected->part; part = selected->part;
page = state.page_index + 1U; page = state.page_index + 1U;
pages = selected->total_pages; pages = active_pages(state);
} }
} }
auto focus = menu_row(state, menu_selection(state)).label; auto focus = menu_row(state, menu_selection(state)).label;
@@ -286,7 +294,7 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
if (part == nullptr) { if (part == nullptr) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part"));
} }
if (part->total_pages == 0U || state.page_index >= part->total_pages) { if (active_pages(state) == 0U || state.page_index >= active_pages(state)) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score page index is out of range")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score page index is out of range"));
} }
} }
+6 -2
View File
@@ -201,10 +201,14 @@ auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint
} }
auto beat_frames = frames - count_in; auto beat_frames = frames - count_in;
auto page = std::size_t{}; auto page = std::size_t{};
while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) { while (page + 1 < score.playback_pages.size() && frame_at(score.playback_pages[page + 1].start_beat, bpm) <= beat_frames) {
++page; ++page;
} }
return {page, beat_frames < frame_at(score.duration, bpm) ? "Playing" : "Sustain"}; auto measure = std::size_t{1};
while (measure < score.measure_count && frame_at(score.measure_boundaries[measure], bpm) <= beat_frames) {
++measure;
}
return {page, beat_frames < frame_at(score.sustain_start, bpm) ? "Playing" : "Sustain", measure};
} }
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> { auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
+38 -10
View File
@@ -43,8 +43,35 @@ auto vamp_zero::score_page_cache::size() const -> std::size_t {
return entries_.size(); return entries_.size();
} }
auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std::size_t { auto vamp_zero::score_page_count(vamp_zero::score_document const &score, bool playback) -> std::size_t {
return score.pages.size(); return (playback ? score.playback_pages : score.pages).size();
}
auto vamp_zero::page_rectangle(luma::rect bounds) -> luma::rect {
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
return {.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
.width = 976.0F * scale,
.height = 484.0F * scale};
}
auto vamp_zero::measure_highlight(vamp_zero::score_page const &page, std::optional<std::size_t> measure, luma::rect image) -> std::optional<luma::rect> {
if (!measure) {
return {};
}
for (auto const &system : page.systems) {
for (auto const &label : system.labels) {
if (label.measure == *measure) {
auto scale = image.width / 976.0F;
auto const &b = label.bounds;
return luma::rect{.x = image.x + static_cast<float>(b[0] - 3) * scale,
.y = image.y + static_cast<float>(b[1] - 3) * scale,
.width = static_cast<float>(b[2] - b[0] + 6) * scale,
.height = static_cast<float>(b[3] - b[1] + 6) * scale};
}
}
}
return {};
} }
namespace { namespace {
@@ -54,11 +81,12 @@ struct page_file {
std::vector<std::uchar_t> bytes; std::vector<std::uchar_t> bytes;
}; };
auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<page_file, modulus::error> { auto read_page(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<page_file, modulus::error> {
auto fail = [&](std::string message) -> std::expected<page_file, modulus::error> { auto fail = [&](std::string message) -> std::expected<page_file, modulus::error> {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets"));
}; };
if (index >= score.pages.size()) { auto const &pages = playback ? score.playback_pages : score.pages;
if (index >= pages.size()) {
return fail("page index outside score"); return fail("page index outside score");
} }
auto error = std::error_code{}; auto error = std::error_code{};
@@ -66,7 +94,7 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
if (error) { if (error) {
return fail("score asset directory is missing"); return fail("score asset directory is missing");
} }
auto path = std::filesystem::canonical(root / score.pages[index].path, error); auto path = std::filesystem::canonical(root / pages[index].path, error);
if (error) { if (error) {
return fail("score page is missing"); return fail("score page is missing");
} }
@@ -95,7 +123,7 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
hash.push_back(digits[byte >> 4U]); hash.push_back(digits[byte >> 4U]);
hash.push_back(digits[byte & 15U]); hash.push_back(digits[byte & 15U]);
} }
if (hash != score.pages[index].sha256) { if (hash != pages[index].sha256) {
return fail("score page checksum mismatch"); return fail("score page checksum mismatch");
} }
return page_file{path.string(), std::move(bytes)}; return page_file{path.string(), std::move(bytes)};
@@ -103,16 +131,16 @@ auto read_page(vamp_zero::score_document const &score, std::size_t index) -> std
} }
auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error> { auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<std::string, modulus::error> {
auto page = read_page(score, index); auto page = read_page(score, index, playback);
if (!page) { if (!page) {
return std::unexpected(page.error()); return std::unexpected(page.error());
} }
return std::move(page->path); return std::move(page->path);
} }
auto vamp_zero::validate_score_page(vamp_zero::score_document const &score, std::size_t index) -> std::expected<void, modulus::error> { auto vamp_zero::validate_score_page(vamp_zero::score_document const &score, std::size_t index, bool playback) -> std::expected<void, modulus::error> {
auto page = read_page(score, index); auto page = read_page(score, index, playback);
if (!page) { if (!page) {
return std::unexpected(page.error()); return std::unexpected(page.error());
} }
+19 -13
View File
@@ -23,6 +23,7 @@
#include <vamp_zero/navigation.hpp> #include <vamp_zero/navigation.hpp>
#include <vamp_zero/menu_view.hpp> #include <vamp_zero/menu_view.hpp>
#include <vamp_zero/score.hpp> #include <vamp_zero/score.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/score_loader.hpp> #include <vamp_zero/score_loader.hpp>
#include <vamp_zero/touch_input.hpp> #include <vamp_zero/touch_input.hpp>
@@ -46,8 +47,9 @@ struct score_draw_cache {
if (!score) { if (!score) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "score no longer selected"));
} }
auto found = std::find_if(score->pages.begin(), score->pages.end(), [&](auto const &page) { return page.path == name; }); auto const &pages = playback ? score->playback_pages : score->pages;
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(score->pages.begin(), found))); auto found = std::find_if(pages.begin(), pages.end(), [&](auto const &page) { return page.path == name; });
auto path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(pages.begin(), found)), playback);
if (!path) { if (!path) {
return std::unexpected(path.error()); return std::unexpected(path.error());
} }
@@ -60,30 +62,34 @@ struct score_draw_cache {
textures.clear(); textures.clear();
document = score; document = score;
} }
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> { auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index, bool automatic, std::optional<std::size_t> measure) -> std::expected<void, modulus::error> {
auto score = document.lock(); auto score = document.lock();
if (!score || index >= score->pages.size()) { playback = automatic;
if (!score || index >= vamp_zero::score_page_count(*score, playback)) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score")); return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
} }
auto texture = textures.get(score->pages[index].path); auto const &pages = playback ? score->playback_pages : score->pages;
auto texture = textures.get(pages[index].path);
if (!texture) { if (!texture) {
return std::unexpected(texture.error()); return std::unexpected(texture.error());
} }
if (index + 1 < score->pages.size()) { if (index + 1 < pages.size()) {
auto next = textures.get(score->pages[index + 1].path); auto next = textures.get(pages[index + 1].path);
if (!next) { if (!next) {
return std::unexpected(next.error()); return std::unexpected(next.error());
} }
} }
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F); auto page = vamp_zero::page_rectangle(bounds);
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
.width = 976.0F * scale,
.height = 484.0F * scale};
context.fill_rect(page, vamp_zero::to_luma(vamp_style::background)); context.fill_rect(page, vamp_zero::to_luma(vamp_style::background));
context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255}); context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255});
if (playback) {
if (auto highlight = vamp_zero::measure_highlight(pages[index], measure, page)) {
context.stroke_rect(*highlight, std::max(1.0F, 1.5F * page.width / 976.0F), vamp_zero::to_luma(vamp_style::accent));
}
}
return {}; return {};
} }
bool playback{};
vamp_zero::score_page_cache textures; vamp_zero::score_page_cache textures;
std::weak_ptr<vamp_zero::score_document const> document{}; std::weak_ptr<vamp_zero::score_document const> document{};
}; };
@@ -108,7 +114,7 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
.width = bounds.width - 48.0F, .width = bounds.width - 48.0F,
.height = bounds.height - 36.0F, .height = bounds.height - 36.0F,
}; };
auto status = score_cache.draw(context, paper_bounds, state.page_index); auto status = score_cache.draw(context, paper_bounds, state.page_index, vamp_zero::automatic(state.audio), state.active_measure);
loader.pages_prepared(loaded.document, status); loader.pages_prepared(loaded.document, status);
if (!status) { if (!status) {
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger); vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
+4
View File
@@ -12,3 +12,7 @@ def section_document(document, part_type):
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'): with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing) return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
def melody_performance(document, part_type):
"""Isolate unchanged source melody from accompaniment/ending construction."""
return generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part_type, owned={'melody', 'vocal'})
+31 -3
View File
@@ -20,7 +20,7 @@ inline auto add_timing(boost::json::value &value) -> void {
music.at("metadata").as_object()["song_id"] = "fixture"; music.at("metadata").as_object()["song_id"] = "fixture";
auto &measures = music.at("staves").as_array().front().at("measures").as_array(); auto &measures = music.at("staves").as_array().front().at("measures").as_array();
auto &pages = value.at("presentation").at("pages").as_array(); auto &pages = value.at("presentation").at("pages").as_array();
while (measures.size() < pages.size()) { while (measures.size() < 2 * pages.size() - 1) {
measures.push_back(boost::json::object{}); measures.push_back(boost::json::object{});
} }
auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; }; auto beat = [](std::size_t n) { return boost::json::object{{"numerator", n}, {"denominator", 1}}; };
@@ -32,15 +32,43 @@ inline auto add_timing(boost::json::value &value) -> void {
} }
} }
std::size_t cursor{}; std::size_t cursor{};
auto systems = boost::json::array{};
for (std::size_t i = 0; i < pages.size(); ++i) { for (std::size_t i = 0; i < pages.size(); ++i) {
auto count = i + 1 == pages.size() ? measures.size() - cursor : 1U; auto count = i + 1 == pages.size() ? measures.size() - cursor : 2U;
auto &page = pages[i].as_object(); auto &page = pages[i].as_object();
page["first_measure"] = cursor + 1; page["first_measure"] = cursor + 1;
page["measure_count"] = count; page["measure_count"] = count;
page["start_beat"] = beat(cursor * 4); page["start_beat"] = beat(cursor * 4);
page["end_beat"] = beat((cursor + count) * 4); page["end_beat"] = beat((cursor + count) * 4);
cursor += count; auto members = boost::json::array{};
for (std::size_t j = 0; j < (i + 1 == pages.size() ? 1U : 2U); ++j) {
auto n = i + 1 == pages.size() ? count : 1U;
auto labels = boost::json::array{};
for (std::size_t k = 0; k < n; ++k) {
labels.push_back(boost::json::object{{"measure", cursor + k + 1}, {"bounds", boost::json::array{30 + k * 30, 70 + j * 220, 42 + k * 30, 82 + j * 220}}});
}
auto system = boost::json::object{{"first_measure", cursor + 1}, {"measure_count", n}, {"start_beat", beat(cursor * 4)}, {"end_beat", beat((cursor + n) * 4)}, {"labels", labels}};
members.push_back(system);
systems.push_back(system);
cursor += n;
}
page["systems"] = members;
} }
auto playback = boost::json::array{};
for (std::size_t i = 0; i < systems.size(); ++i) {
auto page = pages[std::min(i / 2, pages.size() - 1)].as_object();
auto members = boost::json::array{systems[i]};
if (i + 1 < systems.size()) {
members.push_back(systems[i + 1]);
}
page["systems"] = members;
page["first_measure"] = members.front().at("first_measure");
page["start_beat"] = members.front().at("start_beat");
page["end_beat"] = members.back().at("end_beat");
page["measure_count"] = members.back().at("first_measure").to_number<std::size_t>() + members.back().at("measure_count").to_number<std::size_t>() - members.front().at("first_measure").to_number<std::size_t>();
playback.push_back(page);
}
value.at("presentation").as_object()["playback_pages"] = playback;
value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}}; value.as_object()["performance"] = boost::json::object{{"song_id", "fixture"}, {"duration", beat(measures.size() * 4)}, {"count_in_beats", 4}, {"clicks", clicks}};
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

+204 -6
View File
@@ -4,7 +4,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -29,6 +29,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -1831,6 +1835,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -1839,7 +1869,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "electric", "part_type": "electric",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -1864,6 +1894,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -3666,6 +3700,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -3674,7 +3734,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -3699,6 +3759,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -6113,6 +6177,41 @@
"0+2 half note G4 ", "0+2 half note G4 ",
"2+2 half note G4 " "2+2 half note G4 "
] ]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
} }
] ]
}, },
@@ -6121,7 +6220,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -6146,6 +6245,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -8082,6 +8185,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -8090,7 +8219,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "electric", "part_type": "electric",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -8115,6 +8244,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -10051,6 +10184,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -10059,7 +10218,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -10084,6 +10243,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -12628,6 +12791,41 @@
"vocal_skeleton": [ "vocal_skeleton": [
"0+4 whole rest " "0+4 whole rest "
] ]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
} }
] ]
} }
+2 -2
View File
@@ -245,7 +245,7 @@ BOOST_AUTO_TEST_CASE(empty_catalog_navigation_and_main_menu_wrap) {
BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) { BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display) {
auto parts = std::vector<vamp_zero::library_part>{}; auto parts = std::vector<vamp_zero::library_part>{};
for (auto name : {"Acoustic", "Electric", "Vocal"}) { for (auto name : {"Acoustic", "Electric", "Vocal"}) {
parts.push_back({.id = name, .title = "Song", .composer = "Composer", .part = name, .total_pages = 3}); parts.push_back({.id = name, .title = "Song", .composer = "Composer", .part = name, .total_pages = 3, .playback_pages = 5});
} }
auto state = vamp_zero::app_state{ auto state = vamp_zero::app_state{
.catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts(std::move(parts))), .catalog = std::make_shared<vamp_zero::catalog const>(vamp_zero::group_ordered_parts(std::move(parts))),
@@ -264,7 +264,7 @@ BOOST_AUTO_TEST_CASE(song_part_navigation_preserves_selection_and_remote_display
BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote)); BOOST_REQUIRE(vamp_pico::parse_remote_display_body(vamp_zero::remote_display_text(state), remote));
BOOST_TEST(remote.active); BOOST_TEST(remote.active);
BOOST_TEST(std::string{remote.part} == name); BOOST_TEST(std::string{remote.part} == name);
BOOST_TEST(remote.pages == 3U); BOOST_TEST(remote.pages == 5U);
for (auto index = std::uint32_t{}; index < 4U; ++index) { for (auto index = std::uint32_t{}; index < 4U; ++index) {
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
} }
+93
View File
@@ -0,0 +1,93 @@
"""The held recording tail has exact score time and no vocal attacks."""
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
import generator.model
import generator.musicxml
import generator.parts
import generator.performance
import generator.production
import generator.score
import tests.solo_fixtures
class ArrangementEnding(unittest.TestCase):
def test_current_sources_end_on_effective_harmony_with_one_attack(self):
for name, expected in (('all-of-me', 'GM6'), ('autumn_leaves', 'Dm')):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / (name + '.xml'))
before = copy.deepcopy(source)
for part in ('acoustic', 'electric', 'vocal'):
result = generator.arrange.generate.arrange_document(source, part)
measures = result['staves'][0]['measures']
tail = measures[-3:]
self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in tail], ['169', '170', '171'])
self.assertEqual(generator.harmony.symbol(tail[0]['harmonies'][0]), expected)
self.assertEqual([m['section_label'] for m in tail], ['Ending', '', ''])
self.assertTrue(all(m['ending_sustain'] for m in tail))
notes = [n for m in tail for n in generator.score.measure_notes(m)]
attacks = [n for n in notes if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertEqual(len(attacks), 0 if part == 'vocal' else 1)
if part == 'vocal':
self.assertTrue(all(n['kind'] == 'rest' and not n.get('lyrics') for n in notes))
else:
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes if n['pitches']), 12)
self.assertEqual([p['tablature'] for p in notes[0]['pitches']], [p['tablature'] for p in notes[-1]['pitches']])
if part == 'acoustic':
self.assertIn(tuple(p['tablature']['string'] for p in attacks[0]['pitches']), ((5, 4, 3, 2), (6, 4, 3, 2)))
timing = generator.performance.timing(result)
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.fretboard.validate_document(result)
self.assertEqual(source, before)
def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(count=2, meter=meter, chords=('C7', 'FM7'))
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'))
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)
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)
def test_missing_or_explicitly_absent_final_chord_is_not_invented(self):
for no_chord in (False, True):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
last = source['staves'][0]['measures'][-1]
if no_chord:
last['harmonies'][0]['kind'] = 'none'
else:
last['harmonies'] = []
with self.assertRaisesRegex(ValueError, 'final performed harmony'):
generator.arrange.generate.arrange_document(source, 'acoustic')
def test_partial_combination_and_short_overlap_fail_after_tie_merging(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
doc = generator.arrange.generate.arrange_document(source, 'acoustic')
tail = copy.deepcopy(doc)
tail['staves'][0]['measures'] = tail['staves'][0]['measures'][-3:]
generator.acoustic.validate_comping(tail)
first = generator.score.measure_notes(tail['staves'][0]['measures'][0])[0]
bass = first['pitches'][0]
bass.pop('tie_to')
bass['duration'] = generator.model.rational(1)
with self.assertRaises(ValueError):
generator.acoustic.validate_comping(tail)
def test_ending_markers_cannot_interrupt_the_main_score(self):
source = tests.solo_fixtures.song(count=3, chords=('C7',))
source['staves'][0]['measures'][0]['ending_sustain'] = True
with self.assertRaisesRegex(ValueError, 'contiguous'):
generator.performance.timing(source)
if __name__ == '__main__':
unittest.main()
+7 -7
View File
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 168) self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)]) self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137']) self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672) self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684)
timelines.append([m['duration'] for m in measures]) timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]): for boundary in ([8, 136] if part == 'vocal' else [40]):
@@ -49,17 +49,17 @@ class ArrangementPickupTests(unittest.TestCase):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>') opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False) document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal') result = generator.arrange.generate.arrange_document(document, 'vocal')
self.assertEqual(len(result['staves'][0]['measures']), 12) self.assertEqual(len(result['staves'][0]['measures']), 15)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0) self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self): def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')) document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}] document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}]
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))): for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part): with self.subTest(part=part):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'})
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 6) self.assertEqual(len(measures), 9)
for boundary in boundaries: for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1] pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0] downbeat = generator.score.measure_notes(measures[boundary])[0]
+1 -1
View File
@@ -15,7 +15,7 @@ class CatalogTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (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(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({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir(): for path in output.iterdir():
+2 -1
View File
@@ -285,7 +285,7 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
// Complete cached metadata must not require parsing or retaining the score body. // 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, " db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
"key_signature, measure_count, total_pages, title_image) " "key_signature, measure_count, total_pages, title_image) "
"VALUES('tune', 'Metadata Song', 'Composer', '{}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')"); "VALUES('tune', 'Metadata Song', 'Composer', '{\"presentation\":{\"playback_pages\":[{},{},{}]}}', 'Lead', 'songs/tune.json', 'F', 3, 2, '0000000000000000000000000000000000000000000000000000000000000000')");
auto songs = db.rows("songs"); auto songs = db.rows("songs");
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) { for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
auto performance = db.performance(); auto performance = db.performance();
@@ -295,6 +295,7 @@ BOOST_AUTO_TEST_CASE(catalog_reload_uses_cached_metadata_without_parsing_score_j
BOOST_TEST(performance->parts[0].key_signature == "F"); BOOST_TEST(performance->parts[0].key_signature == "F");
BOOST_TEST(performance->parts[0].measure_count == 3U); BOOST_TEST(performance->parts[0].measure_count == 3U);
BOOST_TEST(performance->parts[0].total_pages == 2U); BOOST_TEST(performance->parts[0].total_pages == 2U);
BOOST_TEST(performance->parts[0].playback_pages == 3U);
} }
BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element()); BOOST_TEST(db.rows("songs") == songs, boost::test_tools::per_element());
auto song = db.score("tune"); auto song = db.score("tune");
+5 -6
View File
@@ -87,7 +87,7 @@ class ChordFamilies(unittest.TestCase):
def test_acoustic_policy_change_does_not_reseed_electric_roles(self): def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
resources = generator.vocabulary.load() resources = generator.vocabulary.load()
changed = copy.deepcopy(resources) changed = copy.deepcopy(resources)
changed['accompaniment']['instruments']['acoustic']['families']['quarters'] = ['1/2'] changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2'
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'): for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed)) 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.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed))
@@ -142,16 +142,15 @@ class CombinedAcoustic(unittest.TestCase):
m = source['staves'][0]['measures'][0] m = source['staves'][0]['measures'][0]
choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths') choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths')
self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events)) self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events))
policy = generator.vocabulary.accompaniment('acoustic')
upper = generator.comping_rhythm.lane_candidates(m, None, 'upper', (), policy, 'pairs')
self.assertTrue(any(b.start - a.start == fractions.Fraction(1, 2) for c in upper for a, b in zip(c.events, c.events[1:])))
canonical = generator.comping.Canonical(source, 'acoustic', 'short') canonical = generator.comping.Canonical(source, 'acoustic', 'short')
self.assertFalse(any(l['tied_continuations'] for row in canonical.rhythm_report for l in row['layers'].values())) for row in canonical.rhythm_report:
for event in row['events']:
self.assertGreater(fractions.Fraction(event['duration']), 0)
def test_unknown_harmony_subdivision_is_not_silently_quantized(self): def test_unknown_harmony_subdivision_is_not_silently_quantized(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',)) source = tests.solo_fixtures.song(count=1, chords=('C7',))
m = source['staves'][0]['measures'][0] m = source['staves'][0]['measures'][0]
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))}) m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))})
with self.assertRaisesRegex(ValueError, 'no bass rhythm'): with self.assertRaisesRegex(ValueError, 'no acoustic pattern'):
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact') generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
if __name__ == '__main__': if __name__ == '__main__':
+151 -74
View File
@@ -6,6 +6,7 @@ import pathlib
import unittest import unittest
import generator.accompaniment import generator.accompaniment
import generator.acoustic
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.comping import generator.comping
import generator.comping_rhythm import generator.comping_rhythm
@@ -29,56 +30,119 @@ class LayeredComping(unittest.TestCase):
def setUp(self): def setUp(self):
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7')) self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
self.resources = generator.vocabulary.load() self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self): def block(self, part, source=None, melody=()):
m = self.source['staves'][0]['measures'][0] source = source or self.source
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})): measures = source['staves'][0]['measures'][:2]
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources) inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures))))
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test') flat = generator.comping_rhythm.flatten(inputs)
self.assertEqual({len(c.events) for c in candidates}, expected) lengths = tuple(generator.model.fraction(m['duration']) for m in measures)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True) return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths)
if lane == 'electric': def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self):
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events)) expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'}
else: choices = {part: self.block(part) for part in ('acoustic', 'electric')}
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)}) for part, candidates in choices.items():
if lane == 'bass': self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
self.assertTrue(all(c.events[0].start == 0 for c in candidates)) for c in candidates:
def test_ranges_downbeats_and_overlap(self): generator.comping_rhythm.validate_events(c.events, part, (4, 4))
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light') for start in (0, 4):
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures']) self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)): self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
result = canonical(m, prior, i) a = {tuple(e.start for e in c.events) for c in choices['acoustic']}
voices = {v['id']: v['notes'] for v in result['voices']} e = {tuple(e.start for e in c.events) for c in choices['electric']}
self.assertEqual(set(voices), {'bass', 'upper'}) self.assertFalse(a & e)
self.assertTrue(voices['bass'][0]['pitches']) self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0) self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
for lane, high in (('bass', 2), ('upper', 3)): self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])] def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
self.assertTrue(1 <= len(attacks) <= high) F = fractions.Fraction
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report)) attack = generator.comping_rhythm.Attack
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report)) generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
def test_combined_crowding_favors_shared_attacks(self): generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
m = self.source['staves'][0]['measures'][0] 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)),)):
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint') with self.assertRaises(ValueError):
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates)) generator.comping_rhythm.validate_events(events, 'acoustic')
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates)) def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1))) source = tests.solo_fixtures.song(count=2, chords=('C7',))
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2))) flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated)) patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
def test_harmonic_changes_share_one_measure_budget(self): before = copy.deepcopy(patterns)
for pattern in patterns:
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(before, patterns)
def test_missing_harmony_reassigns_first_survivor_before_anticipation(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0]) m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))] m['harmonies'] = [{'id': 'absent', 'kind': 'none', 'onset': generator.model.rational(0)},
{**generator.harmony.parse_symbol('C7'), 'id': 'current', 'onset': generator.model.rational(1)},
{**generator.harmony.parse_symbol('FM7'), 'id': 'next', 'onset': generator.model.rational(2)}]
pattern = {'lengths': ['4'], 'events': [
{'onset': '0', 'duration': '1', 'layer': 'bass'},
{'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.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],))
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))
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.
pattern['events'][-1]['duration'] = '2'
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['acoustic']['patterns'] = [pattern]
with generator.vocabulary.using(resources, 'acoustic_accompaniment'):
canonical = generator.comping.Canonical(source, 'acoustic', 'bass-first')
measures = source['staves'][0]['measures']
source['staves'][0]['measures'] = [canonical(m, None, i) for i, m in enumerate(measures)]
generator.acoustic.validate_comping(source)
first = source['staves'][0]['measures'][0]
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_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
source = copy.deepcopy(self.source)
for m in source['staves'][0]['measures'][:2]:
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes') for c in self.block(part, source):
for c in choices: self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4)
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
count = sum(e.lane == lane for e in c.events)
self.assertLessEqual(count, high + 1)
if count > high:
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
for e in c.events: for e in c.events:
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4) if e.harmonic_time is None:
if part == 'electric': self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6)))
self.assertTrue(all(c.exceptions for c in choices)) def test_anticipation_binds_exact_upcoming_chord_and_keeps_direct_alternative(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
for part in ('acoustic', 'electric'):
candidates = self.block(part, source)
anticipated = [c for c in candidates if any(e.harmonic_time is not None for e in c.events)]
self.assertTrue(anticipated)
for c in anticipated:
for e in c.events:
if e.harmonic_time is not None:
self.assertIn(e.lane, ('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))
flat = generator.comping_rhythm.flatten(tuple((m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))))
pattern = next(p for p in self.resources['accompaniment']['instruments']['electric']['patterns'] if p['id'] == 'electric.forward_motion.statement')
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'electric', anticipate=True, limit=fractions.Fraction(4))
self.assertTrue(all(e.harmonic_time is None for e in events))
def test_no_chord_is_silent_and_partial_measure_is_bounded(self): def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0]) m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'][0]['kind'] = 'none' m['harmonies'][0]['kind'] = 'none'
@@ -86,23 +150,21 @@ class LayeredComping(unittest.TestCase):
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)} m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
m['duration'] = generator.model.rational(fractions.Fraction(1, 2)) m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events)) self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
def test_two_other_meters_preserve_full_bar_bounds(self): def test_two_other_meters_use_authored_patterns(self):
for meter in (3, 5): for meter in (3, 5):
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',)) source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'meter') canonical = generator.comping.Canonical(source, part, 'meter')
self.assertTrue(all(str(meter) + '_beat' in r.pattern_id for r in canonical.block_rhythms.values()))
for rhythm in canonical.rhythms.values(): for rhythm in canonical.rhythms.values():
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events)) self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
def test_melody_changes_the_objective_without_changing_seed(self): def test_melody_changes_the_objective_without_changing_seed(self):
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2)) busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8))
m = self.source['staves'][0]['measures'][0] space = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
harmony = generator.comping_rhythm.spans(m, None) first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)}
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4)) second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)}
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),) common = first.keys() & second.keys()
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4))) self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common))
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
self.assertGreater(first['melody_support'], second['melody_support'])
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
def test_tempo_changes_metadata_but_not_accompaniment_music(self): def test_tempo_changes_metadata_but_not_accompaniment_music(self):
changed = copy.deepcopy(self.source) changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240 changed['metadata']['tempo'] = 240
@@ -144,24 +206,39 @@ class LayeredComping(unittest.TestCase):
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)] m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'): with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
generator.fretboard.validate_document(doc) generator.fretboard.validate_document(doc)
def test_sustained_join_is_one_attack_and_keeps_position(self): def test_explicit_cross_bar_hold_survives_projection_without_reattack(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',)) source = tests.solo_fixtures.song(count=2, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'tie') for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')):
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))] resources = copy.deepcopy(self.resources)
chord = inputs[0][0]['harmonies'][0] patterns = resources['accompaniment']['instruments'][part]['patterns']
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0] resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id]
for m, p, i in inputs: with generator.vocabulary.using(resources, part + '_accompaniment'):
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip)) canonical = generator.comping.Canonical(source, part, 'held')
note['voice'] = 'electric' measures = source['staves'][0]['measures']
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}] rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0) a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')]
canonical.connect_holds(inputs) b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']}
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs] cross = [p for p in a if p['tie_to'] in b]
for p, q in zip(a['pitches'], b['pitches']): self.assertTrue(cross)
self.assertEqual(p['tie_to'], q['id']) for p in cross:
self.assertEqual(q['tie_from'], p['id']) self.assertEqual(p['tablature'], b[p['tie_to']]['tablature'])
self.assertEqual(p['tablature'], q['tablature']) self.assertEqual(p['id'], b[p['tie_to']]['tie_from'])
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0) if part == 'acoustic':
document = copy.deepcopy(source)
document['staves'][0]['measures'] = rendered
generator.acoustic.validate_comping(document)
def test_separate_repeated_attacks_are_never_joined(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['electric']['patterns'] = [{'id': 'test.repeated', 'family': 'quarters', 'lengths': ['4', '4'],
'events': [{'onset': str(t), 'duration': '4', 'layer': 'electric'} for t in (0, 4)]}]
with generator.vocabulary.using(resources, 'electric_accompaniment'):
canonical = generator.comping.Canonical(source, 'electric', 'repeat')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
notes = [n for m in rendered for n in generator.score.measure_notes(m) if n['pitches']]
self.assertEqual(len(notes), 2)
self.assertTrue(all(not p.get('tie_from') and not p.get('tie_to') for n in notes for p in n['pitches']))
class SoloRhythmConditioning(unittest.TestCase): class SoloRhythmConditioning(unittest.TestCase):
def test_melody_rhythm_and_progression_change_learned_probabilities(self): def test_melody_rhythm_and_progression_change_learned_probabilities(self):
+108 -1
View File
@@ -2,7 +2,9 @@
import copy import copy
import fractions import fractions
import itertools import itertools
import json
import pathlib import pathlib
import shutil
import tempfile import tempfile
import unittest import unittest
@@ -16,6 +18,8 @@ import generator.production
import generator.score import generator.score
import generator.vocabulary import generator.vocabulary
import tests.solo_fixtures import tests.solo_fixtures
import tests.test_acoustic_voicing
import tests.test_voicing
def options(symbol, part): def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part) return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
@@ -27,6 +31,109 @@ def path_cost(path, costs, part, left=(), right=(), close=False):
return tuple(total) return tuple(total)
class CompingVoicings(unittest.TestCase): class CompingVoicings(unittest.TestCase):
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
low, high = (tests.test_voicing.grip(f) for f in ((2, 3, 1, 3), (10, 12, 11, 12)))
key = generator.comping.semantic_key(generator.harmony.parse_symbol('C7'))
self.assertTrue(all(v in options('C7', 'electric') for v in (low, high)))
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')
self.assertEqual(path, (low,))
path, _ = 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')))
root = pathlib.Path('data')
current = generator.production.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)
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):
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)))
left, right = tests.test_acoustic_voicing.voicing((8, 10, 9, 10)), low[0]
layers = tuple(zip(low, high))
costs = []
for symbol, layer in zip(symbols, layers):
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)
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))
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
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)
self.assertEqual(path, (high,))
def test_acoustic_preferences_are_exact_and_weights_are_operational(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)
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
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')))
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:
root = pathlib.Path(directory) / 'data'
shutil.copytree(pathlib.Path('data/composition'), root / 'composition')
role_path = root / 'composition/roles.json'
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)
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')
self.assertTrue(warm['cache_hit'])
self.assertEqual(old['document'], warm['document'])
self.assertNotEqual(current['document'], old['document'])
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'])
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))
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self): 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'): for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
with self.subTest(chord=symbol): with self.subTest(chord=symbol):
@@ -76,7 +183,7 @@ class CompingVoicings(unittest.TestCase):
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self): def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
with self.subTest(part=part): with self.subTest(part=part):
chords = ('C', 'C', 'Dm7') chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C')
layers = tuple(options(s, part) for s in chords) 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)) 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 = generator.comping.select_grips(layers, costs, part, 'test')
+51
View File
@@ -21,6 +21,7 @@ import generator.engraving.types
import generator.guitar import generator.guitar
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.performance
import tests.score_events import tests.score_events
import tests.arrangement_fixtures import tests.arrangement_fixtures
import tests.test_accompaniment import tests.test_accompaniment
@@ -139,6 +140,56 @@ class EngravingTests(unittest.TestCase):
actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts) actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts)
with PIL.Image.open('tests/fixtures/notation_python.png') as expected: with PIL.Image.open('tests/fixtures/notation_python.png') as expected:
self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox()) self.assertIsNone(PIL.ImageChops.difference(actual, expected.convert('RGB')).getbbox())
def test_playback_windows_reuse_complete_systems_and_finish_on_top(self):
for count in (1, 2, 3, 4):
with self.subTest(systems=count):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note()] * count))
style = generator.engraving.types.Style()
index = generator.engraving.notation.ScoreIndex(source)
systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(count)]
pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style, index=index)
generator.engraving.audit.audit_layout(source, generator.engraving.types.LayoutDocument(pages, style, ''), self.fonts)
generator.engraving.audit.audit_playback(pages, playback, style, self.fonts)
self.assertEqual(len(pages), (count + 1) // 2)
self.assertEqual(len(playback), count)
self.assertEqual([[s.start for s in p.systems] for p in playback], [[i * 4, (i + 1) * 4] if i + 1 < count else [i * 4] for i in range(count)])
self.assertLess(playback[-1].systems[0].box.top, style.height / 2)
for sequence in (pages, playback):
for page in sequence:
self.assertEqual(len({s.scale for s in page.systems}), 1)
normal = [generator.performance.page_timing(source, p) for p in pages]
sliding = [generator.performance.page_timing(source, p) for p in playback]
generator.performance.validate_views(source, normal, sliding)
def test_transition_pair_of_tall_systems_fits_at_shared_scale(self):
short = tests.test_musicxml.xml_note()
tall = tests.test_musicxml.xml_note('C', 2, 'half', octave=2) + tests.test_musicxml.xml_note('C', 2, 'half', octave=7)
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([short, tall, tall, short]))
style = generator.engraving.types.Style()
index = generator.engraving.notation.ScoreIndex(source)
systems = [generator.engraving.layout.make_system(index, i * 4, (i + 1) * 4, self.fonts, style) for i in range(4)]
pages, playback = generator.engraving.layout.page_views(systems, source, self.fonts, style)
generator.engraving.audit.audit_playback(pages, playback, style, self.fonts)
self.assertEqual(playback[1].systems[0].scale, pages[0].systems[0].scale)
self.assertLess(playback[1].systems[1].box.bottom, style.height - style.margin_y)
def test_measure_numbers_clear_high_notes_and_follow_barlines(self):
source = tests.test_musicxml.parse(tests.test_accompaniment.xml_bars([tests.test_musicxml.xml_note('C', octave=7)] * 3))
for i, measure in enumerate(source['staves'][0]['measures']):
measure['number'] = str(169 + i)
layout = self.layout(source)
for page in layout.pages:
for system in page.systems:
labels = [p for p in system.primitives if p.role == 'measure_number']
self.assertEqual(len({round(p.points[0][1], 6) for p in labels}), 1)
bars = [p for p in system.primitives if p.role == 'barline']
for i, label in enumerate(labels):
center = (label.box.left + label.box.right) / 2
if i == 0:
self.assertAlmostEqual(center, layout.style.margin_x + 1, places=2)
else:
self.assertTrue(any(abs(center - p.points[0][0]) < system.scale for p in bars))
for other in system.primitives:
if other is not label:
self.assertFalse(label.box.overlaps(other.box, 3 * system.scale))
def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self): def test_aligned_bands_match_full_pages_with_spans_tab_and_sampling_phases(self):
tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>') tied = tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', extra='<tie type="stop"/>')
content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8)) content = ''.join(tests.test_musicxml.xml_note('G', 1, 'eighth') for _ in range(8))
+2 -2
View File
@@ -353,7 +353,7 @@ class EngravingCacheTests(unittest.TestCase):
data, output = self.root / 'data', self.root / 'output' data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True) (data / 'songs').mkdir(parents=True)
source = data / 'songs/fixture.xml' source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) 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()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
original = self.files(output) original = self.files(output)
@@ -364,7 +364,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertEqual(original, self.files(output)) self.assertEqual(original, self.files(output))
self.assertFalse((output / 'solo_xml').exists()) self.assertFalse((output / 'solo_xml').exists())
self.assertFalse(list(output.rglob('*.xml'))) self.assertFalse(list(output.rglob('*.xml')))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>'))) 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'): with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output)) self.assertEqual(original, self.files(output))
+2 -2
View File
@@ -255,7 +255,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (source / '0000.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(generator.arrange.generate.generate_performance_scores(source, output), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')} first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output) generator.arrange.generate.generate_performance_scores(source, output)
@@ -264,7 +264,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True) (root / 'data/songs').mkdir(parents=True)
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1))) (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(octave=7, alter=1)))
output = root / 'output' output = root / 'output'
output.mkdir() output.mkdir()
(output / 'last-good').write_text('preserved') (output / 'last-good').write_text('preserved')
+1 -1
View File
@@ -137,7 +137,7 @@ class GuitarPartTests(unittest.TestCase):
def test_song_melody_only_is_raised_an_octave(self): def test_song_melody_only_is_raised_an_octave(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
before = copy.deepcopy(source) before = copy.deepcopy(source)
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
result = builder(source, part) result = builder(source, part)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']] notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
+3 -3
View File
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(source, part) result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody']) self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
self.assertFalse(measures[-1]['harmonies']) self.assertFalse(measures[-1]['harmonies'])
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
@@ -115,7 +115,7 @@ class MusicModelTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()), encoding='utf-8')
args.check = True args.check = True
# --check uses the validation-only repository fixture. An alternate # --check uses the validation-only repository fixture. An alternate
# source tree fails its form contract before atomic publication. # source tree fails its form contract before atomic publication.
@@ -134,7 +134,7 @@ class MusicModelTests(unittest.TestCase):
data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output' data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
(data / 'songs').mkdir(parents=True) (data / 'songs').mkdir(parents=True)
source = data / 'songs' / 'fixture.xml' source = data / 'songs' / 'fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') 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()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
+3 -1
View File
@@ -40,6 +40,8 @@ class PartGenerationTests(unittest.TestCase):
generator.model.validate_document(result) generator.model.validate_document(result)
for measure in result['staves'][0]['measures']: for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes'] events = measure['voices'][0]['notes']
if measure.get('ending_sustain'):
continue
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'): if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'):
continue continue
self.assert_complete_measure(measure) self.assert_complete_measure(measure)
@@ -91,7 +93,7 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode')) source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
before = copy.deepcopy(source) before = copy.deepcopy(source)
expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']] expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']]
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part): with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part) result = builder(source, part)
+110 -2
View File
@@ -9,6 +9,7 @@
#include <vamp_zero/notation.hpp> #include <vamp_zero/notation.hpp>
#include <vamp_zero/page_control.hpp> #include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp> #include <vamp_zero/performance.hpp>
#include <vamp_zero/score.hpp>
namespace { namespace {
@@ -29,7 +30,7 @@ auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio")); auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
BOOST_REQUIRE(audio); BOOST_REQUIRE(audio);
catalog->audio_assets = *audio; catalog->audio_assets = *audio;
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture"}); catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture", .playback_pages = 3});
catalog->groups.push_back({.title = "Fixture", .parts = {0}}); catalog->groups.push_back({.title = "Fixture", .parts = {0}});
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE}; auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
result.selected_song_index = 1; result.selected_song_index = 1;
@@ -58,7 +59,7 @@ BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
BOOST_REQUIRE(engine); BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared)); auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session); BOOST_REQUIRE(session);
auto frames = backing ? 576000U : 288000U; auto frames = 576000U;
auto buffer = std::array<float, 960>{}; auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) { for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480)); BOOST_REQUIRE(engine->render(buffer.data(), 480));
@@ -139,6 +140,94 @@ BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU); BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
} }
BOOST_AUTO_TEST_CASE(playback_advances_one_system_and_highlights_each_measure) {
auto document = score();
BOOST_TEST(document.pages.size() == 2U);
BOOST_TEST(document.playback_pages.size() == 3U);
for (auto bpm : {60U, 90U, 120U}) {
auto count_in = vamp_zero::frame_at({4, 1}, bpm);
BOOST_CHECK(!vamp_zero::performance_at(document, count_in - 1, bpm).measure);
for (std::size_t m = 0; m < document.measure_count; ++m) {
auto frame = count_in + vamp_zero::frame_at(document.measure_boundaries[m], bpm);
auto now = vamp_zero::performance_at(document, frame, bpm);
BOOST_REQUIRE(now.measure);
BOOST_TEST(*now.measure == m + 1);
BOOST_TEST(now.page == std::min(m, std::size_t{2}));
if (m) {
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == m);
}
}
}
BOOST_TEST(document.playback_pages.back().systems.size() == 1U);
auto app = state(true);
auto control = vamp_zero::control_state{{}, app};
control.update_performance(1, 2, "Sustain", app.catalog, 3);
auto revision = control.snapshot().input_revision;
control.update_performance(1, 2, "Sustain", app.catalog, 4);
BOOST_TEST(*control.snapshot().active_measure == 4U);
BOOST_TEST(control.snapshot().input_revision == revision + 1);
auto remote = vamp_zero::remote_display_text(control.snapshot());
BOOST_TEST(remote.find("page=3\npages=3") != std::string::npos);
control.finish_playback(1, "failed", app.catalog);
BOOST_CHECK(!control.snapshot().active_measure);
}
BOOST_AUTO_TEST_CASE(fractional_pickups_and_unequal_measures_use_exact_boundaries) {
auto document = score();
document.measure_boundaries = {{0, 1}, {1, 2}, {7, 2}, {41, 6}, {65, 6}, {89, 6}};
document.playback_pages[1].start_beat = {1, 2};
document.playback_pages[2].start_beat = {7, 2};
document.duration = {89, 6};
for (auto bpm : {60U, 110U, 120U}) {
for (std::size_t i = 1; i < document.measure_count; ++i) {
auto frame = vamp_zero::frame_at({4, 1}, bpm) + vamp_zero::frame_at(document.measure_boundaries[i], bpm);
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == i);
BOOST_TEST(*vamp_zero::performance_at(document, frame, bpm).measure == i + 1);
}
}
}
BOOST_AUTO_TEST_CASE(highlight_geometry_follows_letterboxed_page_and_active_view) {
auto document = score();
for (auto bounds : {luma::rect{0, 0, 976, 484}, luma::rect{10, 20, 488, 484}, luma::rect{10, 20, 1952, 968}}) {
auto image = vamp_zero::page_rectangle(bounds);
auto a = vamp_zero::measure_highlight(document.playback_pages[0], 2, image);
auto b = vamp_zero::measure_highlight(document.playback_pages[1], 2, image);
BOOST_REQUIRE(a);
BOOST_REQUIRE(b);
auto scale = image.width / 976.F;
BOOST_TEST(b->x == image.x + 27.F * scale);
BOOST_TEST(b->y == image.y + 67.F * scale);
BOOST_TEST(a->y - b->y == 220.F * scale);
BOOST_TEST(b->width == 18.F * scale);
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], {}, image));
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], 5, image));
}
}
BOOST_AUTO_TEST_CASE(playback_parser_requires_complete_views_and_safe_label_geometry) {
auto original = boost::json::parse(read("score.json"));
for (auto fault : {"missing", "pair", "label", "bounds", "time", "path"}) {
auto value = original;
auto &presentation = value.at("presentation").as_object();
auto &views = presentation.at("playback_pages").as_array();
if (std::string_view{fault} == "missing") {
presentation.erase("playback_pages");
} else if (std::string_view{fault} == "pair") {
views[1] = views[0];
} else if (std::string_view{fault} == "label") {
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("measure") = 99;
} else if (std::string_view{fault} == "bounds") {
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("bounds").as_array()[2] = 1000;
} else if (std::string_view{fault} == "time") {
views[1].at("start_beat").at("numerator") = 3;
} else {
views[1].at("path") = "../outside.png";
}
BOOST_CHECK_MESSAGE(!vamp_zero::parse_notation_document(boost::json::serialize(value), "score.json"), fault);
}
}
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) { BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F); auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
BOOST_REQUIRE(prepared); BOOST_REQUIRE(prepared);
@@ -164,6 +253,25 @@ BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F)); BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
} }
BOOST_AUTO_TEST_CASE(written_ending_boundary_and_clicks_are_validated) {
auto original = boost::json::parse(read("score.json"));
auto document = score();
BOOST_TEST(document.measure_count == 5U);
BOOST_TEST(document.duration.numerator == 20U);
BOOST_TEST(document.sustain_start.numerator == 8U);
BOOST_TEST(vamp_zero::performance_at(document, 287999, 120).phase == "Playing");
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
auto changed = original;
changed.at("performance").at("sustain_start").at("numerator") = 9;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("music").at("staves").as_array()[0].at("measures").as_array()[2].as_object().erase("ending_sustain");
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("performance").at("clicks").as_array().push_back(boost::json::object{{"beat", boost::json::object{{"numerator", 8}, {"denominator", 1}}}, {"downbeat", true}});
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) { BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true}); auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine); BOOST_REQUIRE(engine);
+38 -4
View File
@@ -17,10 +17,21 @@ def fixture(root):
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
first = music['staves'][0]['measures'][0] first = music['staves'][0]['measures'][0]
music['staves'][0]['measures'].append(copy.deepcopy(first)) music['staves'][0]['measures'].append(copy.deepcopy(first))
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
music['metadata']['song_id'] = 'fixture' music['metadata']['song_id'] = 'fixture'
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)] def system(first, count, y):
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music)) return dict(first_measure=first, measure_count=count, start_beat=generator.model.rational((first - 1) * 4),
end_beat=generator.model.rational((first + count - 1) * 4),
labels=[dict(measure=m, bounds=[30 + 40 * (m - first), y, 42 + 40 * (m - first), y + 12]) for m in range(first, first + count)])
def page(index, systems):
return dict(path=f'{index}.png', sha256='0' * 64, first_measure=systems[0]['first_measure'],
measure_count=sum(s['measure_count'] for s in systems), start_beat=systems[0]['start_beat'], end_beat=systems[-1]['end_beat'], systems=systems)
pages = [page(1, [system(1, 1, 70), system(2, 1, 290)]), page(2, [system(3, 3, 70)])]
playback = [pages[0], page(3, [system(2, 1, 70), system(3, 3, 290)]), pages[1]]
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages, playback_pages=playback), performance=generator.performance.timing(music))
(root / 'score.json').write_text(json.dumps(bundle)) (root / 'score.json').write_text(json.dumps(bundle))
entries = [] entries = []
# Constant PCM makes accents, recorded count-in and the silent click tail observable. # Constant PCM makes accents, recorded count-in and the silent click tail observable.
@@ -73,10 +84,33 @@ class PerformanceTests(unittest.TestCase):
bundle = json.loads((root / 'score.json').read_text()) bundle = json.loads((root / 'score.json').read_text())
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages']) generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2) self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
self.assertEqual(bundle['performance']['duration'], generator.model.rational(8)) self.assertEqual(bundle['performance']['duration'], generator.model.rational(20))
self.assertEqual(bundle['performance']['sustain_start'], generator.model.rational(8))
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3) bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
with self.assertRaisesRegex(ValueError, 'timing'): with self.assertRaisesRegex(ValueError, 'timing'):
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages']) generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
def test_playback_metadata_rejects_missing_views_wrong_pairs_and_bad_labels(self):
with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary)
fixture(root)
bundle = json.loads((root / 'score.json').read_text())
pages = bundle['presentation']['pages']
playback = bundle['presentation']['playback_pages']
generator.performance.validate_views(bundle['music'], pages, playback)
for fault in ('missing', 'pair', 'bounds', 'label', 'timing'):
altered = copy.deepcopy(playback)
if fault == 'missing':
altered.pop()
elif fault == 'pair':
altered[1] = copy.deepcopy(altered[0])
elif fault == 'bounds':
altered[0]['systems'][0]['labels'][0]['bounds'][0] = -1
elif fault == 'label':
altered[0]['systems'][0]['labels'][0]['measure'] = 2
else:
altered[1]['start_beat'] = generator.model.rational(3)
with self.subTest(fault=fault), self.assertRaises(ValueError):
generator.performance.validate_views(bundle['music'], pages, altered)
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
+4 -2
View File
@@ -213,8 +213,10 @@ class ScoreBundleTests(unittest.TestCase):
page.write_bytes(data) page.write_bytes(data)
document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml')) document = generator.musicxml.load_musicxml(pathlib.Path('tests/fixtures/engraving.xml'))
bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}} bundle = {'music': document, 'presentation': {'engine': generator.engraving.validation.ENGINE_NAME, 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
times = generator.performance.boundaries(document) fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
bundle['presentation']['pages'][0].update(first_measure=1, measure_count=len(times) - 1, start_beat=generator.model.rational(0), end_beat=generator.model.rational(times[-1])) layout = generator.engraving.layout.layout_score(document, generator.engraving.types.Style(), fonts)
bundle['presentation']['pages'][0].update(generator.performance.page_timing(document, layout.pages[0]))
bundle['presentation']['playback_pages'] = copy.deepcopy(bundle['presentation']['pages'])
bundle['performance'] = generator.performance.timing(document) bundle['performance'] = generator.performance.timing(document)
path = assets / 'score.json' path = assets / 'score.json'
path.write_text(json.dumps(bundle)) path.write_text(json.dumps(bundle))
+28 -3
View File
@@ -14,6 +14,10 @@
#include <vamp_pico/screen.hpp> #include <vamp_pico/screen.hpp>
#include <vamp_zero/menu_view.hpp> #include <vamp_zero/menu_view.hpp>
#include <vamp_zero/page_control.hpp> #include <vamp_zero/page_control.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/color.hpp>
namespace { namespace {
@@ -133,12 +137,21 @@ auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pi
auto context = image_context{}; auto context = image_context{};
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state); vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
auto source = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json")); auto source = vamp_zero::parse_notation_document(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"), "score.json");
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / std::string{source.at("presentation").at("pages").as_array().front().at("path").as_string()}; BOOST_REQUIRE(source);
auto playback = vamp_zero::automatic(state.audio);
auto const &pages = playback ? source->playback_pages : source->pages;
auto const &page = pages.at(state.page_index);
auto path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / page.path;
auto sheet = LoadImage(path.c_str()); auto sheet = LoadImage(path.c_str());
BOOST_REQUIRE(sheet.data); BOOST_REQUIRE(sheet.data);
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE); ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
UnloadImage(sheet); UnloadImage(sheet);
if (playback) {
if (auto highlight = vamp_zero::measure_highlight(page, state.active_measure, {24, 98, 976, 484})) {
context.stroke_rect(*highlight, 1.5F, vamp_zero::to_luma(vamp_style::accent));
}
}
} else { } else {
vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state); vamp_zero::draw_menu(context, {0.0F, 80.0F, 1024.0F, 520.0F}, state);
} }
@@ -184,7 +197,7 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
auto catalog = std::make_shared<vamp_zero::catalog>(); auto catalog = std::make_shared<vamp_zero::catalog>();
auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json")); auto score = boost::json::parse(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"));
auto title = std::string{score.at("presentation").at("title_image").as_string()}; auto title = std::string{score.at("presentation").at("title_image").as_string()};
catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves"}); catalog->parts.push_back({.id = "electric", .title = "Autumn Leaves", .composer = "Joseph Kosma", .part = "Electric", .total_pages = score.at("presentation").at("pages").as_array().size(), .title_image = title, .song_id = "autumn_leaves", .playback_pages = score.at("presentation").at("playback_pages").as_array().size()});
catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}}); catalog->groups.push_back({"Autumn Leaves", "Joseph Kosma", {0U}});
catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120}); catalog->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
state.catalog = catalog; state.catalog = catalog;
@@ -206,9 +219,21 @@ BOOST_AUTO_TEST_CASE(device_menu_rendering_uses_petaluma_and_fits_both_displays)
state.active_part_id = "electric"; state.active_part_id = "electric";
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) { for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
state.audio.phase = phase; state.audio.phase = phase;
state.audio.backing_enabled = std::string_view{phase} != "Manual";
state.audio.metronome_enabled = state.audio.backing_enabled;
state.active_measure = std::string_view{phase} == "Playing" ? std::optional<std::size_t>{1} : std::nullopt;
preview(state, phase); preview(state, phase);
} }
state.audio.phase = "Playing";
state.page_index = 1;
state.active_measure = score.at("presentation").at("playback_pages").as_array()[1].at("first_measure").to_number<std::size_t>();
preview(state, "transition");
state.page_index = catalog->parts[0].playback_pages - 1;
state.active_measure = score.at("music").at("staves").as_array()[0].at("measures").as_array().size();
state.audio.phase = "Sustain";
preview(state, "final_line");
state.audio.phase = "Error"; state.audio.phase = "Error";
state.active_measure.reset();
state.audio.playback_error = "Recording unavailable"; state.audio.playback_error = "Recording unavailable";
preview(state, "audio_error"); preview(state, "audio_error");
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU; state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
+25
View File
@@ -9,6 +9,7 @@ import generator.harmony
import generator.model import generator.model
import generator.musicxml import generator.musicxml
import generator.score import generator.score
import generator.vocabulary
import tests.test_musicxml import tests.test_musicxml
import tests.test_parts import tests.test_parts
@@ -23,6 +24,30 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape)) return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
class VoicingTests(unittest.TestCase): class VoicingTests(unittest.TestCase):
def test_melody_preferences_cannot_purchase_a_lower_priority_string(self):
resources = generator.vocabulary.load()
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 1000, 'high_position_weight': 1000}}):
for symbol, melody in (('C7', (67,)), ('FM7', (69,)), ('Cm7', (70,))):
candidates = ranked(symbol, melody)
primary = [c for c in candidates if generator.fretboard.span_penalty(c) == generator.fretboard.span_penalty(candidates[0])]
self.assertEqual(min(p.string_number for p in candidates[0] if p.midi in melody), min(p.string_number for c in primary for p in c if p.midi in melody))
self.assertTrue(set(melody) <= {p.midi for p in candidates[0]})
def test_melody_preference_cost_and_pruning_caches_include_role_settings(self):
resources = generator.vocabulary.load()
shape = grip((10, 12, 11, 12))
original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
preference = generator.guitar.voicing_preferences(shape, generator.guitar.voicing_preference('electric_melody'))
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0, 'high_position_weight': 0}}):
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
self.assertEqual(original.effort - plain.effort, sum(preference))
self.assertEqual(original[:6], plain[:6])
self.assertEqual(original, generator.guitar.voicing_cost(shape, 'electric', melody=(76,)))
for center in (45, 76, 45):
with generator.vocabulary.using(resources, 'electric_melody', {'center': center}):
best = {}
for candidate in ranked('C7', (72,)):
best.setdefault(generator.guitar.position_center(candidate), candidate)
self.assertEqual(set(ranked('C7', (72,), prune=True)), set(best.values()))
def test_electric_chords_use_the_shared_four_fret_limit(self): def test_electric_chords_use_the_shared_four_fret_limit(self):
for prune in (False, True): for prune in (False, True):
candidates = ranked('CM7', prune=prune) candidates = ranked('CM7', prune=prune)