From c7f6a30a1379833e1a129113795ba15b33f089e2 Mon Sep 17 00:00:00 2001 From: khodowany Date: Tue, 6 Oct 2026 21:28:42 -0500 Subject: [PATCH] Comping --- README.md | 10 +- SETUP.md | 4 +- TECHNICAL_GUIDE.md | 18 +- data/composition/accompaniment.json | 2101 +++++++++++++++++++++-- generator/acoustic.py | 39 + generator/arrange/accompaniment.py | 12 +- generator/arrange/ending.py | 80 + generator/arrange/generate.py | 18 +- generator/comping.py | 266 +-- generator/comping_rhythm.py | 254 ++- generator/performance.py | 10 +- generator/production.py | 4 + generator/vocabulary.py | 59 +- include/vamp_zero/types.hpp | 1 + src/vamp_zero/notation.cpp | 14 +- src/vamp_zero/performance.cpp | 2 +- tests/arrangement_fixtures.py | 4 + tests/fixtures/performance_summary.json | 210 ++- tests/test_arrangement_ending.py | 93 + tests/test_arrangement_pickup.py | 14 +- tests/test_catalog.py | 2 +- tests/test_chord_families.py | 11 +- tests/test_comping_rhythm.py | 172 +- tests/test_engraving_cache.py | 4 +- tests/test_guitar.py | 4 +- tests/test_guitar_parts.py | 2 +- tests/test_music_model.py | 6 +- tests/test_parts.py | 4 +- tests/test_performance.cpp | 21 +- tests/test_performance.py | 8 +- 30 files changed, 2957 insertions(+), 490 deletions(-) create mode 100644 generator/arrange/ending.py create mode 100644 tests/test_arrangement_ending.py diff --git a/README.md b/README.md index 0ab11a4..0ed1a69 100644 --- a/README.md +++ b/README.md @@ -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. -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. @@ -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. -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. @@ -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. -Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The six-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass. +Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The performance arrangements, including the held Ending, remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final vocal pass. Settings provides independent **Metronome** and **Backing** toggles, both off by default, plus **Tempo**. Song -> Part -> Score navigation stays the same. With both toggles off, pages turn manually. With either enabled, opening a part starts a one-bar count-in and an automatic performance; tap/double-tap page commands are ignored, and hold exits to the part menu. Natural completion also returns there. -A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. There is no separate Ending section. The current 32-bar songs contain 168 numbered bars. Optional turnaround chords are omitted from the final vocal pass. +A performance is an eight-bar intro followed by five complete passes: Vocal Melody, Guitar Melody, Acoustic Solo, Electric Solo, and Vocal Melody. A final Ending section adds one guitar chord attack sustained across three full measures, with vocal rests. The current 32-bar songs contain 171 numbered bars. The ending holds the final performed harmony (GM6 for All of Me, Dm for Autumn Leaves). Optional turnaround chords are omitted from the final vocal pass. Audio comes exclusively from supplied WAV files: `package/assets/sounds/beat1.wav` accents each measure's first beat, and `beat2.wav` plays its other beats. Backing tracks belong in `package/assets/backing/_.wav`, for example `all_of_me_120.wav`. The normalized song ID comes from the source filename. Files are copied unchanged; the app never synthesizes notes or stretches recordings. Tempo choices are 60, 70, 80, 90, 100, 110, and 120 BPM. With Backing on, choices are the tempos available for every catalog song; currently that is 120 BPM. Enabling Backing selects the closest available tempo, preferring the slower choice on a tie. If no tempo is shared, Backing is unavailable. Add recordings at the other tempos and rebuild to enable them. -Backing recordings include one bar of count-in, the complete score, and the held ending with decay. The recorded count-in plays alone. When both toggles are on, metronome clicks start at the score and stop at its end, leaving the last three held bars and decay unclicked. Metronome-only mode supplies its own count-in and stops at score end. Pages follow the rendered audio clock, using each engraved page's actual measure range; the final page stays visible through the backing tail. +Backing recordings include one bar of count-in, the complete arrangement including its three-bar held ending, and decay. The existing WAVs are copied unchanged. The recorded count-in plays alone. Metronome clicks stop at the start of the written Ending section; those three measures and the decay remain unclicked. Metronome-only mode supplies its own count-in, waits silently through the ending, and then stops. 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. diff --git a/SETUP.md b/SETUP.md index 6b82d6b..06e272a 100644 --- a/SETUP.md +++ b/SETUP.md @@ -59,6 +59,6 @@ Controls: - Double tap: previous menu item, or previous page when both audio toggles are off. - Tap and hold: select the highlighted menu row, enter/leave tempo editing, or return from score view to the part menu. While editing tempo, tap increases BPM and double tap decreases it. -Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks accompany the score but stop before the three-bar held tail. Hold exits at any time. +Settings has independent Metronome and Backing toggles. Either starts automatic playback when a part opens; both off enables manual reading. Backing plays the recorded count-in and held ending. Metronome clicks stop at the written three-bar Ending section; page timing continues through the hold. Hold exits at any time. -Keep the two metronome samples under `package/assets/sounds/`. Put song recordings under `package/assets/backing/`, named `_.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 `_.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. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index a16a773..ae52cfe 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -97,11 +97,11 @@ Parenthesized chord symbols mark optional turnarounds, including Soundslice's te ### Linear Reading Order -There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal) explicitly onto successive pages; neither display nor playback jumps backward. +There is no repeat analyzer, expansion pass, or repeat/ending engraver. Performance writes an eight-bar intro and five full passes (vocal, guitar melody, acoustic solo, electric solo, vocal), followed by three held ending measures, explicitly onto successive pages; neither display nor playback jumps backward. Repeat signs, numbered endings, D.C./D.S./coda navigation, and `time-only` ties fail import rather than being ignored or expanded. Write the intended notes and rests explicitly, including any alternate ending. Legacy repeat fields in generated musical documents are rejected; regenerate assets instead of retaining a compatibility interpreter. Full leading-rest measures remain full measures, not inferred pickups. Missing titles fall back to readable, spaced filename titles and missing composer credits remain empty, as with the current Soundslice exports. -An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 168 arrangement bars, including the eight-bar intro. +An explicitly marked opening pickup is excluded from the repeated song body. At each vocal or electric melody entry, it is right-aligned within the preceding arrangement bar. Outgoing music continues until the pickup's first sounding note, then releases as the incoming melody takes over; pickup rests do not erase earlier accompaniment. Lyrics, ties into the following downbeat, tuplets, and octave spans retain their timing. Pickup harmony changes also apply to the accompanying parts at their relocated offsets. All parts share the same bar count, and a pickup longer than its destination bar fails generation. Autumn Leaves uses a four-beat padded pickup and a 32-bar body, yielding 171 arrangement bars: the eight-bar intro, five 32-bar passes, and three written held-ending measures. Ordinary ties still link adjacent matching tones across measures and systems. Tuplets, independent voices, local staff changes, and manual page navigation are unchanged. The recording’s three-bar sustain tail has no added score section or metronome clicks. @@ -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. -`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. @@ -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. -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. diff --git a/data/composition/accompaniment.json b/data/composition/accompaniment.json index 281efcd..678e680 100644 --- a/data/composition/accompaniment.json +++ b/data/composition/accompaniment.json @@ -1,112 +1,1124 @@ { - "version": 3, - "provenance": "Independently authored sparse guitar rhythm rules: metrical placement, support of source melody, releases and harmonic changes. No training-score extraction.", + "version": 4, + "provenance": "Independently authored two-bar realizations of general jazz comping concepts. Exact attacks, releases and layer assignments; no training-score extraction.", "license": "MIT", - "grid": "1/2", - "lane_candidates": 24, - "measure_candidates": 32, + "pattern_candidates": 32, "instruments": { - "electric": { - "families": { - "stabs": [ - "1/2" - ], - "quarter_stabs": [ - "1" - ], - "sustain": [ - "3" - ], - "broad": [ - "4" - ], - "short_long": [ - "1/2", - "2" - ], - "long_short": [ - "2", - "1/2" - ] - }, - "layers": { - "electric": { - "attacks": [ - 0, - 2 - ], - "families": [ - "stabs", - "quarter_stabs", - "sustain", - "broad", - "short_long", - "long_short" - ] - } - }, - "attack_spacing": "1", - "connect_holds": true, - "voicing_families": { - "provenance": "Compact inversions of the actual chord: drop-two, close position, other compact open voicings, doubled triads and three-note shells. No fixed root/key or song-specific grips.", - "rank": { - "drop-two": 0, - "close-position": 0, - "compact-open": 1, - "compact-doubled": 2, - "three-note-shell": 0 - } - } - }, "acoustic": { - "families": { - "quarters": [ - "1" - ], - "eighths": [ - "1/2" - ], - "quarter_eighth": [ - "1", - "1/2" - ], - "eighth_quarter": [ - "1/2", - "1" - ] - }, - "layers": { - "bass": { - "attacks": [ - 1, - 2 + "patterns": [ + { + "id": "acoustic.charleston.statement", + "family": "charleston", + "lengths": [ + "4", + "4" ], - "families": [ - "quarters", - "eighths", - "quarter_eighth", - "eighth_quarter" + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "4", + "duration": "1", + "layer": "upper" + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "bass" + } ] }, - "upper": { - "attacks": [ - 1, - 3 + { + "id": "acoustic.charleston.answer", + "family": "charleston", + "lengths": [ + "4", + "4" ], - "families": [ - "quarters", - "eighths", - "quarter_eighth", - "eighth_quarter" + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.shifted_charleston.statement", + "family": "shifted_charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "2", + "duration": "1", + "layer": "bass" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "6", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.shifted_charleston.answer", + "family": "shifted_charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.forward_motion.statement", + "family": "forward_motion", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "7/2", + "duration": "1", + "layer": "upper", + "anticipate": true + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "7", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.forward_motion.answer", + "family": "forward_motion", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "7/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.anticipated_heavy_beats.statement", + "family": "anticipated_heavy_beats", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "3/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "7/2", + "duration": "1", + "layer": "upper", + "anticipate": true + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "15/2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.anticipated_heavy_beats.answer", + "family": "anticipated_heavy_beats", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "7/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "15/2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.syncopated_riff.statement", + "family": "syncopated_riff", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "6", + "duration": "1", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.syncopated_riff.answer", + "family": "syncopated_riff", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.quarters.statement", + "family": "quarters", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "2", + "duration": "1", + "layer": "upper" + }, + { + "onset": "4", + "duration": "1", + "layer": "upper" + }, + { + "onset": "5", + "duration": "1", + "layer": "bass" + }, + { + "onset": "7", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.quarters.answer", + "family": "quarters", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.dotted_spacing.statement", + "family": "dotted_spacing", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "9/2", + "duration": "1", + "layer": "bass" + }, + { + "onset": "6", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.dotted_spacing.answer", + "family": "dotted_spacing", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.shifting_motif.statement", + "family": "shifting_motif", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.shifting_motif.answer", + "family": "shifting_motif", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "9/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.call_response.statement", + "family": "call_response", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "2", + "duration": "1", + "layer": "upper" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "15/2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.call_response.answer", + "family": "call_response", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "15/2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.anticipated_four.statement", + "family": "anticipated_four", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "5/2", + "duration": "1", + "layer": "upper", + "anticipate": true + }, + { + "onset": "4", + "duration": "1", + "layer": "bass" + }, + { + "onset": "13/2", + "duration": "1", + "layer": "upper", + "anticipate": true + } + ] + }, + { + "id": "acoustic.anticipated_four.answer", + "family": "anticipated_four", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "bass" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.call_response.held_answer", + "family": "call_response", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1", + "layer": "bass" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "7/2", + "duration": "3", + "layer": "combined" + } + ] + }, + { + "id": "acoustic.space.breath", + "family": "space", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "4", + "duration": "1", + "layer": "bass" + }, + { + "onset": "11/2", + "duration": "1", + "layer": "upper", + "anticipate": true + } + ] + }, + { + "id": "acoustic.charleston.3_beat", + "family": "charleston", + "lengths": [ + "3", + "3" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "4", + "duration": "1", + "layer": "upper" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.quarters.3_beat", + "family": "quarters", + "lengths": [ + "3", + "3" + ], + "events": [ + { + "onset": "1", + "duration": "1", + "layer": "upper" + }, + { + "onset": "3", + "duration": "1", + "layer": "bass" + }, + { + "onset": "5", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.charleston.5_beat", + "family": "charleston", + "lengths": [ + "5", + "5" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + }, + { + "onset": "6", + "duration": "1", + "layer": "upper" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.quarters.5_beat", + "family": "quarters", + "lengths": [ + "5", + "5" + ], + "events": [ + { + "onset": "1", + "duration": "1", + "layer": "upper" + }, + { + "onset": "5", + "duration": "1", + "layer": "bass" + }, + { + "onset": "9", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.1/2.0", + "family": "partial", + "lengths": [ + "1/2" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.1/2.1", + "family": "partial", + "lengths": [ + "1/2" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.1.0", + "family": "partial", + "lengths": [ + "1" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.1.1", + "family": "partial", + "lengths": [ + "1" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.3/2.0", + "family": "partial", + "lengths": [ + "3/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.3/2.1", + "family": "partial", + "lengths": [ + "3/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.2.0", + "family": "partial", + "lengths": [ + "2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "1", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.2.1", + "family": "partial", + "lengths": [ + "2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "1", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.5/2.0", + "family": "partial", + "lengths": [ + "5/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.5/2.1", + "family": "partial", + "lengths": [ + "5/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.3.0", + "family": "partial", + "lengths": [ + "3" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.3.1", + "family": "partial", + "lengths": [ + "3" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.7/2.0", + "family": "partial", + "lengths": [ + "7/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.7/2.1", + "family": "partial", + "lengths": [ + "7/2" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.4.0", + "family": "partial", + "lengths": [ + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.4.1", + "family": "partial", + "lengths": [ + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "bass" + } + ] + }, + { + "id": "acoustic.partial.5.0", + "family": "partial", + "lengths": [ + "5" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "bass" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "upper" + } + ] + }, + { + "id": "acoustic.partial.5.1", + "family": "partial", + "lengths": [ + "5" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "upper" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "bass" + } ] } - }, - "attack_spacing": "1/2", - "allowed_durations": [ - "1/2", - "1" ], - "connect_holds": false, "voicing_families": { "provenance": "Transposable chord-degree orders for compact guitar voicings. Guide is the written third or suspension; terminal is the written seventh or sixth; color is the highest written extension. Apply actual alterations and omissions, never add tones. Both bass strings and both root/fifth bass roles are equal family options.", "triad": { @@ -255,6 +1267,899 @@ ] } } + }, + "electric": { + "patterns": [ + { + "id": "electric.charleston.statement", + "family": "charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "3/2", + "duration": "3/2", + "layer": "electric", + "anticipate": true + }, + { + "onset": "11/2", + "duration": "2", + "layer": "electric", + "anticipate": true + } + ] + }, + { + "id": "electric.charleston.answer", + "family": "charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "3/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "11/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.shifted_charleston.statement", + "family": "shifted_charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "2", + "duration": "1", + "layer": "electric" + }, + { + "onset": "6", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.shifted_charleston.answer", + "family": "shifted_charleston", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.forward_motion.statement", + "family": "forward_motion", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7/2", + "duration": "2", + "layer": "electric", + "anticipate": true + }, + { + "onset": "7", + "duration": "1", + "layer": "electric" + } + ] + }, + { + "id": "electric.forward_motion.answer", + "family": "forward_motion", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.anticipated_heavy_beats.statement", + "family": "anticipated_heavy_beats", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "3/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7/2", + "duration": "3/2", + "layer": "electric", + "anticipate": true + }, + { + "onset": "13/2", + "duration": "1", + "layer": "electric", + "anticipate": true + } + ] + }, + { + "id": "electric.anticipated_heavy_beats.answer", + "family": "anticipated_heavy_beats", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "3/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.syncopated_riff.statement", + "family": "syncopated_riff", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "4", + "duration": "2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.syncopated_riff.answer", + "family": "syncopated_riff", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.quarters.statement", + "family": "quarters", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1", + "layer": "electric" + }, + { + "onset": "3", + "duration": "1", + "layer": "electric" + }, + { + "onset": "5", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.quarters.answer", + "family": "quarters", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.dotted_spacing.statement", + "family": "dotted_spacing", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "3/2", + "layer": "electric" + }, + { + "onset": "3", + "duration": "3/2", + "layer": "electric" + }, + { + "onset": "6", + "duration": "3/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.dotted_spacing.answer", + "family": "dotted_spacing", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "3", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "6", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.shifting_motif.statement", + "family": "shifting_motif", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5/2", + "duration": "1", + "layer": "electric", + "anticipate": true + }, + { + "onset": "5", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1", + "layer": "electric", + "anticipate": true + } + ] + }, + { + "id": "electric.shifting_motif.answer", + "family": "shifting_motif", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.call_response.statement", + "family": "call_response", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.call_response.answer", + "family": "call_response", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "5", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "7", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.anticipated_four.statement", + "family": "anticipated_four", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "5/2", + "duration": "3/2", + "layer": "electric", + "anticipate": true + }, + { + "onset": "4", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "3/2", + "layer": "electric", + "anticipate": true + } + ] + }, + { + "id": "electric.anticipated_four.answer", + "family": "anticipated_four", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "5/2", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "4", + "duration": "1/2", + "layer": "electric" + }, + { + "onset": "13/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.space.breath", + "family": "space", + "lengths": [ + "4", + "4" + ], + "events": [ + { + "onset": "1/2", + "duration": "5/2", + "layer": "electric", + "anticipate": true + } + ] + }, + { + "id": "electric.charleston.3_beat", + "family": "charleston", + "lengths": [ + "3", + "3" + ], + "events": [ + { + "onset": "1/2", + "duration": "1", + "layer": "electric", + "anticipate": true + }, + { + "onset": "3", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.quarters.3_beat", + "family": "quarters", + "lengths": [ + "3", + "3" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + }, + { + "onset": "4", + "duration": "3/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.charleston.5_beat", + "family": "charleston", + "lengths": [ + "5", + "5" + ], + "events": [ + { + "onset": "1/2", + "duration": "1", + "layer": "electric", + "anticipate": true + }, + { + "onset": "5", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.quarters.5_beat", + "family": "quarters", + "lengths": [ + "5", + "5" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + }, + { + "onset": "8", + "duration": "3/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.1/2.0", + "family": "partial", + "lengths": [ + "1/2" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.1/2.1", + "family": "partial", + "lengths": [ + "1/2" + ], + "events": [ + { + "onset": "0", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.1.0", + "family": "partial", + "lengths": [ + "1" + ], + "events": [ + { + "onset": "0", + "duration": "1", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.1.1", + "family": "partial", + "lengths": [ + "1" + ], + "events": [ + { + "onset": "1/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.3/2.0", + "family": "partial", + "lengths": [ + "3/2" + ], + "events": [ + { + "onset": "0", + "duration": "3/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.3/2.1", + "family": "partial", + "lengths": [ + "3/2" + ], + "events": [ + { + "onset": "1", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.2.0", + "family": "partial", + "lengths": [ + "2" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.2.1", + "family": "partial", + "lengths": [ + "2" + ], + "events": [ + { + "onset": "3/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.5/2.0", + "family": "partial", + "lengths": [ + "5/2" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.5/2.1", + "family": "partial", + "lengths": [ + "5/2" + ], + "events": [ + { + "onset": "2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.3.0", + "family": "partial", + "lengths": [ + "3" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.3.1", + "family": "partial", + "lengths": [ + "3" + ], + "events": [ + { + "onset": "5/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.7/2.0", + "family": "partial", + "lengths": [ + "7/2" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.7/2.1", + "family": "partial", + "lengths": [ + "7/2" + ], + "events": [ + { + "onset": "3", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.4.0", + "family": "partial", + "lengths": [ + "4" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.4.1", + "family": "partial", + "lengths": [ + "4" + ], + "events": [ + { + "onset": "7/2", + "duration": "1/2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.5.0", + "family": "partial", + "lengths": [ + "5" + ], + "events": [ + { + "onset": "0", + "duration": "2", + "layer": "electric" + } + ] + }, + { + "id": "electric.partial.5.1", + "family": "partial", + "lengths": [ + "5" + ], + "events": [ + { + "onset": "9/2", + "duration": "1/2", + "layer": "electric" + } + ] + } + ], + "voicing_families": { + "provenance": "Compact inversions of the actual chord: drop-two, close position, other compact open voicings, doubled triads and three-note shells. No fixed root/key or song-specific grips.", + "rank": { + "drop-two": 0, + "close-position": 0, + "compact-open": 1, + "compact-doubled": 2, + "three-note-shell": 0 + } + } } } } diff --git a/generator/acoustic.py b/generator/acoustic.py index fcff146..dc75347 100644 --- a/generator/acoustic.py +++ b/generator/acoustic.py @@ -6,6 +6,7 @@ import itertools import generator.accompaniment import generator.model +import generator.score @dataclasses.dataclass class HarmonySpan: @@ -107,3 +108,41 @@ def combine(document: dict) -> None: a = fragments[start][-1] if start in fragments else originals[start] b = fragments[end][0] if end in fragments else originals[end] a['tie_to'], b['tie_from'] = b['id'], a['id'] + +def validate_comping(document: dict) -> None: + """Check complete sounding events, not barline or notation fragments.""" + tones, starts, cursor = {}, {}, fractions.Fraction() + for measure in document['staves'][0]['measures']: + for note in generator.score.measure_notes(measure): + for pitch in note['pitches']: + tones[pitch['id']] = pitch + starts[pitch['id']] = cursor + generator.model.fraction(note['onset']) + cursor += generator.model.fraction(measure['duration']) + groups = {} + for identifier, pitch in tones.items(): + if pitch.get('tie_from') in tones: + continue + start, tail = starts[identifier], pitch + seen = {identifier} + while tail.get('tie_to') in tones: + target = tail['tie_to'] + if target in seen: + raise ValueError('cyclic acoustic sustain') + seen.add(target) + tail = tones[target] + end = starts[tail['id']] + generator.model.fraction(tail['duration']) + string = pitch.get('tablature', {}).get('string') + if string is None: + raise ValueError('acoustic comping requires placed notes') + groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper') + events = [] + for (start, end), lanes in groups.items(): + if len(lanes) == 2: + if end - start <= 1: + raise ValueError('combined acoustic sustain must exceed one beat') + elif end - start not in (fractions.Fraction(1, 2), 1): + raise ValueError('separate acoustic comping requires quarter/eighth notes') + events.append((start, end)) + events.sort() + if any(a[1] > b[0] for a, b in zip(events, events[1:])): + raise ValueError('acoustic bass and upper must release together or remain separate') diff --git a/generator/arrange/accompaniment.py b/generator/arrange/accompaniment.py index e19053e..79af523 100644 --- a/generator/arrange/accompaniment.py +++ b/generator/arrange/accompaniment.py @@ -50,7 +50,15 @@ def audit(source, documents): exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'}) continue position = section.source_measure_index + j - key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j]))) + # A complete figure depends on both measures' effective harmony and + # source-defined participation. Changed final turnarounds/pickups are + # explicit contexts, not incidental changes between iterations. + first = generator.score.body_start_measure_index(authored) + block_start = first + 2 * ((position - first) // 2) + neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2)) + context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors) + boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else '' + key = (position, context, boundary) signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']), tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches'])) for v in m['voices'] for n in v['notes'] if n['pitches']) @@ -60,7 +68,7 @@ def audit(source, documents): reused += 1 else: if any(k[0] == key[0] for k in seen): - exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'}) + exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'}) seen[key] = signature offset += section.measure_count result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions} diff --git a/generator/arrange/ending.py b/generator/arrange/ending.py new file mode 100644 index 0000000..b24aa3e --- /dev/null +++ b/generator/arrange/ending.py @@ -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:], []) diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 3ca2c8a..80e82ef 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -8,6 +8,7 @@ import generator.accompaniment import generator.arrange.accompaniment import generator.acoustic import generator.acoustic_voicing +import generator.arrange.ending import generator.arrange.form import generator.arrange.pickup import generator.electric @@ -61,10 +62,20 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None) previous = generator.accompaniment.carried_chords(section_measures) segment = [] + stop = None + next_section = sections[index + 1] if index + 1 < len(sections) else None + if next_section and generator.score.body_start_measure_index(source): + last = section.source_measure_index + section.measure_count - 1 + if next_section.role == 'slash': + stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration'])) + elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source): + attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']] + if attacks: + stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks)) for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): m = section_measures[si] if role == 'accompaniment' and accompaniment is not None: - result = accompaniment(m, previous[si], si) + result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop) result['id'] = f'measure_{len(output) + len(segment) + 1}' result['section_label'] = section.label if not segment else '' else: @@ -97,6 +108,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)}) output.extend(segment) absolute += segment_length + if sections and sections[-1].section_index == FINAL_SECTION_INDEX: + generator.arrange.ending.append(source, output, part_type, + sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned), + center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55)) tuplets = generator.arrange.pickup.retain_links(output, tuplets) result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result['metadata'].setdefault('tempo', 124) @@ -104,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None, if accompaniment is None and (owned is None or 'accompaniment' in owned): generator.acoustic.prepare(result) generator.acoustic_voicing.place(result) + generator.acoustic.validate_comping(result) generator.acoustic.combine(result) elif part_type == 'electric' and (owned is None or 'melody' in owned): generator.melody.harmonize(result) diff --git a/generator/comping.py b/generator/comping.py index 9fca0e6..da4ccdd 100644 --- a/generator/comping.py +++ b/generator/comping.py @@ -135,91 +135,157 @@ class HarmonyChoice: def key(self): return (self.index, self.start, self.end, semantic_key(self.chord), self.events) +def split_measures(flat, inputs, part): + """Project a selected sounding figure into notation; barlines create ties only.""" + output, fragments, original = [], {}, {} + cursor = fractions.Fraction() + for m, prior, index in inputs: + length = generator.model.fraction(m['duration']) + result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior) + voices = [] + for voice in flat['voices']: + notes = [] + for n in voice['notes']: + start = generator.model.fraction(n['onset']) + end = start + generator.model.fraction(n['duration']) + a, b = max(cursor, start), min(cursor + length, end) + if a >= b: + continue + for duration, written in generator.accompaniment.written_durations(b - a): + pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']] + event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written) + event['voice'] = voice['id'] + for old, new in zip(n['pitches'], event['pitches']): + fragments.setdefault(old['id'], []).append(new) + original[old['id']] = old + notes.append(event) + a += duration + voices.append({'id': voice['id'], 'notes': notes}) + result['voices'] = voices + for chord in result['harmonies']: + chord.pop('chord_optional', None) + output.append(result) + cursor += length + for pieces in fragments.values(): + for a, b in zip(pieces, pieces[1:]): + a['tie_to'], b['tie_from'] = b['id'], a['id'] + for identifier, old in original.items(): + if old.get('tie_to') in fragments: + a, b = fragments[identifier][-1], fragments[old['tie_to']][0] + a['tie_to'], b['tie_from'] = b['id'], a['id'] + return tuple(output) + class Canonical: def __init__(self, document, part, seed): self.part, self.seed = part, seed self.policy = generator.vocabulary.role(part + '_accompaniment') self.cache, self._reports, self._rhythm_reports = {}, {}, {} - measures = generator.score.source_staff(document)['measures'] + self.measures = generator.score.source_staff(document)['measures'] self.melody = generator.comping_rhythm.melody_contexts(document) - first_index = generator.score.body_start_measure_index(measures) - sections = generator.arrange.form.full_arrangement_sections(measures, part) - self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures) - and b.source_measure_index == first_index for a, b in zip(sections, sections[1:])) - carried = generator.accompaniment.carried_chords(measures) - inputs = [] - for index in range(first_index, len(measures)): - source = copy.deepcopy(measures[index]) - for chord in source['harmonies']: - chord.pop('chord_optional', None) - inputs.append((source, carried[index], index)) - layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs) - rhythms = generator.comping_rhythm.select(layers, seed) - self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)} - prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)] - active = tuple(s for _, _, _, spans in prepared for s in spans if s.events) + self.first = generator.score.body_start_measure_index(self.measures) + sections = generator.arrange.form.full_arrangement_sections(self.measures, part) + self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures) + and b.source_measure_index == self.first for a, b in zip(sections, sections[1:])) + carried = generator.accompaniment.carried_chords(self.measures) + self.groups = [] + index = self.first + while index < len(self.measures): + size = 1 + if index + 1 < len(self.measures): + lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]] + if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']): + size = 2 + self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size))) + index += size + layers = [self.candidates(group) for group in self.groups] + selected = generator.comping_rhythm.select(layers, seed) + self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)} + prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)] + active = tuple(span for spans in prepared for span in spans if span.events) path, self.path_cost = self.select(active, close=self.closed) self.base = dict(zip((s.key for s in active), path)) self.order = tuple((s.index, s.start, v) for s, v in zip(active, path)) + self.rhythms = {} previous = path[-1] if self.closed and path else () - for source, prior, index, spans in prepared: - selected = tuple(self.base[s.key] for s in spans if s.events) - self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False) - if selected: - previous = selected[-1] - if generator.vocabulary.accompaniment(part)['connect_holds']: - self.connect_holds(inputs) + for group, rhythm, spans in zip(self.groups, selected, prepared): + grips = tuple(self.base[s.key] for s in spans if s.events) + self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False) + cursor = fractions.Fraction() + for m, _, i in group: + length = generator.model.fraction(m['duration']) + self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor)) + cursor += length + if grips: + previous = grips[-1] @property def report(self): - return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]] + return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]] @property def rhythm_report(self): - return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))] + return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]] @staticmethod - def key(source, prior, index): - return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)), - source['duration'].__repr__()) - def prepare(self, source, prior, index, rhythm): + def key(inputs, limit=None): + return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit)) + def candidates(self, inputs, limit=None): + flat = generator.comping_rhythm.flatten(inputs) + melody, cursor, lengths = [], fractions.Fraction(), [] + for m, _, i in inputs: + melody.extend((a + cursor, b + cursor) for a, b in self.melody[i]) + lengths.append(generator.model.fraction(m['duration'])) + cursor += lengths[-1] + return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit) + def prepare(self, inputs, rhythm): + flat = generator.comping_rhythm.flatten(inputs) spans = [] - for start, end, chord in generator.comping_rhythm.spans(source, prior): + offsets, cursor = [], fractions.Fraction() + for m, _, i in inputs: + offsets.append((cursor, i)) + cursor += generator.model.fraction(m['duration']) + for start, end, chord in generator.comping_rhythm.spans(flat, None): if not chord: continue - events = tuple(e for e in rhythm.events if start <= e.start < end) + offset, index = next((a, i) for a, i in reversed(offsets) if a <= start) + events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end) try: options = voicings(semantic_key(chord), self.part) except ValueError as error: - raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error + raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options)) - self._rhythm_reports[self.key(source, prior, index)] = { - 'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]], - 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions, - 'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events), - 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families}, - 'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))} return tuple(spans) - def select(self, spans, *, left=(), right=(), close=False, fixed=None): - layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans) - costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} - for s, layer in zip(spans, layers)) + def select(self, spans, *, left=(), right=(), close=False): + layers = tuple(s.options for s in spans) + costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers)) return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close) - def __call__(self, source, prior, index): - key = self.key(source, prior, index) + def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None): + group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),)) + sources = sources or self.measures + carried = generator.accompaniment.carried_chords(sources) + low, high = bounds or (self.first, len(sources)) + group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high) + limit = None + if stop and any(i == stop[0] for _, _, i in group): + limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1] + key = self.key(group, limit) if key not in self.cache: - # Effective pickup/turnaround harmony may differ from the main form. - # Solve that measure between fixed canonical neighbors. Request order - # and previously generated arrangement sections cannot change it. - choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index)) - rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0] - spans = self.prepare(source, prior, index, rhythm) - before = [v for i, _, v in self.order if i < index] - after = [v for i, _, v in self.order if i > index] + choices = self.candidates(group, limit) + start, end = group[0][2], group[-1][2] + # Preserve the canonical whole figure where still legal under changed + # effective harmony. Recompute only genuinely different source context. + original = self.block_rhythms.get(start) + matches = [r for r in choices if original and r.pattern_id == original.pattern_id] + rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0] + spans = self.prepare(group, rhythm) + before = [v for i, _, v in self.order if i < start] + after = [v for i, _, v in self.order if i > end] left = before[-1] if before else self.order[-1][2] if self.closed and self.order else () right = after[0] if after else self.order[0][2] if self.closed and self.order else () - selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base) - self.cache[key] = self.render(source, prior, index, spans, selected, left, True) - return copy.deepcopy(self.cache[key]) - def render(self, source, prior, index, spans, selected, previous, variant): - length = generator.model.fraction(source['duration']) + selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right) + self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit) + return copy.deepcopy(self.cache[key][index - group[0][2]]) + def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None): + flat = generator.comping_rhythm.flatten(inputs) + length = generator.model.fraction(flat['duration']) + index = inputs[0][2] attacks, rows = [], [] choices = iter(selected) for span in spans: @@ -227,8 +293,8 @@ class Canonical: bass = grip[0] if grip and self.part == 'acoustic' else None degrees = generator.harmony.degrees(span.chord) root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter'])) - rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), - 'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, + rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), + 'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, 'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None, 'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part), 'fret_span': generator.fretboard.fret_span(grip), @@ -239,66 +305,46 @@ class Canonical: 'bass_string': bass.string_number if bass else None, 'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)}) for event in span.events: - positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip - attacks.append((event.lane, event.start, event.end, positions, span.chord)) + lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane] + for lane in lanes: + positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip + attacks.append((lane, event.start, event.end, positions, span.chord)) if grip: previous = grip - self._reports[self.key(source, prior, index)] = rows + key = self.key(inputs, limit) + self._reports[key] = rows voices = [] - notes, cursor, lane = [], fractions.Fraction(), '' - def emit(a, b, positions=(), chord=None): - nonlocal cursor - previous = None - for duration, written in generator.accompaniment.written_durations(b - a): - pitches = generator.fretboard.chord_pitches(chord, positions) if positions else [] - n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written) - n['voice'] = lane - if self.part == 'acoustic': - n['stem'] = 'down' if lane == 'bass' else 'up' - if previous and pitches: - for old, new in zip(previous['pitches'], n['pitches']): - old['tie_to'], new['tie_from'] = new['id'], old['id'] - notes.append(n) - previous = n - a += duration - cursor = b for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']: notes, cursor = [], fractions.Fraction() + 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]): if cursor < start: emit(cursor, start) emit(start, end, positions, chord) + cursor = end if cursor < length: emit(cursor, length) voices.append({'id': lane, 'notes': notes}) - result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior) - result['voices'] = voices + result = split_measures({'voices': voices}, inputs, self.part) + reports, cursor = [], fractions.Fraction() + for m, _, i in inputs: + duration = generator.model.fraction(m['duration']) + reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index, + 'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, + 'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]], + 'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)), + 'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]}) + cursor += duration + self._rhythm_reports[key] = reports return result - def connect_holds(self, inputs): - """Only explicit sustained neighbors can share an attack across a barline. - - These are canonical internal joins, never links across role boundaries. - The direct reattack remains when a grip or required attack count differs. - """ - for left, right in zip(inputs, inputs[1:]): - before = self.cache[self.key(*left)] - after = self.cache[self.key(*right)] - previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1]) - next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1]) - if not generator.accompaniment.same_chord(previous_chord, next_chord): - continue - for a, b in zip(before['voices'], after['voices']): - if a['id'] == 'bass': - continue - old, new = a['notes'][-1], b['notes'][0] - minimum = 1 if a['id'] == 'upper' else 0 - attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes']) - if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1: - continue - if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]: - continue - for p, q in zip(old['pitches'], new['pitches']): - p['tie_to'], q['tie_from'] = q['id'], p['id'] - row = self._rhythm_reports[self.key(*right)]['layers'][a['id']] - row['attacks'] -= 1 - row['tied_continuations'] += 1 diff --git a/generator/comping_rhythm.py b/generator/comping_rhythm.py index 3a795fa..99cbd4b 100644 --- a/generator/comping_rhythm.py +++ b/generator/comping_rhythm.py @@ -1,7 +1,6 @@ -"""Bounded, source-only measure rhythms; acoustic layers are selected jointly.""" +"""Complete, source-only comping figures. Time is in rational quarter notes.""" import dataclasses import fractions -import itertools import generator.accompaniment import generator.model @@ -14,6 +13,7 @@ class Attack: lane: str start: fractions.Fraction end: fractions.Fraction + harmonic_time: fractions.Fraction | None = None @dataclasses.dataclass(frozen=True) class Rhythm: @@ -21,12 +21,13 @@ class Rhythm: families: tuple contributions: tuple exceptions: tuple = () + pattern_id: str = '' @property def cost(self): return sum(value for _, value in self.contributions) @property def signature(self): - return tuple((e.lane, str(e.start), str(e.end - e.start)) for e in self.events) + return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events) def melody_contexts(document): melody = generator.solo.analysis.attacks(document) @@ -49,151 +50,136 @@ def spans(measure, prior): result.append((a, b, chord)) return tuple(result) -def lane_score(events, lane, melody, harmony, length): - """Named source-context preferences, independent of the deterministic seed.""" - attacks = [a for a, b in melody if a >= 0] - occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction()) - space = max(0, length - occupied) - busy = len(attacks) >= length - if lane == 'bass': - target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony)) - elif lane == 'upper': - target = 2.5 if space >= length / 2 else 1.5 if busy else 2 - else: - target = .5 if busy else 1.5 if space >= length / 2 else 1 - # A half-attack tolerance makes neighboring densities genuine alternatives. - # The objective does not always fill up to a layer's permitted maximum. - activity = .35 * max(0, abs(len(events) - target) - .5) - support, articulation = 0., 0. - for e in events: - # Supporting an entrance is different from doubling every moving note. - entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody)) - moving = any(a <= e.start < b and b - a <= 1 for a, b in melody) - gap = not any(a <= e.start < b for a, b in melody) - if lane != 'bass': - support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0 - articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0 - elif e.start and e.start.denominator != 1: - support += .15 - divisor = max(1, len(events)) - return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor)) +def flatten(inputs): + """Harmony/timing adapter only; never concatenate separately chosen rhythms.""" + first = inputs[0][0] + result = {**first, 'harmonies': []} + cursor = fractions.Fraction() + for m, prior, _ in inputs: + chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior) + initial = chord or {'kind': 'none'} + result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)}) + result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset'])) + cursor += generator.model.fraction(m['duration']) + result['duration'] = generator.model.rational(cursor) + return result + +def validate_events(events, part): + ordered = sorted(events, key=lambda e: e.start) + for e in ordered: + duration = e.end - e.start + if duration <= 0: + raise ValueError('comping event must have positive sounding duration') + if part == 'acoustic': + if e.lane == 'combined': + if duration <= 1: + raise ValueError('combined acoustic sustain must exceed one beat') + elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1): + raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes') + elif e.lane != 'electric': + raise ValueError('electric comping requires chord events') + if any(a.end > b.start for a, b in zip(ordered, ordered[1:])): + raise ValueError('separate comping events cannot overlap; use one combined sustain') + +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): - """Keep counts, placements and duration families before the final cutoff.""" unique = {} - for candidate in candidates: - key = candidate.signature - rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key)) - if key not in unique or rank < unique[key][0]: - unique[key] = rank, candidate - ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])] - groups = {} - shapes = set() + for c in candidates: + rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature))) + if c.signature not in unique or rank < unique[c.signature][0]: + unique[c.signature] = rank, c + ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])] + retained, families = [], set() for c in ordered: - counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families) - shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events)) - # Round-robin attack counts first. Otherwise many cosmetic variants of - # the cheapest density can exclude every one- or three-attack idea. - if shape not in shapes: - groups.setdefault(counts, []).append(c) - shapes.add(shape) - result = [] - while groups and len(result) < limit: - for key in list(groups): - result.append(groups[key].pop(0)) - if not groups[key]: - del groups[key] - if len(result) == limit: - break - return tuple(result) - -def lane_candidates(measure, prior, lane, melody, policy, seed): - length = generator.model.fraction(measure['duration']) - harmony = spans(measure, prior) - active = [(a, b, c) for a, b, c in harmony if c] - if not active: - return Rhythm((), ((lane, 'space'),), (('activity', 0.),)), - low, high = policy['layers'][lane]['attacks'] - # Genuinely different harmony gets a gesture, not its own density budget. - required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)] - if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required): - required.insert(0, active[0][:2]) - if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior): - required = [] - # With very fast harmonic rhythm, establish the edges and longest spans. - # Short intervening chords may be silent; no incompatible sustain is kept. - if len(required) > high + 1: - edges = [required[0], required[-1]] - required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1]) - extra = int(len(required) > high) - grid = fractions.Fraction(policy['grid']) - positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length} - positions.update(a for a, _, _ in active) - positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active)) - result = [] - for count in range(low, high + extra + 1): - for onsets in itertools.combinations(positions, count): - if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0): - continue - if any(not any(a <= t < b for t in onsets) for a, b in required): - continue - if any(b - a < fractions.Fraction(policy['attack_spacing']) for a, b in zip(onsets, onsets[1:])): - continue - for family in policy['layers'][lane]['families']: - durations = policy['families'][family] - events = [] - for i, a in enumerate(onsets): - end = next(b for start, b, c in active if start <= a < b) - following = onsets[i + 1] if i + 1 < len(onsets) else length - duration = fractions.Fraction(durations[i % len(durations)]) - events.append(Attack(lane, a, min(a + duration, following, end))) - if policy.get('allowed_durations') and any(e.end - e.start not in tuple(map(fractions.Fraction, policy['allowed_durations'])) for e in events): - continue - exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else () - contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions))) - result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions)) - if not result: - # A short partial bar can have fewer gestures, not invented subdivisions. - if length < 1 and not policy.get('allowed_durations'): - a, b, _ = active[0] - return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)), - raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy') - return representatives(result, policy['lane_candidates'], seed + '/' + lane) - -def crowding(events): - times = sorted({e.start for e in events}) - cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:])) - # Three successive half-beat changes are a busy figure even though each - # layer is sparse. Coincident attacks count as one hand action. - return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:])) - -def candidates(measure, prior, part, melody, seed): - policy = generator.vocabulary.accompaniment(part) - if part == 'electric': - return lane_candidates(measure, prior, 'electric', melody, policy, seed) - bass = lane_candidates(measure, prior, 'bass', melody, policy, seed) - upper = lane_candidates(measure, prior, 'upper', melody, policy, seed) - result = [] - for a, b in itertools.product(bass, upper): - costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions) - result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions))) - return representatives(result, policy['measure_candidates'], seed) + if c.families not in families: + retained.append(c) + families.add(c.families) + retained.extend(c for c in ordered if c not in retained) + return tuple(retained[:limit]) def transition(previous, current): if previous is None: return 0. - # Recurrence is allowed; penalize repeating an entire bar more than sharing - # a duration family. This stays separate from the source-context objective. - same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families) - return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts + return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families) def select(layers, seed, left=None, right=None): - """One state per concrete bar rhythm; compare complete equal-span paths.""" + """Compare complete two-bar paths, not independently chosen bar fragments.""" states = [(0., (), (), left)] for i, layer in enumerate(layers): following = [] for c in layer: - options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.signature))), (*path, c), c) for cost, ties, path, previous in states] + options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.pattern_id, c.signature))), (*path, c), c) for cost, ties, path, previous in states] following.append(min(options, key=lambda item: item[:2])) states = following return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2] diff --git a/generator/performance.py b/generator/performance.py index 80c05c9..02d50c0 100644 --- a/generator/performance.py +++ b/generator/performance.py @@ -43,9 +43,15 @@ def validate_pages(document, pages): def timing(document): times = boundaries(document) measures = document['staves'][0]['measures'] - # Metronome accents follow each actual measure, including meter changes. + ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')] + if ending and ending != list(range(ending[0], len(measures))): + raise ValueError('held ending must be a contiguous terminal section') + sustain_start = times[ending[0]] if ending else times[-1] + # The written held ending participates in page time, not metronome time. clicks = [] for start, measure in zip(times, measures): + if start >= sustain_start: + break duration = generator.model.fraction(measure['duration']) beat = fractions.Fraction() while beat < duration: @@ -53,7 +59,7 @@ def timing(document): beat += 1 return dict(song_id=document['metadata'].get('song_id', ''), duration=generator.model.rational(times[-1]), - count_in_beats=4, clicks=clicks) + sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks) def describe_wav(path, root, *, song_id='', bpm=0): if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()): diff --git a/generator/production.py b/generator/production.py index ccdddcd..8e3c158 100644 --- a/generator/production.py +++ b/generator/production.py @@ -26,6 +26,7 @@ import generator.parts import generator.score import generator.vocabulary import generator.arrange.accompaniment +import generator.arrange.ending import generator.arrange.form import generator.arrange.generate import generator.arrange.pickup @@ -94,6 +95,7 @@ def windows(document, part): result[-1] = (a, start, old) result.append((start, end, part + '_' + role)) cursor = end + result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest'))) return tuple(result) def dependencies(role, resources): @@ -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'}) allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role) _mask(result, allowed) + if role == "acoustic_accompaniment": + generator.acoustic.validate_comping(result) staff = result['staves'][0] for measure in staff['measures']: measure['id'] = role + '_' + measure['id'] diff --git a/generator/vocabulary.py b/generator/vocabulary.py index cd00bab..c137bdf 100644 --- a/generator/vocabulary.py +++ b/generator/vocabulary.py @@ -23,25 +23,44 @@ def _load(contents): result = {} for name, text in contents: value = json.loads(text) - if value.get('version') != (3 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'): + if value.get('version') != (4 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'): raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary') if name == 'accompaniment': - if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2): - raise ValueError('accompaniment requires an eighth-note placement grid') - for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))): + signatures = {} + for part in ('electric', 'acoustic'): policy = value['instruments'][part] - if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2): - raise ValueError('accompaniment attacks must be at least an eighth apart') - for lane in lanes: - layer = policy['layers'][lane] - if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']: - raise ValueError('invalid accompaniment attack range or duration families') - for family in layer['families']: - durations = policy['families'][family] - if not durations or any(fractions.Fraction(d) <= 0 for d in durations): - raise ValueError('accompaniment durations must be positive') - if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations): - raise ValueError('acoustic rhythm uses quarter and eighth notes') + identifiers = set() + signatures[part] = set() + for pattern in policy['patterns']: + if pattern['id'] in identifiers or not pattern['family']: + raise ValueError('duplicate or unnamed accompaniment pattern') + identifiers.add(pattern['id']) + lengths = tuple(map(fractions.Fraction, pattern['lengths'])) + if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths): + raise ValueError('patterns require one or two positive measure lengths') + # Validate musical events before any measure projection or ties. + previous = fractions.Fraction() + onsets = [] + for row in pattern['events']: + onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration']) + if onset < previous or duration <= 0 or onset + duration > sum(lengths): + raise ValueError('overlapping or out-of-bounds accompaniment pattern') + if onset.denominator > 2 or duration.denominator > 2: + raise ValueError('comping vocabulary uses a quarter/eighth grid') + if part == 'acoustic': + if row['layer'] == 'combined': + if duration <= 1: + raise ValueError('combined acoustic sustain must exceed one beat') + elif row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1): + raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes') + elif row['layer'] != 'electric': + raise ValueError('electric pattern must contain electric chord attacks') + if row.get('anticipate') and row['layer'] not in ('upper', 'electric'): + raise ValueError('only upper/electric chords can anticipate') + previous = onset + duration + onsets.append(onset) + if len(lengths) == 2: + signatures[part].add((lengths, tuple(onsets))) families = policy['voicing_families'] if not families.get('provenance'): raise ValueError('guitar voicing families require provenance') @@ -49,16 +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()): raise ValueError('invalid electric family ranking') else: - if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']: - raise ValueError('acoustic rhythm must retain short quarter/eighth attacks') for kind in ('triad', 'tetrad', 'extended'): if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}: raise ValueError('acoustic families require root/fifth bass on both bass strings') for orders in families[kind].values(): if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders): raise ValueError('invalid acoustic upper chord order') - if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64: - raise ValueError('accompaniment candidate budgets must be 1–64') + if signatures['acoustic'] & signatures['electric']: + raise ValueError('acoustic/electric two-bar onset patterns must be distinct') + if not 1 <= value['pattern_candidates'] <= 64: + raise ValueError('accompaniment pattern budget must be 1–64') entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', [])))) if len({v['id'] for v in entries}) != len(entries): raise ValueError(f'{name}: duplicate vocabulary ID') diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index f4c36ef..ca524d8 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -39,6 +39,7 @@ struct score_document { std::vector pages{}; std::string song_id{}; vamp_zero::beat_position duration{}; + vamp_zero::beat_position sustain_start{}; std::uint32_t count_in_beats{4}; }; diff --git a/src/vamp_zero/notation.cpp b/src/vamp_zero/notation.cpp index 8ca7592..c37ee20 100644 --- a/src/vamp_zero/notation.cpp +++ b/src/vamp_zero/notation.cpp @@ -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"); } document.duration = *duration; + auto sustain = performance.if_contains("sustain_start") ? vamp_zero::parse_beat(performance.at("sustain_start")) : duration; + if (!sustain || std::find(times.begin(), times.end(), rational(*sustain)) == times.end()) { + return invalid("invalid held-ending boundary"); + } + document.sustain_start = *sustain; + for (auto i = std::size_t{}; i < measures->size(); ++i) { + auto marker = (*measures)[i].as_object().if_contains("ending_sustain"); + auto ending = marker && marker->is_bool() && marker->as_bool(); + if (ending != (times[i] >= rational(*sustain))) { + return invalid("held-ending measures disagree with performance timing"); + } + } auto const &clicks = performance.at("clicks").as_array(); auto click = std::size_t{}; - for (auto measure = std::size_t{}; measure + 1 < times.size(); ++measure) { + for (auto measure = std::size_t{}; measure + 1 < times.size() && times[measure] < rational(*sustain); ++measure) { for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) { if (click >= clicks.size()) { return invalid("missing metronome beat"); diff --git a/src/vamp_zero/performance.cpp b/src/vamp_zero/performance.cpp index 798c719..a328efb 100644 --- a/src/vamp_zero/performance.cpp +++ b/src/vamp_zero/performance.cpp @@ -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) { ++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 { diff --git a/tests/arrangement_fixtures.py b/tests/arrangement_fixtures.py index 27422a1..4bae0c3 100644 --- a/tests/arrangement_fixtures.py +++ b/tests/arrangement_fixtures.py @@ -12,3 +12,7 @@ def section_document(document, part_type): with unittest.mock.patch('generator.arrange.form.full_arrangement_sections', return_value=[section]), generator.vocabulary.using(generator.vocabulary.load(), part_type + '_accompaniment'): backing = generator.comping.Canonical(prepared, part_type, 'fixture') if part_type == 'acoustic' else None return generator.arrange.generate._arrange_document(prepared, part_type, accompaniment=backing) + +def melody_performance(document, part_type): + """Isolate unchanged source melody from accompaniment/ending construction.""" + return generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part_type, owned={'melody', 'vocal'}) diff --git a/tests/fixtures/performance_summary.json b/tests/fixtures/performance_summary.json index 6ede2e7..061ef8f 100644 --- a/tests/fixtures/performance_summary.json +++ b/tests/fixtures/performance_summary.json @@ -4,7 +4,7 @@ "composer": "", "key_signature": "G", "part_type": "acoustic", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -29,6 +29,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -1831,6 +1835,32 @@ "harmonies": [], "duration": "4", "slash_count": 0 + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "GM6" + } + ], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 } ] }, @@ -1839,7 +1869,7 @@ "composer": "", "key_signature": "G", "part_type": "electric", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -1864,6 +1894,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -3666,6 +3700,32 @@ "harmonies": [], "duration": "4", "slash_count": 0 + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "GM6" + } + ], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 } ] }, @@ -3674,7 +3734,7 @@ "composer": "", "key_signature": "G", "part_type": "vocal", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -3699,6 +3759,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -6113,6 +6177,41 @@ "0+2 half note G4 ", "2+2 half note G4 " ] + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "GM6" + } + ], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] } ] }, @@ -6121,7 +6220,7 @@ "composer": "", "key_signature": "D minor", "part_type": "acoustic", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -6146,6 +6245,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -8082,6 +8185,32 @@ "harmonies": [], "duration": "4", "slash_count": 0 + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "Dm" + } + ], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 } ] }, @@ -8090,7 +8219,7 @@ "composer": "", "key_signature": "D minor", "part_type": "electric", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -8115,6 +8244,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -10051,6 +10184,32 @@ "harmonies": [], "duration": "4", "slash_count": 0 + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "Dm" + } + ], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0 } ] }, @@ -10059,7 +10218,7 @@ "composer": "", "key_signature": "D minor", "part_type": "vocal", - "measure_count": 168, + "measure_count": 171, "section_labels": [ { "measure": 1, @@ -10084,6 +10243,10 @@ { "measure": 137, "label": "Vocal Melody" + }, + { + "measure": 169, + "label": "Ending" } ], "measures": [ @@ -12628,6 +12791,41 @@ "vocal_skeleton": [ "0+4 whole rest " ] + }, + { + "measure": 169, + "section_label": "Ending", + "harmonies": [ + { + "onset": "0", + "symbol": "Dm" + } + ], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] + }, + { + "measure": 170, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] + }, + { + "measure": 171, + "section_label": "", + "harmonies": [], + "duration": "4", + "slash_count": 0, + "vocal_skeleton": [ + "0+4 whole rest " + ] } ] } diff --git a/tests/test_arrangement_ending.py b/tests/test_arrangement_ending.py new file mode 100644 index 0000000..23258d1 --- /dev/null +++ b/tests/test_arrangement_ending.py @@ -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() diff --git a/tests/test_arrangement_pickup.py b/tests/test_arrangement_pickup.py index 8c7a3e0..407e191 100644 --- a/tests/test_arrangement_pickup.py +++ b/tests/test_arrangement_pickup.py @@ -31,10 +31,10 @@ class ArrangementPickupTests(unittest.TestCase): result = generator.arrange.generate.arrange_document(document, part) generator.model.validate_document(result) measures = result['staves'][0]['measures'] - self.assertEqual(len(measures), 168) - self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 169)]) - self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137']) - self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 672) + self.assertEqual(len(measures), 171) + self.assertEqual([m['number'] for m in measures], [str(i) for i in range(1, 172)]) + self.assertEqual([m['number'] for m in measures if m.get('section_label')], ['1', '9', '41', '73', '105', '137', '169']) + self.assertEqual(sum(generator.model.fraction(m['duration']) for m in measures), 684) timelines.append([m['duration'] for m in measures]) if path.stem == 'autumn_leaves' and part in ('vocal', 'electric'): for boundary in ([8, 136] if part == 'vocal' else [40]): @@ -49,17 +49,17 @@ class ArrangementPickupTests(unittest.TestCase): opening = '1quarter' + tests.test_musicxml.xml_note(duration=3, kind='half', extra='') document = source(opening, implicit=False) result = generator.arrange.generate.arrange_document(document, 'vocal') - self.assertEqual(len(result['staves'][0]['measures']), 12) + self.assertEqual(len(result['staves'][0]['measures']), 15) self.assertEqual(generator.score.body_start_measure_index(document['staves'][0]['measures']), 0) def test_short_pickup_ties_and_octave_spans_survive_each_melody_entry(self): document = source(tests.test_musicxml.xml_note(duration=1, kind='quarter', extra=''), tests.test_musicxml.xml_note(extra='')) document['staves'][0]['octave_shifts'] = [{'id': 'pickup-shift', 'start': generator.model.rational(0), 'end': generator.model.rational(1), 'octaves': -1}] for part, boundaries in (('vocal', (1, 5)), ('electric', (2,))): with self.subTest(part=part): - result = generator.arrange.generate.arrange_document(document, part) + result = generator.arrange.generate._arrange_document(generator.parts.prepare_document(document), part, owned={'melody', 'vocal'}) generator.model.validate_document(result) measures = result['staves'][0]['measures'] - self.assertEqual(len(measures), 6) + self.assertEqual(len(measures), 9) for boundary in boundaries: pickup = generator.score.measure_notes(measures[boundary - 1])[-1] downbeat = generator.score.measure_notes(measures[boundary])[0] diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 556a08a..738e24e 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -15,7 +15,7 @@ class CatalogTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: source, output = pathlib.Path(temporary) / 'songs', pathlib.Path(temporary) / 'output' source.mkdir() - (source / 'song.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '-3')) + (source / 'song.xml').write_text(tests.test_musicxml.xml_score('E-1major' + tests.test_musicxml.xml_note('E', alter=-1), '-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'}) for path in output.iterdir(): diff --git a/tests/test_chord_families.py b/tests/test_chord_families.py index f93f02d..b640378 100644 --- a/tests/test_chord_families.py +++ b/tests/test_chord_families.py @@ -87,7 +87,7 @@ class ChordFamilies(unittest.TestCase): def test_acoustic_policy_change_does_not_reseed_electric_roles(self): resources = generator.vocabulary.load() changed = copy.deepcopy(resources) - changed['accompaniment']['instruments']['acoustic']['families']['quarters'] = ['1/2'] + changed['accompaniment']['instruments']['acoustic']['patterns'][0]['events'][0]['duration'] = '1/2' for role in ('electric_accompaniment', 'electric_melody', 'electric_solo', 'vocal_melody'): self.assertEqual(generator.production.dependencies(role, resources), generator.production.dependencies(role, changed)) self.assertNotEqual(generator.production.dependencies('acoustic_accompaniment', resources), generator.production.dependencies('acoustic_accompaniment', changed)) @@ -142,16 +142,15 @@ class CombinedAcoustic(unittest.TestCase): m = source['staves'][0]['measures'][0] choices = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'quarters-eighths') self.assertTrue(all(e.end - e.start in (fractions.Fraction(1, 2), 1) for c in choices for e in c.events)) - policy = generator.vocabulary.accompaniment('acoustic') - upper = generator.comping_rhythm.lane_candidates(m, None, 'upper', (), policy, 'pairs') - self.assertTrue(any(b.start - a.start == fractions.Fraction(1, 2) for c in upper for a, b in zip(c.events, c.events[1:]))) canonical = generator.comping.Canonical(source, 'acoustic', 'short') - self.assertFalse(any(l['tied_continuations'] for row in canonical.rhythm_report for l in row['layers'].values())) + for row in canonical.rhythm_report: + for event in row['events']: + self.assertGreater(fractions.Fraction(event['duration']), 0) def test_unknown_harmony_subdivision_is_not_silently_quantized(self): source = tests.solo_fixtures.song(count=1, chords=('C7',)) m = source['staves'][0]['measures'][0] m['harmonies'].append({**generator.harmony.parse_symbol('FM7'), 'id': 'change', 'onset': generator.model.rational(fractions.Fraction(1, 3))}) - with self.assertRaisesRegex(ValueError, 'no bass rhythm'): + with self.assertRaisesRegex(ValueError, 'no acoustic pattern'): generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact') if __name__ == '__main__': diff --git a/tests/test_comping_rhythm.py b/tests/test_comping_rhythm.py index 01a0c79..4b14833 100644 --- a/tests/test_comping_rhythm.py +++ b/tests/test_comping_rhythm.py @@ -6,6 +6,7 @@ import pathlib import unittest import generator.accompaniment +import generator.acoustic import generator.arrange.accompaniment import generator.comping import generator.comping_rhythm @@ -29,56 +30,64 @@ class LayeredComping(unittest.TestCase): def setUp(self): self.source = tests.solo_fixtures.song(count=6, chords=('C7', 'C7', 'FM7', 'FM7', 'Dm7', 'G7')) self.resources = generator.vocabulary.load() - def test_candidates_preserve_all_counts_and_articulations(self): - m = self.source['staves'][0]['measures'][0] - for lane, expected in (('electric', {0, 1, 2}), ('bass', {1, 2}), ('upper', {1, 2, 3})): - policy = generator.vocabulary.accompaniment('electric' if lane == 'electric' else 'acoustic', self.resources) - candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test') - self.assertEqual({len(c.events) for c in candidates}, expected) - self.assertTrue(any(e.end - e.start <= fractions.Fraction(1, 2) for c in candidates for e in c.events) if lane != 'bass' else True) - if lane == 'electric': - self.assertTrue(any(e.end - e.start >= 2 for c in candidates for e in c.events)) - else: - self.assertEqual({e.end - e.start for c in candidates for e in c.events}, {fractions.Fraction(1, 2), fractions.Fraction(1)}) - if lane == 'bass': - self.assertTrue(all(c.events[0].start == 0 for c in candidates)) - def test_ranges_downbeats_and_overlap(self): - canonical = generator.comping.Canonical(self.source, 'acoustic', 'light') - carried = generator.accompaniment.carried_chords(self.source['staves'][0]['measures']) - for i, (m, prior) in enumerate(zip(self.source['staves'][0]['measures'], carried)): - result = canonical(m, prior, i) - voices = {v['id']: v['notes'] for v in result['voices']} - self.assertEqual(set(voices), {'bass', 'upper'}) - self.assertTrue(voices['bass'][0]['pitches']) - self.assertEqual(generator.model.fraction(voices['bass'][0]['onset']), 0) - for lane, high in (('bass', 2), ('upper', 3)): - attacks = [n for n in voices[lane] if n['pitches'] and not any(p.get('tie_from') for p in n['pitches'])] - self.assertTrue(1 <= len(attacks) <= high) - self.assertTrue(any(fractions.Fraction(r['overlap_beats']) > 0 for r in canonical.rhythm_report)) - self.assertTrue(all(abs(sum(r['contributions'].values()) - r['selection_cost']) < 1e-10 for r in canonical.rhythm_report)) - def test_combined_crowding_favors_shared_attacks(self): - m = self.source['staves'][0]['measures'][0] - candidates = generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'joint') - self.assertTrue(any(dict(c.contributions)['combined_crowding'] == 0 for c in candidates)) - self.assertTrue(any(len({e.start for e in c.events}) < len(c.events) for c in candidates)) - shared = (generator.comping_rhythm.Attack('bass', fractions.Fraction(), fractions.Fraction(2)), generator.comping_rhythm.Attack('upper', fractions.Fraction(), fractions.Fraction(1))) - separated = (shared[0], dataclasses.replace(shared[1], start=fractions.Fraction(1, 2))) - self.assertLess(generator.comping_rhythm.crowding(shared), generator.comping_rhythm.crowding(separated)) - def test_harmonic_changes_share_one_measure_budget(self): - 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)))] + def block(self, part, source=None, melody=()): + source = source or self.source + measures = source['staves'][0]['measures'][:2] + inputs = tuple((m, prior, i) for i, (m, prior) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))) + flat = generator.comping_rhythm.flatten(inputs) + lengths = tuple(generator.model.fraction(m['duration']) for m in measures) + return generator.comping_rhythm.candidates(flat, None, part, melody, 'test', lengths=lengths) + def test_all_ten_families_are_reachable_and_roles_have_distinct_onsets(self): + expected = {'charleston', 'shifted_charleston', 'forward_motion', 'anticipated_heavy_beats', 'syncopated_riff', 'quarters', 'dotted_spacing', 'shifting_motif', 'call_response', 'anticipated_four'} + choices = {part: self.block(part) for part in ('acoustic', 'electric')} + for part, candidates in choices.items(): + self.assertTrue(expected <= {dict(c.families)['pattern'] for c in candidates}) + for c in candidates: + generator.comping_rhythm.validate_events(c.events, part) + for start in (0, 4): + self.assertLessEqual(sum(start <= e.start < start + 4 for e in c.events), 3 if part == 'acoustic' else 2) + self.assertAlmostEqual(c.cost, sum(dict(c.contributions).values())) + a = {tuple(e.start for e in c.events) for c in choices['acoustic']} + e = {tuple(e.start for e in c.events) for c in choices['electric']} + self.assertFalse(a & e) + self.assertTrue(any(c.events[0].start > 0 for c in choices['acoustic'])) + self.assertTrue(any(c.events[0].lane == 'upper' for c in choices['acoustic'])) + self.assertTrue(any(e.end - e.start > 1 for c in choices['electric'] for e in c.events)) + def test_separate_short_attacks_and_only_synchronized_long_combinations(self): + F = fractions.Fraction + attack = generator.comping_rhythm.Attack + generator.comping_rhythm.validate_events((attack('bass', F(0), F(1)), attack('upper', F(1), F(3, 2))), 'acoustic') + generator.comping_rhythm.validate_events((attack('combined', F(0), F(3, 2)),), 'acoustic') + for events in ((attack('combined', F(0), F(1)),), (attack('bass', F(0), F(1)), attack('upper', F(1, 2), F(1))), (attack('bass', F(0), F(2)),)): + with self.assertRaises(ValueError): + generator.comping_rhythm.validate_events(events, 'acoustic') + def test_harmonic_changes_clip_direct_holds_without_extra_attacks(self): + source = copy.deepcopy(self.source) + for m in source['staves'][0]['measures'][:2]: + m['harmonies'] = [{**generator.harmony.parse_symbol(s), 'id': str(i), 'onset': generator.model.rational(t)} for i, (s, t) in enumerate((('C7', 0), ('FM7', 1), ('G7', 2)))] for part in ('acoustic', 'electric'): - choices = generator.comping_rhythm.candidates(m, None, part, (), 'changes') - for c in choices: - for lane, high in (('bass', 2), ('upper', 3), ('electric', 2)): - count = sum(e.lane == lane for e in c.events) - self.assertLessEqual(count, high + 1) - if count > high: - self.assertIn((lane, 'extra attack for harmonic changes'), c.exceptions) + for c in self.block(part, source): + self.assertLessEqual(len(c.events), 6 if part == 'acoustic' else 4) for e in c.events: - self.assertLessEqual(e.end, 1 if e.start < 1 else 2 if e.start < 2 else 4) - if part == 'electric': - self.assertTrue(all(c.exceptions for c in choices)) + if e.harmonic_time is None: + self.assertFalse(any(e.start < boundary < e.end for boundary in (1, 2, 4, 5, 6))) + def test_anticipation_binds_exact_upcoming_chord_and_keeps_direct_alternative(self): + source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7')) + for part in ('acoustic', 'electric'): + candidates = self.block(part, source) + anticipated = [c for c in candidates if any(e.harmonic_time is not None for e in c.events)] + self.assertTrue(anticipated) + for c in anticipated: + for e in c.events: + if e.harmonic_time is not None: + self.assertIn(e.lane, ('upper', 'electric')) + self.assertEqual(e.harmonic_time - e.start, fractions.Fraction(1, 2)) + self.assertLess(e.harmonic_time, e.end) + self.assertTrue(any(all(e.harmonic_time is None for e in c.events) for c in candidates)) + flat = generator.comping_rhythm.flatten(tuple((m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures']))))) + pattern = next(p for p in self.resources['accompaniment']['instruments']['electric']['patterns'] if p['id'] == 'electric.forward_motion.statement') + events, _ = generator.comping_rhythm.fit(pattern, flat, None, 'electric', anticipate=True, limit=fractions.Fraction(4)) + self.assertTrue(all(e.harmonic_time is None for e in events)) def test_no_chord_is_silent_and_partial_measure_is_bounded(self): m = copy.deepcopy(self.source['staves'][0]['measures'][0]) m['harmonies'][0]['kind'] = 'none' @@ -86,23 +95,21 @@ class LayeredComping(unittest.TestCase): m['harmonies'][0] = {**generator.harmony.parse_symbol('C7'), 'id': 'chord', 'onset': generator.model.rational(0)} m['duration'] = generator.model.rational(fractions.Fraction(1, 2)) self.assertTrue(all(0 <= e.start < e.end <= fractions.Fraction(1, 2) for c in generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'pickup') for e in c.events)) - def test_two_other_meters_preserve_full_bar_bounds(self): + def test_two_other_meters_use_authored_patterns(self): for meter in (3, 5): source = tests.solo_fixtures.song(meter=meter, count=2, chords=('C7',)) for part in ('acoustic', 'electric'): canonical = generator.comping.Canonical(source, part, 'meter') + self.assertTrue(all(str(meter) + '_beat' in r.pattern_id for r in canonical.block_rhythms.values())) for rhythm in canonical.rhythms.values(): self.assertTrue(all(0 <= e.start < e.end <= meter for e in rhythm.events)) def test_melody_changes_the_objective_without_changing_seed(self): - e = generator.comping_rhythm.Attack('electric', fractions.Fraction(1), fractions.Fraction(3, 2)) - m = self.source['staves'][0]['measures'][0] - harmony = generator.comping_rhythm.spans(m, None) - busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(4)) - rest = ((fractions.Fraction(), fractions.Fraction(1, 2)),) - first = dict(generator.comping_rhythm.lane_score((e,), 'electric', busy, harmony, fractions.Fraction(4))) - second = dict(generator.comping_rhythm.lane_score((e,), 'electric', rest, harmony, fractions.Fraction(4))) - self.assertGreater(first['melody_support'], second['melody_support']) - self.assertNotEqual(generator.comping_rhythm.candidates(m, None, 'electric', busy, 'same'), generator.comping_rhythm.candidates(m, None, 'electric', rest, 'same')) + busy = tuple((fractions.Fraction(i), fractions.Fraction(i + 1)) for i in range(8)) + space = ((fractions.Fraction(), fractions.Fraction(1, 2)),) + first = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=busy)} + second = {c.pattern_id: dict(c.contributions) for c in self.block('electric', melody=space)} + common = first.keys() & second.keys() + self.assertTrue(any(first[k]['melody_support'] != second[k]['melody_support'] for k in common)) def test_tempo_changes_metadata_but_not_accompaniment_music(self): changed = copy.deepcopy(self.source) changed['metadata']['tempo'] = 240 @@ -144,24 +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)] with self.assertRaisesRegex(ValueError, 'overlapping guitar voices'): generator.fretboard.validate_document(doc) - def test_sustained_join_is_one_attack_and_keeps_position(self): + def test_explicit_cross_bar_hold_survives_projection_without_reattack(self): source = tests.solo_fixtures.song(count=2, chords=('C7',)) - canonical = generator.comping.Canonical(source, 'electric', 'tie') - inputs = [(m, p, i) for i, (m, p) in enumerate(zip(source['staves'][0]['measures'], generator.accompaniment.carried_chords(source['staves'][0]['measures'])))] - chord = inputs[0][0]['harmonies'][0] - grip = generator.comping.voicings(generator.comping.semantic_key(chord), 'electric')[0] - for m, p, i in inputs: - note = generator.model.make_event('held' + str(i), fractions.Fraction(), fractions.Fraction(4), 'note', generator.fretboard.chord_pitches(chord, grip)) - note['voice'] = 'electric' - canonical.cache[canonical.key(m, p, i)]['voices'] = [{'id': 'electric', 'notes': [note]}] - canonical._rhythm_reports[canonical.key(m, p, i)]['layers']['electric'].update(attacks=1, tied_continuations=0) - canonical.connect_holds(inputs) - a, b = [canonical.cache[canonical.key(*args)]['voices'][0]['notes'][0] for args in inputs] - for p, q in zip(a['pitches'], b['pitches']): - self.assertEqual(p['tie_to'], q['id']) - self.assertEqual(q['tie_from'], p['id']) - self.assertEqual(p['tablature'], q['tablature']) - self.assertEqual(canonical.rhythm_report[1]['layers']['electric']['attacks'], 0) + for part, pattern_id in (('electric', 'electric.forward_motion.statement'), ('acoustic', 'acoustic.call_response.held_answer')): + resources = copy.deepcopy(self.resources) + patterns = resources['accompaniment']['instruments'][part]['patterns'] + resources['accompaniment']['instruments'][part]['patterns'] = [p for p in patterns if p['id'] == pattern_id] + with generator.vocabulary.using(resources, part + '_accompaniment'): + canonical = generator.comping.Canonical(source, part, 'held') + measures = source['staves'][0]['measures'] + rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))] + a = [p for n in generator.score.measure_notes(rendered[0]) for p in n['pitches'] if p.get('tie_to')] + b = {p['id']: p for n in generator.score.measure_notes(rendered[1]) for p in n['pitches']} + cross = [p for p in a if p['tie_to'] in b] + self.assertTrue(cross) + for p in cross: + self.assertEqual(p['tablature'], b[p['tie_to']]['tablature']) + self.assertEqual(p['id'], b[p['tie_to']]['tie_from']) + if part == 'acoustic': + document = copy.deepcopy(source) + document['staves'][0]['measures'] = rendered + generator.acoustic.validate_comping(document) + def test_separate_repeated_attacks_are_never_joined(self): + source = tests.solo_fixtures.song(count=2, chords=('C7',)) + resources = copy.deepcopy(self.resources) + resources['accompaniment']['instruments']['electric']['patterns'] = [{'id': 'test.repeated', 'family': 'quarters', 'lengths': ['4', '4'], + 'events': [{'onset': str(t), 'duration': '4', 'layer': 'electric'} for t in (0, 4)]}] + with generator.vocabulary.using(resources, 'electric_accompaniment'): + canonical = generator.comping.Canonical(source, 'electric', 'repeat') + measures = source['staves'][0]['measures'] + rendered = [canonical(m, p, i) for i, (m, p) in enumerate(zip(measures, generator.accompaniment.carried_chords(measures)))] + notes = [n for m in rendered for n in generator.score.measure_notes(m) if n['pitches']] + self.assertEqual(len(notes), 2) + self.assertTrue(all(not p.get('tie_from') and not p.get('tie_to') for n in notes for p in n['pitches'])) class SoloRhythmConditioning(unittest.TestCase): def test_melody_rhythm_and_progression_change_learned_probabilities(self): diff --git a/tests/test_engraving_cache.py b/tests/test_engraving_cache.py index 4c64b20..d81530d 100644 --- a/tests/test_engraving_cache.py +++ b/tests/test_engraving_cache.py @@ -353,7 +353,7 @@ class EngravingCacheTests(unittest.TestCase): data, output = self.root / 'data', self.root / 'output' (data / 'songs').mkdir(parents=True) source = data / 'songs/fixture.xml' - source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) + source.write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note())) args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False, engraving_cache=self.cache, jobs=2) generator.__main__.generate_assets(args) original = self.files(output) @@ -364,7 +364,7 @@ class EngravingCacheTests(unittest.TestCase): self.assertEqual(original, self.files(output)) self.assertFalse((output / 'solo_xml').exists()) self.assertFalse(list(output.rglob('*.xml'))) - source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(extra='other'))) + source.write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note(extra='other'))) with self.assertRaisesRegex(generator.engraving.types.EngravingError, 'unavailableGlyph'): generator.__main__.generate_assets(args) self.assertEqual(original, self.files(output)) diff --git a/tests/test_guitar.py b/tests/test_guitar.py index 649bf3d..098260c 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -255,7 +255,7 @@ class GuitarTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: source, output = pathlib.Path(temporary) / 'source', pathlib.Path(temporary) / 'output' source.mkdir() - (source / '0000.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', alter=-1), '-3')) + (source / '0000.xml').write_text(tests.test_musicxml.xml_score('E-1major' + tests.test_musicxml.xml_note('E', alter=-1), '-3')) self.assertEqual(generator.arrange.generate.generate_performance_scores(source, output), 3) first = {p.name: p.read_bytes() for p in output.rglob('*.json')} generator.arrange.generate.generate_performance_scores(source, output) @@ -264,7 +264,7 @@ class GuitarTests(unittest.TestCase): with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) (root / 'data/songs').mkdir(parents=True) - (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(octave=7, alter=1))) + (root / 'data/songs/0001.xml').write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note(octave=7, alter=1))) output = root / 'output' output.mkdir() (output / 'last-good').write_text('preserved') diff --git a/tests/test_guitar_parts.py b/tests/test_guitar_parts.py index cd1c230..3bd6c3e 100644 --- a/tests/test_guitar_parts.py +++ b/tests/test_guitar_parts.py @@ -137,7 +137,7 @@ class GuitarPartTests(unittest.TestCase): def test_song_melody_only_is_raised_an_octave(self): source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('E', octave=4))) before = copy.deepcopy(source) - for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): + for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance): for part in ('electric', 'vocal'): result = builder(source, part) notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] if n['pitches']] diff --git a/tests/test_music_model.py b/tests/test_music_model.py index 9ebe314..73a0f0c 100644 --- a/tests/test_music_model.py +++ b/tests/test_music_model.py @@ -84,7 +84,7 @@ class MusicModelTests(unittest.TestCase): result = generator.arrange.generate.arrange_document(source, part) generator.model.validate_document(result) measures = result['staves'][0]['measures'] - self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody']) + self.assertEqual([m['section_label'] for m in measures if m.get('section_label')], ['Rhythm Lead-in', 'Vocal Melody', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo', 'Vocal Melody', 'Ending']) self.assertFalse(measures[-1]['harmonies']) self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) @@ -115,7 +115,7 @@ class MusicModelTests(unittest.TestCase): with self.assertRaises(ValueError): generator.__main__.generate_assets(args) self.assertEqual((output / 'last-good').read_text(), 'preserved') - (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') + (data / 'songs' / 'bad.xml').write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note()), encoding='utf-8') args.check = True # --check uses the validation-only repository fixture. An alternate # source tree fails its form contract before atomic publication. @@ -134,7 +134,7 @@ class MusicModelTests(unittest.TestCase): data, output = pathlib.Path(temp) / 'data', pathlib.Path(temp) / 'output' (data / 'songs').mkdir(parents=True) source = data / 'songs' / 'fixture.xml' - source.write_text(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()), encoding='utf-8') + source.write_text(tests.test_musicxml.xml_score('Cmajor' + tests.test_musicxml.xml_note()), encoding='utf-8') args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=pathlib.Path('package/assets'), check=False) generator.__main__.generate_assets(args) first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} diff --git a/tests/test_parts.py b/tests/test_parts.py index 4000e59..37e5de4 100644 --- a/tests/test_parts.py +++ b/tests/test_parts.py @@ -40,6 +40,8 @@ class PartGenerationTests(unittest.TestCase): generator.model.validate_document(result) for measure in result['staves'][0]['measures']: events = measure['voices'][0]['notes'] + if measure.get('ending_sustain'): + continue if measure.get('section_label') in ('Rhythm Lead-in', 'Guitar Melody', 'Acoustic Solo', 'Electric Solo'): continue self.assert_complete_measure(measure) @@ -91,7 +93,7 @@ class PartGenerationTests(unittest.TestCase): source = tests.test_musicxml.parse(xml.etree.ElementTree.tostring(root, encoding='unicode')) before = copy.deepcopy(source) expected = [n for m in source['staves'][0]['measures'] for n in m['voices'][0]['notes']] - for builder in (tests.arrangement_fixtures.section_document, generator.arrange.generate.arrange_document): + for builder in (tests.arrangement_fixtures.section_document, tests.arrangement_fixtures.melody_performance): for part in ('electric', 'vocal'): with self.subTest(builder=builder.__module__, part=part): result = builder(source, part) diff --git a/tests/test_performance.cpp b/tests/test_performance.cpp index cb7f1ea..03c692b 100644 --- a/tests/test_performance.cpp +++ b/tests/test_performance.cpp @@ -58,7 +58,7 @@ BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) { BOOST_REQUIRE(engine); auto session = engine->play_transport(std::move(*prepared)); BOOST_REQUIRE(session); - auto frames = backing ? 576000U : 288000U; + auto frames = 576000U; auto buffer = std::array{}; for (unsigned cursor = 0; cursor < frames; cursor += 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_AUTO_TEST_CASE(written_ending_boundary_and_clicks_are_validated) { + auto original = boost::json::parse(read("score.json")); + auto document = score(); + BOOST_TEST(document.measure_count == 5U); + BOOST_TEST(document.duration.numerator == 20U); + BOOST_TEST(document.sustain_start.numerator == 8U); + BOOST_TEST(vamp_zero::performance_at(document, 287999, 120).phase == "Playing"); + BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain"); + auto changed = original; + changed.at("performance").at("sustain_start").at("numerator") = 9; + BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json")); + changed = original; + changed.at("music").at("staves").as_array()[0].at("measures").as_array()[2].as_object().erase("ending_sustain"); + BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json")); + changed = original; + changed.at("performance").at("clicks").as_array().push_back(boost::json::object{{"beat", boost::json::object{{"numerator", 8}, {"denominator", 1}}}, {"downbeat", true}}); + BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json")); +} + BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) { auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true}); BOOST_REQUIRE(engine); diff --git a/tests/test_performance.py b/tests/test_performance.py index b4acf6e..258791f 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -17,9 +17,10 @@ def fixture(root): music = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note())) first = music['staves'][0]['measures'][0] music['staves'][0]['measures'].append(copy.deepcopy(first)) + music['staves'][0]['measures'].extend({**copy.deepcopy(first), 'ending_sustain': True} for _ in range(3)) music['metadata']['song_id'] = 'fixture' - pages = [dict(path=f'{i + 1}.png', sha256='0' * 64, first_measure=i + 1, measure_count=1, - start_beat=generator.model.rational(i * 4), end_beat=generator.model.rational((i + 1) * 4)) for i in range(2)] + 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(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)) (root / 'score.json').write_text(json.dumps(bundle)) entries = [] @@ -73,7 +74,8 @@ class PerformanceTests(unittest.TestCase): bundle = json.loads((root / 'score.json').read_text()) generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages']) self.assertEqual([c['downbeat'] for c in bundle['performance']['clicks']], [True, False, False, False] * 2) - self.assertEqual(bundle['performance']['duration'], generator.model.rational(8)) + self.assertEqual(bundle['performance']['duration'], generator.model.rational(20)) + self.assertEqual(bundle['performance']['sustain_start'], generator.model.rational(8)) bundle['presentation']['pages'][1]['start_beat'] = generator.model.rational(3) with self.assertRaisesRegex(ValueError, 'timing'): generator.performance.validate_pages(bundle['music'], bundle['presentation']['pages'])