Chord Families

This commit is contained in:
khodowany
2026-10-06 02:57:37 -05:00
parent df83cae38c
commit e71385dad1
16 changed files with 590 additions and 126 deletions
+3 -3
View File
@@ -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. 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. **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`. 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. 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, within three consecutive fret positions (e.g. 7–9, not 7–10). Acoustic bass remains within frets 1–12 and upper chords need their own anchor. Some chord/key combinations have no legal inversion under these combined constraints; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded.
There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged. There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged.
+12 -6
View File
@@ -123,15 +123,17 @@ 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. `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. Its lowest feasible string number and foreground position precede chord size in selection. 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. Exhaustive checks across C, F# and Bb cover 35 common chord qualities. Under the existing constraints, Bb9, BbM9, Bbm9, Bb7#9 and Bb6add9 have no acoustic variant: for example, the low Bb9 root grip needs an open string, its octave needs bass fret 13, and fifth-bass frets 8/10/7/9 span four consecutive positions. 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. Electric accompaniment costs prioritize chord size and shared family rank, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic costs prefer declared degree-order families on either bass string, then travel of the bass and all three upper voices, optional-tone coverage and register preference. Other valid compact grips remain available. 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 family, bass/string family availability, selected positions, bass degree and movement.
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. `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.
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. 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 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. 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.
@@ -199,7 +201,7 @@ 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/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/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/{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. | | `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. | | Solo event, expression, placement, notation and export helpers | Retained physical/timing safeguards shared by authored and predicted music. |
@@ -213,6 +215,10 @@ 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. 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/`.
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. 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. 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.
+199 -11
View File
@@ -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.", "provenance": "Independently authored sparse guitar rhythm rules: metrical placement, support of source melody, releases and harmonic changes. No training-score extraction.",
"license": "MIT", "license": "MIT",
"grid": "1/2", "grid": "1/2",
"lane_candidates": 24, "lane_candidates": 24,
"measure_candidates": 32, "measure_candidates": 32,
"instruments": {
"electric": {
"families": { "families": {
"stabs": [ "stabs": [
"1/2" "1/2"
@@ -41,17 +43,49 @@
"short_long", "short_long",
"long_short" "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": { "bass": {
"attacks": [ "attacks": [
1, 1,
2 2
], ],
"families": [ "families": [
"quarter_stabs", "quarters",
"sustain", "eighths",
"broad", "quarter_eighth",
"long_short" "eighth_quarter"
] ]
}, },
"upper": { "upper": {
@@ -60,13 +94,167 @@
3 3
], ],
"families": [ "families": [
"stabs", "quarters",
"quarter_stabs", "eighths",
"sustain", "quarter_eighth",
"broad", "eighth_quarter"
"short_long", ]
"long_short" }
},
"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
View File
@@ -76,27 +76,34 @@ def prepare(document: dict) -> None:
generator.accompaniment.normalize_beams(notes) generator.accompaniment.normalize_beams(notes)
def combine(document: dict) -> None: 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): for measure in measures(document):
length = generator.model.fraction(measure['duration']) 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'])) sounding = [(generator.model.fraction(n['onset']), generator.model.fraction(n['onset']) + generator.model.fraction(p['duration']), p)
notes, cursor = [], fractions.Fraction() 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))})
def rest(end): notes = []
nonlocal cursor for a, b in zip(boundaries, boundaries[1:]):
for duration, written in generator.accompaniment.written_durations(end - cursor): active = sorted((p for start, end, p in sounding if start <= a < end), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice_arranged'}) for duration, written in generator.accompaniment.written_durations(b - a):
cursor += duration 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)
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])): note['voice'] = 'voice_arranged'
rest(start) for old, new in zip(active, note['pitches']):
group = list(group) fragments.setdefault(old['id'], []).append(new)
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string'])) notes.append(note)
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches] a += duration
duration = generator.model.fraction(group[0]['duration']) if not sounding and length == generator.model.meter_length(measure['meter']):
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']):
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}] 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}] 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']
+2 -1
View File
@@ -100,7 +100,8 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
tuplets = generator.arrange.pickup.retain_links(output, tuplets) tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124) result['metadata'].setdefault('tempo', 124)
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.prepare(result)
generator.acoustic_voicing.place(result) generator.acoustic_voicing.place(result)
generator.acoustic.combine(result) generator.acoustic.combine(result)
+49
View File
@@ -0,0 +1,49 @@
"""Transposable guitar families, derived from the written chord and legal grips."""
import generator.harmony
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
+8 -3
View File
@@ -8,6 +8,7 @@ import fractions
import generator.accompaniment import generator.accompaniment
import generator.comping_rhythm import generator.comping_rhythm
import generator.chord_families
import generator.acoustic_voicing import generator.acoustic_voicing
import generator.arrange.form import generator.arrange.form
import generator.fretboard import generator.fretboard
@@ -67,9 +68,10 @@ def local_cost(part, key, grip, center):
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center) register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
if part == 'electric': if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed) value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
return (value.string_shortfall, value.effort, value.missing_tones, register) return (3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register)
# As in acoustic harmony-span placement: travel first, then completeness. # Familiar degree orders on either bass string, then whole-form movement.
return (0, 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register) family = generator.chord_families.acoustic(json.loads(key), grip)
return (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): def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
"""Exact small-state dynamic programming; never prune by hand center. """Exact small-state dynamic programming; never prune by hand center.
@@ -146,6 +148,7 @@ class Canonical:
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False) self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
if selected: if selected:
previous = selected[-1] previous = selected[-1]
if generator.vocabulary.accompaniment(part)['connect_holds']:
self.connect_holds(inputs) self.connect_holds(inputs)
@property @property
def report(self): def report(self):
@@ -207,6 +210,8 @@ class Canonical:
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter'])) root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord), rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, 'figure': span.figure, '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),
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip], '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_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None, 'bass_string': bass.string_number if bass else None,
+5 -3
View File
@@ -137,7 +137,7 @@ def lane_candidates(measure, prior, lane, melody, policy, seed):
continue continue
if any(not any(a <= t < b for t in onsets) for a, b in required): if any(not any(a <= t < b for t in onsets) for a, b in required):
continue 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 continue
for family in policy['layers'][lane]['families']: for family in policy['layers'][lane]['families']:
durations = policy['families'][family] 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 following = onsets[i + 1] if i + 1 < len(onsets) else length
duration = fractions.Fraction(durations[i % len(durations)]) duration = fractions.Fraction(durations[i % len(durations)])
events.append(Attack(lane, a, min(a + duration, following, end))) 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 () exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions))) 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)) result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
if not result: if not result:
# A short partial bar can have fewer gestures, not invented subdivisions. # 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] a, b, _ = active[0]
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 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') 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:])) 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): def candidates(measure, prior, part, melody, seed):
policy = generator.vocabulary.data('accompaniment') policy = generator.vocabulary.accompaniment(part)
if part == 'electric': if part == 'electric':
return lane_candidates(measure, prior, 'electric', melody, policy, seed) return lane_candidates(measure, prior, 'electric', melody, policy, seed)
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed) bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
+21 -5
View File
@@ -10,14 +10,18 @@ import typing
import generator.fretboard import generator.fretboard
import generator.model import generator.model
import generator.pitch import generator.pitch
import generator.chord_families
import generator.vocabulary
PREFERRED_MAX_FRET = 12 PREFERRED_MAX_FRET = 12
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1)) ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
class VoicingCost(typing.NamedTuple): class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0 melody_string: int = 0
covered_melody: int = 0 covered_melody: int = 0
covering_tones: int = 0 covering_tones: int = 0
string_shortfall: int = 0
family_penalty: int = 0
effort: int = 0 effort: int = 0
missing_tones: int = 0 missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost: def plus(self, other: VoicingCost) -> VoicingCost:
@@ -26,8 +30,12 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance: int) -> VoicingCost: def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance) return self._replace(effort=self.effort + distance)
@functools.lru_cache(maxsize=8192)
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost: 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} frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions} classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0 above = sum(p.midi > max(melody) for p in positions) if melody else 0
@@ -36,10 +44,16 @@ def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile
effort = 2 * max(0, len(frets) - 1) + 3 * span effort = 2 * max(0, len(frets) - 1) + 3 * span
else: else:
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
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(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) @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 = [] result = []
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING] options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
@@ -57,7 +71,9 @@ def chord_positions(requirements: generator.fretboard.ChordRequirements, prune:
if profile: if profile:
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)} 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)): 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}: 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 continue
+5 -3
View File
@@ -97,8 +97,10 @@ def windows(document, part):
return tuple(result) return tuple(result)
def dependencies(role, resources): 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']}, 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}} **{name: resources[name] for name in names}}
def _code_key(role): def _code_key(role):
@@ -109,11 +111,11 @@ def _code_key(role):
paths += list((root / 'solo').glob('*.py')) paths += list((root / 'solo').glob('*.py'))
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py'] paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
elif role.endswith('_accompaniment'): 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': if role == 'acoustic_accompaniment':
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py'] paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
elif role == 'electric_melody': 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))]) 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): def _resource_key(directory, role):
+30 -4
View File
@@ -23,18 +23,40 @@ def _load(contents):
result = {} result = {}
for name, text in contents: for name, text in contents:
value = json.loads(text) value = json.loads(text)
if value.get('version') != (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') raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
if name == 'accompaniment': if name == 'accompaniment':
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2): if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
raise ValueError('accompaniment requires an eighth-note placement grid') raise ValueError('accompaniment requires an eighth-note placement grid')
for lane in ('electric', 'bass', 'upper'): for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
layer = value['layers'][lane] 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']: if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
raise ValueError('invalid accompaniment attack range or duration families') raise ValueError('invalid accompaniment attack range or duration families')
for family in layer['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') 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: if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
raise ValueError('accompaniment candidate budgets must be 1–64') raise ValueError('accompaniment candidate budgets must be 1–64')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', [])))) entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
@@ -53,6 +75,10 @@ def current():
def data(name): def data(name):
return current()['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): def role(name=None):
active = current() active = current()
name = name or active['role'] name = name or active['role']
+159
View File
@@ -0,0 +1,159 @@
"""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', 'F#', 'Bb'):
for suffix in suffixes:
chord = generator.harmony.parse_symbol(root + suffix)
for part in ('acoustic', 'electric'):
with self.subTest(chord=root + suffix, part=part):
# These transpositions have no inversion satisfying all of the
# three-consecutive-frets, bass <=12 and written-tone constraints.
# A familiar low Bb9 root grip needs fret zero; its octave needs
# bass fret 13. The fifth-bass grip 8/10/7/9 spans four positions.
if part == 'acoustic' and root == 'Bb' and suffix in ('9', 'M9', 'm9', '7#9', '6add9'):
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('C9')
coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('C9', '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)
self.assertEqual(top_string(choices[0]), min(map(top_string, choices)))
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()
+5 -2
View File
@@ -31,12 +31,15 @@ class LayeredComping(unittest.TestCase):
self.resources = generator.vocabulary.load() self.resources = generator.vocabulary.load()
def test_candidates_preserve_all_counts_and_articulations(self): def test_candidates_preserve_all_counts_and_articulations(self):
m = self.source['staves'][0]['measures'][0] 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})): 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') candidates = generator.comping_rhythm.lane_candidates(m, m['harmonies'][0], lane, (), policy, 'test')
self.assertEqual({len(c.events) for c in candidates}, expected) 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) 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)) 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': if lane == 'bass':
self.assertTrue(all(c.events[0].start == 0 for c in candidates)) self.assertTrue(all(c.events[0].start == 0 for c in candidates))
def test_ranges_downbeats_and_overlap(self): def test_ranges_downbeats_and_overlap(self):
@@ -125,7 +128,7 @@ class LayeredComping(unittest.TestCase):
n.update(kind='rest', pitches=[]) n.update(kind='rest', pitches=[])
results[part + '_solo'] = empty results[part + '_solo'] = empty
assembled = generator.production.assemble(source, results) 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) audit = generator.arrange.accompaniment.audit(self.source, assembled)
self.assertGreater(audit['acoustic']['reused_bars'], 0) self.assertGreater(audit['acoustic']['reused_bars'], 0)
for doc in assembled.values(): for doc in assembled.values():
+5 -5
View File
@@ -52,24 +52,24 @@ class GuitarPartTests(unittest.TestCase):
generator.fretboard.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']] 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(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: 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): def test_explicit_bass_is_preserved_for_triads_and_extended_chords(self):
for symbol, expected in (('CM/E', 4), ('C7b9/E', 4)): 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())) source = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note()))
chord = generator.harmony.parse_symbol(symbol) chord = generator.harmony.parse_symbol(symbol)
source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}] source['staves'][0]['measures'][0]['harmonies'] = [{**chord, 'id': 'h1', 'onset': generator.model.rational(0)}]
result = tests.arrangement_fixtures.section_document(source, 'acoustic') 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']] 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({min(generator.model.pitch_number(p) for p in n['pitches']) % 12 for n in notes if n['pitches']}, {expected}) self.assertEqual({generator.model.pitch_number(p) % 12 for p in bass}, {expected})
def test_long_melody_chords_and_short_notes(self): 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) 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)) notes = tests.test_musicxml.notes(self.section(content))
self.assertIn(len(notes[0]['pitches']), (3, 4)) self.assertIn(len(notes[0]['pitches']), (3, 4))
self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1]) self.assertEqual([len(n['pitches']) for n in notes[1:]], [1, 1])
self.assertEqual(generator.model.pitch_number(notes[0]['pitches'][0]), 76) 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): 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>' 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) result = self.section(content)
+1 -1
View File
@@ -125,7 +125,7 @@ class IndependentRoles(unittest.TestCase):
def signature(m): 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']] 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.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): class TypedExpressionContracts(unittest.TestCase):
def setUp(self): def setUp(self):
+2 -2
View File
@@ -36,11 +36,11 @@ class VoicingTests(unittest.TestCase):
for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))): for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))):
with self.subTest(symbol=symbol): with self.subTest(symbol=symbol):
self.assertEqual(ranked(symbol, (melody,))[0], grip(expected)) 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,)) 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) 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) 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,)) 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}) 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}) rooted = next(c for c in candidates if len(c) == 3 and 5 in {p.midi % 12 for p in c})