This commit is contained in:
khodowany
2026-10-06 21:28:42 -05:00
parent e15dc31568
commit c7f6a30a13
30 changed files with 2957 additions and 490 deletions
+5 -5
View File
@@ -102,7 +102,7 @@ Six roles generate independently: acoustic accompaniment/solo; electric accompan
Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement. Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
Accompaniment rhythm is light at every tempo: normally 0–2 electric chord attacks, 1–2 acoustic bass attacks, and 1–3 acoustic upper-chord attacks per full measure. Bass attacks beat one wherever harmony is available. Acoustic attacks last a quarter or eighth note; electric chords mix stabs, sustains and space. Both consider the original melody and harmonic rhythm. Acoustic bass and upper notes appear in one rhythmic voice on the same staff/TAB, with ties preserving any overlapping durations. Combined attacks are evaluated for crowding. An extra attack is permitted only for harmonic changes and reported. Compatible electric holds can tie across barlines. The selected accompaniment repeats at corresponding form positions, with source-defined pickup/handoff exceptions, and does not change when only tempo changes. Accompaniment uses independently authored two-bar jazz pattern families: Charleston and shifted Charleston, forward-motion syncopation, anticipated heavy beats, syncopated riffs, quarters, dotted spacing, shifting motifs, call-response and anticipated beat four. Acoustic distributes a single figure between bass and upper chord, normally 1–3 total attacks per full bar, without mandatory beat-one bass. Separate attacks are quarters/eighths and never overlap; a combined bass-plus-upper chord is allowed only when both attack and sustain together for more than one beat. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustained chords and space. The source melody and harmonic rhythm guide complete-pattern selection. Half-beat upper/electric anticipations may establish the next written chord; bass stays on current harmony. Explicit cross-bar holds use ties, never automatic merging of repeated attacks. Canonical patterns repeat at corresponding form positions with documented harmonic/pickup exceptions, and changing only tempo leaves the music unchanged.
**Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me. **Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me.
@@ -134,7 +134,7 @@ Add a source and its authored solo, then retrain the pooled model, without chang
To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations. To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--leave-one-song-out` for held-out likelihood diagnostics. Training writes `data/models/solo.json` and `build/training/report.json`. Ordinary builds use registered authored music or the saved model and never retrain automatically. Exact registered playback verifies authored reuse; it does not demonstrate model memorization or musical quality on unfamiliar songs. [Training and playback details](TECHNICAL_GUIDE.md#authored-repertoire-and-learned-solos) cover pairing, timing, pitch convention, data migration, and limitations.
The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six main performance sections plus the held Ending as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure.
All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded. All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
@@ -142,17 +142,17 @@ There are no genre presets or runtime composition. The complete arranged score i
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. 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.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The six-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass. Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass.
Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there. Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there.
A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. There is no separate Ending section. The current 32-bar songs contain 168 numbered bars. Optional turnaround chords are omitted from the final vocal pass. A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. A final Ending section adds one guitar chord attack sustained across three full measures, with vocal rests. The current 32-bar songs contain 171 numbered bars. The ending holds the final performed harmony (GM6 for All of Me, Dm for Autumn Leaves). Optional turnaround chords are omitted from the final vocal pass.
Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/<song_id>_<bpm>.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings. Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/<song_id>_<bpm>.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings.
Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them. Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them.
Backing recordings include one bar of count-in, the complete score, and the held ending with decay. The recorded count-in plays alone. When both toggles are on, metronome clicks start at the score and stop at its end, leaving the last three held bars and decay unclicked. Metronome-only mode supplies its own count-in and stops at score end. Pages follow the rendered audio clock, using each engraved page's actual measure range; the final page stays visible through the backing tail. Backing recordings include one bar of count-in, the complete arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. Pages follow the rendered audio clock through all 171 measures; the final page stays visible through the recording decay.
Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. Code style is managed by Modulus, using compact two-space formatting. Python imports modules by their full names and qualifies imported symbols; only `from __future__ import annotations` is exempt, and imports have no aliases. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files.
+2 -2
View File
@@ -59,6 +59,6 @@ Controls:
- Double tap: previous menu item, or previous page when both audio toggles are off. - Double tap: previous menu item, or previous page when both audio toggles are off.
- Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it. - Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it.
Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks accompany the score but stop before the three-bar held tail. Hold exits at any time. Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks stop at the written three-bar Ending section; page timing continues through the hold. Hold exits at any time.
Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `<normalized_song_id>_<bpm>.wav`. Supply separate files at 60, 70, 80, 90, 100, 110 and 120 BPM, each with one bar of count-in, the complete eight-bar intro plus five body passes, and its held ending. Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs. Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `<normalized_song_id>_<bpm>.wav`. Supply separate files at 60, 70, 80, 90, 100, 110 and 120 BPM, each with one bar of count-in, the complete eight-bar intro plus five body passes, and its three-bar held ending plus decay (171 written bars for the current songs). Rebuild/package after adding recordings. Backing tempo choices include only tempos supplied for every song; the current package has 120 BPM for both songs.
+12 -6
View File
@@ -97,11 +97,11 @@ Parenthesized chord symbols mark optional turnarounds, including Soundslice's te
### Linear Reading Order ### Linear Reading Order
There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal) explicitly onto successive pages; neither display nor playback jumps backward. There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal), followed by three held ending measures, explicitly onto successive pages; neither display nor playback jumps backward.
Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports.
An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 168 arrangement bars, including the eight-bar intro. An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 171 arrangement bars: the eight-bar intro, five 32-bar passes, and three written held-ending measures.
Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. The recording’s three-bar sustain tail has no added score section or metronome clicks. Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. The recording’s three-bar sustain tail has no added score section or metronome clicks.
@@ -131,11 +131,15 @@ The active voicing selector compares complete paths with dynamic programming ove
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged. Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
`comping_rhythm.py` constructs whole-measure rhythms from version 3 of `composition/accompaniment.json`, with instrument-specific duration and voicing families. Normal full-measure ranges are 0–2 electric chord attacks, 1–2 acoustic bass attacks and 1–3 acoustic upper-chord attacks. Bass starts on beat one whenever harmony is available there. An absent harmony or N.C. remains silent. Acoustic attacks last exactly a quarter or eighth note, with same-layer attacks at least an eighth apart. Electric retains short stabs, quarters, longer holds and alternating durations. Exact chord offsets constrain sounding intervals; short partial bars have bounded gestures. A required acoustic attack with less than an eighth of valid harmony fails explicitly rather than creating a sixteenth or silently quantizing the change. Rapid harmonic changes can authorize one extra attack per layer, recorded as an exception, without assigning a fresh density budget to every chord. `comping_rhythm.py` selects complete source-only figures from version 4 of `composition/accompaniment.json`. Entries contain stable IDs, a family, exact one/two-bar lengths, and timed events labeled bass, upper, combined, or electric. The ten 4/4 families have statement/answer variants, with additional held-answer/space variants and explicit 3/4, 5/4 and short-bar alternatives. No independent bass/upper Cartesian product or unconstrained onset enumeration remains. Role pools have different complete two-bar onset signatures; clipped partial figures and N.C. passages can coincide. Unknown measure lengths fail explicitly rather than being proportionally stretched.
The original head supplies entrances, moving-note intervals and space; the actual progression supplies harmonic support requirements and release boundaries. Candidates retain distinct attack counts and articulation families before the cutoff. Acoustic bass/upper candidates are paired and scored for combined crowding, counting coincident attacks as one action. A bounded dynamic program selects the form's complete rhythm path using source-context costs and preferences against immediate whole-bar repetition. This chooses from retained candidates, not every possible rhythm. Compatible electric sustained neighbors with identical harmony and grip can join across a barline without reattack; acoustic automatic hold joining is disabled. Changing only tempo leaves these musical decisions unchanged. Tempo remains in the concrete asset cache key so score metadata refreshes correctly. Normal acoustic figures use 1–3 total attacks per full bar, interleaving bass and upper chords without a downbeat-bass quota. Ordinary events last a quarter or eighth and must release before the next event. Only a single combined event lasting more than one quarter-note beat can sound bass and upper together; its components start/release together. This is checked again on complete sounding tie chains. Electric figures normally have 0–2 attacks per bar and mix stabs, sustains and space. A figure may cross its internal barline: flat sounding events are selected and voiced first, then projected into notation with exact ties. Repeated attacks remain attacks. Figures do not extend beyond their selected two-bar span.
Acoustic bass and upper events are planned as two layers, then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report per-measure families, attack counts, overlaps, ties and exceptions. Their `selection_cost` records local rhythm-candidate contributions before cross-bar joining; the form selector also applies the documented transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances. Direct events release at changed harmony. Eligible upper/electric events also offer a half-beat anticipation: the upcoming written chord must begin exactly half a beat after the attack, the event must continue into that chord, and it must release by the following change or protected participation boundary. The selected grip belongs to that upcoming chord. Bass and combined acoustic events cannot anticipate. Direct alternatives remain available. Missing harmony/N.C. stays silent; incompatible short durations reject a complete candidate rather than introducing unexplained subdivisions.
Named activity, melody-support, harmonic-support and articulation contributions evaluate complete figures. A bounded dynamic program selects the form path, with a modest preference against immediate exact repetition. Family representatives survive the candidate cutoff. Source entrances, movement and space affect selection, while no generated role does. Effective final turnarounds and pickup masks revalidate the whole affected figure between fixed canonical neighbors. Reports identify the chosen pattern, its start measure, projected events, continuations, anticipation target times, score contributions and source-context exceptions. Tempo affects metadata/cache identity but never musical choice.
Acoustic events use temporary bass/upper notation lanes for projection; then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report pattern IDs, exact projected layer events, continuations, anticipation targets and exceptions. Their `selection_cost` records the complete figure’s local contributions, repeated on each projected bar for context; the form selector additionally applies its documented transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing. Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
@@ -145,7 +149,9 @@ Vamp uses only supplied PCM16 WAVs. `package/assets/sounds/beat1.wav` and `beat2
Settings exposes `audio.backing_enabled`, `audio.metronome_enabled` and `audio.tempo_bpm`; both toggles default off and tempo defaults to 120. The fixed tempo set is 60 through 120 in steps of 10. Backing restricts it to the intersection available for all catalog songs. Turning it on snaps to the nearest available tempo, with ties going slower; an empty intersection makes it unavailable. Obsolete playback and configurable tempo-range settings are rejected. Recordings are never stretched or trimmed. Settings exposes `audio.backing_enabled`, `audio.metronome_enabled` and `audio.tempo_bpm`; both toggles default off and tempo defaults to 120. The fixed tempo set is 60 through 120 in steps of 10. Backing restricts it to the intersection available for all catalog songs. Turning it on snaps to the nearest available tempo, with ties going slower; an empty intersection makes it unavailable. Obsolete playback and configurable tempo-range settings are rejected. Recordings are never stretched or trimmed.
The existing recordings contain one bar of count-in, 168 score bars, then three held bars and decay. Backing-only plays the complete file. Both toggles play the recorded count-in alone, then backing plus sample clicks during the score; no clicks play over the held ending. Metronome-only supplies its own one-bar count-in and completes at score end. Both off enables manual page navigation. Opening a part with either toggle on starts automatic mode. Next/previous are ignored during automatic mode; hold exits, and natural completion returns to the selected part menu. The final page remains displayed through a recording tail. The existing recordings contain one bar of count-in, 168 main-form bars, three held bars and decay. `arrange/ending.py` now writes that existing tail as measures 169–171, without extending or transforming the recordings. Both accompaniment roles choose a valid closing voicing using their own outgoing position and hold it with ties; melody/solo roles are silent there and the vocal has three full rests. The chord is the final effective harmony after omitting optional turnarounds, including carried harmony. Missing/N.C. final harmony fails accompaniment generation explicitly. The original final bar is untouched.
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.
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. 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.
File diff suppressed because it is too large Load Diff
+39
View File
@@ -6,6 +6,7 @@ import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
import generator.score
@dataclasses.dataclass @dataclasses.dataclass
class HarmonySpan: class HarmonySpan:
@@ -107,3 +108,41 @@ def combine(document: dict) -> None:
a = fragments[start][-1] if start in fragments else originals[start] a = fragments[start][-1] if start in fragments else originals[start]
b = fragments[end][0] if end in fragments else originals[end] b = fragments[end][0] if end in fragments else originals[end]
a['tie_to'], b['tie_from'] = b['id'], a['id'] a['tie_to'], b['tie_from'] = b['id'], a['id']
def validate_comping(document: dict) -> None:
"""Check complete sounding events, not barline or notation fragments."""
tones, starts, cursor = {}, {}, fractions.Fraction()
for measure in document['staves'][0]['measures']:
for note in generator.score.measure_notes(measure):
for pitch in note['pitches']:
tones[pitch['id']] = pitch
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
cursor += generator.model.fraction(measure['duration'])
groups = {}
for identifier, pitch in tones.items():
if pitch.get('tie_from') in tones:
continue
start, tail = starts[identifier], pitch
seen = {identifier}
while tail.get('tie_to') in tones:
target = tail['tie_to']
if target in seen:
raise ValueError('cyclic acoustic sustain')
seen.add(target)
tail = tones[target]
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
string = pitch.get('tablature', {}).get('string')
if string is None:
raise ValueError('acoustic comping requires placed notes')
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
events = []
for (start, end), lanes in groups.items():
if len(lanes) == 2:
if end - start <= 1:
raise ValueError('combined acoustic sustain must exceed one beat')
elif end - start not in (fractions.Fraction(1, 2), 1):
raise ValueError('separate acoustic comping requires quarter/eighth notes')
events.append((start, end))
events.sort()
if any(a[1] > b[0] for a, b in zip(events, events[1:])):
raise ValueError('acoustic bass and upper must release together or remain separate')
+10 -2
View File
@@ -50,7 +50,15 @@ def audit(source, documents):
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'}) exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
continue continue
position = section.source_measure_index + j position = section.source_measure_index + j
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j]))) # A complete figure depends on both measures' effective harmony and
# source-defined participation. Changed final turnarounds/pickups are
# explicit contexts, not incidental changes between iterations.
first = generator.score.body_start_measure_index(authored)
block_start = first + 2 * ((position - first) // 2)
neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2))
context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors)
boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else ''
key = (position, context, boundary)
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches'])) tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
for v in m['voices'] for n in v['notes'] if n['pitches']) for v in m['voices'] for n in v['notes'] if n['pitches'])
@@ -60,7 +68,7 @@ def audit(source, documents):
reused += 1 reused += 1
else: else:
if any(k[0] == key[0] for k in seen): if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'}) exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
seen[key] = signature seen[key] = signature
offset += section.measure_count offset += section.measure_count
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions} result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
+80
View File
@@ -0,0 +1,80 @@
"""The recorded three-bar hold, expressed once as source-derived score time."""
import copy
import fractions
import generator.accompaniment
import generator.acoustic_voicing
import generator.comping
import generator.fretboard
import generator.model
import generator.parts
import generator.score
BARS = 3
def template(source):
result = copy.deepcopy(source[-1])
# An ending is three full bars in the final active meter, never a pickup.
for change in result.get('attributes', []):
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
if key in change:
result[key] = copy.deepcopy(change[key])
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
result['time_signature'] = generator.model.meter_label(result['meter'])
return result
def duration(source):
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
def chord(source):
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
prior = generator.accompaniment.carried_chords(effective)[-1]
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
def append(source, output, part, *, sounding=False, center=55):
if not source or not output:
return
model = template(source)
harmony = chord(source)
grip = ()
if sounding:
if harmony is None:
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part)
recent = {}
for m in reversed(output):
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
for p in n['pitches']:
if 'tablature' in p:
tab = p['tablature']
recent.setdefault(tab['string'], generator.fretboard.TabPosition(tab['string'], tab['fret'], int(generator.model.pitch_number(p)) - 12))
if len(recent) >= 4:
break
previous = tuple(recent[s] for s in sorted(recent, reverse=True))
if part == 'acoustic':
bass = next((recent[s] for s in (5, 6) if s in recent), None)
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v))
length = generator.model.fraction(model['duration'])
previous = None
for index in range(BARS):
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
measure['ending_sustain'] = True
if index == 0 and harmony:
measure['harmonies'] = [{**copy.deepcopy(harmony), 'onset': generator.model.rational(0)}]
measure['harmonies'][0].pop('chord_optional', None)
if grip:
notes, cursor = [], fractions.Fraction()
for duration, written in generator.accompaniment.written_durations(length):
n = generator.model.make_event(f'ending_{part}_{index}_{len(notes)}', cursor, duration, 'note', generator.fretboard.chord_pitches(harmony, grip), written)
n['voice'] = 'voice_arranged'
if previous:
for a, b in zip(previous['pitches'], n['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes.append(n)
previous = n
cursor += duration
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
output.append(measure)
generator.score.reidentify(output[-BARS:], [])
+17 -1
View File
@@ -8,6 +8,7 @@ import generator.accompaniment
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.acoustic import generator.acoustic
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.ending
import generator.arrange.form import generator.arrange.form
import generator.arrange.pickup import generator.arrange.pickup
import generator.electric import generator.electric
@@ -61,10 +62,20 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None) section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures) previous = generator.accompaniment.carried_chords(section_measures)
segment = [] segment = []
stop = None
next_section = sections[index + 1] if index + 1 < len(sections) else None
if next_section and generator.score.body_start_measure_index(source):
last = section.source_measure_index + section.measure_count - 1
if next_section.role == 'slash':
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks:
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si] m = section_measures[si]
if role == 'accompaniment' and accompaniment is not None: if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si) result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
result['id'] = f'measure_{len(output) + len(segment) + 1}' result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else '' result['section_label'] = section.label if not segment else ''
else: else:
@@ -97,6 +108,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)}) octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
output.extend(segment) output.extend(segment)
absolute += segment_length absolute += segment_length
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
generator.arrange.ending.append(source, output, part_type,
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55))
tuplets = generator.arrange.pickup.retain_links(output, tuplets) tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124) result['metadata'].setdefault('tempo', 124)
@@ -104,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
if accompaniment is None and (owned is None or 'accompaniment' in owned): if accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result) generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result) generator.acoustic_voicing.place(result)
generator.acoustic.validate_comping(result)
generator.acoustic.combine(result) generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned): elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result) generator.melody.harmonize(result)
+156 -110
View File
@@ -135,91 +135,157 @@ class HarmonyChoice:
def key(self): def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events) return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical: class Canonical:
def __init__(self, document, part, seed): def __init__(self, document, part, seed):
self.part, self.seed = part, seed self.part, self.seed = part, seed
self.policy = generator.vocabulary.role(part + '_accompaniment') self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {} self.cache, self._reports, self._rhythm_reports = {}, {}, {}
measures = generator.score.source_staff(document)['measures'] self.measures = generator.score.source_staff(document)['measures']
self.melody = generator.comping_rhythm.melody_contexts(document) self.melody = generator.comping_rhythm.melody_contexts(document)
first_index = generator.score.body_start_measure_index(measures) self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(measures, part) sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures) self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:])) and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(measures) carried = generator.accompaniment.carried_chords(self.measures)
inputs = [] self.groups = []
for index in range(first_index, len(measures)): index = self.first
source = copy.deepcopy(measures[index]) while index < len(self.measures):
for chord in source['harmonies']: size = 1
chord.pop('chord_optional', None) if index + 1 < len(self.measures):
inputs.append((source, carried[index], index)) lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs) if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
rhythms = generator.comping_rhythm.select(layers, seed) size = 2
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)} self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)] index += size
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events) layers = [self.candidates(group) for group in self.groups]
selected = generator.comping_rhythm.select(layers, seed)
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
path, self.path_cost = self.select(active, close=self.closed) path, self.path_cost = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path)) self.base = dict(zip((s.key for s in active), path))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path)) self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
self.rhythms = {}
previous = path[-1] if self.closed and path else () previous = path[-1] if self.closed and path else ()
for source, prior, index, spans in prepared: for group, rhythm, spans in zip(self.groups, selected, prepared):
selected = tuple(self.base[s.key] for s in spans if s.events) grips = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False) self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
if selected: cursor = fractions.Fraction()
previous = selected[-1] for m, _, i in group:
if generator.vocabulary.accompaniment(part)['connect_holds']: length = generator.model.fraction(m['duration'])
self.connect_holds(inputs) self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property @property
def report(self): def report(self):
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]] return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property @property
def rhythm_report(self): def rhythm_report(self):
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))] return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod @staticmethod
def key(source, prior, index): def key(inputs, limit=None):
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)), return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
source['duration'].__repr__()) def candidates(self, inputs, limit=None):
def prepare(self, source, prior, index, rhythm): flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = [] spans = []
for start, end, chord in generator.comping_rhythm.spans(source, prior): offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord: if not chord:
continue continue
events = tuple(e for e in rhythm.events if start <= e.start < end) offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try: try:
options = voicings(semantic_key(chord), self.part) options = voicings(semantic_key(chord), self.part)
except ValueError as error: except ValueError as error:
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options)) spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
self._rhythm_reports[self.key(source, prior, index)] = {
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
return tuple(spans) return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False, fixed=None): def select(self, spans, *, left=(), right=(), close=False):
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans) layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close) return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index): def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
key = self.key(source, prior, index) group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
sources = sources or self.measures
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
limit = None
if stop and any(i == stop[0] for _, _, i in group):
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit)
if key not in self.cache: if key not in self.cache:
# Effective pickup/turnaround harmony may differ from the main form. choices = self.candidates(group, limit)
# Solve that measure between fixed canonical neighbors. Request order start, end = group[0][2], group[-1][2]
# and previously generated arrangement sections cannot change it. # Preserve the canonical whole figure where still legal under changed
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index)) # effective harmony. Recompute only genuinely different source context.
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0] original = self.block_rhythms.get(start)
spans = self.prepare(source, prior, index, rhythm) matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
before = [v for i, _, v in self.order if i < index] rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
after = [v for i, _, v in self.order if i > index] spans = self.prepare(group, rhythm)
before = [v for i, _, v in self.order if i < start]
after = [v for i, _, v in self.order if i > end]
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else () left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
right = after[0] if after else self.order[0][2] if self.closed and self.order else () right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base) selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(source, prior, index, spans, selected, left, True) self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
return copy.deepcopy(self.cache[key]) return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, source, prior, index, spans, selected, previous, variant): def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
length = generator.model.fraction(source['duration']) flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], [] attacks, rows = [], []
choices = iter(selected) choices = iter(selected)
for span in spans: for span in spans:
@@ -227,8 +293,8 @@ class Canonical:
bass = grip[0] if grip and self.part == 'acoustic' else None bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord) degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter'])) root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, 'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None, 'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part), 'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
'fret_span': generator.fretboard.fret_span(grip), 'fret_span': generator.fretboard.fret_span(grip),
@@ -239,66 +305,46 @@ class Canonical:
'bass_string': bass.string_number if bass else None, 'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)}) 'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
for event in span.events: for event in span.events:
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
attacks.append((event.lane, event.start, event.end, positions, span.chord)) for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip: if grip:
previous = grip previous = grip
self._reports[self.key(source, prior, index)] = rows key = self.key(inputs, limit)
self._reports[key] = rows
voices = [] voices = []
notes, cursor, lane = [], fractions.Fraction(), ''
def emit(a, b, positions=(), chord=None):
nonlocal cursor
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = lane
if self.part == 'acoustic':
n['stem'] = 'down' if lane == 'bass' else 'up'
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
cursor = b
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']: for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
notes, cursor = [], fractions.Fraction() notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None):
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = lane
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]): for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start: if cursor < start:
emit(cursor, start) emit(cursor, start)
emit(start, end, positions, chord) emit(start, end, positions, chord)
cursor = end
if cursor < length: if cursor < length:
emit(cursor, length) emit(cursor, length)
voices.append({'id': lane, 'notes': notes}) voices.append({'id': lane, 'notes': notes})
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior) result = split_measures({'voices': voices}, inputs, self.part)
result['voices'] = voices reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result return result
def connect_holds(self, inputs):
"""Only explicit sustained neighbors can share an attack across a barline.
These are canonical internal joins, never links across role boundaries.
The direct reattack remains when a grip or required attack count differs.
"""
for left, right in zip(inputs, inputs[1:]):
before = self.cache[self.key(*left)]
after = self.cache[self.key(*right)]
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
if not generator.accompaniment.same_chord(previous_chord, next_chord):
continue
for a, b in zip(before['voices'], after['voices']):
if a['id'] == 'bass':
continue
old, new = a['notes'][-1], b['notes'][0]
minimum = 1 if a['id'] == 'upper' else 0
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
continue
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
continue
for p, q in zip(old['pitches'], new['pitches']):
p['tie_to'], q['tie_from'] = q['id'], p['id']
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
row['attacks'] -= 1
row['tied_continuations'] += 1
+120 -134
View File
@@ -1,7 +1,6 @@
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly.""" """Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses import dataclasses
import fractions import fractions
import itertools
import generator.accompaniment import generator.accompaniment
import generator.model import generator.model
@@ -14,6 +13,7 @@ class Attack:
lane: str lane: str
start: fractions.Fraction start: fractions.Fraction
end: fractions.Fraction end: fractions.Fraction
harmonic_time: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class Rhythm: class Rhythm:
@@ -21,12 +21,13 @@ class Rhythm:
families: tuple families: tuple
contributions: tuple contributions: tuple
exceptions: tuple = () exceptions: tuple = ()
pattern_id: str = ''
@property @property
def cost(self): def cost(self):
return sum(value for _, value in self.contributions) return sum(value for _, value in self.contributions)
@property @property
def signature(self): def signature(self):
return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events) return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
def melody_contexts(document): def melody_contexts(document):
melody = generator.solo.analysis.attacks(document) melody = generator.solo.analysis.attacks(document)
@@ -49,151 +50,136 @@ def spans(measure, prior):
result.append((a, b, chord)) result.append((a, b, chord))
return tuple(result) return tuple(result)
def lane_score(events, lane, melody, harmony, length): def flatten(inputs):
"""Named source-context preferences, independent of the deterministic seed.""" """Harmony/timing adapter only; never concatenate separately chosen rhythms."""
attacks = [a for a, b in melody if a >= 0] first = inputs[0][0]
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction()) result = {**first, 'harmonies': []}
space = max(0, length - occupied) cursor = fractions.Fraction()
busy = len(attacks) >= length for m, prior, _ in inputs:
if lane == 'bass': chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony)) initial = chord or {'kind': 'none'}
elif lane == 'upper': result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
target = 2.5 if space >= length / 2 else 1.5 if busy else 2 result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset']))
else: cursor += generator.model.fraction(m['duration'])
target = .5 if busy else 1.5 if space >= length / 2 else 1 result['duration'] = generator.model.rational(cursor)
# A half-attack tolerance makes neighboring densities genuine alternatives. return result
# The objective does not always fill up to a layer's permitted maximum.
activity = .35 * max(0, abs(len(events) - target) - .5) def validate_events(events, part):
support, articulation = 0., 0. ordered = sorted(events, key=lambda e: e.start)
for e in events: for e in ordered:
# Supporting an entrance is different from doubling every moving note. duration = e.end - e.start
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody)) if duration <= 0:
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody) raise ValueError('comping event must have positive sounding duration')
gap = not any(a <= e.start < b for a, b in melody) if part == 'acoustic':
if lane != 'bass': if e.lane == 'combined':
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0 if duration <= 1:
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0 raise ValueError('combined acoustic sustain must exceed one beat')
elif e.start and e.start.denominator != 1: elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
support += .15 raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
divisor = max(1, len(events)) elif e.lane != 'electric':
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor)) raise ValueError('electric comping requires chord events')
if any(a.end > b.start for a, b in zip(ordered, ordered[1:])):
raise ValueError('separate comping events cannot overlap; use one combined sustain')
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 = [], []
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
_, 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)
except ValueError:
return None
return tuple(events), tuple(exceptions)
def evaluate(events, part, melody, harmony, lengths):
activity, support, coverage, articulation = 0., 0., 0., 0.
cursor = fractions.Fraction()
for length in lengths:
local = [e for e in events if cursor <= e.start < cursor + length]
attacks = [a for a, b in melody if cursor <= a < cursor + length]
busy = len(attacks) >= length
sounding = any(e.start < cursor + length and e.end > cursor for e in events)
target = (1 if busy else 1.5) if part == 'electric' else (2 if busy else 2.5)
# Sustained occupancy and planned space are not missing attacks.
activity += .22 * max(0, abs(len(local) - target) - .75) if not (sounding and not local) else 0
for e in local:
gap = not any(a <= e.start < b for a, b in melody)
entrance = any(a == e.start and (i == 0 or a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
support += -.2 if entrance else -.12 if gap else .18 if busy and e.start in attacks else 0
articulation += .08 if busy and e.end - e.start > 2 else 0
cursor += length
for a, b, chord in harmony:
if chord and b - a >= 1 and not any(e.start < b and e.end > a for e in events):
coverage += .18 # A preference, never a chord-attack quota.
divisor = max(1, len(lengths))
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None):
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part)
result = []
for pattern in policy['patterns']:
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
continue
for anticipate in (False, True):
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
if fitted is None:
continue
events, exceptions = fitted
if anticipate and not any(e.harmonic_time is not None for e in events):
continue
contributions = evaluate(events, part, melody, spans(measure, prior), lengths)
result.append(Rhythm(events, (('pattern', pattern['family']),), contributions, exceptions, pattern['id'] + ('.anticipated' if anticipate else '')))
if not result:
raise ValueError(f'measure {measure["number"]}: no {part} pattern for lengths {lengths} and written harmonic/release constraints')
return representatives(result, policy['pattern_candidates'], seed)
def representatives(candidates, limit, seed): def representatives(candidates, limit, seed):
"""Keep counts, placements and duration families before the final cutoff."""
unique = {} unique = {}
for candidate in candidates: for c in candidates:
key = candidate.signature rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key)) if c.signature not in unique or rank < unique[c.signature][0]:
if key not in unique or rank < unique[key][0]: unique[c.signature] = rank, c
unique[key] = rank, candidate ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])] retained, families = [], set()
groups = {}
shapes = set()
for c in ordered: for c in ordered:
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families) if c.families not in families:
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events)) retained.append(c)
# Round-robin attack counts first. Otherwise many cosmetic variants of families.add(c.families)
# the cheapest density can exclude every one- or three-attack idea. retained.extend(c for c in ordered if c not in retained)
if shape not in shapes: return tuple(retained[:limit])
groups.setdefault(counts, []).append(c)
shapes.add(shape)
result = []
while groups and len(result) < limit:
for key in list(groups):
result.append(groups[key].pop(0))
if not groups[key]:
del groups[key]
if len(result) == limit:
break
return tuple(result)
def lane_candidates(measure, prior, lane, melody, policy, seed):
length = generator.model.fraction(measure['duration'])
harmony = spans(measure, prior)
active = [(a, b, c) for a, b, c in harmony if c]
if not active:
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
low, high = policy['layers'][lane]['attacks']
# Genuinely different harmony gets a gesture, not its own density budget.
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
required.insert(0, active[0][:2])
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
required = []
# With very fast harmonic rhythm, establish the edges and longest spans.
# Short intervening chords may be silent; no incompatible sustain is kept.
if len(required) > high + 1:
edges = [required[0], required[-1]]
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
extra = int(len(required) > high)
grid = fractions.Fraction(policy['grid'])
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
positions.update(a for a, _, _ in active)
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
result = []
for count in range(low, high + extra + 1):
for onsets in itertools.combinations(positions, count):
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
continue
if any(not any(a <= t < b for t in onsets) for a, b in required):
continue
if any(b - a < fractions.Fraction(policy['attack_spacing']) for a, b in zip(onsets, onsets[1:])):
continue
for family in policy['layers'][lane]['families']:
durations = policy['families'][family]
events = []
for i, a in enumerate(onsets):
end = next(b for start, b, c in active if start <= a < b)
following = onsets[i + 1] if i + 1 < len(onsets) else length
duration = fractions.Fraction(durations[i % len(durations)])
events.append(Attack(lane, a, min(a + duration, following, end)))
if policy.get('allowed_durations') and any(e.end - e.start not in tuple(map(fractions.Fraction, policy['allowed_durations'])) for e in events):
continue
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
if not result:
# A short partial bar can have fewer gestures, not invented subdivisions.
if length < 1 and not policy.get('allowed_durations'):
a, b, _ = active[0]
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
def crowding(events):
times = sorted({e.start for e in events})
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
# Three successive half-beat changes are a busy figure even though each
# layer is sparse. Coincident attacks count as one hand action.
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
def candidates(measure, prior, part, melody, seed):
policy = generator.vocabulary.accompaniment(part)
if part == 'electric':
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
result = []
for a, b in itertools.product(bass, upper):
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
return representatives(result, policy['measure_candidates'], seed)
def transition(previous, current): def transition(previous, current):
if previous is None: if previous is None:
return 0. return 0.
# Recurrence is allowed; penalize repeating an entire bar more than sharing return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
# a duration family. This stays separate from the source-context objective.
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
def select(layers, seed, left=None, right=None): def select(layers, seed, left=None, right=None):
"""One state per concrete bar rhythm; compare complete equal-span paths.""" """Compare complete two-bar paths, not independently chosen bar fragments."""
states = [(0., (), (), left)] states = [(0., (), (), left)]
for i, layer in enumerate(layers): for i, layer in enumerate(layers):
following = [] following = []
for c in layer: for c in layer:
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states] options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.pattern_id, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
following.append(min(options, key=lambda item: item[:2])) following.append(min(options, key=lambda item: item[:2]))
states = following states = following
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2] return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
+8 -2
View File
@@ -43,9 +43,15 @@ def validate_pages(document, pages):
def timing(document): def timing(document):
times = boundaries(document) times = boundaries(document)
measures = document['staves'][0]['measures'] measures = document['staves'][0]['measures']
# Metronome accents follow each actual measure, including meter changes. ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')]
if ending and ending != list(range(ending[0], len(measures))):
raise ValueError('held ending must be a contiguous terminal section')
sustain_start = times[ending[0]] if ending else times[-1]
# The written held ending participates in page time, not metronome time.
clicks = [] clicks = []
for start, measure in zip(times, measures): for start, measure in zip(times, measures):
if start >= sustain_start:
break
duration = generator.model.fraction(measure['duration']) duration = generator.model.fraction(measure['duration'])
beat = fractions.Fraction() beat = fractions.Fraction()
while beat < duration: while beat < duration:
@@ -53,7 +59,7 @@ def timing(document):
beat += 1 beat += 1
return dict(song_id=document['metadata'].get('song_id', ''), return dict(song_id=document['metadata'].get('song_id', ''),
duration=generator.model.rational(times[-1]), duration=generator.model.rational(times[-1]),
count_in_beats=4, clicks=clicks) sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
def describe_wav(path, root, *, song_id='', bpm=0): def describe_wav(path, root, *, song_id='', bpm=0):
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()): if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
+4
View File
@@ -26,6 +26,7 @@ import generator.parts
import generator.score import generator.score
import generator.vocabulary import generator.vocabulary
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.arrange.ending
import generator.arrange.form import generator.arrange.form
import generator.arrange.generate import generator.arrange.generate
import generator.arrange.pickup import generator.arrange.pickup
@@ -94,6 +95,7 @@ def windows(document, part):
result[-1] = (a, start, old) result[-1] = (a, start, old)
result.append((start, end, part + '_' + role)) result.append((start, end, part + '_' + role))
cursor = end cursor = end
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result) return tuple(result)
def dependencies(role, resources): def dependencies(role, resources):
@@ -254,6 +256,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'}) result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role) allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
_mask(result, allowed) _mask(result, allowed)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0] staff = result['staves'][0]
for measure in staff['measures']: for measure in staff['measures']:
measure['id'] = role + '_' + measure['id'] measure['id'] = role + '_' + measure['id']
+39 -20
View File
@@ -23,25 +23,44 @@ def _load(contents):
result = {} result = {}
for name, text in contents: for name, text in contents:
value = json.loads(text) value = json.loads(text)
if value.get('version') != (3 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'): if value.get('version') != (4 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary') raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
if name == 'accompaniment': if name == 'accompaniment':
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2): signatures = {}
raise ValueError('accompaniment requires an eighth-note placement grid') for part in ('electric', 'acoustic'):
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
policy = value['instruments'][part] policy = value['instruments'][part]
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2): identifiers = set()
raise ValueError('accompaniment attacks must be at least an eighth apart') signatures[part] = set()
for lane in lanes: for pattern in policy['patterns']:
layer = policy['layers'][lane] if pattern['id'] in identifiers or not pattern['family']:
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']: raise ValueError('duplicate or unnamed accompaniment pattern')
raise ValueError('invalid accompaniment attack range or duration families') identifiers.add(pattern['id'])
for family in layer['families']: lengths = tuple(map(fractions.Fraction, pattern['lengths']))
durations = policy['families'][family] if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
if not durations or any(fractions.Fraction(d) <= 0 for d in durations): raise ValueError('patterns require one or two positive measure lengths')
raise ValueError('accompaniment durations must be positive') # Validate musical events before any measure projection or ties.
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations): previous = fractions.Fraction()
raise ValueError('acoustic rhythm uses quarter and eighth notes') onsets = []
for row in pattern['events']:
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
if onset < previous or duration <= 0 or onset + duration > sum(lengths):
raise ValueError('overlapping or out-of-bounds accompaniment pattern')
if onset.denominator > 2 or duration.denominator > 2:
raise ValueError('comping vocabulary uses a quarter/eighth grid')
if part == 'acoustic':
if row['layer'] == 'combined':
if duration <= 1:
raise ValueError('combined acoustic sustain must exceed one beat')
elif row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes')
elif row['layer'] != 'electric':
raise ValueError('electric pattern must contain electric chord attacks')
if row.get('anticipate') and row['layer'] not in ('upper', 'electric'):
raise ValueError('only upper/electric chords can anticipate')
previous = onset + duration
onsets.append(onset)
if len(lengths) == 2:
signatures[part].add((lengths, tuple(onsets)))
families = policy['voicing_families'] families = policy['voicing_families']
if not families.get('provenance'): if not families.get('provenance'):
raise ValueError('guitar voicing families require provenance') raise ValueError('guitar voicing families require provenance')
@@ -49,16 +68,16 @@ def _load(contents):
if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()): if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()):
raise ValueError('invalid electric family ranking') raise ValueError('invalid electric family ranking')
else: else:
if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']:
raise ValueError('acoustic rhythm must retain short quarter/eighth attacks')
for kind in ('triad', 'tetrad', 'extended'): for kind in ('triad', 'tetrad', 'extended'):
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}: if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
raise ValueError('acoustic families require root/fifth bass on both bass strings') raise ValueError('acoustic families require root/fifth bass on both bass strings')
for orders in families[kind].values(): for orders in families[kind].values():
if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders): if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders):
raise ValueError('invalid acoustic upper chord order') raise ValueError('invalid acoustic upper chord order')
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64: if signatures['acoustic'] & signatures['electric']:
raise ValueError('accompaniment candidate budgets must be 1–64') raise ValueError('acoustic/electric two-bar onset patterns must be distinct')
if not 1 <= value['pattern_candidates'] <= 64:
raise ValueError('accompaniment pattern budget must be 1–64')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', [])))) entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
if len({v['id'] for v in entries}) != len(entries): if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID') raise ValueError(f'{name}: duplicate vocabulary ID')
+1
View File
@@ -39,6 +39,7 @@ struct score_document {
std::vector<vamp_zero::score_page> pages{}; std::vector<vamp_zero::score_page> pages{};
std::string song_id{}; std::string song_id{};
vamp_zero::beat_position duration{}; vamp_zero::beat_position duration{};
vamp_zero::beat_position sustain_start{};
std::uint32_t count_in_beats{4}; std::uint32_t count_in_beats{4};
}; };
+13 -1
View File
@@ -109,9 +109,21 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
return invalid("invalid performance duration or count-in"); return invalid("invalid performance duration or count-in");
} }
document.duration = *duration; document.duration = *duration;
auto sustain = performance.if_contains("sustain_start") ? vamp_zero::parse_beat(performance.at("sustain_start")) : duration;
if (!sustain || std::find(times.begin(), times.end(), rational(*sustain)) == times.end()) {
return invalid("invalid held-ending boundary");
}
document.sustain_start = *sustain;
for (auto i = std::size_t{}; i < measures->size(); ++i) {
auto marker = (*measures)[i].as_object().if_contains("ending_sustain");
auto ending = marker && marker->is_bool() && marker->as_bool();
if (ending != (times[i] >= rational(*sustain))) {
return invalid("held-ending measures disagree with performance timing");
}
}
auto const &clicks = performance.at("clicks").as_array(); auto const &clicks = performance.at("clicks").as_array();
auto click = std::size_t{}; auto click = std::size_t{};
for (auto measure = std::size_t{}; measure + 1 < times.size(); ++measure) { for (auto measure = std::size_t{}; measure + 1 < times.size() && times[measure] < rational(*sustain); ++measure) {
for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) { for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) {
if (click >= clicks.size()) { if (click >= clicks.size()) {
return invalid("missing metronome beat"); return invalid("missing metronome beat");
+1 -1
View File
@@ -204,7 +204,7 @@ auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint
while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) { while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) {
++page; ++page;
} }
return {page, beat_frames < frame_at(score.duration, bpm) ? "Playing" : "Sustain"}; return {page, beat_frames < frame_at(score.sustain_start, bpm) ? "Playing" : "Sustain"};
} }
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> { auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
+4
View File
@@ -12,3 +12,7 @@ def section_document(document, part_type):
with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'): with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'):
backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None
return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing) return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing)
def melody_performance(document, part_type):
"""Isolate unchanged source melody from accompaniment/ending construction."""
return generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part_type, owned={'melody', 'vocal'})
+204 -6
View File
@@ -4,7 +4,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -29,6 +29,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -1831,6 +1835,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -1839,7 +1869,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "electric", "part_type": "electric",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -1864,6 +1894,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -3666,6 +3700,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -3674,7 +3734,7 @@
"composer": "", "composer": "",
"key_signature": "G", "key_signature": "G",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -3699,6 +3759,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -6113,6 +6177,41 @@
"0+2 half note G4 ", "0+2 half note G4 ",
"2+2 half note G4 " "2+2 half note G4 "
] ]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "GM6"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
} }
] ]
}, },
@@ -6121,7 +6220,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "acoustic", "part_type": "acoustic",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -6146,6 +6245,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -8082,6 +8185,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -8090,7 +8219,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "electric", "part_type": "electric",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -8115,6 +8244,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -10051,6 +10184,32 @@
"harmonies": [], "harmonies": [],
"duration": "4", "duration": "4",
"slash_count": 0 "slash_count": 0
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0
} }
] ]
}, },
@@ -10059,7 +10218,7 @@
"composer": "", "composer": "",
"key_signature": "D minor", "key_signature": "D minor",
"part_type": "vocal", "part_type": "vocal",
"measure_count": 168, "measure_count": 171,
"section_labels": [ "section_labels": [
{ {
"measure": 1, "measure": 1,
@@ -10084,6 +10243,10 @@
{ {
"measure": 137, "measure": 137,
"label": "Vocal Melody" "label": "Vocal Melody"
},
{
"measure": 169,
"label": "Ending"
} }
], ],
"measures": [ "measures": [
@@ -12628,6 +12791,41 @@
"vocal_skeleton": [ "vocal_skeleton": [
"0+4 whole rest " "0+4 whole rest "
] ]
},
{
"measure": 169,
"section_label": "Ending",
"harmonies": [
{
"onset": "0",
"symbol": "Dm"
}
],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 170,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
},
{
"measure": 171,
"section_label": "",
"harmonies": [],
"duration": "4",
"slash_count": 0,
"vocal_skeleton": [
"0+4 whole rest "
]
} }
] ]
} }
+93
View File
@@ -0,0 +1,93 @@
"""The held recording tail has exact score time and no vocal attacks."""
import copy
import fractions
import pathlib
import unittest
import generator.acoustic
import generator.arrange.ending
import generator.arrange.generate
import generator.fretboard
import generator.harmony
import generator.model
import generator.musicxml
import generator.parts
import generator.performance
import generator.production
import generator.score
import tests.solo_fixtures
class ArrangementEnding(unittest.TestCase):
def test_current_sources_end_on_effective_harmony_with_one_attack(self):
for name, expected in (('all-of-me', 'GM6'), ('autumn_leaves', 'Dm')):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs') / (name + '.xml'))
before = copy.deepcopy(source)
for part in ('acoustic', 'electric', 'vocal'):
result = generator.arrange.generate.arrange_document(source, part)
measures = result['staves'][0]['measures']
tail = measures[-3:]
self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in tail], ['169', '170', '171'])
self.assertEqual(generator.harmony.symbol(tail[0]['harmonies'][0]), expected)
self.assertEqual([m['section_label'] for m in tail], ['Ending', '', ''])
self.assertTrue(all(m['ending_sustain'] for m in tail))
notes = [n for m in tail for n in generator.score.measure_notes(m)]
attacks = [n for n in notes if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])]
self.assertEqual(len(attacks), 0 if part == 'vocal' else 1)
if part == 'vocal':
self.assertTrue(all(n['kind'] == 'rest' and not n.get('lyrics') for n in notes))
else:
self.assertEqual(sum(generator.model.fraction(n['duration']) for n in notes if n['pitches']), 12)
self.assertEqual([p['tablature'] for p in notes[0]['pitches']], [p['tablature'] for p in notes[-1]['pitches']])
if part == 'acoustic':
self.assertIn(tuple(p['tablature']['string'] for p in attacks[0]['pitches']), ((5, 4, 3, 2), (6, 4, 3, 2)))
timing = generator.performance.timing(result)
self.assertEqual(generator.model.fraction(timing['sustain_start']), 672)
self.assertEqual(generator.model.fraction(timing['duration']), 684)
self.assertEqual(len(timing['clicks']), 672)
generator.model.validate_document(result)
generator.fretboard.validate_document(result)
self.assertEqual(source, before)
def test_ending_uses_final_meter_and_does_not_extend_solo_windows(self):
for meter in (3, 5):
source = tests.solo_fixtures.song(count=2, meter=meter, chords=('C7', 'FM7'))
prepared = generator.parts.prepare_document(source)
for part in ('acoustic', 'electric'):
role = part + '_accompaniment'
result = generator.production.generate_role(generator.production.SourceAnalysis.read(prepared), role, pathlib.Path('data'))
tail = result['document']['staves'][0]['measures'][-3:]
self.assertEqual([generator.model.fraction(m['duration']) for m in tail], [meter] * 3)
windows = generator.production.windows(prepared, part)
self.assertEqual(windows[-1][2], role)
self.assertEqual(windows[-1][1] - windows[-1][0], 3 * meter)
self.assertEqual(sum(b - a for a, b, r in windows if r == part + '_solo'), 2 * meter)
def test_missing_or_explicitly_absent_final_chord_is_not_invented(self):
for no_chord in (False, True):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
last = source['staves'][0]['measures'][-1]
if no_chord:
last['harmonies'][0]['kind'] = 'none'
else:
last['harmonies'] = []
with self.assertRaisesRegex(ValueError, 'final performed harmony'):
generator.arrange.generate.arrange_document(source, 'acoustic')
def test_partial_combination_and_short_overlap_fail_after_tie_merging(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',))
doc = generator.arrange.generate.arrange_document(source, 'acoustic')
tail = copy.deepcopy(doc)
tail['staves'][0]['measures'] = tail['staves'][0]['measures'][-3:]
generator.acoustic.validate_comping(tail)
first = generator.score.measure_notes(tail['staves'][0]['measures'][0])[0]
bass = first['pitches'][0]
bass.pop('tie_to')
bass['duration'] = generator.model.rational(1)
with self.assertRaises(ValueError):
generator.acoustic.validate_comping(tail)
def test_ending_markers_cannot_interrupt_the_main_score(self):
source = tests.solo_fixtures.song(count=3, chords=('C7',))
source['staves'][0]['measures'][0]['ending_sustain'] = True
with self.assertRaisesRegex(ValueError, 'contiguous'):
generator.performance.timing(source)
if __name__ == '__main__':
unittest.main()
+7 -7
View File
@@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate.arrange_document(document, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 168) self.assertEqual(len(measures), 171)
self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)]) self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)])
self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137']) self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169'])
self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672) self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684)
timelines.append([m['duration'] for m in measures]) timelines.append([m['duration'] for m in measures])
if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'):
for boundary in ([8, 136] if part == 'vocal' else [40]): for boundary in ([8, 136] if part == 'vocal' else [40]):
@@ -49,17 +49,17 @@ class ArrangementPickupTests(unittest.TestCase):
opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>') opening = '<note><rest/><duration>1</duration><type>quarter</type></note>' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='<dot/>')
document = source(opening, implicit=False) document = source(opening, implicit=False)
result = generator.arrange.generate.arrange_document(document, 'vocal') result = generator.arrange.generate.arrange_document(document, 'vocal')
self.assertEqual(len(result['staves'][0]['measures']), 12) self.assertEqual(len(result['staves'][0]['measures']), 15)
self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0) self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0)
def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self): def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self):
document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>')) document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), tests.test_musicxml.xml_note(extra='<tie type="stop"/>'))
document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}] document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}]
for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))): for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))):
with self.subTest(part=part): with self.subTest(part=part):
result = generator.arrange.generate.arrange_document(document, part) result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'})
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual(len(measures), 6) self.assertEqual(len(measures), 9)
for boundary in boundaries: for boundary in boundaries:
pickup = generator.score.measure_notes(measures[boundary - 1])[-1] pickup = generator.score.measure_notes(measures[boundary - 1])[-1]
downbeat = generator.score.measure_notes(measures[boundary])[0] downbeat = generator.score.measure_notes(measures[boundary])[0]
+1 -1
View File
@@ -15,7 +15,7 @@ class CatalogTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (source / 'song.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3) self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'}) self.assertEqual({p.name for p in output.iterdir()}, {'song_acoustic.json', 'song_electric.json', 'song_vocal.json'})
for path in output.iterdir(): for path in output.iterdir():
+5 -6
View File
@@ -87,7 +87,7 @@ class ChordFamilies(unittest.TestCase):
def test_acoustic_policy_change_does_not_reseed_electric_roles(self): def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
resources = generator.vocabulary.load() resources = generator.vocabulary.load()
changed = copy.deepcopy(resources) changed = copy.deepcopy(resources)
changed['accompaniment']['instruments']['acoustic']['families']['quarters'] = ['1/2'] changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2'
for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'): for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'):
self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed)) self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed))
self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed)) self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed))
@@ -142,16 +142,15 @@ class CombinedAcoustic(unittest.TestCase):
m = source['staves'][0]['measures'][0] m = source['staves'][0]['measures'][0]
choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths') choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths')
self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events)) self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events))
policy = generator.vocabulary.accompaniment('acoustic')
upper = generator.comping_rhythm.lane_candidates(m, None, 'upper', (), policy, 'pairs')
self.assertTrue(any(b.start - a.start == fractions.Fraction(1, 2) for c in upper for a, b in zip(c.events, c.events[1:])))
canonical = generator.comping.Canonical(source, 'acoustic', 'short') canonical = generator.comping.Canonical(source, 'acoustic', 'short')
self.assertFalse(any(l['tied_continuations'] for row in canonical.rhythm_report for l in row['layers'].values())) for row in canonical.rhythm_report:
for event in row['events']:
self.assertGreater(fractions.Fraction(event['duration']), 0)
def test_unknown_harmony_subdivision_is_not_silently_quantized(self): def test_unknown_harmony_subdivision_is_not_silently_quantized(self):
source = tests.solo_fixtures.song(count=1, chords=('C7',)) source = tests.solo_fixtures.song(count=1, chords=('C7',))
m = source['staves'][0]['measures'][0] m = source['staves'][0]['measures'][0]
m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))}) m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))})
with self.assertRaisesRegex(ValueError, 'no bass rhythm'): with self.assertRaisesRegex(ValueError, 'no acoustic pattern'):
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact') generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
if __name__ == '__main__': if __name__ == '__main__':
+97 -75
View File
@@ -6,6 +6,7 @@ import pathlib
import unittest import unittest
import generator.accompaniment import generator.accompaniment
import generator.acoustic
import generator.arrange.accompaniment import generator.arrange.accompaniment
import generator.comping import generator.comping
import generator.comping_rhythm import generator.comping_rhythm
@@ -29,56 +30,64 @@ class LayeredComping(unittest.TestCase):
def setUp(self): def setUp(self):
self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7')) self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7'))
self.resources = generator.vocabulary.load() self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self): def block(self, part, source=None, melody=()):
m = self.source['staves'][0]['measures'][0] source = source or self.source
for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})): measures = source['staves'][0]['measures'][:2]
policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources) inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures))))
candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test') flat = generator.comping_rhythm.flatten(inputs)
self.assertEqual({len(c.events) for c in candidates}, expected) lengths = tuple(generator.model.fraction(m['duration']) for m in measures)
self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True) return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths)
if lane == 'electric': def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self):
self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events)) expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'}
else: choices = {part: self.block(part) for part in ('acoustic', 'electric')}
self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)}) for part, candidates in choices.items():
if lane == 'bass': self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates})
self.assertTrue(all(c.events[0].start == 0 for c in candidates)) for c in candidates:
def test_ranges_downbeats_and_overlap(self): generator.comping_rhythm.validate_events(c.events, part)
canonical = generator.comping.Canonical(self.source, 'acoustic', 'light') for start in (0, 4):
carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures']) self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2)
for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)): self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values()))
result = canonical(m, prior, i) a = {tuple(e.start for e in c.events) for c in choices['acoustic']}
voices = {v['id']: v['notes'] for v in result['voices']} e = {tuple(e.start for e in c.events) for c in choices['electric']}
self.assertEqual(set(voices), {'bass', 'upper'}) self.assertFalse(a & e)
self.assertTrue(voices['bass'][0]['pitches']) self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic']))
self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0) self.assertTrue(any(c.events[0].lane == 'upper' for c in choices['acoustic']))
for lane, high in (('bass', 2), ('upper', 3)): self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events))
attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])] def test_separate_short_attacks_and_only_synchronized_long_combinations(self):
self.assertTrue(1 <= len(attacks) <= high) F = fractions.Fraction
self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report)) attack = generator.comping_rhythm.Attack
self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report)) generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic')
def test_combined_crowding_favors_shared_attacks(self): generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic')
m = self.source['staves'][0]['measures'][0] for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)):
candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint') with self.assertRaises(ValueError):
self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates)) generator.comping_rhythm.validate_events(events, 'acoustic')
self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates)) def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self):
shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1))) source = copy.deepcopy(self.source)
separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2))) for m in source['staves'][0]['measures'][:2]:
self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated)) 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)))]
def test_harmonic_changes_share_one_measure_budget(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))]
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes') for c in self.block(part, source):
for c in choices: self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4)
for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)):
count = sum(e.lane == lane for e in c.events)
self.assertLessEqual(count, high + 1)
if count > high:
self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions)
for e in c.events: for e in c.events:
self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4) if e.harmonic_time is None:
if part == 'electric': self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6)))
self.assertTrue(all(c.exceptions for c in choices)) def test_anticipation_binds_exact_upcoming_chord_and_keeps_direct_alternative(self):
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
for part in ('acoustic', 'electric'):
candidates = self.block(part, source)
anticipated = [c for c in candidates if any(e.harmonic_time is not None for e in c.events)]
self.assertTrue(anticipated)
for c in anticipated:
for e in c.events:
if e.harmonic_time is not None:
self.assertIn(e.lane, ('upper', 'electric'))
self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2))
self.assertLess(e.harmonic_time, e.end)
self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates))
flat = generator.comping_rhythm.flatten(tuple((m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))))
pattern = next(p for p in self.resources['accompaniment']['instruments']['electric']['patterns'] if p['id'] == 'electric.forward_motion.statement')
events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'electric', anticipate=True, limit=fractions.Fraction(4))
self.assertTrue(all(e.harmonic_time is None for e in events))
def test_no_chord_is_silent_and_partial_measure_is_bounded(self): def test_no_chord_is_silent_and_partial_measure_is_bounded(self):
m = copy.deepcopy(self.source['staves'][0]['measures'][0]) m = copy.deepcopy(self.source['staves'][0]['measures'][0])
m['harmonies'][0]['kind'] = 'none' m['harmonies'][0]['kind'] = 'none'
@@ -86,23 +95,21 @@ class LayeredComping(unittest.TestCase):
m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)} m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)}
m['duration'] = generator.model.rational(fractions.Fraction(1, 2)) m['duration'] = generator.model.rational(fractions.Fraction(1, 2))
self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events)) self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events))
def test_two_other_meters_preserve_full_bar_bounds(self): def test_two_other_meters_use_authored_patterns(self):
for meter in (3, 5): for meter in (3, 5):
source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',)) source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',))
for part in ('acoustic', 'electric'): for part in ('acoustic', 'electric'):
canonical = generator.comping.Canonical(source, part, 'meter') canonical = generator.comping.Canonical(source, part, 'meter')
self.assertTrue(all(str(meter) + '_beat' in r.pattern_id for r in canonical.block_rhythms.values()))
for rhythm in canonical.rhythms.values(): for rhythm in canonical.rhythms.values():
self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events)) self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events))
def test_melody_changes_the_objective_without_changing_seed(self): def test_melody_changes_the_objective_without_changing_seed(self):
e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2)) busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8))
m = self.source['staves'][0]['measures'][0] space = ((fractions.Fraction(), fractions.Fraction(1, 2)),)
harmony = generator.comping_rhythm.spans(m, None) first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)}
busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4)) second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)}
rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),) common = first.keys() & second.keys()
first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4))) self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common))
second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4)))
self.assertGreater(first['melody_support'], second['melody_support'])
self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same'))
def test_tempo_changes_metadata_but_not_accompaniment_music(self): def test_tempo_changes_metadata_but_not_accompaniment_music(self):
changed = copy.deepcopy(self.source) changed = copy.deepcopy(self.source)
changed['metadata']['tempo'] = 240 changed['metadata']['tempo'] = 240
@@ -144,24 +151,39 @@ class LayeredComping(unittest.TestCase):
m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)] m['voices'] = [{'id': str(i), 'notes': [generator.model.make_event(str(i), fractions.Fraction(i), fractions.Fraction(2), 'note', [p])]} for i, p in enumerate(pitches)]
with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'): with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'):
generator.fretboard.validate_document(doc) generator.fretboard.validate_document(doc)
def test_sustained_join_is_one_attack_and_keeps_position(self): def test_explicit_cross_bar_hold_survives_projection_without_reattack(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',)) source = tests.solo_fixtures.song(count=2, chords=('C7',))
canonical = generator.comping.Canonical(source, 'electric', 'tie') for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')):
inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))] resources = copy.deepcopy(self.resources)
chord = inputs[0][0]['harmonies'][0] patterns = resources['accompaniment']['instruments'][part]['patterns']
grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0] resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id]
for m, p, i in inputs: with generator.vocabulary.using(resources, part + '_accompaniment'):
note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip)) canonical = generator.comping.Canonical(source, part, 'held')
note['voice'] = 'electric' measures = source['staves'][0]['measures']
canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}] rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0) a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')]
canonical.connect_holds(inputs) b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']}
a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs] cross = [p for p in a if p['tie_to'] in b]
for p, q in zip(a['pitches'], b['pitches']): self.assertTrue(cross)
self.assertEqual(p['tie_to'], q['id']) for p in cross:
self.assertEqual(q['tie_from'], p['id']) self.assertEqual(p['tablature'], b[p['tie_to']]['tablature'])
self.assertEqual(p['tablature'], q['tablature']) self.assertEqual(p['id'], b[p['tie_to']]['tie_from'])
self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0) if part == 'acoustic':
document = copy.deepcopy(source)
document['staves'][0]['measures'] = rendered
generator.acoustic.validate_comping(document)
def test_separate_repeated_attacks_are_never_joined(self):
source = tests.solo_fixtures.song(count=2, chords=('C7',))
resources = copy.deepcopy(self.resources)
resources['accompaniment']['instruments']['electric']['patterns'] = [{'id': 'test.repeated', 'family': 'quarters', 'lengths': ['4', '4'],
'events': [{'onset': str(t), 'duration': '4', 'layer': 'electric'} for t in (0, 4)]}]
with generator.vocabulary.using(resources, 'electric_accompaniment'):
canonical = generator.comping.Canonical(source, 'electric', 'repeat')
measures = source['staves'][0]['measures']
rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))]
notes = [n for m in rendered for n in generator.score.measure_notes(m) if n['pitches']]
self.assertEqual(len(notes), 2)
self.assertTrue(all(not p.get('tie_from') and not p.get('tie_to') for n in notes for p in n['pitches']))
class SoloRhythmConditioning(unittest.TestCase): class SoloRhythmConditioning(unittest.TestCase):
def test_melody_rhythm_and_progression_change_learned_probabilities(self): def test_melody_rhythm_and_progression_change_learned_probabilities(self):
+2 -2
View File
@@ -353,7 +353,7 @@ class EngravingCacheTests(unittest.TestCase):
data, output = self.root / 'data', self.root / 'output' data, output = self.root / 'data', self.root / 'output'
(data / 'songs').mkdir(parents=True) (data / 'songs').mkdir(parents=True)
source = data / 'songs/fixture.xml' source = data / 'songs/fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2)
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
original = self.files(output) original = self.files(output)
@@ -364,7 +364,7 @@ class EngravingCacheTests(unittest.TestCase):
self.assertEqual(original, self.files(output)) self.assertEqual(original, self.files(output))
self.assertFalse((output / 'solo_xml').exists()) self.assertFalse((output / 'solo_xml').exists())
self.assertFalse(list(output.rglob('*.xml'))) self.assertFalse(list(output.rglob('*.xml')))
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>'))) source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'): with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual(original, self.files(output)) self.assertEqual(original, self.files(output))
+2 -2
View File
@@ -255,7 +255,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output' source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output'
source.mkdir() source.mkdir()
(source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>')) (source / '0000.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>E</root-step><root-alter>-1</root-alter></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note('E', alter=-1), '<key><fifths>-3</fifths></key>'))
self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3) self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3)
first = {p.name: p.read_bytes() for p in output.rglob('*.json')} first = {p.name: p.read_bytes() for p in output.rglob('*.json')}
generator.arrange.generate.generate_performance_scores(source, output) generator.arrange.generate.generate_performance_scores(source, output)
@@ -264,7 +264,7 @@ class GuitarTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
root = pathlib.Path(temporary) root = pathlib.Path(temporary)
(root / 'data/songs').mkdir(parents=True) (root / 'data/songs').mkdir(parents=True)
(root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1))) (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note(octave=7, alter=1)))
output = root / 'output' output = root / 'output'
output.mkdir() output.mkdir()
(output / 'last-good').write_text('preserved') (output / 'last-good').write_text('preserved')
+1 -1
View File
@@ -137,7 +137,7 @@ class GuitarPartTests(unittest.TestCase):
def test_song_melody_only_is_raised_an_octave(self): def test_song_melody_only_is_raised_an_octave(self):
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4)))
before = copy.deepcopy(source) before = copy.deepcopy(source)
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
result = builder(source, part) result = builder(source, part)
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']] notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']]
+3 -3
View File
@@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase):
result = generator.arrange.generate.arrange_document(source, part) result = generator.arrange.generate.arrange_document(source, part)
generator.model.validate_document(result) generator.model.validate_document(result)
measures = result['staves'][0]['measures'] measures = result['staves'][0]['measures']
self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody']) self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending'])
self.assertFalse(measures[-1]['harmonies']) self.assertFalse(measures[-1]['harmonies'])
self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies']))
self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies']))
@@ -115,7 +115,7 @@ class MusicModelTests(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
self.assertEqual((output / 'last-good').read_text(), 'preserved') self.assertEqual((output / 'last-good').read_text(), 'preserved')
(data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()), encoding='utf-8')
args.check = True args.check = True
# --check uses the validation-only repository fixture. An alternate # --check uses the validation-only repository fixture. An alternate
# source tree fails its form contract before atomic publication. # source tree fails its form contract before atomic publication.
@@ -134,7 +134,7 @@ class MusicModelTests(unittest.TestCase):
data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output' data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output'
(data / 'songs').mkdir(parents=True) (data / 'songs').mkdir(parents=True)
source = data / 'songs' / 'fixture.xml' source = data / 'songs' / 'fixture.xml'
source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') source.write_text(tests.test_musicxml.xml_score('<harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note()), encoding='utf-8')
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False)
generator.__main__.generate_assets(args) generator.__main__.generate_assets(args)
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
+3 -1
View File
@@ -40,6 +40,8 @@ class PartGenerationTests(unittest.TestCase):
generator.model.validate_document(result) generator.model.validate_document(result)
for measure in result['staves'][0]['measures']: for measure in result['staves'][0]['measures']:
events = measure['voices'][0]['notes'] events = measure['voices'][0]['notes']
if measure.get('ending_sustain'):
continue
if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'): if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'):
continue continue
self.assert_complete_measure(measure) self.assert_complete_measure(measure)
@@ -91,7 +93,7 @@ class PartGenerationTests(unittest.TestCase):
source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode')) source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode'))
before = copy.deepcopy(source) before = copy.deepcopy(source)
expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']] expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']]
for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance):
for part in ('electric', 'vocal'): for part in ('electric', 'vocal'):
with self.subTest(builder=builder.__module__, part=part): with self.subTest(builder=builder.__module__, part=part):
result = builder(source, part) result = builder(source, part)
+20 -1
View File
@@ -58,7 +58,7 @@ BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
BOOST_REQUIRE(engine); BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared)); auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session); BOOST_REQUIRE(session);
auto frames = backing ? 576000U : 288000U; auto frames = 576000U;
auto buffer = std::array<float, 960>{}; auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) { for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480)); BOOST_REQUIRE(engine->render(buffer.data(), 480));
@@ -164,6 +164,25 @@ BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F)); BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
} }
BOOST_AUTO_TEST_CASE(written_ending_boundary_and_clicks_are_validated) {
auto original = boost::json::parse(read("score.json"));
auto document = score();
BOOST_TEST(document.measure_count == 5U);
BOOST_TEST(document.duration.numerator == 20U);
BOOST_TEST(document.sustain_start.numerator == 8U);
BOOST_TEST(vamp_zero::performance_at(document, 287999, 120).phase == "Playing");
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
auto changed = original;
changed.at("performance").at("sustain_start").at("numerator") = 9;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("music").at("staves").as_array()[0].at("measures").as_array()[2].as_object().erase("ending_sustain");
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("performance").at("clicks").as_array().push_back(boost::json::object{{"beat", boost::json::object{{"numerator", 8}, {"denominator", 1}}}, {"downbeat", true}});
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) { BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true}); auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine); BOOST_REQUIRE(engine);
+5 -3
View File
@@ -17,9 +17,10 @@ def fixture(root):
music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
first = music['staves'][0]['measures'][0] first = music['staves'][0]['measures'][0]
music['staves'][0]['measures'].append(copy.deepcopy(first)) music['staves'][0]['measures'].append(copy.deepcopy(first))
music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3))
music['metadata']['song_id'] = 'fixture' music['metadata']['song_id'] = 'fixture'
pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1, pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1 if i == 0 else 4,
start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)] start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational(4 if i == 0 else 20)) for i in range(2)]
bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music)) bundle = dict(music=music, presentation=dict(width=976, height=484, pages=pages), performance=generator.performance.timing(music))
(root / 'score.json').write_text(json.dumps(bundle)) (root / 'score.json').write_text(json.dumps(bundle))
entries = [] entries = []
@@ -73,7 +74,8 @@ class PerformanceTests(unittest.TestCase):
bundle = json.loads((root / 'score.json').read_text()) bundle = json.loads((root / 'score.json').read_text())
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages']) generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])
self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2) self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2)
self.assertEqual(bundle['performance']['duration'], generator.model.rational(8)) self.assertEqual(bundle['performance']['duration'], generator.model.rational(20))
self.assertEqual(bundle['performance']['sustain_start'], generator.model.rational(8))
bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3) bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3)
with self.assertRaisesRegex(ValueError, 'timing'): with self.assertRaisesRegex(ValueError, 'timing'):
generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages']) generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])