Compare commits
4 Commits
e15dc31568
...
d2f493c17b
| Author | SHA1 | Date | |
|---|---|---|---|
| d2f493c17b | |||
| 125aaa1637 | |||
| 2f21ec1f16 | |||
| c7f6a30a13 |
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -59,6 +59,6 @@ Controls:
|
||||
- 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.
|
||||
|
||||
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
@@ -97,11 +97,11 @@ Parenthesized chord symbols mark optional turnarounds, including Soundslice's te
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
`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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+2002
-96
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,31 @@
|
||||
"acoustic_accompaniment": {
|
||||
"seed": "acoustic_comp",
|
||||
"bass": "primary",
|
||||
"center": 53
|
||||
"center": 53,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
}
|
||||
},
|
||||
"electric_accompaniment": {
|
||||
"seed": "electric_comp",
|
||||
"bass": "essential",
|
||||
"center": 60
|
||||
"center": 60,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
}
|
||||
},
|
||||
"electric_melody": {
|
||||
"seed": "head"
|
||||
"seed": "head",
|
||||
"center": 60,
|
||||
"voicing": {
|
||||
"register_weight": 1,
|
||||
"high_position_weight": 1,
|
||||
"comfortable_max_fret": 9
|
||||
}
|
||||
},
|
||||
"vocal_melody": {
|
||||
"seed": "voice"
|
||||
|
||||
@@ -6,6 +6,7 @@ import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
import generator.score
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HarmonySpan:
|
||||
@@ -107,3 +108,48 @@ def combine(document: dict) -> None:
|
||||
a = fragments[start][-1] if start in fragments else originals[start]
|
||||
b = fragments[end][0] if end in fragments else originals[end]
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
|
||||
def validate_comping(document: dict) -> None:
|
||||
"""Check complete sounding events, not barline or notation fragments."""
|
||||
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
|
||||
|
||||
@@ -50,7 +50,15 @@ def audit(source, documents):
|
||||
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
|
||||
continue
|
||||
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']),
|
||||
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'])
|
||||
@@ -60,7 +68,7 @@ def audit(source, documents):
|
||||
reused += 1
|
||||
else:
|
||||
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
|
||||
offset += section.measure_count
|
||||
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
|
||||
|
||||
@@ -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:], [])
|
||||
@@ -8,6 +8,7 @@ import generator.accompaniment
|
||||
import generator.arrange.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.pickup
|
||||
import generator.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)
|
||||
previous = generator.accompaniment.carried_chords(section_measures)
|
||||
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):
|
||||
m = section_measures[si]
|
||||
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['section_label'] = section.label if not segment 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)})
|
||||
output.extend(segment)
|
||||
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)
|
||||
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)
|
||||
@@ -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):
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.validate_comping(result)
|
||||
generator.acoustic.combine(result)
|
||||
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
||||
generator.melody.harmonize(result)
|
||||
|
||||
+163
-106
@@ -67,7 +67,7 @@ def movement(part, previous, current):
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
family: int = 0
|
||||
effort: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
missing_tones: int = 0
|
||||
register: fractions.Fraction = fractions.Fraction()
|
||||
def plus(self, other):
|
||||
@@ -75,21 +75,27 @@ class VoicingCost(typing.NamedTuple):
|
||||
def moved(self, 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):
|
||||
required = requirements(json.loads(key), part)
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
||||
preference = sum(voicing_preferences(part, grip, center))
|
||||
if part == 'electric':
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
|
||||
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register)
|
||||
# Familiar degree orders on either bass string, then whole-form movement.
|
||||
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
|
||||
# Familiar degree orders on either bass string, then balanced whole-form
|
||||
# movement, sounding register and hand position. Neither is an absolute ban.
|
||||
family = generator.chord_families.acoustic(json.loads(key), grip)
|
||||
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 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):
|
||||
"""Exact small-state dynamic programming; never prune by hand center.
|
||||
|
||||
Electric effort includes its melody grip cost and hand-center travel.
|
||||
Acoustic effort follows all four voices, including a bass-string change.
|
||||
Both roles balance register/position with travel. Electric includes its
|
||||
base grip effort; acoustic follows all four voices and bass-string changes.
|
||||
Compact span and family remain higher tiers.
|
||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
||||
"""
|
||||
if not layers:
|
||||
@@ -135,91 +141,157 @@ class HarmonyChoice:
|
||||
def key(self):
|
||||
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:
|
||||
def __init__(self, document, part, seed):
|
||||
self.part, self.seed = part, seed
|
||||
self.policy = generator.vocabulary.role(part + '_accompaniment')
|
||||
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)
|
||||
first_index = generator.score.body_start_measure_index(measures)
|
||||
sections = generator.arrange.form.full_arrangement_sections(measures, part)
|
||||
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
|
||||
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(measures)
|
||||
inputs = []
|
||||
for index in range(first_index, len(measures)):
|
||||
source = copy.deepcopy(measures[index])
|
||||
for chord in source['harmonies']:
|
||||
chord.pop('chord_optional', None)
|
||||
inputs.append((source, carried[index], index))
|
||||
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
|
||||
rhythms = generator.comping_rhythm.select(layers, seed)
|
||||
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
|
||||
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
|
||||
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
|
||||
self.first = generator.score.body_start_measure_index(self.measures)
|
||||
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(self.measures)
|
||||
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
|
||||
carried = generator.accompaniment.carried_chords(self.measures)
|
||||
self.groups = []
|
||||
index = self.first
|
||||
while index < len(self.measures):
|
||||
size = 1
|
||||
if index + 1 < len(self.measures):
|
||||
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
|
||||
if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
|
||||
size = 2
|
||||
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
|
||||
index += size
|
||||
layers = [self.candidates(group) for group in self.groups]
|
||||
selected = generator.comping_rhythm.select(layers, seed)
|
||||
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
|
||||
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
||||
active = tuple(span for spans in prepared for span in spans if span.events)
|
||||
path, self.path_cost = self.select(active, close=self.closed)
|
||||
self.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.rhythms = {}
|
||||
previous = path[-1] if self.closed and path else ()
|
||||
for source, prior, index, spans in prepared:
|
||||
selected = 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)
|
||||
if selected:
|
||||
previous = selected[-1]
|
||||
if generator.vocabulary.accompaniment(part)['connect_holds']:
|
||||
self.connect_holds(inputs)
|
||||
for group, rhythm, spans in zip(self.groups, selected, prepared):
|
||||
grips = tuple(self.base[s.key] for s in spans if s.events)
|
||||
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
|
||||
cursor = fractions.Fraction()
|
||||
for m, _, i in group:
|
||||
length = generator.model.fraction(m['duration'])
|
||||
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
|
||||
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
|
||||
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
|
||||
def key(source, prior, index):
|
||||
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
|
||||
source['duration'].__repr__())
|
||||
def prepare(self, source, prior, index, rhythm):
|
||||
def key(inputs, limit=None):
|
||||
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))
|
||||
def candidates(self, inputs, limit=None):
|
||||
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 = []
|
||||
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:
|
||||
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:
|
||||
options = voicings(semantic_key(chord), self.part)
|
||||
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))
|
||||
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)
|
||||
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
|
||||
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer}
|
||||
for s, layer in zip(spans, layers))
|
||||
def select(self, spans, *, left=(), right=(), close=False):
|
||||
layers = tuple(s.options for s in spans)
|
||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
|
||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
|
||||
def __call__(self, source, prior, index):
|
||||
key = self.key(source, prior, index)
|
||||
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
|
||||
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
|
||||
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:
|
||||
# Effective pickup/turnaround harmony may differ from the main form.
|
||||
# Solve that measure between fixed canonical neighbors. Request order
|
||||
# and previously generated arrangement sections cannot change it.
|
||||
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
|
||||
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
|
||||
spans = self.prepare(source, prior, index, rhythm)
|
||||
before = [v for i, _, v in self.order if i < index]
|
||||
after = [v for i, _, v in self.order if i > index]
|
||||
choices = self.candidates(group, limit)
|
||||
start, end = group[0][2], group[-1][2]
|
||||
# Preserve the canonical whole figure where still legal under changed
|
||||
# effective harmony. Recompute only genuinely different source context.
|
||||
original = self.block_rhythms.get(start)
|
||||
matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
|
||||
rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
|
||||
spans = self.prepare(group, rhythm)
|
||||
before = [v for i, _, v in self.order if i < start]
|
||||
after = [v for i, _, v in self.order if i > end]
|
||||
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
|
||||
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base)
|
||||
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
|
||||
return copy.deepcopy(self.cache[key])
|
||||
def render(self, source, prior, index, spans, selected, previous, variant):
|
||||
length = generator.model.fraction(source['duration'])
|
||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
|
||||
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
|
||||
return copy.deepcopy(self.cache[key][index - group[0][2]])
|
||||
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
length = generator.model.fraction(flat['duration'])
|
||||
index = inputs[0][2]
|
||||
attacks, rows = [], []
|
||||
choices = iter(selected)
|
||||
for span in spans:
|
||||
@@ -227,8 +299,8 @@ class Canonical:
|
||||
bass = grip[0] if grip and self.part == 'acoustic' else None
|
||||
degrees = generator.harmony.degrees(span.chord)
|
||||
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
||||
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
|
||||
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
|
||||
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
|
||||
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
|
||||
'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_string': bass.string_number if bass else None,
|
||||
'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:
|
||||
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
|
||||
attacks.append((event.lane, event.start, event.end, positions, span.chord))
|
||||
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
|
||||
for lane in lanes:
|
||||
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
|
||||
attacks.append((lane, event.start, event.end, positions, span.chord))
|
||||
if grip:
|
||||
previous = grip
|
||||
self._reports[self.key(source, prior, index)] = rows
|
||||
key = self.key(inputs, limit)
|
||||
self._reports[key] = rows
|
||||
voices = []
|
||||
notes, cursor, lane = [], fractions.Fraction(), ''
|
||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||
notes, cursor = [], 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']:
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
|
||||
if cursor < start:
|
||||
emit(cursor, start)
|
||||
emit(start, end, positions, chord)
|
||||
cursor = end
|
||||
if cursor < length:
|
||||
emit(cursor, length)
|
||||
voices.append({'id': lane, 'notes': notes})
|
||||
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
|
||||
result['voices'] = voices
|
||||
result = split_measures({'voices': voices}, inputs, self.part)
|
||||
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
|
||||
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
|
||||
|
||||
+141
-132
@@ -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 fractions
|
||||
import itertools
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.model
|
||||
@@ -14,6 +13,7 @@ class Attack:
|
||||
lane: str
|
||||
start: fractions.Fraction
|
||||
end: fractions.Fraction
|
||||
harmonic_time: fractions.Fraction | None = None
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Rhythm:
|
||||
@@ -21,12 +21,13 @@ class Rhythm:
|
||||
families: tuple
|
||||
contributions: tuple
|
||||
exceptions: tuple = ()
|
||||
pattern_id: str = ''
|
||||
@property
|
||||
def cost(self):
|
||||
return sum(value for _, value in self.contributions)
|
||||
@property
|
||||
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):
|
||||
melody = generator.solo.analysis.attacks(document)
|
||||
@@ -49,151 +50,159 @@ def spans(measure, prior):
|
||||
result.append((a, b, chord))
|
||||
return tuple(result)
|
||||
|
||||
def lane_score(events, lane, melody, harmony, length):
|
||||
"""Named source-context preferences, independent of the deterministic seed."""
|
||||
attacks = [a for a, b in melody if a >= 0]
|
||||
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction())
|
||||
space = max(0, length - occupied)
|
||||
def flatten(inputs):
|
||||
"""Harmony/timing adapter only; never concatenate separately chosen rhythms."""
|
||||
first = inputs[0][0]
|
||||
result = {**first, 'harmonies': []}
|
||||
cursor = fractions.Fraction()
|
||||
for m, prior, _ in inputs:
|
||||
chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
|
||||
initial = chord or {'kind': 'none'}
|
||||
result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
|
||||
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']))
|
||||
cursor += generator.model.fraction(m['duration'])
|
||||
result['duration'] = generator.model.rational(cursor)
|
||||
return result
|
||||
|
||||
def validate_events(events, part, lengths=None):
|
||||
ordered = sorted(events, key=lambda e: e.start)
|
||||
for e in ordered:
|
||||
duration = e.end - e.start
|
||||
if duration <= 0:
|
||||
raise ValueError('comping event must have positive sounding duration')
|
||||
if part == 'acoustic':
|
||||
if e.lane == 'combined':
|
||||
if duration <= 1:
|
||||
raise ValueError('combined acoustic sustain must exceed one beat')
|
||||
elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
|
||||
raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
|
||||
elif e.lane != 'electric':
|
||||
raise ValueError('electric comping requires chord events')
|
||||
if any(a.end > b.start for a, b in zip(ordered, ordered[1:])):
|
||||
raise ValueError('separate comping events cannot overlap; use one combined sustain')
|
||||
if part == 'acoustic' and lengths is not None:
|
||||
cursor = fractions.Fraction()
|
||||
for length in lengths:
|
||||
first = next((e for e in ordered if cursor <= e.start < cursor + length), None)
|
||||
if first and first.lane not in ('bass', 'combined'):
|
||||
raise ValueError('first acoustic attack in each measure must include bass')
|
||||
cursor += length
|
||||
|
||||
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
|
||||
if lane == 'bass':
|
||||
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony))
|
||||
elif lane == 'upper':
|
||||
target = 2.5 if space >= length / 2 else 1.5 if busy else 2
|
||||
else:
|
||||
target = .5 if busy else 1.5 if space >= length / 2 else 1
|
||||
# A half-attack tolerance makes neighboring densities genuine alternatives.
|
||||
# The objective does not always fill up to a layer's permitted maximum.
|
||||
activity = .35 * max(0, abs(len(events) - target) - .5)
|
||||
support, articulation = 0., 0.
|
||||
for e in events:
|
||||
# Supporting an entrance is different from doubling every moving note.
|
||||
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))
|
||||
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody)
|
||||
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)
|
||||
if lane != 'bass':
|
||||
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0
|
||||
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0
|
||||
elif e.start and e.start.denominator != 1:
|
||||
support += .15
|
||||
divisor = max(1, len(events))
|
||||
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor))
|
||||
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):
|
||||
"""Keep counts, placements and duration families before the final cutoff."""
|
||||
unique = {}
|
||||
for candidate in candidates:
|
||||
key = candidate.signature
|
||||
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key))
|
||||
if key not in unique or rank < unique[key][0]:
|
||||
unique[key] = rank, candidate
|
||||
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])]
|
||||
groups = {}
|
||||
shapes = set()
|
||||
for c in candidates:
|
||||
rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
|
||||
if c.signature not in unique or rank < unique[c.signature][0]:
|
||||
unique[c.signature] = rank, c
|
||||
ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
|
||||
retained, families = [], set()
|
||||
for c in ordered:
|
||||
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families)
|
||||
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events))
|
||||
# Round-robin attack counts first. Otherwise many cosmetic variants of
|
||||
# the cheapest density can exclude every one- or three-attack idea.
|
||||
if shape not in shapes:
|
||||
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)
|
||||
if c.families not in families:
|
||||
retained.append(c)
|
||||
families.add(c.families)
|
||||
retained.extend(c for c in ordered if c not in retained)
|
||||
return tuple(retained[:limit])
|
||||
|
||||
def transition(previous, current):
|
||||
if previous is None:
|
||||
return 0.
|
||||
# Recurrence is allowed; penalize repeating an entire bar more than sharing
|
||||
# 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
|
||||
return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
|
||||
|
||||
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)]
|
||||
for i, layer in enumerate(layers):
|
||||
following = []
|
||||
for c in layer:
|
||||
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.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]))
|
||||
states = following
|
||||
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
|
||||
|
||||
@@ -79,6 +79,9 @@ def audit_layout(document: dict, layout: generator.engraving.types.LayoutDocumen
|
||||
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
|
||||
# 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')]
|
||||
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 other in checked[i + 1:]:
|
||||
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', []):
|
||||
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')
|
||||
|
||||
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')
|
||||
|
||||
@@ -61,7 +61,7 @@ def restore_pages(entry: pathlib.Path, destination: pathlib.Path, validated_page
|
||||
return None
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for index, page in enumerate(pages, 1):
|
||||
if not isinstance(page, dict) or set(page) != {'path', 'sha256', '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
|
||||
path = entry / page['path']
|
||||
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'])
|
||||
(staged / 'pages.json').write_text(json.dumps(pages), encoding='utf-8')
|
||||
# Identical keys have identical pages; publish complete files, then the manifest.
|
||||
for name in [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
|
||||
if target.is_dir() and not target.is_symlink():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
@@ -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.extend(generator.engraving.scales.column(index, time, start, fonts))
|
||||
if time == measure_start:
|
||||
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure_number', (measure['id'],)))
|
||||
if label := measure.get('section_label'):
|
||||
items.append(fonts.text(label, 0, 0, 16 if index.staff.get('tablature') else 19, 'section', (measure['id'] + ':section',)))
|
||||
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, []):
|
||||
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['id'] + f':attributes:{ci}', tuple(change))
|
||||
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
|
||||
if 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:
|
||||
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)
|
||||
@@ -162,7 +165,7 @@ def fit_part_systems(systems, index, available_height, fonts, style):
|
||||
|
||||
def fitting_scale(items):
|
||||
scale = 1
|
||||
for first in range(0, len(items), 2):
|
||||
for first in range(len(items)):
|
||||
pair = items[first:first + 2]
|
||||
height = sum(s.box.bottom - s.box.top for s in pair)
|
||||
scale = min(scale, (available_height - style.system_gap * (len(pair) - 1)) / height)
|
||||
@@ -214,17 +217,19 @@ def part_header(document, fonts, style):
|
||||
result.append(fonts.text(line, style.margin_x, baseline, 12, 'subtitle', ()))
|
||||
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, ...]:
|
||||
pages = []
|
||||
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, ...]]:
|
||||
index = index or generator.engraving.notation.ScoreIndex(document)
|
||||
header = part_header(document, fonts, style)
|
||||
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
|
||||
if bottom - top <= style.system_gap:
|
||||
raise generator.engraving.types.EngravingError('page header leaves no room for two music systems')
|
||||
fitted = fit_part_systems(systems, index, bottom - top, fonts, style)
|
||||
for first in range(0, len(systems), 2):
|
||||
|
||||
def place(stride):
|
||||
pages = []
|
||||
for first in range(0, len(fitted), stride):
|
||||
number = len(pages) + 1
|
||||
pair = fitted[first:first + 2]
|
||||
slack = max(0, bottom - top - sum(s.box.bottom - s.box.top for s in pair) - style.system_gap * (len(pair) - 1))
|
||||
@@ -235,10 +240,14 @@ def paginate(systems: list[generator.engraving.types.System], document: dict, fo
|
||||
dx, dy = style.margin_x + 1, cursor - system.box.top
|
||||
placed.append(dataclasses.replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy)))
|
||||
cursor = placed[-1].box.bottom + style.system_gap + spacing
|
||||
footer = footers[first // 2]
|
||||
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])))
|
||||
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, ...]:
|
||||
if index is None:
|
||||
generator.model.validate_document(document)
|
||||
|
||||
@@ -101,14 +101,19 @@ def clear_pages(destination):
|
||||
shutil.rmtree(destination)
|
||||
|
||||
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
|
||||
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}}
|
||||
generator.performance.validate_pages(document, pages)
|
||||
'width': generator.engraving.validation.PAGE_WIDTH, 'height': generator.engraving.validation.PAGE_HEIGHT, 'pages': normal, 'playback_pages': playback}}
|
||||
validate_cached_views(document, pages)
|
||||
bundle['performance'] = generator.performance.timing(document)
|
||||
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||
|
||||
def validate_cached_views(document, pages):
|
||||
generator.performance.validate_views(document, [p for p in pages if not p['playback']], [p for p in pages if p['playback']])
|
||||
|
||||
def engrave_group(notation, paths, root, font_path, cache_dir, fingerprint, progress=False):
|
||||
family = generator.engraving.fonts.Fonts(font_path)
|
||||
style = generator.engraving.types.Style()
|
||||
@@ -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)
|
||||
result.timings['layout'] += time.monotonic() - started
|
||||
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)
|
||||
if placement not in audited:
|
||||
layout = generator.engraving.types.LayoutDocument(pages_layout, style, fingerprint)
|
||||
generator.engraving.audit.audit_layout(document, layout, family)
|
||||
generator.engraving.audit.audit_playback(pages_layout, playback_layout, style, family)
|
||||
audited.add(placement)
|
||||
else:
|
||||
for page in pages_layout:
|
||||
for page in pages_layout + playback_layout:
|
||||
decoration = tuple(p for p in page.primitives if p.role in ('title', 'subtitle', 'page_number'))
|
||||
generator.engraving.audit.audit_primitives(decoration, page.width, page.height, family)
|
||||
result.timings['pagination'] += time.monotonic() - started
|
||||
pages = []
|
||||
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)
|
||||
if bands is None:
|
||||
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)
|
||||
started = time.monotonic()
|
||||
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:
|
||||
image.save(target, format='PNG')
|
||||
result.timings['raster'] += time.monotonic() - started
|
||||
result.renderedpages += 1
|
||||
pages.append({'path': target.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest(), **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)
|
||||
write_bundle(path, document, pages, fingerprint)
|
||||
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
|
||||
if pages is not None:
|
||||
try:
|
||||
generator.performance.validate_pages(document, pages)
|
||||
validate_cached_views(document, pages)
|
||||
except (ValueError, TypeError, KeyError):
|
||||
pages = None
|
||||
if pages is None:
|
||||
|
||||
@@ -123,7 +123,7 @@ def draw_spans(index: generator.engraving.notation.ScoreIndex, anchors: list[gen
|
||||
y = anchor.y - anchor.direction * 8
|
||||
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))
|
||||
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 = []
|
||||
for span in index.staff.get('tuplets', []):
|
||||
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
|
||||
|
||||
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)
|
||||
result = list(music)
|
||||
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]
|
||||
if lane:
|
||||
extent = generator.engraving.types.bounds(lane)
|
||||
|
||||
+30
-9
@@ -23,7 +23,7 @@ class VoicingCost(typing.NamedTuple):
|
||||
covering_tones: int = 0
|
||||
string_shortfall: int = 0
|
||||
family_penalty: int = 0
|
||||
effort: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
missing_tones: int = 0
|
||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||
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:
|
||||
return self._replace(effort=self.effort + distance)
|
||||
|
||||
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 ()
|
||||
return _voicing_cost(positions, profile, allowed, melody, families)
|
||||
def voicing_preference(role, center=None):
|
||||
"""Immutable role settings for both selection and scored-retrieval caches."""
|
||||
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)
|
||||
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}
|
||||
classes = {p.midi % 12 for p in positions}
|
||||
above = sum(p.midi > max(melody) for p in positions) if melody else 0
|
||||
span = compactness(positions)
|
||||
if profile == 'electric':
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
|
||||
else:
|
||||
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
|
||||
@@ -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, ...], ...]:
|
||||
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)
|
||||
def _chord_positions(requirements, prune, profile, melody, families):
|
||||
def _chord_positions(requirements, prune, profile, melody, families, preference):
|
||||
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]
|
||||
|
||||
@@ -90,7 +111,7 @@ def _chord_positions(requirements, prune, profile, melody, families):
|
||||
best = {}
|
||||
for positions in result:
|
||||
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]:
|
||||
best[center] = (rank, positions)
|
||||
return tuple(sorted(item[1] for item in best.values()))
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import fractions
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
@@ -23,7 +24,63 @@ def page_timing(document, page):
|
||||
times = boundaries(document)
|
||||
start, end = page.systems[0].start, page.systems[-1].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):
|
||||
times = boundaries(document)
|
||||
@@ -43,9 +100,15 @@ def validate_pages(document, pages):
|
||||
def timing(document):
|
||||
times = boundaries(document)
|
||||
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 = []
|
||||
for start, measure in zip(times, measures):
|
||||
if start >= sustain_start:
|
||||
break
|
||||
duration = generator.model.fraction(measure['duration'])
|
||||
beat = fractions.Fraction()
|
||||
while beat < duration:
|
||||
@@ -53,7 +116,7 @@ def timing(document):
|
||||
beat += 1
|
||||
return dict(song_id=document['metadata'].get('song_id', ''),
|
||||
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):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
|
||||
|
||||
+12
-1
@@ -26,6 +26,7 @@ import generator.parts
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import generator.arrange.accompaniment
|
||||
import generator.arrange.ending
|
||||
import generator.arrange.form
|
||||
import generator.arrange.generate
|
||||
import generator.arrange.pickup
|
||||
@@ -94,6 +95,7 @@ def windows(document, part):
|
||||
result[-1] = (a, start, old)
|
||||
result.append((start, end, part + '_' + role))
|
||||
cursor = end
|
||||
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
|
||||
return tuple(result)
|
||||
|
||||
def dependencies(role, resources):
|
||||
@@ -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'],
|
||||
'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
|
||||
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 = {}
|
||||
solo_report = ''
|
||||
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'})
|
||||
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
|
||||
_mask(result, allowed)
|
||||
if role == "acoustic_accompaniment":
|
||||
generator.acoustic.validate_comping(result)
|
||||
staff = result['staves'][0]
|
||||
for measure in staff['measures']:
|
||||
measure['id'] = role + '_' + measure['id']
|
||||
|
||||
+2
-2
@@ -24,10 +24,10 @@ def validate_json(document: dict, path: pathlib.Path, validated_pages: set[str]
|
||||
raise ValueError('invalid score bundle; regenerate score assets')
|
||||
if (presentation.get('engine'), presentation.get('width'), presentation.get('height')) != (generator.engraving.validation.ENGINE_NAME, generator.engraving.validation.PAGE_WIDTH, generator.engraving.validation.PAGE_HEIGHT):
|
||||
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||
generator.performance.validate_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']):
|
||||
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):
|
||||
raise ValueError('invalid score page manifest')
|
||||
name = page['path']
|
||||
|
||||
+41
-20
@@ -23,25 +23,46 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
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')
|
||||
if name == 'accompaniment':
|
||||
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment requires an eighth-note placement grid')
|
||||
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
|
||||
signatures = {}
|
||||
for part in ('electric', 'acoustic'):
|
||||
policy = value['instruments'][part]
|
||||
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2):
|
||||
raise ValueError('accompaniment attacks must be at least an eighth apart')
|
||||
for lane in lanes:
|
||||
layer = policy['layers'][lane]
|
||||
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
|
||||
raise ValueError('invalid accompaniment attack range or duration families')
|
||||
for family in layer['families']:
|
||||
durations = policy['families'][family]
|
||||
if not durations or any(fractions.Fraction(d) <= 0 for d in durations):
|
||||
raise ValueError('accompaniment durations must be positive')
|
||||
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations):
|
||||
raise ValueError('acoustic rhythm uses quarter and eighth notes')
|
||||
if part == 'acoustic' and policy.get('first_attack') != 'bass_or_combined':
|
||||
raise ValueError('acoustic patterns require a bass-or-combined first attack policy')
|
||||
identifiers = set()
|
||||
signatures[part] = set()
|
||||
for pattern in policy['patterns']:
|
||||
if pattern['id'] in identifiers or not pattern['family']:
|
||||
raise ValueError('duplicate or unnamed accompaniment pattern')
|
||||
identifiers.add(pattern['id'])
|
||||
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
|
||||
if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
|
||||
raise ValueError('patterns require one or two positive measure lengths')
|
||||
# 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']
|
||||
if not families.get('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()):
|
||||
raise ValueError('invalid electric family ranking')
|
||||
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'):
|
||||
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
|
||||
raise ValueError('acoustic families require root/fifth bass on both bass strings')
|
||||
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):
|
||||
raise ValueError('invalid acoustic upper chord order')
|
||||
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
|
||||
raise ValueError('accompaniment candidate budgets must be 1–64')
|
||||
if signatures['acoustic'] & signatures['electric']:
|
||||
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', []))))
|
||||
if len({v['id'] for v in entries}) != len(entries):
|
||||
raise ValueError(f'{name}: duplicate vocabulary ID')
|
||||
|
||||
@@ -25,7 +25,7 @@ public:
|
||||
auto refresh_connection() -> std::optional<vamp_zero::app_state>;
|
||||
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 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:
|
||||
auto pico_connected_locked() const -> std::bool_t;
|
||||
auto snapshot_locked() const -> vamp_zero::app_state;
|
||||
|
||||
@@ -19,6 +19,7 @@ auto prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_
|
||||
struct performance_position {
|
||||
std::size_t page{};
|
||||
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;
|
||||
|
||||
@@ -28,8 +28,10 @@ private:
|
||||
std::vector<entry> entries_{};
|
||||
};
|
||||
|
||||
auto score_page_count(vamp_zero::score_document const &score) -> std::size_t;
|
||||
auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> 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 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, bool playback = false) -> std::expected<std::string, 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>;
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -23,6 +25,18 @@ struct score_page {
|
||||
std::size_t measure_count{};
|
||||
vamp_zero::beat_position start_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 {
|
||||
@@ -39,7 +53,10 @@ struct score_document {
|
||||
std::vector<vamp_zero::score_page> pages{};
|
||||
std::string song_id{};
|
||||
vamp_zero::beat_position duration{};
|
||||
vamp_zero::beat_position sustain_start{};
|
||||
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 {
|
||||
@@ -53,6 +70,7 @@ struct library_part {
|
||||
std::size_t total_pages{};
|
||||
std::string title_image{};
|
||||
std::string song_id{};
|
||||
std::size_t playback_pages{};
|
||||
};
|
||||
|
||||
struct song_group {
|
||||
@@ -115,6 +133,7 @@ struct app_state {
|
||||
std::uint64_t score_session{};
|
||||
std::uint64_t input_revision{};
|
||||
std::bool_t pico_connected{false};
|
||||
std::optional<std::size_t> active_measure{};
|
||||
};
|
||||
|
||||
auto to_string(vamp_zero::app_mode value) -> std::string;
|
||||
|
||||
@@ -180,12 +180,14 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
|
||||
if (!score) {
|
||||
co_return std::unexpected(score.error());
|
||||
}
|
||||
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score); ++page) {
|
||||
auto valid = vamp_zero::validate_score_page(*score, page);
|
||||
for (auto playback : {false, true}) {
|
||||
for (auto page = std::size_t{}; page < vamp_zero::score_page_count(*score, playback); ++page) {
|
||||
auto valid = vamp_zero::validate_score_page(*score, page, playback);
|
||||
if (!valid) {
|
||||
co_return std::unexpected(valid.error());
|
||||
}
|
||||
}
|
||||
}
|
||||
co_return std::expected<void, modulus::error>{};
|
||||
}
|
||||
|
||||
@@ -301,7 +303,7 @@ private:
|
||||
}
|
||||
if (loaded.document) {
|
||||
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()) {
|
||||
|
||||
@@ -227,6 +227,7 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
|
||||
} else {
|
||||
state_.audio.playback_error = std::move(error);
|
||||
state_.audio.phase = "Error";
|
||||
state_.active_measure.reset();
|
||||
}
|
||||
updated = snapshot_locked();
|
||||
observer = observer_;
|
||||
@@ -280,7 +281,7 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
|
||||
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;
|
||||
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()) {
|
||||
return;
|
||||
}
|
||||
if (state_.page_index == page && state_.audio.phase == phase) {
|
||||
if (state_.page_index == page && state_.audio.phase == phase && state_.active_measure == measure) {
|
||||
return;
|
||||
}
|
||||
state_.page_index = page;
|
||||
state_.audio.phase = std::move(phase);
|
||||
state_.active_measure = measure;
|
||||
++state_.input_revision;
|
||||
updated = snapshot_locked();
|
||||
observer = observer_;
|
||||
|
||||
@@ -159,7 +159,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
|
||||
co_return std::unexpected(connected.error());
|
||||
}
|
||||
auto selected =
|
||||
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id') 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");
|
||||
if (!selected) {
|
||||
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;
|
||||
}
|
||||
auto const &row = **next;
|
||||
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
|
||||
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
|
||||
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; regenerate score assets"));
|
||||
break;
|
||||
}
|
||||
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]),
|
||||
.title_image = text_value(row[8]),
|
||||
.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>{};
|
||||
|
||||
@@ -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));
|
||||
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) {
|
||||
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 + 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <algorithm>
|
||||
|
||||
#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 * {
|
||||
@@ -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 {
|
||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
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;
|
||||
}
|
||||
|
||||
+109
-18
@@ -1,5 +1,6 @@
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#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");
|
||||
}
|
||||
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 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) {
|
||||
if (click >= clicks.size()) {
|
||||
return invalid("missing metronome beat");
|
||||
@@ -129,42 +142,120 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
|
||||
} catch (...) {
|
||||
return invalid("missing performance timing; regenerate score assets");
|
||||
}
|
||||
auto cursor = std::size_t{};
|
||||
for (auto const &item : *pages) {
|
||||
try {
|
||||
for (auto const &time : times) {
|
||||
document.measure_boundaries.push_back({numerator(time).convert_to<std::uint64_t>(), denominator(time).convert_to<std::uint64_t>()});
|
||||
}
|
||||
auto const *playback = array_field(*presentation, "playback_pages");
|
||||
if (!playback || playback->empty() || playback->size() > 10000) {
|
||||
return invalid("missing playback pages; regenerate score assets");
|
||||
}
|
||||
auto parse_page = [&](boost::json::value const &item) -> score_page {
|
||||
if (!item.is_object()) {
|
||||
return invalid("page must be an object");
|
||||
throw std::invalid_argument("page must be an object");
|
||||
}
|
||||
auto path = string_field(item.as_object(), "path");
|
||||
auto hash = string_field(item.as_object(), "sha256");
|
||||
if (!path || !hash) {
|
||||
return invalid("page requires path and sha256 strings");
|
||||
throw std::invalid_argument("page requires path and sha256 strings");
|
||||
}
|
||||
auto relative = std::filesystem::path{*path};
|
||||
if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos ||
|
||||
relative.is_absolute() || relative.extension() != ".png" ||
|
||||
std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) {
|
||||
return invalid("page paths must be safe relative PNG paths");
|
||||
throw std::invalid_argument("page paths must be safe relative PNG paths");
|
||||
}
|
||||
if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) {
|
||||
return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f');
|
||||
})) {
|
||||
return invalid("page sha256 must be 64 lowercase hexadecimal digits");
|
||||
throw std::invalid_argument("page sha256 must be 64 lowercase hexadecimal digits");
|
||||
}
|
||||
try {
|
||||
auto first = item.at("first_measure").to_number<std::size_t>();
|
||||
auto count = item.at("measure_count").to_number<std::size_t>();
|
||||
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]) {
|
||||
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");
|
||||
}
|
||||
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end});
|
||||
cursor += count;
|
||||
} catch (...) {
|
||||
return invalid("missing page timing; regenerate score assets");
|
||||
cursor += page.measure_count;
|
||||
canonical.insert(canonical.end(), page.systems.begin(), page.systems.end());
|
||||
document.pages.push_back(std::move(page));
|
||||
}
|
||||
if (cursor != measures->size() + 1 || playback->size() != canonical.size()) {
|
||||
return invalid("incomplete normal or playback 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");
|
||||
}
|
||||
}
|
||||
if (cursor != measures->size()) {
|
||||
return invalid("incomplete page measure coverage");
|
||||
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.source_path = std::move(source_path);
|
||||
|
||||
@@ -90,6 +90,7 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
|
||||
: vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
state.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
state.active_measure.reset();
|
||||
}
|
||||
if (state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
|
||||
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);
|
||||
} else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||
state.page_index = 0;
|
||||
state.active_measure.reset();
|
||||
}
|
||||
if (!automatic(state.audio)) {
|
||||
state.active_measure.reset();
|
||||
}
|
||||
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.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
state.active_measure.reset();
|
||||
} else if (auto const *part = selected_part(state); part != nullptr) {
|
||||
state.active_part_id = part->id;
|
||||
state.page_index = 0;
|
||||
state.active_measure.reset();
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
|
||||
++state.score_session;
|
||||
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.active_part_id.clear();
|
||||
state.page_index = 0;
|
||||
state.active_measure.reset();
|
||||
}
|
||||
}
|
||||
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;
|
||||
part = selected->part;
|
||||
page = state.page_index + 1U;
|
||||
pages = selected->total_pages;
|
||||
pages = active_pages(state);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +201,14 @@ auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint
|
||||
}
|
||||
auto beat_frames = frames - count_in;
|
||||
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;
|
||||
}
|
||||
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> {
|
||||
|
||||
+38
-10
@@ -43,8 +43,35 @@ auto vamp_zero::score_page_cache::size() const -> std::size_t {
|
||||
return entries_.size();
|
||||
}
|
||||
|
||||
auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std::size_t {
|
||||
return score.pages.size();
|
||||
auto vamp_zero::score_page_count(vamp_zero::score_document const &score, bool playback) -> std::size_t {
|
||||
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 {
|
||||
@@ -54,11 +81,12 @@ struct page_file {
|
||||
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> {
|
||||
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");
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
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 & 15U]);
|
||||
}
|
||||
if (hash != score.pages[index].sha256) {
|
||||
if (hash != pages[index].sha256) {
|
||||
return fail("score page checksum mismatch");
|
||||
}
|
||||
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 page = read_page(score, index);
|
||||
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, playback);
|
||||
if (!page) {
|
||||
return std::unexpected(page.error());
|
||||
}
|
||||
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 page = read_page(score, index);
|
||||
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, playback);
|
||||
if (!page) {
|
||||
return std::unexpected(page.error());
|
||||
}
|
||||
|
||||
+19
-13
@@ -23,6 +23,7 @@
|
||||
#include <vamp_zero/navigation.hpp>
|
||||
#include <vamp_zero/menu_view.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
#include <vamp_zero/performance.hpp>
|
||||
#include <vamp_zero/score_loader.hpp>
|
||||
#include <vamp_zero/touch_input.hpp>
|
||||
|
||||
@@ -46,8 +47,9 @@ struct score_draw_cache {
|
||||
if (!score) {
|
||||
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 path = vamp_zero::validated_page_path(*score, static_cast<std::size_t>(std::distance(score->pages.begin(), found)));
|
||||
auto const &pages = playback ? score->playback_pages : score->pages;
|
||||
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) {
|
||||
return std::unexpected(path.error());
|
||||
}
|
||||
@@ -60,30 +62,34 @@ struct score_draw_cache {
|
||||
textures.clear();
|
||||
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();
|
||||
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"));
|
||||
}
|
||||
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) {
|
||||
return std::unexpected(texture.error());
|
||||
}
|
||||
if (index + 1 < score->pages.size()) {
|
||||
auto next = textures.get(score->pages[index + 1].path);
|
||||
if (index + 1 < pages.size()) {
|
||||
auto next = textures.get(pages[index + 1].path);
|
||||
if (!next) {
|
||||
return std::unexpected(next.error());
|
||||
}
|
||||
}
|
||||
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
|
||||
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};
|
||||
auto page = vamp_zero::page_rectangle(bounds);
|
||||
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});
|
||||
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 {};
|
||||
}
|
||||
bool playback{};
|
||||
vamp_zero::score_page_cache textures;
|
||||
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,
|
||||
.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);
|
||||
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);
|
||||
|
||||
@@ -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'):
|
||||
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)
|
||||
|
||||
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'})
|
||||
|
||||
@@ -20,7 +20,7 @@ inline auto add_timing(boost::json::value &value) -> void {
|
||||
music.at("metadata").as_object()["song_id"] = "fixture";
|
||||
auto &measures = music.at("staves").as_array().front().at("measures").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{});
|
||||
}
|
||||
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{};
|
||||
auto systems = boost::json::array{};
|
||||
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();
|
||||
page["first_measure"] = cursor + 1;
|
||||
page["measure_count"] = count;
|
||||
page["start_beat"] = beat(cursor * 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}};
|
||||
}
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
+204
-6
@@ -4,7 +4,7 @@
|
||||
"composer": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "acoustic",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -29,6 +29,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -1831,6 +1835,32 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"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": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "electric",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -1864,6 +1894,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -3666,6 +3700,32 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"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": "",
|
||||
"key_signature": "G",
|
||||
"part_type": "vocal",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -3699,6 +3759,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -6113,6 +6177,41 @@
|
||||
"0+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": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "acoustic",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -6146,6 +6245,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -8082,6 +8185,32 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"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": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "electric",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -8115,6 +8244,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -10051,6 +10184,32 @@
|
||||
"harmonies": [],
|
||||
"duration": "4",
|
||||
"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": "",
|
||||
"key_signature": "D minor",
|
||||
"part_type": "vocal",
|
||||
"measure_count": 168,
|
||||
"measure_count": 171,
|
||||
"section_labels": [
|
||||
{
|
||||
"measure": 1,
|
||||
@@ -10084,6 +10243,10 @@
|
||||
{
|
||||
"measure": 137,
|
||||
"label": "Vocal Melody"
|
||||
},
|
||||
{
|
||||
"measure": 169,
|
||||
"label": "Ending"
|
||||
}
|
||||
],
|
||||
"measures": [
|
||||
@@ -12628,6 +12791,41 @@
|
||||
"vocal_skeleton": [
|
||||
"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
@@ -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) {
|
||||
auto parts = std::vector<vamp_zero::library_part>{};
|
||||
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{
|
||||
.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_TEST(remote.active);
|
||||
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) {
|
||||
state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(document, part)
|
||||
generator.model.validate_document(result)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 168)
|
||||
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)])
|
||||
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137'])
|
||||
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672)
|
||||
self.assertEqual(len(measures), 171)
|
||||
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', '169'])
|
||||
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684)
|
||||
timelines.append([m['duration'] for m in measures])
|
||||
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
|
||||
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/>')
|
||||
document = source(opening, implicit=False)
|
||||
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)
|
||||
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['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,))):
|
||||
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)
|
||||
measures = result['staves'][0]['measures']
|
||||
self.assertEqual(len(measures), 6)
|
||||
self.assertEqual(len(measures), 9)
|
||||
for boundary in boundaries:
|
||||
pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
|
||||
downbeat = generator.score.measure_notes(measures[boundary])[0]
|
||||
|
||||
@@ -15,7 +15,7 @@ class CatalogTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
|
||||
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({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
|
||||
for path in output.iterdir():
|
||||
|
||||
@@ -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.
|
||||
db.query("INSERT INTO songs(id, title, composer, source_json, part, source_path, "
|
||||
"key_signature, measure_count, total_pages, title_image) "
|
||||
"VALUES('tune', 'Metadata Song', 'Composer', '{}', '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");
|
||||
for (auto attempt = std::uint32_t{}; attempt < 2U; ++attempt) {
|
||||
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].measure_count == 3U);
|
||||
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());
|
||||
auto song = db.score("tune");
|
||||
|
||||
@@ -87,7 +87,7 @@ class ChordFamilies(unittest.TestCase):
|
||||
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
|
||||
resources = generator.vocabulary.load()
|
||||
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'):
|
||||
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))
|
||||
@@ -142,16 +142,15 @@ class CombinedAcoustic(unittest.TestCase):
|
||||
m = source['staves'][0]['measures'][0]
|
||||
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))
|
||||
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')
|
||||
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):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7',))
|
||||
m = source['staves'][0]['measures'][0]
|
||||
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')
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+150
-73
@@ -6,6 +6,7 @@ import pathlib
|
||||
import unittest
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.acoustic
|
||||
import generator.arrange.accompaniment
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
@@ -29,56 +30,119 @@ class LayeredComping(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
|
||||
self.resources = generator.vocabulary.load()
|
||||
def test_candidates_preserve_all_counts_and_articulations(self):
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})):
|
||||
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources)
|
||||
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
|
||||
self.assertEqual({len(c.events) for c in candidates}, expected)
|
||||
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)
|
||||
if lane == 'electric':
|
||||
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events))
|
||||
else:
|
||||
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)})
|
||||
if lane == 'bass':
|
||||
self.assertTrue(all(c.events[0].start == 0 for c in candidates))
|
||||
def test_ranges_downbeats_and_overlap(self):
|
||||
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light')
|
||||
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures'])
|
||||
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)):
|
||||
result = canonical(m, prior, i)
|
||||
voices = {v['id']: v['notes'] for v in result['voices']}
|
||||
self.assertEqual(set(voices), {'bass', 'upper'})
|
||||
self.assertTrue(voices['bass'][0]['pitches'])
|
||||
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0)
|
||||
for lane, high in (('bass', 2), ('upper', 3)):
|
||||
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
|
||||
self.assertTrue(1 <= len(attacks) <= high)
|
||||
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report))
|
||||
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report))
|
||||
def test_combined_crowding_favors_shared_attacks(self):
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint')
|
||||
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates))
|
||||
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates))
|
||||
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1)))
|
||||
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2)))
|
||||
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated))
|
||||
def test_harmonic_changes_share_one_measure_budget(self):
|
||||
def block(self, part, source=None, melody=()):
|
||||
source = source or self.source
|
||||
measures = source['staves'][0]['measures'][:2]
|
||||
inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures))))
|
||||
flat = generator.comping_rhythm.flatten(inputs)
|
||||
lengths = tuple(generator.model.fraction(m['duration']) for m in measures)
|
||||
return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths)
|
||||
def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self):
|
||||
expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'}
|
||||
choices = {part: self.block(part) for part in ('acoustic', 'electric')}
|
||||
for part, candidates in choices.items():
|
||||
self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
|
||||
for c in candidates:
|
||||
generator.comping_rhythm.validate_events(c.events, part, (4, 4))
|
||||
for start in (0, 4):
|
||||
self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
|
||||
self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
|
||||
a = {tuple(e.start for e in c.events) for c in choices['acoustic']}
|
||||
e = {tuple(e.start for e in c.events) for c in choices['electric']}
|
||||
self.assertFalse(a & e)
|
||||
self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
|
||||
self.assertTrue(all(c.events[0].lane in ('bass', 'combined') for c in choices['acoustic'] if c.events))
|
||||
self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
|
||||
def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
|
||||
F = fractions.Fraction
|
||||
attack = generator.comping_rhythm.Attack
|
||||
generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
|
||||
generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
|
||||
for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.comping_rhythm.validate_events(events, 'acoustic')
|
||||
def test_bass_first_reassigns_layers_without_moving_or_adding_attacks(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7',))
|
||||
flat = generator.comping_rhythm.flatten(tuple((m, None, i) for i, m in enumerate(source['staves'][0]['measures'])))
|
||||
patterns = self.resources['accompaniment']['instruments']['acoustic']['patterns']
|
||||
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['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'):
|
||||
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes')
|
||||
for c in choices:
|
||||
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 c in self.block(part, source):
|
||||
self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4)
|
||||
for e in c.events:
|
||||
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4)
|
||||
if part == 'electric':
|
||||
self.assertTrue(all(c.exceptions for c in choices))
|
||||
if e.harmonic_time is None:
|
||||
self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6)))
|
||||
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):
|
||||
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
|
||||
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['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))
|
||||
def test_two_other_meters_preserve_full_bar_bounds(self):
|
||||
def test_two_other_meters_use_authored_patterns(self):
|
||||
for meter in (3, 5):
|
||||
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
|
||||
for part in ('acoustic', 'electric'):
|
||||
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():
|
||||
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
|
||||
def test_melody_changes_the_objective_without_changing_seed(self):
|
||||
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2))
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
harmony = generator.comping_rhythm.spans(m, None)
|
||||
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4))
|
||||
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
|
||||
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4)))
|
||||
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'))
|
||||
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8))
|
||||
space = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
|
||||
first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)}
|
||||
second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)}
|
||||
common = first.keys() & second.keys()
|
||||
self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common))
|
||||
def test_tempo_changes_metadata_but_not_accompaniment_music(self):
|
||||
changed = copy.deepcopy(self.source)
|
||||
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)]
|
||||
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
|
||||
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',))
|
||||
canonical = generator.comping.Canonical(source, 'electric', 'tie')
|
||||
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))]
|
||||
chord = inputs[0][0]['harmonies'][0]
|
||||
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0]
|
||||
for m, p, i in inputs:
|
||||
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip))
|
||||
note['voice'] = 'electric'
|
||||
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}]
|
||||
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0)
|
||||
canonical.connect_holds(inputs)
|
||||
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs]
|
||||
for p, q in zip(a['pitches'], b['pitches']):
|
||||
self.assertEqual(p['tie_to'], q['id'])
|
||||
self.assertEqual(q['tie_from'], p['id'])
|
||||
self.assertEqual(p['tablature'], q['tablature'])
|
||||
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0)
|
||||
for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')):
|
||||
resources = copy.deepcopy(self.resources)
|
||||
patterns = resources['accompaniment']['instruments'][part]['patterns']
|
||||
resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id]
|
||||
with generator.vocabulary.using(resources, part + '_accompaniment'):
|
||||
canonical = generator.comping.Canonical(source, part, 'held')
|
||||
measures = source['staves'][0]['measures']
|
||||
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
|
||||
a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')]
|
||||
b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']}
|
||||
cross = [p for p in a if p['tie_to'] in b]
|
||||
self.assertTrue(cross)
|
||||
for p in cross:
|
||||
self.assertEqual(p['tablature'], b[p['tie_to']]['tablature'])
|
||||
self.assertEqual(p['id'], b[p['tie_to']]['tie_from'])
|
||||
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):
|
||||
def test_melody_rhythm_and_progression_change_learned_probabilities(self):
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import copy
|
||||
import fractions
|
||||
import itertools
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
@@ -16,6 +18,8 @@ import generator.production
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic_voicing
|
||||
import tests.test_voicing
|
||||
|
||||
def options(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)
|
||||
|
||||
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):
|
||||
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
|
||||
with self.subTest(chord=symbol):
|
||||
@@ -76,7 +183,7 @@ class CompingVoicings(unittest.TestCase):
|
||||
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
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)
|
||||
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')
|
||||
|
||||
@@ -21,6 +21,7 @@ import generator.engraving.types
|
||||
import generator.guitar
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.performance
|
||||
import tests.score_events
|
||||
import tests.arrangement_fixtures
|
||||
import tests.test_accompaniment
|
||||
@@ -139,6 +140,56 @@ class EngravingTests(unittest.TestCase):
|
||||
actual = generator.engraving.raster.rasterize_page(layout.pages[0], self.fonts)
|
||||
with PIL.Image.open('tests/fixtures/notation_python.png') as expected:
|
||||
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):
|
||||
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))
|
||||
|
||||
@@ -353,7 +353,7 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
data, output = self.root / 'data', self.root / 'output'
|
||||
(data / 'songs').mkdir(parents=True)
|
||||
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)
|
||||
generator.__main__.generate_assets(args)
|
||||
original = self.files(output)
|
||||
@@ -364,7 +364,7 @@ class EngravingCacheTests(unittest.TestCase):
|
||||
self.assertEqual(original, self.files(output))
|
||||
self.assertFalse((output / 'solo_xml').exists())
|
||||
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'):
|
||||
generator.__main__.generate_assets(args)
|
||||
self.assertEqual(original, self.files(output))
|
||||
|
||||
@@ -255,7 +255,7 @@ class GuitarTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
|
||||
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)
|
||||
first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
|
||||
generator.arrange.generate.generate_performance_scores(source, output)
|
||||
@@ -264,7 +264,7 @@ class GuitarTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
(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.mkdir()
|
||||
(output / 'last-good').write_text('preserved')
|
||||
|
||||
@@ -137,7 +137,7 @@ class GuitarPartTests(unittest.TestCase):
|
||||
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)))
|
||||
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'):
|
||||
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']]
|
||||
|
||||
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
result = generator.arrange.generate.arrange_document(source, part)
|
||||
generator.model.validate_document(result)
|
||||
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.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']))
|
||||
@@ -115,7 +115,7 @@ class MusicModelTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
generator.__main__.generate_assets(args)
|
||||
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
|
||||
# --check uses the validation-only repository fixture. An alternate
|
||||
# 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 / 'songs').mkdir(parents=True)
|
||||
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)
|
||||
generator.__main__.generate_assets(args)
|
||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||
|
||||
+3
-1
@@ -40,6 +40,8 @@ class PartGenerationTests(unittest.TestCase):
|
||||
generator.model.validate_document(result)
|
||||
for measure in result['staves'][0]['measures']:
|
||||
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'):
|
||||
continue
|
||||
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'))
|
||||
before = copy.deepcopy(source)
|
||||
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'):
|
||||
with self.subTest(builder=builder.__module__, part=part):
|
||||
result = builder(source, part)
|
||||
|
||||
+110
-2
@@ -9,6 +9,7 @@
|
||||
#include <vamp_zero/notation.hpp>
|
||||
#include <vamp_zero/page_control.hpp>
|
||||
#include <vamp_zero/performance.hpp>
|
||||
#include <vamp_zero/score.hpp>
|
||||
|
||||
|
||||
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"));
|
||||
BOOST_REQUIRE(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}});
|
||||
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
|
||||
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);
|
||||
auto session = engine->play_transport(std::move(*prepared));
|
||||
BOOST_REQUIRE(session);
|
||||
auto frames = backing ? 576000U : 288000U;
|
||||
auto frames = 576000U;
|
||||
auto buffer = std::array<float, 960>{};
|
||||
for (unsigned cursor = 0; cursor < frames; cursor += 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_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) {
|
||||
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
|
||||
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_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) {
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
|
||||
@@ -17,10 +17,21 @@ def fixture(root):
|
||||
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
first = music['staves'][0]['measures'][0]
|
||||
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'
|
||||
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)]
|
||||
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
|
||||
|
||||
def system(first, count, y):
|
||||
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))
|
||||
entries = []
|
||||
# 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())
|
||||
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(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)
|
||||
with self.assertRaisesRegex(ValueError, 'timing'):
|
||||
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__':
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
@@ -213,8 +213,10 @@ class ScoreBundleTests(unittest.TestCase):
|
||||
page.write_bytes(data)
|
||||
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()}]}}
|
||||
times = generator.performance.boundaries(document)
|
||||
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]))
|
||||
fonts = generator.engraving.fonts.Fonts(pathlib.Path('package/assets/fonts/petaluma'))
|
||||
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)
|
||||
path = assets / 'score.json'
|
||||
path.write_text(json.dumps(bundle))
|
||||
|
||||
+28
-3
@@ -14,6 +14,10 @@
|
||||
#include <vamp_pico/screen.hpp>
|
||||
#include <vamp_zero/menu_view.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 {
|
||||
@@ -133,12 +137,21 @@ auto preview(vamp_zero::app_state const &state, std::string const &name, vamp_pi
|
||||
auto context = image_context{};
|
||||
vamp_zero::draw_header(context, {0.0F, 0.0F, 1024.0F, 600.0F}, state);
|
||||
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 path = std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs" / std::string{source.at("presentation").at("pages").as_array().front().at("path").as_string()};
|
||||
auto source = vamp_zero::parse_notation_document(read_bytes(std::filesystem::path{VAMP_TEST_ASSETS_DIR} / "songs/autumn_leaves_electric.json"), "score.json");
|
||||
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());
|
||||
BOOST_REQUIRE(sheet.data);
|
||||
ImageDraw(&context.image, sheet, {0, 0, 976, 484}, {24, 98, 976, 484}, WHITE);
|
||||
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 {
|
||||
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 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()};
|
||||
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->audio_assets.push_back({.path = "backing/autumn_leaves_120.wav", .song_id = "autumn_leaves", .bpm = 120});
|
||||
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";
|
||||
for (auto phase : {"Manual", "Preparing", "Count-in", "Playing", "Sustain"}) {
|
||||
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);
|
||||
}
|
||||
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.active_measure.reset();
|
||||
state.audio.playback_error = "Recording unavailable";
|
||||
preview(state, "audio_error");
|
||||
state.mode = vamp_zero::app_mode::PERFORMANCE_PART_MENU;
|
||||
|
||||
@@ -9,6 +9,7 @@ import generator.harmony
|
||||
import generator.model
|
||||
import generator.musicxml
|
||||
import generator.score
|
||||
import generator.vocabulary
|
||||
import tests.test_musicxml
|
||||
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))
|
||||
|
||||
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):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
|
||||
Reference in New Issue
Block a user