Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb3bba6bbb | |||
| 8423f3d788 | |||
| e71385dad1 |
@@ -100,9 +100,9 @@ Uncompressed MusicXML 3.1/4.0 source scores exported from Soundslice or MuseScor
|
||||
|
||||
Six roles generate independently: acoustic accompaniment/solo; electric accompaniment/melody/solo; vocal melody. A process pool and per-role caches support parallel and incremental builds. Each role uses the source and its own inputs without reading another generated role. Canonical accompaniment repeats at corresponding source-form positions and does not react to generated solos. Vocal melody preserves the authored notes and lyrics. Electric melody preserves source pitches and harmonizes eligible sustained notes, reattacking them at chord changes.
|
||||
|
||||
Electric accompaniment uses the electric melody's three/four-note voicing families on strings 1–4. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Its bass can be the root or written fifth, or any triad member for plain major/minor chords; an explicit slash bass or inversion takes priority. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
|
||||
Electric accompaniment and electric melody share compact chord families on strings 1–4. Melody harmonization adapts these to keep the original melody on the lowest-numbered feasible string, preferring it above the added chord tones. The separate Electric Solo stays monophonic. Acoustic accompaniment uses one bass note on string 5 or 6 and three upper notes on strings 2, 3 and 4. Transposable degree families cover triads, sevenths/sixths, suspensions, diminished/augmented and extended/altered chords, with root/fifth bass alternatives on both bass strings. Plain major/minor chords also allow third basses; explicit slash basses and inversions take priority. Unplayable variants are discarded. Each instrument selects a connected voicing path across the source form to reduce hand movement, then reuses it through the arrangement.
|
||||
|
||||
Accompaniment rhythm is light at every tempo: normally 0–2 electric chord attacks, 1–2 acoustic bass attacks, and 1–3 acoustic upper-chord attacks per full measure. Bass attacks beat one wherever harmony is available. Stabs, sustains and space follow the original melody and harmonic rhythm. Separate acoustic voices allow bass and upper notes to overlap; their combined attacks are evaluated for crowding. An extra attack is permitted only for harmonic changes and reported. Compatible 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 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -138,7 +138,7 @@ To retrain existing pairs, run `./modulus.py python -m generator.train`; add `--
|
||||
|
||||
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. Standalone comparison XMLs use local measure numbers and retain the original arrangement numbers in `manifest.solo`.
|
||||
|
||||
All generated monophonic guitar material uses frets 1–12 without open strings. Accompaniment uses all legal compact positions; a chord can extend to fret 20 while retaining at least one sounding anchor at fret 12 or below and the existing three-fret span restriction. Electric source-melody harmonization retains its separate placement and chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
|
||||
All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
|
||||
|
||||
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
|
||||
|
||||
|
||||
+20
-8
@@ -123,17 +123,21 @@ Measure durations, event onsets/durations, harmony offsets, span times, and pitc
|
||||
|
||||
`parts.py` retains exact source notes, rests, lyrics and ties. `melody.py` harmonizes eligible sustained electric source-melody notes using their actual chord, preserving source pitches while splitting and reattacking held notes at harmony changes. Guitar notation applies its octave transposition once. `comping.Canonical` independently chooses compact chord voicings and rhythm from the source form and shared policy, then reuses that part at the same source-form position throughout the arrangement. There is no generated-solo response or reuse of a generated melody's grips. Sparse bars and deliberate releases are legal.
|
||||
|
||||
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. It uses the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice. Unsupported grips fail with the source measure, beat and chord rather than weakening the layout or harmonic requirements.
|
||||
Electric accompaniment shares the electric melody's `guitar.chord_positions(..., profile='electric')` families: strings 1–3 or 1–4, with a preference for four-note grips. `chord_families.py` classifies actual shapes as drop-two, close-position, compact-open, compact-doubled or three-note shells, ranked by versioned policy. Melody adaptation also permits strings 2–4 when the exact source pitch cannot sit above the chord on string 1. Within the selected span tier, its lowest feasible string number and foreground position precede chord size. A primary grip with the melody on string 2 beats a four-fret grip with it on string 1. Short monophonic melody notes retain their movement-based placement; Electric Solo is unchanged. These electric roles use the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice.
|
||||
|
||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. Electric costs prioritize the shared melody family's string count, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic costs prioritize travel of the bass and all three upper voices, then optional-tone coverage and register preference. Stable role-specific keys resolve equal costs. 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 string/fret positions, bass degree and movement.
|
||||
Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Coverage checks exercise 35 common qualities in all 12 keys. Four-fret fallbacks support previously unavailable acoustic Bb9, BbM9, Bb7#9 and Bb6add9; for example, Bb9 uses fifth-bass frets 8/10/7/9 on strings 5/4/3/2. Acoustic Bbm9 and Bm9 still have no grip meeting all constraints. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones.
|
||||
|
||||
`comping_rhythm.py` constructs whole-measure rhythms from version 2 of `composition/accompaniment.json`. 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. Candidates mix half-beat stabs, quarters, longer holds and alternating durations. Exact chord offsets constrain sounding intervals; short partial bars have bounded gestures. Rapid harmonic changes can authorize one extra attack per layer, recorded as an exception, without assigning a fresh density budget to every chord.
|
||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic prefers declared degree-order families on either bass string, then travel of the bass and all three upper voices, optional-tone coverage and register preference. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
|
||||
|
||||
The 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 sustained neighbors with identical harmony and grip can then join across a barline without reattack; the upper layer retains at least one new attack per bar and bass always reattacks. Changing only tempo leaves these musical decisions unchanged. Tempo remains in the concrete asset cache key so score metadata refreshes correctly.
|
||||
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.
|
||||
|
||||
Acoustic bass and upper events retain independent voice lanes through assembly and notation, so either layer may sustain under another attack. Validation also checks the combined sounding grip across those voices. 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. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances.
|
||||
`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.
|
||||
|
||||
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 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Recording Playback and Page Timing
|
||||
|
||||
@@ -199,11 +203,11 @@ Migration inventory:
|
||||
| `data/training/` (four XMLs + manifest) | Explicit trainer and registered authored playback. All slots are pooled for learning. Annotation-only cleanup preserves the music and learned counts. |
|
||||
| `data/models/solo.json` | Frozen prediction model; reproducible by `generator.train`. |
|
||||
| `data/composition/{roles,harmony,lead_ins}.json` | Physical/difficulty settings and generic harmonic validity. Fixed-map permissions removed. |
|
||||
| `data/composition/accompaniment.json` | Version 2: independently authored duration families, per-layer ranges and bounded retrieval settings, consumed by `comping_rhythm.py`. Replaces the old per-chord figure list; no reference-derived material. |
|
||||
| `data/composition/accompaniment.json` | Version 3: independently authored per-instrument duration families, layer ranges, bounded retrieval and generic chord-degree/geometry families. Consumers: `comping_rhythm.py`, `comping.py`, `chord_families.py` and `guitar.py`. Migrates the v2 shared-duration schema; no parallel legacy data or reference-derived material. |
|
||||
| `generator/harmonic_time.py` | Exact chord/key spans retained from the removed scale subsystem. |
|
||||
| Solo event, expression, placement, notation and export helpers | Retained physical/timing safeguards shared by authored and predicted music. |
|
||||
|
||||
Removed: `data/{scales,sequences,rhythms}`, old `composition/solo.json` and mixed `rhythms.json`, catalog import/cache code, old solo construction/phrase/selection/scoring/sequence modules, unused accompaniment catalog planner, three evaluation-song excerpts, and tests specific to those retired algorithms. Shared importer, timing, guitar, playback, engraving, arrangement and independence tests remain, with new pooled-learning, registration and exact-repertoire tests. No `docs/` or `audition/` directory is required. Temporary comparisons and diagnostics belong under `/tmp/`; old comparison pages, diagnostic reports, formatter-debug scripts and the copied verification bundle have been removed from `build/`. Product builds, caches and toolchains remain. The installed `build/sonus_arm` dependency remains because the configured ARM build uses it; its unused separate compilation directory has been removed.
|
||||
Removed: `data/{scales,sequences,rhythms}`, old `composition/solo.json` and mixed `rhythms.json`, catalog import/cache code, old solo construction/phrase/selection/scoring/sequence modules, unused accompaniment catalog planner, three evaluation-song excerpts, and tests specific to those retired algorithms. Shared importer, timing, guitar, playback, engraving, arrangement and independence tests remain, with new pooled-learning, registration and exact-repertoire tests. No `docs/` or `audition/` directory is required. Temporary comparisons and diagnostics belong under `/tmp/`; old comparison pages, diagnostic reports, formatter-debug scripts and the copied verification bundle have been removed from `build/`. Product builds, caches and toolchains remain. Desktop and ARM builds resolve Sonus through Conan; separate local Sonus installations and temporary CMake dependency overrides are unnecessary. Refresh both with `python3 modulus.py configure --update --target x86_64_gcc12_debug --target arm_64_gcc12_release` when dependency packages change. The obsolete playback-test assets are superseded by the current performance-test fixtures.
|
||||
|
||||
Verification on October 5, 2026: `unittest discover -s tests -p 'test_*.py'` passed 361 tests in 78.62 seconds. These include all four approved solos without annotations or authored-role privileges, source-form pickup context, invalid resolutions, passing endpoints, anticipated colors, tied-event partition invariance and active prediction construction. The normal six-part asset build passed `--check`, including playback, engraving, TAB and catalog validation. With two composition workers, newly generated solo results, cached accompaniment/melody and a warm engraving cache, it took 5.10 seconds; performance-part generation occupied 2.37 seconds. Each registered solo took 0.36–0.40 seconds. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` also passed. These measurements describe registered playback, not novel prediction; the disposable diagnostic reports have since been removed.
|
||||
|
||||
@@ -213,6 +217,14 @@ Rhythm verification on October 6, 2026: `unittest discover -s tests -p 'test_*.p
|
||||
|
||||
The ordinary six-score rebuild with `--check --export-solo-xml --composition-jobs 2 --jobs 2` passed in 13.39 seconds: 4.44 seconds generating parts and 8.48 seconds engraving. All role composition results were newly generated; the warm engraving namespace reused two unchanged vocal scores. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role/score caches reused. Actual selected acoustic upper rhythms use one or two attacks in these songs; three-attack alternatives are available and tested but are not forced. All of Me electric has three zero-attack source/harmony contexts; Autumn Leaves electric uses one or two attacks. Neither song required a density exception. Reuse audits checked 103/62 acoustic/electric repeated bars for Autumn Leaves and 102/62 for All of Me, with only source-defined pickups and final-harmony exceptions. Sample acoustic notation/TAB pages were visually inspected. Supplied audio recordings remain unchanged; no ear-based audition of the new accompaniment was performed. Disposable logs, profiles and comparisons are under `/tmp/`, outside product assets.
|
||||
|
||||
Chord-family and unified-acoustic verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 386 tests in 81.67 seconds. New cases cover 35 chord qualities in three keys for both instruments, discarded bass variants and explicit unsupported combinations, the six suggested acoustic grips, highest feasible electric melody strings, scoped policy dependencies, and one-voice tie preservation including repeated attacks and cross-bar ties. Quarter/eighth acoustic attack durations and available consecutive-eighth patterns are tested. The acoustic sounding events in both complete arrangements contain only quarter/eighth durations, even where notation splits them into ties.
|
||||
|
||||
The full checked rebuild with two composition and two engraving workers took 13.76 seconds: 4.68 seconds for all parts and 8.63 seconds engraving. All role caches missed after the code change; two unchanged vocal engravings were reused. Serial composition-only generation into a disposable directory also passed in 5.58 seconds. The subsequent CMake asset build passed and reproduced every role document from warm caches. Both registered solos and vocal documents are exactly unchanged from the saved prechange baseline; the electric melody's original pitches/onsets/durations also match. Reuse audits retain the same 103/62 and 102/62 repeated-bar counts. Acoustic upper rhythms now select one to three attacks in Autumn Leaves and one or two in All of Me. Acoustic and electric melody pages were visually inspected; this is notation/constraint verification, not an ear-based audition. Only product artifacts and normal caches were written under `build/`; diagnostic logs and baseline comparisons remain under `/tmp/`.
|
||||
|
||||
Four-fret fallback verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 392 tests in 85.65 seconds. This includes primary-tier precedence over family, arbitrary movement savings and melody-string height; a complete held-note path that needs an earlier four-fret option; tied-fragment cost invariance; and anchor release checks. The first full run exposed one obsolete C5 rejection expectation, now replaced by a test verifying its legal four-fret voicings contain only C/G. Coverage tests check 35 qualities in 12 keys for each instrument: 418/420 acoustic contexts and 420/420 electric contexts have a legal grip. Acoustic Bbm9/Bm9 remain explicitly unsupported; this is measured coverage, not a guarantee for every written extension or slash chord.
|
||||
|
||||
The checked six-score rebuild with `--composition-jobs 2 --jobs 2 --export-solo-xml` passed in 9.21 seconds (4.58 seconds composing all 12 roles, 3.90 seconds processing six cached engravings). All composition caches missed after the code change. Every resulting role document and accompaniment rhythm report exactly matches the saved baseline: current production chords already have primary grips, so none needed fallback substitutions. New sidecar span diagnostics identify the tiers and available candidates. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role caches reused and identical per-role musical hashes. Acoustic and electric score/TAB pages were visually checked; supplied recordings are unchanged. Formatter and diff checks passed. Temporary verification files remain under `/tmp/`.
|
||||
|
||||
The optional exact comparison confirmed all four exports: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four now pass contextual harmony validation as well. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. Commands used the ordinary asset generator with `--export-solo-xml --check` and `python -m tests.solo_compare` with four explicit `--reference` pairs, `--reference-pitch guitar-written` and `--require-exact`. No ear-based audition or claim of transfer quality was made.
|
||||
|
||||
Forced prediction, without reading the training XMLs, completed all four source/role combinations with valid harmony. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. The expanded treatment candidates initially took 99.03–120.33 seconds per role with one worker and no concrete-result cache. Removing repeated validation of identical resolution endpoints reduced the isolated All of Me acoustic check from 104.30 to 60.13 seconds, with an identical complete output hash. Six additional contexts retained all 32,566 ordered action candidates exactly. These expanded prediction choices remain slower than the earlier 21.81–27.16-second decoder; registered playback is unaffected. Training the pooled 2,860-context model took 0.57 seconds. Prediction remains the slower path, chiefly candidate construction and event-likelihood evaluation; further profiling is needed before expanding its choices again.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"provenance": "Independently authored sparse guitar rhythm rules: metrical placement, support of source melody, releases and harmonic changes. No training-score extraction.",
|
||||
"license": "MIT",
|
||||
"grid": "1/2",
|
||||
"lane_candidates": 24,
|
||||
"measure_candidates": 32,
|
||||
"instruments": {
|
||||
"electric": {
|
||||
"families": {
|
||||
"stabs": [
|
||||
"1/2"
|
||||
@@ -41,17 +43,49 @@
|
||||
"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
|
||||
],
|
||||
"families": [
|
||||
"quarter_stabs",
|
||||
"sustain",
|
||||
"broad",
|
||||
"long_short"
|
||||
"quarters",
|
||||
"eighths",
|
||||
"quarter_eighth",
|
||||
"eighth_quarter"
|
||||
]
|
||||
},
|
||||
"upper": {
|
||||
@@ -60,13 +94,167 @@
|
||||
3
|
||||
],
|
||||
"families": [
|
||||
"stabs",
|
||||
"quarter_stabs",
|
||||
"sustain",
|
||||
"broad",
|
||||
"short_long",
|
||||
"long_short"
|
||||
"quarters",
|
||||
"eighths",
|
||||
"quarter_eighth",
|
||||
"eighth_quarter"
|
||||
]
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"5/1": [
|
||||
[
|
||||
"fifth",
|
||||
"root",
|
||||
"guide"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"fifth",
|
||||
"root"
|
||||
]
|
||||
],
|
||||
"6/1": [
|
||||
[
|
||||
"root",
|
||||
"guide",
|
||||
"fifth"
|
||||
],
|
||||
[
|
||||
"fifth",
|
||||
"root",
|
||||
"guide"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"fifth",
|
||||
"root"
|
||||
]
|
||||
],
|
||||
"5/5": [
|
||||
[
|
||||
"root",
|
||||
"guide",
|
||||
"fifth"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"root",
|
||||
"fifth"
|
||||
]
|
||||
],
|
||||
"6/5": [
|
||||
[
|
||||
"fifth",
|
||||
"root",
|
||||
"guide"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"fifth",
|
||||
"root"
|
||||
],
|
||||
[
|
||||
"root",
|
||||
"guide",
|
||||
"fifth"
|
||||
]
|
||||
]
|
||||
},
|
||||
"tetrad": {
|
||||
"5/1": [
|
||||
[
|
||||
"fifth",
|
||||
"terminal",
|
||||
"guide"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"terminal",
|
||||
"root"
|
||||
]
|
||||
],
|
||||
"6/1": [
|
||||
[
|
||||
"terminal",
|
||||
"guide",
|
||||
"fifth"
|
||||
],
|
||||
[
|
||||
"guide",
|
||||
"terminal",
|
||||
"root"
|
||||
]
|
||||
],
|
||||
"5/5": [
|
||||
[
|
||||
"root",
|
||||
"guide",
|
||||
"terminal"
|
||||
],
|
||||
[
|
||||
"terminal",
|
||||
"guide",
|
||||
"fifth"
|
||||
]
|
||||
],
|
||||
"6/5": [
|
||||
[
|
||||
"guide",
|
||||
"terminal",
|
||||
"root"
|
||||
]
|
||||
]
|
||||
},
|
||||
"extended": {
|
||||
"5/1": [
|
||||
[
|
||||
"guide",
|
||||
"terminal",
|
||||
"color"
|
||||
],
|
||||
[
|
||||
"color",
|
||||
"terminal",
|
||||
"guide"
|
||||
]
|
||||
],
|
||||
"6/1": [
|
||||
[
|
||||
"terminal",
|
||||
"guide",
|
||||
"color"
|
||||
],
|
||||
[
|
||||
"terminal",
|
||||
"color",
|
||||
"guide"
|
||||
]
|
||||
],
|
||||
"5/5": [
|
||||
[
|
||||
"color",
|
||||
"guide",
|
||||
"terminal"
|
||||
]
|
||||
],
|
||||
"6/5": [
|
||||
[
|
||||
"guide",
|
||||
"terminal",
|
||||
"color"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-20
@@ -76,27 +76,34 @@ def prepare(document: dict) -> None:
|
||||
generator.accompaniment.normalize_beams(notes)
|
||||
|
||||
def combine(document: dict) -> None:
|
||||
"""Write prepared bass/upper attacks as one voice with shared stems and rests."""
|
||||
"""One rhythmic voice; split held pitches into ties rather than release them."""
|
||||
originals = {p['id']: p for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
|
||||
edges = [(p['id'], p['tie_to']) for p in originals.values() if p.get('tie_to')]
|
||||
fragments = {}
|
||||
for measure in measures(document):
|
||||
length = generator.model.fraction(measure['duration'])
|
||||
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches']), key=lambda n: generator.model.fraction(n['onset']))
|
||||
notes, cursor = [], fractions.Fraction()
|
||||
|
||||
def rest(end):
|
||||
nonlocal cursor
|
||||
for duration, written in generator.accompaniment.written_durations(end - cursor):
|
||||
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice_arranged'})
|
||||
cursor += duration
|
||||
|
||||
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])):
|
||||
rest(start)
|
||||
group = list(group)
|
||||
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
|
||||
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches]
|
||||
duration = generator.model.fraction(group[0]['duration'])
|
||||
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice_arranged'})
|
||||
cursor = start + duration
|
||||
rest(length)
|
||||
if not attacks and length == generator.model.meter_length(measure['meter']):
|
||||
sounding = [(generator.model.fraction(n['onset']), generator.model.fraction(n['onset']) + generator.model.fraction(p['duration']), p)
|
||||
for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
|
||||
boundaries = sorted({fractions.Fraction(), length, *(t for a, b, _ in sounding for t in (a, b))})
|
||||
notes = []
|
||||
for a, b in zip(boundaries, boundaries[1:]):
|
||||
active = sorted((p for start, end, p in sounding if start <= a < end), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
|
||||
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', 'stem')} for p in active]
|
||||
note = generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
||||
note['voice'] = 'voice_arranged'
|
||||
for old, new in zip(active, note['pitches']):
|
||||
fragments.setdefault(old['id'], []).append(new)
|
||||
notes.append(note)
|
||||
a += duration
|
||||
if not sounding and length == generator.model.meter_length(measure['meter']):
|
||||
notes = [generator.model.make_event(f'{measure["id"]}_acoustic_1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice_arranged', 'measure_rest': True}]
|
||||
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
|
||||
for pieces in fragments.values():
|
||||
for a, b in zip(pieces, pieces[1:]):
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
for start, end in edges:
|
||||
if start in fragments or end in fragments:
|
||||
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']
|
||||
|
||||
@@ -39,6 +39,7 @@ def movement(previous: Voicing, current: Voicing) -> int:
|
||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
||||
|
||||
class Cost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
travel: int = 0
|
||||
missing_tones: int = 0
|
||||
|
||||
@@ -52,7 +53,7 @@ def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.Chor
|
||||
result = []
|
||||
for voicing in options:
|
||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||
cost, index = min((Cost(p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
cost, index = min((Cost(p.cost.extended_grips + generator.fretboard.span_penalty(voicing), p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
result.append(Placement(cost, voicing, states[index]))
|
||||
return result
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
|
||||
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
|
||||
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
|
||||
result['metadata'].setdefault('tempo', 124)
|
||||
if part_type == 'acoustic' and accompaniment is None and (owned is None or 'accompaniment' in owned):
|
||||
if part_type == 'acoustic':
|
||||
if accompaniment is None and (owned is None or 'accompaniment' in owned):
|
||||
generator.acoustic.prepare(result)
|
||||
generator.acoustic_voicing.place(result)
|
||||
generator.acoustic.combine(result)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Transposable guitar families, derived from the written chord and legal grips."""
|
||||
import generator.harmony
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
|
||||
def acoustic(chord, grip):
|
||||
degrees = generator.harmony.degrees(chord)
|
||||
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
|
||||
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
||||
guide = next((d for d in (3, 4, 2) if d in degrees), None)
|
||||
terminal = next((d for d in (7, 6) if d in degrees), None)
|
||||
color = max((d for d in degrees if d > 7), default=None)
|
||||
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
|
||||
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
|
||||
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
|
||||
policy = generator.vocabulary.accompaniment('acoustic')['voicing_families']
|
||||
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
|
||||
upper = tuple(p.midi % 12 for p in grip[1:])
|
||||
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
|
||||
# The physical/harmonic enumerator remains authoritative: an explicit bass
|
||||
# or an unrepresented extension can use another valid compact voicing.
|
||||
return {'id': f'{kind}/s{grip[0].string_number}/bass{bass or "written"}/' + '-'.join(str(next((d for d in sorted(classes) if pc == classes[d]), 'bass')) for pc in upper),
|
||||
'preferred': preferred, 'bass_degree': bass, 'bass_string': grip[0].string_number,
|
||||
'relative_frets': [p.fret - grip[0].fret for p in grip]}
|
||||
|
||||
def electric(grip):
|
||||
"""Classify actual voicing geometry, without inventing chord permissions."""
|
||||
pitches = sorted(p.midi for p in grip)
|
||||
if len(grip) == 3:
|
||||
return 'three-note-shell'
|
||||
if len(set(p % 12 for p in pitches)) < len(pitches):
|
||||
return 'compact-doubled'
|
||||
if len(pitches) == 4:
|
||||
lifted = sorted([pitches[0] + 12, *pitches[1:]])
|
||||
if lifted[-1] - lifted[0] < 12 and lifted[2] == pitches[0] + 12:
|
||||
return 'drop-two'
|
||||
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
|
||||
|
||||
def coverage(chord, options, part):
|
||||
if part == 'electric':
|
||||
return {name: sum(electric(v) == name for v in options) for name in generator.vocabulary.accompaniment('electric')['voicing_families']['rank']}
|
||||
families = [acoustic(chord, v) for v in options]
|
||||
result = []
|
||||
for string in (5, 6):
|
||||
for degree in (1, 5):
|
||||
found = [f for f in families if f['bass_degree'] == degree and f['bass_string'] == string]
|
||||
result.append({'bass_string': string, 'bass_degree': degree, 'candidates': len(found), 'preferred': sum(f['preferred'] for f in found),
|
||||
'reason': '' if found else 'no legal grip for this bass under written harmony and current fret/span limits'})
|
||||
return result
|
||||
|
||||
def span_coverage(options):
|
||||
return {'primary': sum(not generator.fretboard.span_penalty(v) for v in options),
|
||||
'fallback': sum(generator.fretboard.span_penalty(v) for v in options)}
|
||||
+36
-9
@@ -5,9 +5,11 @@ import dataclasses
|
||||
import functools
|
||||
import json
|
||||
import fractions
|
||||
import typing
|
||||
|
||||
import generator.accompaniment
|
||||
import generator.comping_rhythm
|
||||
import generator.chord_families
|
||||
import generator.acoustic_voicing
|
||||
import generator.arrange.form
|
||||
import generator.fretboard
|
||||
@@ -52,7 +54,7 @@ def voicings(key, part):
|
||||
options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v))
|
||||
if not options:
|
||||
layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4'
|
||||
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and three consecutive frets remain binding')
|
||||
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
|
||||
return tuple(sorted(options))
|
||||
|
||||
def movement(part, previous, current):
|
||||
@@ -62,14 +64,26 @@ def movement(part, previous, current):
|
||||
return generator.acoustic_voicing.movement(previous, current)
|
||||
return generator.guitar.movement(previous, current)
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
family: int = 0
|
||||
effort: int = 0
|
||||
missing_tones: int = 0
|
||||
register: fractions.Fraction = fractions.Fraction()
|
||||
def plus(self, other):
|
||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
||||
def moved(self, distance):
|
||||
return self._replace(effort=self.effort + distance)
|
||||
|
||||
def local_cost(part, key, grip, center):
|
||||
required = requirements(json.loads(key), part)
|
||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
||||
if part == 'electric':
|
||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
|
||||
return (value.string_shortfall, value.effort, value.missing_tones, register)
|
||||
# As in acoustic harmony-span placement: travel first, then completeness.
|
||||
return (0, 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
||||
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register)
|
||||
# Familiar degree orders on either bass string, then whole-form movement.
|
||||
family = generator.chord_families.acoustic(json.loads(key), grip)
|
||||
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
||||
|
||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
||||
"""Exact small-state dynamic programming; never prune by hand center.
|
||||
@@ -79,25 +93,32 @@ def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
||||
"""
|
||||
if not layers:
|
||||
return (), (0, 0, 0, 0)
|
||||
return (), VoicingCost()
|
||||
# Every accompaniment transition is feasible (travel is a cost). A wider
|
||||
# option cannot beat a primary grip at the same span, regardless of travel.
|
||||
# This exact dominance reduction avoids growing the cubic repeat search.
|
||||
retained = []
|
||||
for i, layer in enumerate(layers):
|
||||
tier = min(costs[i][v].extended_grips for v in layer)
|
||||
retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier))
|
||||
layers = tuple(retained)
|
||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
|
||||
winners = []
|
||||
for first in layers[0] if close else [None]:
|
||||
states = {left: ((0, 0, 0, 0), (), ())}
|
||||
states = {left: (VoicingCost(), (), ())}
|
||||
for i, layer in enumerate(layers):
|
||||
following = {}
|
||||
for grip in [first] if i == 0 and first is not None else layer:
|
||||
local = costs[i][grip]
|
||||
choices = []
|
||||
for previous, (cost, ties, path) in states.items():
|
||||
total = tuple(a + b for a, b in zip(cost, local))
|
||||
total = (total[0], total[1] + movement(part, previous, grip), *total[2:])
|
||||
total = cost.plus(local).moved(movement(part, previous, grip))
|
||||
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
|
||||
following[grip] = min(choices)
|
||||
states = following
|
||||
for grip, (cost, ties, path) in states.items():
|
||||
travel = movement(part, grip, first if close else right)
|
||||
winners.append(((cost[0], cost[1] + travel, *cost[2:]), ties, path))
|
||||
winners.append((cost.moved(travel), ties, path))
|
||||
cost, _, path = min(winners)
|
||||
return path, cost
|
||||
|
||||
@@ -146,6 +167,7 @@ class Canonical:
|
||||
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)
|
||||
@property
|
||||
def report(self):
|
||||
@@ -207,6 +229,11 @@ class Canonical:
|
||||
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,
|
||||
'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),
|
||||
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
|
||||
'span_candidates': generator.chord_families.span_coverage(span.options),
|
||||
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
|
||||
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
|
||||
'bass_string': bass.string_number if bass else None,
|
||||
|
||||
@@ -137,7 +137,7 @@ def lane_candidates(measure, prior, lane, melody, policy, seed):
|
||||
continue
|
||||
if any(not any(a <= t < b for t in onsets) for a, b in required):
|
||||
continue
|
||||
if any(b - a < 1 for a, b in zip(onsets, onsets[1:])):
|
||||
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]
|
||||
@@ -147,12 +147,14 @@ def lane_candidates(measure, prior, lane, melody, policy, seed):
|
||||
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:
|
||||
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')
|
||||
@@ -166,7 +168,7 @@ def crowding(events):
|
||||
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.data('accompaniment')
|
||||
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)
|
||||
|
||||
+12
-4
@@ -12,7 +12,8 @@ STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
MAX_SINGLE_FRET = 12
|
||||
MAX_FRET_SPAN = 3
|
||||
PREFERRED_FRET_SPAN = 3
|
||||
MAX_FRET_SPAN = 4
|
||||
MAX_ANCHOR_FRET = 12
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
@@ -21,10 +22,17 @@ class TabPosition(typing.NamedTuple):
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
"""Count occupied fret positions inclusively: frets 1-3 fit, 1-4 do not."""
|
||||
def fret_span(positions) -> int:
|
||||
"""Inclusive occupied positions: frets 7-9 span three, 7-10 span four."""
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) + 1 <= MAX_FRET_SPAN
|
||||
return max(frets) - min(frets) + 1 if frets else 0
|
||||
|
||||
def span_penalty(positions) -> int:
|
||||
"""Two tiers for legal grips; all one-to-three-fret shapes share tier zero."""
|
||||
return int(fret_span(positions) > PREFERRED_FRET_SPAN)
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
return fret_span(positions) <= MAX_FRET_SPAN
|
||||
|
||||
def has_fret_anchor(positions) -> bool:
|
||||
return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions)
|
||||
|
||||
+22
-5
@@ -10,14 +10,19 @@ import typing
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.pitch
|
||||
import generator.chord_families
|
||||
import generator.vocabulary
|
||||
|
||||
PREFERRED_MAX_FRET = 12
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
string_shortfall: int = 0
|
||||
extended_grips: int = 0
|
||||
melody_string: int = 0
|
||||
covered_melody: int = 0
|
||||
covering_tones: int = 0
|
||||
string_shortfall: int = 0
|
||||
family_penalty: int = 0
|
||||
effort: int = 0
|
||||
missing_tones: int = 0
|
||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||
@@ -26,8 +31,12 @@ class VoicingCost(typing.NamedTuple):
|
||||
def moved(self, distance: int) -> VoicingCost:
|
||||
return self._replace(effort=self.effort + distance)
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
|
||||
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
return _voicing_cost(positions, profile, allowed, melody, families)
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def _voicing_cost(positions, profile, allowed, melody, families):
|
||||
frets = {p.fret for p in positions}
|
||||
classes = {p.midi % 12 for p in positions}
|
||||
above = sum(p.midi > max(melody) for p in positions) if melody else 0
|
||||
@@ -36,10 +45,16 @@ def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||
else:
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
|
||||
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
|
||||
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
|
||||
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
|
||||
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes))
|
||||
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
|
||||
return _chord_positions(requirements, prune, profile, melody, families)
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
def _chord_positions(requirements, prune, profile, melody, families):
|
||||
result = []
|
||||
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
|
||||
|
||||
@@ -57,7 +72,9 @@ def chord_positions(requirements: generator.fretboard.ChordRequirements, prune:
|
||||
|
||||
if profile:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
# A lower source melody can be the top of a three-note adaptation on
|
||||
# strings 2-4. It need not be buried beneath an added first-string tone.
|
||||
for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
|
||||
@@ -97,8 +97,10 @@ def windows(document, part):
|
||||
return tuple(result)
|
||||
|
||||
def dependencies(role, resources):
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else ['accompaniment'] if role.endswith('_accompaniment') else []
|
||||
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
|
||||
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
|
||||
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
|
||||
**({'accompaniment': generator.vocabulary.accompaniment(role.split('_')[0], resources)} if role.endswith('_accompaniment') else {}),
|
||||
**{name: resources[name] for name in names}}
|
||||
|
||||
def _code_key(role):
|
||||
@@ -109,11 +111,11 @@ def _code_key(role):
|
||||
paths += list((root / 'solo').glob('*.py'))
|
||||
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
|
||||
elif role.endswith('_accompaniment'):
|
||||
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'accompaniment.py', 'guitar.py')]
|
||||
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'chord_families.py', 'accompaniment.py', 'guitar.py')]
|
||||
if role == 'acoustic_accompaniment':
|
||||
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
|
||||
elif role == 'electric_melody':
|
||||
paths += [root / 'guitar.py', root / 'melody.py']
|
||||
paths += [root / 'guitar.py', root / 'melody.py', root / 'chord_families.py']
|
||||
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
|
||||
|
||||
def _resource_key(directory, role):
|
||||
|
||||
+30
-4
@@ -23,18 +23,40 @@ def _load(contents):
|
||||
result = {}
|
||||
for name, text in contents:
|
||||
value = json.loads(text)
|
||||
if value.get('version') != (2 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||||
if value.get('version') != (3 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 lane in ('electric', 'bass', 'upper'):
|
||||
layer = value['layers'][lane]
|
||||
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
|
||||
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']:
|
||||
if not value['families'][family] or any(fractions.Fraction(d) <= 0 for d in value['families'][family]):
|
||||
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')
|
||||
families = policy['voicing_families']
|
||||
if not families.get('provenance'):
|
||||
raise ValueError('guitar voicing families require provenance')
|
||||
if part == 'electric':
|
||||
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')
|
||||
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
|
||||
@@ -53,6 +75,10 @@ def current():
|
||||
def data(name):
|
||||
return current()['data'][name]
|
||||
|
||||
def accompaniment(part, resources=None):
|
||||
value = (resources or current()['data'])['accompaniment']
|
||||
return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]}
|
||||
|
||||
def role(name=None):
|
||||
active = current()
|
||||
name = name or active['role']
|
||||
|
||||
@@ -68,7 +68,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
requirements = generator.fretboard.chord_requirements(span.harmony)
|
||||
if basses and uppers:
|
||||
self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip)))
|
||||
self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 3)
|
||||
self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 4)
|
||||
self.assertTrue(requirements.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed))
|
||||
for note in span.notes:
|
||||
@@ -188,17 +188,20 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
frets[index] += 1
|
||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), False), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), False), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)):
|
||||
with self.subTest(frets=frets):
|
||||
self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid)
|
||||
for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'):
|
||||
self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol)))
|
||||
def test_autumn_leaves_cm7_and_f7_cannot_span_four_fret_positions(self):
|
||||
def test_four_fret_cm7_and_f7_are_fallbacks_not_preferred_shapes(self):
|
||||
for symbol, frets in (('Cm7', (3, 1, 3, 4)), ('F7', (3, 1, 2, 4))):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(generator.acoustic_voicing.playable(voicing(frets)))
|
||||
self.assertNotIn(voicing(frets), candidates(symbol))
|
||||
self.place(planned_document([[(0, symbol)]]))
|
||||
self.assertTrue(generator.acoustic_voicing.playable(voicing(frets)))
|
||||
self.assertIn(voicing(frets), candidates(symbol))
|
||||
document = planned_document([[(0, symbol)]])
|
||||
selected = generator.acoustic_voicing.select(generator.acoustic.harmony_spans(document))
|
||||
self.assertTrue(all(generator.fretboard.span_penalty(v) == 0 for v in selected))
|
||||
self.place(document)
|
||||
def test_acoustic_cost_has_no_register_span_or_shape_penalties(self):
|
||||
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||
@@ -218,7 +221,7 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
options = [candidates(s) for s in harmonies]
|
||||
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
||||
def cost(path):
|
||||
return generator.acoustic_voicing.Cost(sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
|
||||
return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path)))
|
||||
selected = generator.acoustic_voicing.select(spans)
|
||||
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
|
||||
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
|
||||
@@ -229,16 +232,16 @@ class AcousticVoicingTests(unittest.TestCase):
|
||||
options = candidates(symbol)
|
||||
self.assertTrue(options)
|
||||
self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options))
|
||||
for symbol, omitted in (('C7no1', 0), ('C7no5', 7)):
|
||||
for symbol, omitted in (('C7no1', 0), ('C7no5', 7), ('Dmno1', 2), ('CMno5', 7)):
|
||||
self.assertTrue(candidates(symbol))
|
||||
self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol)))
|
||||
def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self):
|
||||
for symbol in ('Dmno5', 'Dmno1', 'CMno5'):
|
||||
for symbol in ['Dmno5']:
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertFalse(candidates(symbol))
|
||||
document = planned_document([[(0, symbol)]])
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*3 consecutive fret positions'):
|
||||
with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*4 consecutive fret positions'):
|
||||
generator.acoustic_voicing.place(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""General chord families and one-voice acoustic rhythm, not song overrides."""
|
||||
import copy
|
||||
import fractions
|
||||
import unittest
|
||||
|
||||
import generator.acoustic
|
||||
import generator.accompaniment
|
||||
import generator.chord_families
|
||||
import generator.comping
|
||||
import generator.comping_rhythm
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
import generator.harmony
|
||||
import generator.model
|
||||
import generator.production
|
||||
import generator.vocabulary
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic
|
||||
import tests.test_comping_voicings
|
||||
import tests.test_voicing
|
||||
|
||||
class ChordFamilies(unittest.TestCase):
|
||||
def test_common_jazz_qualities_in_multiple_keys(self):
|
||||
suffixes = ('', 'm', 'M7', 'm7', '7', 'mM7', '6', 'm6', 'dim', 'dim7', 'm7b5', 'aug', 'aug7', 'augM7', 'sus2', 'sus4', '7sus4', '9sus4', '9', 'M9', 'm9', '11', 'M11', 'm11', '13', 'M13', 'm13', '7b9', '7#9', '7b5', '7#5', '7#11', '7b13', '7b9#5', '6add9')
|
||||
for root in ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'):
|
||||
for suffix in suffixes:
|
||||
chord = generator.harmony.parse_symbol(root + suffix)
|
||||
for part in ('acoustic', 'electric'):
|
||||
with self.subTest(chord=root + suffix, part=part):
|
||||
# Even four positions cannot satisfy these written tones with
|
||||
# a root/fifth bass <=12 and upper notes on strings 2/3/4.
|
||||
if part == 'acoustic' and root in ('Bb', 'B') and suffix == 'm9':
|
||||
with self.assertRaisesRegex(ValueError, 'no legal acoustic voicing'):
|
||||
generator.comping.voicings(generator.comping.semantic_key(chord), part)
|
||||
continue
|
||||
options = generator.comping.voicings(generator.comping.semantic_key(chord), part)
|
||||
self.assertTrue(options)
|
||||
required = generator.comping.requirements(chord, part)
|
||||
for grip in options:
|
||||
self.assertTrue(required.covers({p.midi % 12 for p in grip}))
|
||||
self.assertTrue({p.midi % 12 for p in grip} <= set(required.allowed))
|
||||
self.assertTrue(generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in grip))
|
||||
coverage = generator.chord_families.coverage(chord, options, part)
|
||||
self.assertTrue(coverage)
|
||||
if part == 'acoustic':
|
||||
self.assertEqual(len(coverage), 4)
|
||||
self.assertTrue(all(c['candidates'] or c['reason'] for c in coverage))
|
||||
else:
|
||||
self.assertEqual(sum(coverage.values()), len(options))
|
||||
def test_root_and_fifth_families_on_both_bass_strings(self):
|
||||
for symbol in ('C', 'Cm', 'CM7', 'Cm7', 'C7', 'CmM7', 'C6', 'Cm6', 'Cdim7', 'Cm7b5', 'C7sus4', 'C7b5'):
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options(symbol, 'acoustic'), 'acoustic')
|
||||
self.assertTrue(all(c['candidates'] for c in coverage), symbol)
|
||||
def test_user_examples_are_transposable_family_members(self):
|
||||
examples = (('Em7b5', (7, 8, 7, 8)), ('A7b9', (7, 8, 6, 8)), ('Dm7', (5, 7, 5, 6)), ('G7', (5, 5, 4, 6)), ('Cm7', (3, 5, 3, 4)), ('F7', (3, 3, 2, 4)))
|
||||
for symbol, frets in examples:
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
grip = tests.test_voicing.grip(frets, (5, 4, 3, 2))
|
||||
self.assertIn(grip, tests.test_comping_voicings.options(symbol, 'acoustic'))
|
||||
family = generator.chord_families.acoustic(chord, grip)
|
||||
self.assertTrue(family['preferred'], symbol)
|
||||
self.assertNotIn(tests.test_voicing.grip((7, 8, 6, 8), (5, 4, 3, 2)), tests.test_comping_voicings.options('A7', 'acoustic'))
|
||||
self.assertNotIn(tests.test_voicing.grip((5, 7, 5, 6), (5, 4, 3, 2)), tests.test_comping_voicings.options('Dm', 'acoustic'))
|
||||
def test_impossible_family_is_explained_without_weakening_harmony(self):
|
||||
chord = generator.harmony.parse_symbol('Bb9')
|
||||
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('Bb9', 'acoustic'), 'acoustic')
|
||||
missing = [c for c in coverage if not c['candidates']]
|
||||
self.assertTrue(missing)
|
||||
self.assertTrue(all('fret/span' in c['reason'] for c in missing))
|
||||
for grip in tests.test_comping_voicings.options('C7/E', 'acoustic'):
|
||||
self.assertEqual(grip.bass.midi % 12, 4)
|
||||
def test_electric_families_and_highest_possible_melody_string(self):
|
||||
for symbol, midi in (('C7', 64), ('FM7', 69), ('C#M7', 65), ('Cm7', 67), ('C7b9', 70)):
|
||||
with self.subTest(chord=symbol, melody=midi):
|
||||
choices = tests.test_voicing.ranked(symbol, (midi,))
|
||||
self.assertTrue(choices)
|
||||
def top_string(v):
|
||||
return min(p.string_number for p in v if p.midi == midi)
|
||||
tier = generator.fretboard.span_penalty(choices[0])
|
||||
self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier))
|
||||
self.assertEqual(max(p.midi for p in choices[0]), midi)
|
||||
self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v))
|
||||
self.assertTrue(all(midi in {p.midi for p in v} for v in choices))
|
||||
low = tests.test_voicing.ranked('C7', (64,))[0]
|
||||
self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2))
|
||||
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']
|
||||
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))
|
||||
|
||||
class CombinedAcoustic(unittest.TestCase):
|
||||
def document(self, bars):
|
||||
document = tests.test_acoustic.acoustic_document(bars)
|
||||
# C bass with F/A above it keeps the overlapping fixture within two frets.
|
||||
for m in document['staves'][0]['measures']:
|
||||
for v in m['voices']:
|
||||
if v['id'] == 'bass':
|
||||
for n in v['notes']:
|
||||
for p in n['pitches']:
|
||||
p['letter'], p['tablature']['fret'] = 'c', 3
|
||||
generator.fretboard.validate_document(document)
|
||||
return document
|
||||
def convert(self, document):
|
||||
before = tests.test_acoustic.sounding(document)
|
||||
generator.acoustic.combine(document)
|
||||
generator.model.validate_document(document)
|
||||
generator.fretboard.validate_document(document)
|
||||
self.assertEqual(tests.test_acoustic.sounding(document), before)
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in document['staves'][0]['measures']))
|
||||
once = copy.deepcopy(document)
|
||||
generator.acoustic.combine(document)
|
||||
self.assertEqual(document, once)
|
||||
return [n for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes']]
|
||||
def test_staggered_quarter_and_eighth_share_a_voice_without_shortening(self):
|
||||
doc = self.document([([(0, 1)], [(fractions.Fraction(1, 2), fractions.Fraction(1, 2))])])
|
||||
notes = self.convert(doc)
|
||||
self.assertEqual([len(n['pitches']) for n in notes[:2]], [1, 3])
|
||||
a, b = notes[:2]
|
||||
self.assertEqual(a['pitches'][0]['tie_to'], b['pitches'][0]['id'])
|
||||
self.assertEqual(b['pitches'][0]['tie_from'], a['pitches'][0]['id'])
|
||||
self.assertTrue(all(n['kind'] == 'rest' for n in notes[2:]))
|
||||
self.assertTrue(all('stem' not in n and all('stem' not in p for p in n['pitches']) for n in notes))
|
||||
def test_repeated_bass_remains_an_attack_under_a_held_upper_chord(self):
|
||||
doc = self.document([([(0, fractions.Fraction(1, 2)), (fractions.Fraction(1, 2), fractions.Fraction(1, 2))], [(0, 1)])])
|
||||
notes = self.convert(doc)
|
||||
bass = [p for n in notes for p in n['pitches'] if p['tablature']['string'] == 5]
|
||||
self.assertEqual(len(bass), 2)
|
||||
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in bass))
|
||||
upper = [p for n in notes for p in n['pitches'] if p['tablature']['string'] == 4]
|
||||
self.assertEqual(upper[0]['tie_to'], upper[1]['id'])
|
||||
def test_existing_cross_bar_tie_survives_combination(self):
|
||||
doc = self.document([([(3, 1)], [(3, 1)]), ([(0, 1)], [(fractions.Fraction(1, 2), fractions.Fraction(1, 2))])])
|
||||
bass = [p for m in doc['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes'] for p in n['pitches']]
|
||||
bass[0]['tie_to'], bass[1]['tie_from'] = bass[1]['id'], bass[0]['id']
|
||||
self.convert(doc)
|
||||
def test_active_acoustic_rhythm_has_short_values_and_eighth_pairs(self):
|
||||
source = tests.solo_fixtures.song(count=2, chords=('C7', 'FM7'))
|
||||
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()))
|
||||
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'):
|
||||
generator.comping_rhythm.candidates(m, None, 'acoustic', (), 'exact')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -31,12 +31,15 @@ class LayeredComping(unittest.TestCase):
|
||||
self.resources = generator.vocabulary.load()
|
||||
def test_candidates_preserve_all_counts_and_articulations(self):
|
||||
m = self.source['staves'][0]['measures'][0]
|
||||
policy = self.resources['accompaniment']
|
||||
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):
|
||||
@@ -125,7 +128,7 @@ class LayeredComping(unittest.TestCase):
|
||||
n.update(kind='rest', pitches=[])
|
||||
results[part + '_solo'] = empty
|
||||
assembled = generator.production.assemble(source, results)
|
||||
self.assertTrue(any(len(m['voices']) == 2 for m in assembled['acoustic']['staves'][0]['measures']))
|
||||
self.assertTrue(all(len(m['voices']) == 1 for m in assembled['acoustic']['staves'][0]['measures']))
|
||||
audit = generator.arrange.accompaniment.audit(self.source, assembled)
|
||||
self.assertGreater(audit['acoustic']['reused_bars'], 0)
|
||||
for doc in assembled.values():
|
||||
|
||||
@@ -21,9 +21,9 @@ def options(symbol, part):
|
||||
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
|
||||
|
||||
def path_cost(path, costs, part, left=(), right=(), close=False):
|
||||
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(4)]
|
||||
total[1] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
|
||||
total[1] += generator.comping.movement(part, path[-1], path[0] if close else right)
|
||||
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
|
||||
total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path))
|
||||
total[2] += generator.comping.movement(part, path[-1], path[0] if close else right)
|
||||
return tuple(total)
|
||||
|
||||
class CompingVoicings(unittest.TestCase):
|
||||
@@ -69,9 +69,9 @@ class CompingVoicings(unittest.TestCase):
|
||||
# Bass responsibility belongs to the acoustic voicing, as in electric melody.
|
||||
self.assertEqual(options('C/E', 'electric'), options('C', 'electric'))
|
||||
def test_impossible_grip_reports_source_location_without_relaxation(self):
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C5',))
|
||||
source = tests.solo_fixtures.song(count=1, chords=('C7b9#11',))
|
||||
for part in ('acoustic', 'electric'):
|
||||
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C5: no legal'):
|
||||
with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C7b9#11: no legal'):
|
||||
generator.comping.Canonical(source, part, 'test')
|
||||
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
@@ -90,7 +90,7 @@ class CompingVoicings(unittest.TestCase):
|
||||
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
|
||||
for part in ('acoustic', 'electric'):
|
||||
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
|
||||
costs = tuple({v: (0, 0, 0, 0) for v in layer} for layer in layers)
|
||||
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers)
|
||||
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
|
||||
with self.subTest(part=part, endpoints=endpoints):
|
||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
||||
|
||||
@@ -7,6 +7,8 @@ import unittest.mock
|
||||
|
||||
import generator.acoustic
|
||||
import generator.acoustic_voicing
|
||||
import generator.chord_families
|
||||
import generator.comping
|
||||
import generator.electric
|
||||
import generator.fretboard
|
||||
import generator.guitar
|
||||
@@ -18,6 +20,8 @@ import generator.solo.placement
|
||||
import tests.solo_fixtures
|
||||
import tests.test_acoustic_voicing
|
||||
import tests.test_musicxml
|
||||
import tests.test_voicing
|
||||
import tests.score_events
|
||||
|
||||
def position(string, fret):
|
||||
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
|
||||
@@ -35,6 +39,89 @@ def document(positions):
|
||||
return result
|
||||
|
||||
class FretPolicyTests(unittest.TestCase):
|
||||
def test_span_tiers_group_one_two_and_three_positions(self):
|
||||
for frets, span, tier, legal in (((7,), 1, 0, True), ((7, 8), 2, 0, True), ((7, 9), 3, 0, True), ((7, 10), 4, 1, True), ((7, 11), 5, 1, False)):
|
||||
grip = tuple(position(s, f) for s, f in zip((2, 1), frets))
|
||||
self.assertEqual(generator.fretboard.fret_span(grip), span)
|
||||
self.assertEqual(generator.fretboard.span_penalty(grip), tier)
|
||||
self.assertEqual(generator.fretboard.within_fret_span(grip), legal)
|
||||
self.assertEqual(generator.fretboard.fret_span(()), 0)
|
||||
def test_primary_comping_grip_beats_family_and_arbitrarily_less_movement(self):
|
||||
chord = generator.harmony.parse_symbol('C')
|
||||
key = generator.comping.semantic_key(chord)
|
||||
primary = tests.test_acoustic_voicing.voicing((7, 5, 5, 5))
|
||||
fallback = tests.test_acoustic_voicing.voicing((8, 5, 5, 5), 6)
|
||||
self.assertFalse(generator.chord_families.acoustic(chord, primary)['preferred'])
|
||||
self.assertTrue(generator.chord_families.acoustic(chord, fallback)['preferred'])
|
||||
self.assertTrue(all(v in generator.comping.voicings(key, 'acoustic') for v in (primary, fallback)))
|
||||
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)}
|
||||
# Movement/family wins cannot purchase entry into the four-fret tier.
|
||||
costs[primary] = costs[primary]._replace(effort=100000)
|
||||
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
||||
self.assertEqual(path, (primary,))
|
||||
self.assertEqual(cost.extended_grips, 0)
|
||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
|
||||
legacy = generator.acoustic_voicing.advance((primary, fallback), generator.comping.requirements(chord, 'acoustic'), states)
|
||||
self.assertEqual(min(legacy, key=lambda s: s.cost).voicing, primary)
|
||||
def test_primary_electric_grip_beats_highest_melody_string_and_pruning(self):
|
||||
for prune in (False, True):
|
||||
choices = tests.test_voicing.ranked('G7', (65,), prune=prune)
|
||||
best = choices[0]
|
||||
self.assertEqual(generator.fretboard.span_penalty(best), 0)
|
||||
self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2)
|
||||
self.assertEqual(max(p.midi for p in best), 65)
|
||||
self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices))
|
||||
primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000)
|
||||
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
|
||||
def test_previously_missing_chords_select_reported_fallbacks(self):
|
||||
for symbol in ('Bb9', 'BbM9', 'Bb7#9', 'Bb6add9'):
|
||||
with self.subTest(chord=symbol):
|
||||
source = tests.solo_fixtures.song(count=1, chords=(symbol,))
|
||||
canonical = generator.comping.Canonical(source, 'acoustic', 'fallback')
|
||||
report = canonical.report[0]
|
||||
self.assertEqual(report['fret_span'], 4)
|
||||
self.assertEqual(report['span_group'], 'fallback')
|
||||
self.assertEqual(report['span_candidates']['primary'], 0)
|
||||
self.assertGreater(report['span_candidates']['fallback'], 0)
|
||||
self.assertEqual(canonical.path_cost.extended_grips, 1)
|
||||
required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic')
|
||||
self.assertTrue(required.covers({p['sounding_midi'] % 12 for p in report['positions']}))
|
||||
def test_future_held_notes_can_require_a_retained_four_fret_history(self):
|
||||
source = document((position(4, 5), position(2, 2))) # G3, C#4
|
||||
measure = source['staves'][0]['measures'][0]
|
||||
for p in tests.test_musicxml.notes(source)[0]['pitches']:
|
||||
p.pop('tablature')
|
||||
incoming = generator.model.make_event('incoming', fractions.Fraction(1), fractions.Fraction(3), 'note', [{'letter': 'a', 'alter': 0, 'octave': 3, 'tablature': {'string': 3, 'fret': 2}}])
|
||||
measure['voices'].append({'id': 'other', 'notes': [generator.model.make_event('rest', fractions.Fraction(), fractions.Fraction(1), 'rest'), incoming]})
|
||||
# G/C# also have primary placements. Neither permits this held A/string 3.
|
||||
self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6))))
|
||||
before = tests.score_events.timeline(source)
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
self.assertEqual(tests.score_events.timeline(source), before)
|
||||
self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}])
|
||||
def test_tied_four_fret_grip_is_charged_once_and_keeps_its_anchor(self):
|
||||
source = document((position(2, 12), position(1, 15)))
|
||||
original = tests.test_musicxml.notes(source)[0]
|
||||
pieces = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)]
|
||||
for a, b in zip(pieces[0]['pitches'], pieces[1]['pitches']):
|
||||
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
||||
source['staves'][0]['measures'][0]['voices'][0]['notes'] = pieces
|
||||
_, tones = generator.guitar.guitar_events(source)
|
||||
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
||||
for i, n in enumerate(pieces):
|
||||
states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True)
|
||||
self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1)
|
||||
generator.guitar.place_guitar(source)
|
||||
generator.model.validate_document(source)
|
||||
generator.fretboard.validate_document(source)
|
||||
self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']])
|
||||
# Removing the low anchor early still leaves an illegal high single tail.
|
||||
pieces[0]['pitches'][0].pop('tie_to')
|
||||
pieces[1]['pitches'].pop(0)
|
||||
with self.assertRaisesRegex(ValueError, 'held/tied tails'):
|
||||
generator.fretboard.validate_document(source)
|
||||
def test_single_limits_and_alternate_string(self):
|
||||
for midi, expected in ((40, False), (41, True), (72, True), (76, True), (77, False), (84, False)):
|
||||
with self.subTest(midi=midi):
|
||||
@@ -59,7 +146,7 @@ class FretPolicyTests(unittest.TestCase):
|
||||
generator.guitar.place_guitar(source)
|
||||
self.assertEqual(source, before)
|
||||
def test_chord_anchor_and_span_are_hard_constraints(self):
|
||||
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), False), ((0, 2), False)):
|
||||
for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), True), ((12, 16), False), ((0, 2), False)):
|
||||
with self.subTest(frets=frets):
|
||||
source = document(tuple(position(s, f) for s, f in zip((2, 1), frets)))
|
||||
if valid:
|
||||
|
||||
+10
-5
@@ -32,20 +32,20 @@ class GuitarTests(unittest.TestCase):
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85)))
|
||||
self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2)))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 13, 72))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84))))
|
||||
def test_fretted_positions_keep_span_and_proximity_scoring(self):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), False), ((1, 5), False), ((7, 9), True), ((7, 10), False), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)):
|
||||
for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 9), True), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)):
|
||||
with self.subTest(frets=frets):
|
||||
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets))
|
||||
self.assertEqual(generator.fretboard.within_fret_span(positions), valid)
|
||||
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
||||
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
|
||||
self.assertTrue(generator.fretboard.within_fret_span(()))
|
||||
def test_held_notes_obey_the_shared_three_fret_limit(self):
|
||||
def test_held_notes_obey_the_shared_four_fret_limit(self):
|
||||
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
||||
for fret, valid in ((9, True), (10, False), (11, False)):
|
||||
for fret, valid in ((9, True), (10, True), (11, False)):
|
||||
with self.subTest(fret=fret):
|
||||
pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))]
|
||||
self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid)
|
||||
@@ -194,8 +194,13 @@ class GuitarTests(unittest.TestCase):
|
||||
content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='<dot/><tie type="stop"/>')
|
||||
content += '<backup><duration>4</duration></backup><forward><duration>1</duration></forward>' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='<dot/><voice>2</voice>')
|
||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||
# These fixed E/F positions span five frets; a tie cannot move to a
|
||||
# different E position to make the incoming F fit.
|
||||
for note in tests.test_musicxml.notes(document):
|
||||
for pitch in note['pitches']:
|
||||
pitch['tablature'] = {'string': 2, 'fret': 5} if pitch['letter'] == 'e' else {'string': 1, 'fret': 1}
|
||||
before = copy.deepcopy(document)
|
||||
with self.assertRaisesRegex(ValueError, '3 consecutive fret positions.*held/tied strings'):
|
||||
with self.assertRaisesRegex(ValueError, '4 consecutive fret positions.*held/tied strings'):
|
||||
generator.guitar.place_guitar(document)
|
||||
self.assertEqual(document, before)
|
||||
def test_proximity_chooses_a_path_without_relocating_tied_notes(self):
|
||||
|
||||
@@ -41,35 +41,37 @@ class GuitarPartTests(unittest.TestCase):
|
||||
self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required)
|
||||
candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric')
|
||||
self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates))
|
||||
def test_power_chord_cannot_expand_span_or_invent_upper_tones(self):
|
||||
def test_power_chord_uses_four_fret_fallback_without_inventing_tones(self):
|
||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C5'))
|
||||
self.assertEqual(set(requirements.allowed), {0, 7})
|
||||
self.assertFalse(generator.acoustic_voicing.candidates(requirements))
|
||||
self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric'))
|
||||
for candidates in (generator.acoustic_voicing.candidates(requirements), generator.guitar.chord_positions(requirements, profile='electric')):
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(generator.fretboard.fret_span(c) == 4 for c in candidates))
|
||||
self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates))
|
||||
def test_acoustic_uses_bass_and_three_upper_notes(self):
|
||||
result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic')
|
||||
generator.model.validate_document(result)
|
||||
generator.fretboard.validate_document(result)
|
||||
sounding = [n for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) if n['pitches']]
|
||||
self.assertTrue(sounding)
|
||||
self.assertTrue(all(len(n['pitches']) in (1, 3) for n in sounding))
|
||||
self.assertTrue(all(len(n['pitches']) in (1, 3, 4) for n in sounding))
|
||||
for n in sounding:
|
||||
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6]))
|
||||
self.assertIn(sorted(p['tablature']['string'] for p in n['pitches']), ([2, 3, 4], [5], [6], [2, 3, 4, 5], [2, 3, 4, 6]))
|
||||
def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
|
||||
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)):
|
||||
source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
|
||||
chord = generator.harmony.parse_symbol(symbol)
|
||||
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
|
||||
result = tests.arrangement_fixtures.section_document(source, 'acoustic')
|
||||
notes = [n for m in result['staves'][0]['measures'] for v in m['voices'] if v['id'] == 'bass' for n in v['notes']]
|
||||
self.assertEqual({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {expected})
|
||||
bass = [p for m in result['staves'][0]['measures'] for n in generator.score.measure_notes(m) for p in n['pitches'] if p['tablature']['string'] in (5, 6)]
|
||||
self.assertEqual({generator.model.pitch_number(p) % 12 for p in bass}, {expected})
|
||||
def test_long_melody_chords_and_short_notes(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 2, 'half', octave=4) + tests.test_musicxml.xml_note('G', 1, 'quarter', octave=4) + tests.test_musicxml.xml_note('C', 1, 'quarter', octave=5)
|
||||
notes = tests.test_musicxml.notes(self.section(content))
|
||||
self.assertIn(len(notes[0]['pitches']), (3, 4))
|
||||
self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1])
|
||||
self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76)
|
||||
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [1, 2, 3, 4]))
|
||||
self.assertIn(sorted(p['tablature']['string'] for p in notes[0]['pitches']), ([1, 2, 3], [2, 3, 4], [1, 2, 3, 4]))
|
||||
def test_tied_quarters_qualify_and_keep_all_strings(self):
|
||||
content = tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='<tie type="stop"/>') + '<note><rest/><duration>2</duration><type>half</type></note>'
|
||||
result = self.section(content)
|
||||
|
||||
@@ -125,7 +125,7 @@ class IndependentRoles(unittest.TestCase):
|
||||
def signature(m):
|
||||
return [(n['onset'], n['duration'], [(generator.model.pitch_number(p), p['tablature']) for p in n['pitches']]) for v in m['voices'] for n in v['notes']]
|
||||
self.assertEqual([signature(m) for m in measures[4:8]], [signature(m) for m in measures[8:12]])
|
||||
self.assertTrue(all(len(n['pitches']) in (0, 1, 3) for m in measures for v in m['voices'] for n in v['notes']))
|
||||
self.assertTrue(all(len(n['pitches']) in (0, 1, 3, 4) for m in measures for v in m['voices'] for n in v['notes']))
|
||||
|
||||
class TypedExpressionContracts(unittest.TestCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -23,24 +23,24 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
|
||||
return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape))
|
||||
|
||||
class VoicingTests(unittest.TestCase):
|
||||
def test_electric_chords_use_the_shared_three_fret_limit(self):
|
||||
def test_electric_chords_use_the_shared_four_fret_limit(self):
|
||||
for prune in (False, True):
|
||||
candidates = ranked('CM7', prune=prune)
|
||||
self.assertTrue(candidates)
|
||||
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 3 for c in candidates))
|
||||
self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 4 for c in candidates))
|
||||
candidates = ranked('CM7')
|
||||
self.assertIn(grip((5, 4, 5, 3)), candidates)
|
||||
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertNotIn(grip((5, 9, 8, 7)), candidates)
|
||||
def test_requested_shapes_win_without_a_shape_dictionary(self):
|
||||
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
|
||||
with self.subTest(symbol=symbol):
|
||||
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected))
|
||||
def test_four_strings_precede_melody_height_and_root_presence(self):
|
||||
def test_melody_height_precedes_four_strings_and_optional_root(self):
|
||||
candidates = ranked('C#M7', (65,))
|
||||
four = next(c for c in candidates if len(c) == 4 and max(p.midi for p in c) > 65)
|
||||
three = next(c for c in candidates if len(c) == 3 and max(p.midi for p in c) == 65)
|
||||
self.assertLess(candidates.index(four), candidates.index(three))
|
||||
self.assertLess(candidates.index(three), candidates.index(four))
|
||||
candidates = ranked('FM7', (69,))
|
||||
rootless = next(c for c in candidates if len(c) == 4 and 5 not in {p.midi % 12 for p in c})
|
||||
rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})
|
||||
@@ -55,7 +55,7 @@ class VoicingTests(unittest.TestCase):
|
||||
def test_wider_low_chord_cannot_displace_a_playable_melody_grip(self):
|
||||
candidates = ranked('CM7', (67,))
|
||||
self.assertEqual(candidates[0], grip((5, 4, 5, 3)))
|
||||
self.assertNotIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertIn(grip((2, 4, 1, 3)), candidates)
|
||||
self.assertEqual(max(p.midi for p in candidates[0]), 67)
|
||||
def test_essential_extensions_are_never_relaxed_to_add_the_root(self):
|
||||
for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):
|
||||
|
||||
Reference in New Issue
Block a user