+29
-4
@@ -133,11 +133,11 @@ 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. `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.
|
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. Electric melody chords must also use strings 1–3 or 1–4, always including string 1. The exact source pitch is retained even when a legal supporting chord tone must sound above it. Within the selected span tier, the melody's 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 and solos use the shared hand-window placement objective. A harmonization with no legal chord retains the existing reported single-note fallback. 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.
|
||||||
|
|
||||||
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.
|
Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Coverage checks exercise 35 common qualities in all 12 keys. Four-fret fallbacks support previously unavailable acoustic Bb9, BbM9, Bb7#9 and Bb6add9; for example, Bb9 uses fifth-bass frets 8/10/7/9 on strings 5/4/3/2. Acoustic Bbm9 and Bm9 still have no grip meeting all constraints. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones.
|
||||||
|
|
||||||
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank, then combined grip effort, hand-center travel, register and position costs, followed by missing optional tones and register tie-breaking. Acoustic prefers declared degree-order families on either bass string, then a combined travel/register/position cost, optional-tone coverage and register tie-breaking. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
|
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; acoustic prefers declared degree-order families on either bass string. Both then minimize hand-window travel, the passage maximum hand position, secondary finger/grip effort, missing optional tones and sounding-register distance, in that order. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
|
||||||
|
|
||||||
Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged.
|
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.
|
||||||
|
|
||||||
@@ -169,9 +169,34 @@ Acoustic sequential-release verification on October 7, 2026: `python3 modulus.py
|
|||||||
|
|
||||||
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing.
|
||||||
|
|
||||||
Both accompaniment roles add `register_weight * abs(mean sounding MIDI - center)` and `high_position_weight * max(0, mean fret - comfortable_max_fret)` to their existing grip effort and movement tier, once per active harmonic span. The three role policies (`acoustic_accompaniment`, `electric_accompaniment`, `electric_melody`) use weights 1 and 1 and threshold 9; their register centers are 53, 60 and 60 respectively. All arithmetic is rational. Electric melody adds the same costs only to harmonized attacks, below compact-span, melody-string, foreground, chord-size and family priorities. Exact melody pitches are never transposed to meet the register center; monophonic placement is unchanged. Scoring and scored-pruning caches explicitly include the normalized preference tuple. Tied continuations incur no second chord cost. Existing travel costs and source-form joins remain in place; high positions can still win when avoiding a costly shift. The ending uses its own accompaniment role's local cost and incoming grip.
|
Guitar placement shares the `hand-window-2` model in `generator/hand_position.py`. **Both chords and single notes** may occupy any legal four-consecutive-fret window. Position 7 covers frets 7–10; a chord occupying only frets 7–8 can use positions 5, 6 or 7, according to its neighbors. Position is the bottom of the selected window, not necessarily an occupied fret. This is an approximate positioning model, not an anatomical finger assignment. Sounded-note limits remain binding: a solo note cannot exceed fret 12 merely because its hand window extends farther.
|
||||||
|
|
||||||
Each selected accompaniment span reports `voicing_cost` contributions for movement, base grip effort, weighted register, weighted position and their combined effort. These describe the balanced tier, not the higher span/family priorities; repeat-join travel is assigned to the first canonical span. Both accompaniment roles keep `voicing` settings in concrete cache dependencies but exclude them from rhythmic choice seeds, including settings overrides. Acoustic rhythm selection can change under its new bass-first policy, but changing voicing weights alone never reshuffles rhythm. Each role still composes independently.
|
After the existing compact-span, chord-family and electric-melody foreground priorities, selection minimizes **total absolute hand-position travel**, then the **maximum hand position across the complete path**. Finger changes and string crossings within the same window contribute zero travel. Only after those two criteria tie do relative finger adjustment, string crossings, grip complexity and time pressure decide between paths. Acoustic bass is one voice even across strings 5 and 6; upper voices follow their strings. Added/removed chord voices contribute one secondary adjustment each. Solo secondary effort uses elapsed time from the preceding attack, not a tied notation fragment. A genuine continuation retains pitch, TAB and hand position without another movement charge.
|
||||||
|
|
||||||
|
A low fret is not rewarded independently at every event. All three voicing policies retain only `register_weight` (default 1), weighting distance from their sounding-register center after travel, peak position, secondary effort and optional-tone coverage. Centers remain 53 for acoustic accompaniment and 60 for electric accompaniment/melody. Exact melody and solo pitches are never transposed to meet these preferences. Canonical accompaniment selection includes source-form repeat joins and solves source-derived variations between actual selected neighboring grips **and hand positions**. Unchanged intervals retain both. The instrumental ending receives the last accompaniment grip and selected window; it does not infer the hand from the lowest occupied fret.
|
||||||
|
|
||||||
|
The dynamic programs retain concrete grips/held strings, selected hand position and distinct earlier peaks. A later high position can equalize two earlier peaks and expose a subordinate tie-break, so retaining only one lowest-peak history is unsafe. Strictly worse primary travel at an identical endpoint/hand can still be discarded. Equal fret averages do not imply equal placement states. Stable ordering resolves equal complete costs.
|
||||||
|
|
||||||
|
Role reports expose `total_travel`, `shift_count`, `largest_shift`, `peak_position` and actual selected hand positions. Accompaniment costs separate travel from secondary `effort`; individual spans separate hand shift, finger adjustment, string changes, grip effort and register tie-breaking. Repeat travel is assigned to the first canonical span. Solo reports call the time-weighted subordinate value `secondary_effort`. Cache fingerprints include placement code while accompaniment rhythm seeds remain unchanged. Every role remains independently composed; the solo model, pitches and rhythms are unchanged.
|
||||||
|
|
||||||
|
The pre-window baseline is preserved outside production inputs under `/tmp/vamp-hand-window-baseline-dwh4anzh`. Verification compares sounded content and rhythms separately from TAB, and checks all electric melody chords for strings 1–3 or 1–4. Independent exhaustive test oracles cover complete grip/window paths, fixed neighbors, cyclic repeats and single-note paths; tests also cover a greedy-position trap, future peak equalization, zero-cost within-window string changes and tied-event invariance.
|
||||||
|
|
||||||
|
Hand-window verification on October 8, 2026: `python3 modulus.py python -m unittest discover -s tests -p 'test_*.py'` passed 472 tests in 100.928 seconds. The subsequent asset build exposed a print-footer overlap: Letter pagination had reserved a nominal font height instead of the actual page-number outline. The new tight-page regression reproduced that failure, and `python3 modulus.py python -m unittest tests.test_printing` passed all eight tests after measured footer bounds replaced the estimate. Content and collision audits remain enabled.
|
||||||
|
|
||||||
|
`python3 modulus.py build --target x86_64_gcc12_debug --target arm_64_gcc12_release` then passed both asset checks and produced six complete PDFs (42 pages). Native regeneration took 33 seconds with cached roles and a cold engraving namespace; ARM reused the assets in six seconds. Before the footer repair, uncached composition completed in five seconds for all twelve roles, with solos taking about 0.5 seconds each. `python3 modulus.py test --target x86_64_gcc12_debug` passed in 13.27 seconds. All 400 score JSON, normal/playback PNG, report and PDF files match between native and ARM.
|
||||||
|
|
||||||
|
Against the saved pre-window baseline, all four solos retain exactly the same pitches, timing and ties; vocal documents and accompaniment rhythm reports remain identical. Electric melody retains its exact source notes and timing; supporting chord tones may change to enforce string 1. All 46 All of Me and 33 Autumn Leaves electric melody chord fragments now use strings 1–3 or 1–4; previously 13 and three fragments respectively omitted string 1. Short monophonic melody remains permissible.
|
||||||
|
|
||||||
|
| Solo | Total hand travel, before → after | Number of shifts, before → after | Peak hand position, before → after |
|
||||||
|
|---|---:|---:|---:|
|
||||||
|
| All of Me acoustic | 131 → 12 | 87 → 10 | 9 → 7 |
|
||||||
|
| All of Me electric | 128 → 9 | 87 → 7 | 9 → 7 |
|
||||||
|
| Autumn Leaves acoustic | 141 → 2 | 85 → 2 | 9 → 3 |
|
||||||
|
| Autumn Leaves electric | 106 → 6 | 77 → 6 | 9 → 5 |
|
||||||
|
|
||||||
|
Travel is the sum of absolute window-position changes, measured in frets. An independent dynamic program over every legal string/fret/window confirmed that each new solo achieves the minimum raw travel for its unchanged sounding notes. This verifies the placement objective, not anatomical comfort or musical preference. The canonical accompaniment cycles now travel 10/14 frets for All of Me/Autumn Leaves acoustic, and 0/6 for electric; they retain source-based repeat consistency. No listening assessment was performed.
|
||||||
|
|
||||||
|
The sibling Modulus Conan helper was repaired to call `modulus.build_context.build_context`, replacing the removed `modulus.cli.build_context` API. Its Python suite passed 132 tests with four existing skips. A locally exported and built Modulus package passed `python3 modulus.py configure --target x86_64_gcc12_release`, followed by the release build and runtime test above. The offline attempt first identified missing cached Ligo sources; allowing ordinary Conan source retrieval completed configuration. An unwritable generated engraving cache was also restored to the build user's ownership. The Modulus source fix must be published through that repository's normal release process before remote CI using `modulus/latest` receives it; no remote package was published during this repair.
|
||||||
|
|
||||||
Recurring-groove verification on October 6, 2026: the full Python regression command passed 423 tests in 149.85 seconds. After the final partial-entry grip fix and one additional real-evaluator fill test, the focused accompaniment/arrangement command passed 59 tests in 21.64 seconds. The native test target passed in 30.58 seconds. The initial native rebuild took 42 seconds, including 7 seconds for independently generated roles and 33.62 seconds of engraving; accompaniment roles took 0.8–1.6 seconds each. The following ARM build reused all role/score caches and took 11 seconds. All four solo documents, both electric melodies and both vocals exactly matched the prechange baseline; native/ARM score assets matched byte for byte. The chosen base cycles differed by song and retained all two/four requested slots. Autumn Leaves selected the authored overlapping-quarter acoustic pattern. No optional fill improved these production winners, so no fills were forced; independently authored tests verify real source-fit selection of a fill. An acoustic score/TAB page was visually checked for overlap ties in a single part. Recordings were unchanged and no listening assessment was performed. Baseline snapshots stayed under `/tmp/`.
|
Recurring-groove verification on October 6, 2026: the full Python regression command passed 423 tests in 149.85 seconds. After the final partial-entry grip fix and one additional real-evaluator fill test, the focused accompaniment/arrangement command passed 59 tests in 21.64 seconds. The native test target passed in 30.58 seconds. The initial native rebuild took 42 seconds, including 7 seconds for independently generated roles and 33.62 seconds of engraving; accompaniment roles took 0.8–1.6 seconds each. The following ARM build reused all role/score caches and took 11 seconds. All four solo documents, both electric melodies and both vocals exactly matched the prechange baseline; native/ARM score assets matched byte for byte. The chosen base cycles differed by song and retained all two/four requested slots. Autumn Leaves selected the authored overlapping-quarter acoustic pattern. No optional fill improved these production winners, so no fills were forced; independently authored tests verify real source-fit selection of a fill. An acoustic score/TAB page was visually checked for overlap ties in a single part. Recordings were unchanged and no listening assessment was performed. Baseline snapshots stayed under `/tmp/`.
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
"bass": "primary",
|
"bass": "primary",
|
||||||
"center": 53,
|
"center": 53,
|
||||||
"voicing": {
|
"voicing": {
|
||||||
"register_weight": 1,
|
"register_weight": 1
|
||||||
"high_position_weight": 1,
|
|
||||||
"comfortable_max_fret": 9
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"electric_accompaniment": {
|
"electric_accompaniment": {
|
||||||
@@ -20,18 +18,14 @@
|
|||||||
"bass": "essential",
|
"bass": "essential",
|
||||||
"center": 60,
|
"center": 60,
|
||||||
"voicing": {
|
"voicing": {
|
||||||
"register_weight": 1,
|
"register_weight": 1
|
||||||
"high_position_weight": 1,
|
|
||||||
"comfortable_max_fret": 9
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"electric_melody": {
|
"electric_melody": {
|
||||||
"seed": "head",
|
"seed": "head",
|
||||||
"center": 60,
|
"center": 60,
|
||||||
"voicing": {
|
"voicing": {
|
||||||
"register_weight": 1,
|
"register_weight": 1
|
||||||
"high_position_weight": 1,
|
|
||||||
"comfortable_max_fret": 9
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"vocal_melody": {
|
"vocal_melody": {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import typing
|
|||||||
import generator.acoustic
|
import generator.acoustic
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
|
import generator.hand_position
|
||||||
|
|
||||||
class Voicing(typing.NamedTuple):
|
class Voicing(typing.NamedTuple):
|
||||||
bass: generator.fretboard.TabPosition
|
bass: generator.fretboard.TabPosition
|
||||||
@@ -35,12 +36,14 @@ def candidates(requirements: generator.fretboard.ChordRequirements, complete_tri
|
|||||||
return tuple(sorted(result))
|
return tuple(sorted(result))
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=16384)
|
@functools.lru_cache(maxsize=16384)
|
||||||
def movement(previous: Voicing, current: Voicing) -> int:
|
def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int:
|
||||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
return generator.hand_position.motion(previous, current, old_hand, hand, True).shift
|
||||||
|
|
||||||
class Cost(typing.NamedTuple):
|
class Cost(typing.NamedTuple):
|
||||||
extended_grips: int = 0
|
extended_grips: int = 0
|
||||||
travel: int = 0
|
travel: int = 0
|
||||||
|
peak_position: int = 0
|
||||||
|
effort: int = 0
|
||||||
missing_tones: int = 0
|
missing_tones: int = 0
|
||||||
|
|
||||||
@dataclasses.dataclass
|
@dataclasses.dataclass
|
||||||
@@ -48,14 +51,20 @@ class Placement:
|
|||||||
cost: Cost
|
cost: Cost
|
||||||
voicing: Voicing | None = None
|
voicing: Voicing | None = None
|
||||||
previous: Placement | None = None
|
previous: Placement | None = None
|
||||||
|
hand: int = 0
|
||||||
|
|
||||||
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
||||||
result = []
|
result = {}
|
||||||
for voicing in options:
|
for voicing in options:
|
||||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||||
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))
|
for hand in generator.hand_position.regions(voicing):
|
||||||
result.append(Placement(cost, voicing, states[index]))
|
for previous in states:
|
||||||
return result
|
motion = generator.hand_position.motion(previous.voicing, voicing, previous.hand, hand, True)
|
||||||
|
cost = Cost(previous.cost.extended_grips + generator.fretboard.span_penalty(voicing), previous.cost.travel + motion.shift, max(previous.cost.peak_position, hand), previous.cost.effort + motion.ergonomics, previous.cost.missing_tones + missing)
|
||||||
|
key = (voicing, hand, cost.peak_position)
|
||||||
|
if key not in result or cost < result[key].cost:
|
||||||
|
result[key] = Placement(cost, voicing, previous, hand)
|
||||||
|
return list(result.values())
|
||||||
|
|
||||||
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
||||||
states = [Placement(Cost())]
|
states = [Placement(Cost())]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ def chord(source):
|
|||||||
prior = generator.accompaniment.carried_chords(effective)[-1]
|
prior = generator.accompaniment.carried_chords(effective)[-1]
|
||||||
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
|
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
|
||||||
|
|
||||||
def append(source, output, part, *, sounding=False, center=55, composition=None):
|
def append(source, output, part, *, sounding=False, center=55, composition=None, incoming=None):
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
if not source or not output:
|
if not source or not output:
|
||||||
return
|
return
|
||||||
@@ -41,7 +41,13 @@ def append(source, output, part, *, sounding=False, center=55, composition=None)
|
|||||||
if part == 'acoustic':
|
if part == 'acoustic':
|
||||||
bass = next((recent[s] for s in (5, 6) if s in recent), None)
|
bass = next((recent[s] for s in (5, 6) if s in recent), None)
|
||||||
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
|
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
|
||||||
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition).moved(generator.comping.movement(part, previous, v)), v))
|
if incoming is not None:
|
||||||
|
previous, hand = incoming
|
||||||
|
else:
|
||||||
|
hand = None
|
||||||
|
costs = {v: generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, composition=composition) for v in options}
|
||||||
|
path, _, _ = generator.comping.select_grips((options,), (costs,), part, 'ending-position', left=previous, left_hand=hand)
|
||||||
|
grip = path[0]
|
||||||
length = generator.model.fraction(model['duration'])
|
length = generator.model.fraction(model['duration'])
|
||||||
previous = None
|
previous = None
|
||||||
for index in range(generator.arrange.ending_plan.BARS):
|
for index in range(generator.arrange.ending_plan.BARS):
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict]
|
|||||||
generator.arrange.pickup.harmonies(destination, result[0])
|
generator.arrange.pickup.harmonies(destination, result[0])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None) -> dict:
|
def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, accompaniment=None, composition=None, placement_report=None) -> dict:
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
if part_type not in generator.parts.PART_TYPES:
|
if part_type not in generator.parts.PART_TYPES:
|
||||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||||
@@ -100,7 +100,7 @@ def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None,
|
|||||||
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
|
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
|
||||||
generator.arrange.ending.append(source, output, part_type,
|
generator.arrange.ending.append(source, output, part_type,
|
||||||
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
|
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
|
||||||
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition)
|
center=composition.role(part_type + '_accompaniment').get('center', 55), composition=composition, incoming=accompaniment.last_position if accompaniment is not None else 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)
|
||||||
@@ -112,9 +112,11 @@ def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None,
|
|||||||
generator.acoustic.combine(result)
|
generator.acoustic.combine(result)
|
||||||
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
elif part_type == 'electric' and (owned is None or 'melody' in owned):
|
||||||
generator.melody.harmonize(result)
|
generator.melody.harmonize(result)
|
||||||
generator.guitar.place_guitar(result, composition=composition)
|
report = generator.guitar.place_guitar(result, composition=composition)
|
||||||
if generator.accompaniment.connect_continuations(result):
|
if generator.accompaniment.connect_continuations(result):
|
||||||
generator.guitar.place_guitar(result, composition=composition)
|
report = generator.guitar.place_guitar(result, composition=composition)
|
||||||
|
if placement_report is not None:
|
||||||
|
placement_report.update(report)
|
||||||
# Source labels identify the head while arranging; published labels identify
|
# Source labels identify the head while arranging; published labels identify
|
||||||
# the complete performance, including its lead-in and repeated choruses.
|
# the complete performance, including its lead-in and repeated choruses.
|
||||||
for number, measure in enumerate(output, 1):
|
for number, measure in enumerate(output, 1):
|
||||||
|
|||||||
+108
-70
@@ -16,6 +16,7 @@ import generator.comping_rhythm
|
|||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.guitar
|
import generator.guitar
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
|
import generator.hand_position
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.parts
|
import generator.parts
|
||||||
import generator.score
|
import generator.score
|
||||||
@@ -61,78 +62,88 @@ def _voicings(key, part, composition):
|
|||||||
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')
|
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))
|
return tuple(sorted(options))
|
||||||
|
|
||||||
def movement(part, previous, current):
|
def movement(part, previous, current, old_hand, hand):
|
||||||
if not previous or not current:
|
return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift
|
||||||
return 0
|
|
||||||
if part == 'acoustic':
|
|
||||||
return generator.acoustic_voicing.movement(previous, current)
|
|
||||||
return generator.guitar.movement(previous, current)
|
|
||||||
|
|
||||||
class VoicingCost(typing.NamedTuple):
|
class VoicingCost(typing.NamedTuple):
|
||||||
extended_grips: int = 0
|
extended_grips: int = 0
|
||||||
family: int = 0
|
family: int = 0
|
||||||
|
travel: int = 0
|
||||||
|
peak_position: int = 0
|
||||||
effort: fractions.Fraction = fractions.Fraction()
|
effort: fractions.Fraction = fractions.Fraction()
|
||||||
missing_tones: int = 0
|
missing_tones: int = 0
|
||||||
register: fractions.Fraction = fractions.Fraction()
|
register: fractions.Fraction = fractions.Fraction()
|
||||||
def plus(self, other):
|
def plus(self, other):
|
||||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
return VoicingCost(*(max(a, b) if field == 'peak_position' else a + b for field, a, b in zip(self._fields, self, other)))
|
||||||
def moved(self, distance):
|
def moved(self, distance, ergonomics=0):
|
||||||
return self._replace(effort=self.effort + distance)
|
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
|
||||||
|
|
||||||
def voicing_preferences(part, grip, center, *, composition=None):
|
|
||||||
composition = composition or generator.vocabulary.current()
|
|
||||||
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
|
|
||||||
|
|
||||||
def local_cost(part, key, grip, center, *, composition=None):
|
def local_cost(part, key, grip, center, *, composition=None):
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
required = requirements(json.loads(key), part)
|
required = requirements(json.loads(key), part)
|
||||||
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
|
register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition))
|
||||||
preference = sum(voicing_preferences(part, grip, center, composition=composition))
|
|
||||||
if part == 'electric':
|
if part == 'electric':
|
||||||
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
|
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed, composition=composition)
|
||||||
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
|
return VoicingCost(extended_grips=value.extended_grips, family=3 * value.string_shortfall + value.family_penalty, effort=value.effort, missing_tones=value.missing_tones, register=register)
|
||||||
# Familiar degree orders on either bass string, then balanced whole-form
|
# Keep the familiar families, then compare physical effort before position.
|
||||||
# movement, sounding register and hand position. Neither is an absolute ban.
|
# Sounding register only breaks otherwise equal placement decisions.
|
||||||
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
|
family = generator.chord_families.acoustic(json.loads(key), grip, composition=composition)
|
||||||
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
|
return VoicingCost(extended_grips=generator.fretboard.span_penalty(grip), family=int(not family['preferred']), missing_tones=len(set(required.allowed) - {p.midi % 12 for p in grip}), register=register)
|
||||||
|
|
||||||
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
|
def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None):
|
||||||
"""Exact small-state dynamic programming; never prune by hand center.
|
"""Return complete grips, cost and hand windows, including repeat travel.
|
||||||
|
|
||||||
Both roles balance register/position with travel. Electric includes its
|
A concrete grip may occupy several windows. Preserve distinct earlier peaks
|
||||||
base grip effort; acoustic follows all four voices and bass-string changes.
|
at a common grip/hand, and fix both starting values for a cyclic path.
|
||||||
Compact span and family remain higher tiers.
|
|
||||||
Fixing the first grip permits an exact repeat-join cost without a beam.
|
|
||||||
"""
|
"""
|
||||||
if not layers:
|
if not layers:
|
||||||
return (), VoicingCost()
|
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.
|
def hands(grip, fixed=None):
|
||||||
# This exact dominance reduction avoids growing the cubic repeat search.
|
options = generator.hand_position.regions(grip)
|
||||||
|
if fixed is not None:
|
||||||
|
return tuple(h for h in options if h == fixed)
|
||||||
|
return options
|
||||||
|
|
||||||
retained = []
|
retained = []
|
||||||
for i, layer in enumerate(layers):
|
for i, layer in enumerate(layers):
|
||||||
tier = min(costs[i][v].extended_grips for v in layer)
|
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))
|
fixed = fixed_hands[i] if fixed_hands is not None else None
|
||||||
layers = tuple(retained)
|
options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed))
|
||||||
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
|
if not options:
|
||||||
|
raise ValueError(f'accompaniment span {i}: no grip fits the committed hand position')
|
||||||
|
retained.append(options)
|
||||||
|
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v, _ in layer} for i, layer in enumerate(retained)]
|
||||||
winners = []
|
winners = []
|
||||||
for first in layers[0] if close else [None]:
|
for first in retained[0] if close else [None]:
|
||||||
states = {left: (VoicingCost(), (), ())}
|
states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)}
|
||||||
for i, layer in enumerate(layers):
|
for i, layer in enumerate(retained):
|
||||||
following = {}
|
following = {}
|
||||||
for grip in [first] if i == 0 and first is not None else layer:
|
for grip, hand in [first] if i == 0 and first is not None else layer:
|
||||||
local = costs[i][grip]
|
local = costs[i][grip]._replace(peak_position=hand)
|
||||||
choices = []
|
for (previous, old_hand, _), (cost, ties, path, positions) in states.items():
|
||||||
for previous, (cost, ties, path) in states.items():
|
motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic')
|
||||||
total = cost.plus(local).moved(movement(part, previous, grip))
|
total = cost.plus(local).moved(motion.shift, motion.ergonomics)
|
||||||
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
|
candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand))
|
||||||
following[grip] = min(choices)
|
key = (grip, hand, total.peak_position)
|
||||||
states = following
|
if key not in following or candidate < following[key]:
|
||||||
for grip, (cost, ties, path) in states.items():
|
following[key] = candidate
|
||||||
travel = movement(part, grip, first if close else right)
|
# Future travel is identical at a common concrete grip and hand.
|
||||||
winners.append((cost.moved(travel), ties, path))
|
best = {}
|
||||||
cost, _, path = min(winners)
|
for (grip, hand, _), value in following.items():
|
||||||
return path, cost
|
primary = value[0][:3]
|
||||||
|
best[grip, hand] = min(best.get((grip, hand), primary), primary)
|
||||||
|
states = {key: value for key, value in following.items() if value[0][:3] == best[key[:2]]}
|
||||||
|
endpoints = [first] if close else [(right, h) for h in hands(right, right_hand)]
|
||||||
|
for (grip, hand, _), (cost, ties, path, positions) in states.items():
|
||||||
|
for endpoint, end_hand in endpoints:
|
||||||
|
motion = generator.hand_position.motion(grip, endpoint, hand, end_hand, part == 'acoustic')
|
||||||
|
total = cost.plus(VoicingCost(peak_position=end_hand)).moved(motion.shift, motion.ergonomics)
|
||||||
|
winners.append((total, ties, path, positions))
|
||||||
|
if not winners:
|
||||||
|
raise ValueError('accompaniment boundary grip does not fit the committed hand position')
|
||||||
|
cost, _, path, positions = min(winners)
|
||||||
|
return path, cost, positions
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class HarmonyChoice:
|
class HarmonyChoice:
|
||||||
@@ -143,6 +154,7 @@ class HarmonyChoice:
|
|||||||
events: tuple
|
events: tuple
|
||||||
figure: dict
|
figure: dict
|
||||||
options: tuple
|
options: tuple
|
||||||
|
hand: int | None = None
|
||||||
@property
|
@property
|
||||||
def key(self):
|
def key(self):
|
||||||
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
|
||||||
@@ -193,6 +205,8 @@ class Canonical:
|
|||||||
self.part, self.seed = part, seed
|
self.part, self.seed = part, seed
|
||||||
self.policy = self.composition.role(part + '_accompaniment')
|
self.policy = self.composition.role(part + '_accompaniment')
|
||||||
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
|
||||||
|
self.cache_positions = {}
|
||||||
|
self.last_position = ((), 0)
|
||||||
self.measures = generator.score.source_staff(document)['measures']
|
self.measures = generator.score.source_staff(document)['measures']
|
||||||
# A held accompaniment chord may continue into the bar containing the
|
# A held accompaniment chord may continue into the bar containing the
|
||||||
# source melody pickup. Keep its voice identity through that splice.
|
# source melody pickup. Keep its voice identity through that splice.
|
||||||
@@ -230,15 +244,16 @@ class Canonical:
|
|||||||
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
|
||||||
active = tuple(span for spans in prepared for span in spans if span.events)
|
active = tuple(span for spans in prepared for span in spans if span.events)
|
||||||
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
|
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
|
||||||
path, self.path_cost = self.select(active, close=self.closed)
|
path, self.path_cost, self.hand_order = self.select(active, close=self.closed)
|
||||||
self.base = dict(zip((s.key for s in active), path))
|
self.base = dict(zip((s.key for s in active), path))
|
||||||
|
self.hands = dict(zip((s.key for s in active), self.hand_order))
|
||||||
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
|
||||||
self.rhythms = {}
|
self.rhythms = {}
|
||||||
previous = path[-1] if self.closed and path else ()
|
previous = (path[-1], self.hand_order[-1]) if self.closed and path else ((), 0)
|
||||||
for group, rhythm, spans in zip(self.groups, selected, prepared):
|
for group, rhythm, spans in zip(self.groups, selected, prepared):
|
||||||
grips = tuple(self.base[s.key] for s in spans if s.events)
|
grips = tuple((self.base[s.key], self.hands[s.key]) for s in spans if s.events)
|
||||||
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
|
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
|
||||||
self.cache[key] = self.render(group, rhythm, spans, grips, previous, False)
|
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, grips, previous, False)
|
||||||
cursor = fractions.Fraction()
|
cursor = fractions.Fraction()
|
||||||
for m, _, i in group:
|
for m, _, i in group:
|
||||||
length = generator.model.fraction(m['duration'])
|
length = generator.model.fraction(m['duration'])
|
||||||
@@ -250,6 +265,17 @@ class Canonical:
|
|||||||
def report(self):
|
def report(self):
|
||||||
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
|
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
|
||||||
@property
|
@property
|
||||||
|
def placement_report(self):
|
||||||
|
previous = self.hand_order[-1] if self.closed and self.hand_order else None
|
||||||
|
shifts = []
|
||||||
|
for hand in self.hand_order:
|
||||||
|
shifts.append(abs(hand - previous) if previous is not None else 0)
|
||||||
|
previous = hand
|
||||||
|
return {'version': generator.hand_position.VERSION, 'priority': 'hand travel, maximum hand position, ergonomics, coverage/register, stable tie',
|
||||||
|
**generator.hand_position.summary(shifts), 'peak_position': self.path_cost.peak_position,
|
||||||
|
'cost': {k: str(v) for k, v in self.path_cost._asdict().items()},
|
||||||
|
'positions': [{'source_index': i, 'onset': str(t), 'hand_position': h} for (i, t, _), h in zip(self.order, self.hand_order)]}
|
||||||
|
@property
|
||||||
def rhythm_report(self):
|
def rhythm_report(self):
|
||||||
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
|
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -314,10 +340,10 @@ class Canonical:
|
|||||||
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
|
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
|
||||||
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
|
||||||
return tuple(spans)
|
return tuple(spans)
|
||||||
def select(self, spans, *, left=(), right=(), close=False):
|
def select(self, spans, *, left=(), right=(), left_hand=None, right_hand=None, close=False):
|
||||||
layers = tuple(s.options for s in spans)
|
layers = tuple(s.options for s in spans)
|
||||||
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
|
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55), composition=self.composition) for v in layer} for s, layer in zip(spans, layers))
|
||||||
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
|
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, left_hand=left_hand, right_hand=right_hand, close=close, fixed_hands=tuple(s.hand for s in spans))
|
||||||
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
|
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
|
||||||
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
|
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
|
||||||
original_group = group
|
original_group = group
|
||||||
@@ -371,23 +397,27 @@ class Canonical:
|
|||||||
for span in spans:
|
for span in spans:
|
||||||
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
|
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
|
||||||
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
|
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
|
||||||
fixed.append(dataclasses.replace(span, options=(self.base[original.key],)) if original else span)
|
fixed.append(dataclasses.replace(span, options=(self.base[original.key],), hand=self.hands[original.key]) if original else span)
|
||||||
spans = tuple(fixed)
|
spans = tuple(fixed)
|
||||||
before = [v for i, _, v in self.order if i < start]
|
before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start]
|
||||||
after = [v for i, _, v in self.order if i > end]
|
after = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i > end]
|
||||||
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
|
left = before[-1] if before else (self.order[-1][2], self.hand_order[-1]) if self.closed and self.order else ((), 0)
|
||||||
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
|
right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0)
|
||||||
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
|
selected, _, hands = self.select(tuple(s for s in spans if s.events), left=left[0], right=right[0], left_hand=left[1], right_hand=right[1])
|
||||||
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
|
self.cache[key], self.cache_positions[key] = self.render(group, rhythm, spans, tuple(zip(selected, hands)), left, True, limit)
|
||||||
|
placement = self.cache_positions[key][index - group[0][2]]
|
||||||
|
if placement is not None:
|
||||||
|
self.last_position = placement
|
||||||
return copy.deepcopy(self.cache[key][index - group[0][2]])
|
return copy.deepcopy(self.cache[key][index - group[0][2]])
|
||||||
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
|
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
|
||||||
flat = generator.comping_rhythm.flatten(inputs)
|
flat = generator.comping_rhythm.flatten(inputs)
|
||||||
length = generator.model.fraction(flat['duration'])
|
length = generator.model.fraction(flat['duration'])
|
||||||
index = inputs[0][2]
|
index = inputs[0][2]
|
||||||
attacks, rows = [], []
|
attacks, rows, placed_events = [], [], []
|
||||||
choices = iter(selected)
|
choices = iter(selected)
|
||||||
for span in spans:
|
for span in spans:
|
||||||
grip = next(choices) if span.events else ()
|
grip, hand = next(choices) if span.events else ((), 0)
|
||||||
|
motion = generator.hand_position.motion(previous[0], grip, previous[1], hand, self.part == 'acoustic')
|
||||||
bass = grip[0] if grip and self.part == 'acoustic' else None
|
bass = grip[0] if grip and self.part == 'acoustic' else None
|
||||||
degrees = generator.harmony.degrees(span.chord)
|
degrees = generator.harmony.degrees(span.chord)
|
||||||
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
|
||||||
@@ -401,22 +431,30 @@ class Canonical:
|
|||||||
'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,
|
||||||
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
|
'movement': motion.shift, 'voicing_candidates': len(span.options)})
|
||||||
if grip:
|
if grip:
|
||||||
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55), composition=self.composition)
|
local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition)
|
||||||
travel = movement(self.part, previous, grip)
|
rows[-1]['hand_position'] = hand
|
||||||
effort = generator.guitar.voicing_cost(grip, 'electric', composition=self.composition).effort if self.part == 'electric' else 0
|
rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers,
|
||||||
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
|
'string_changes': motion.strings, 'grip_effort': str(local.effort), 'register_tiebreak': str(local.register),
|
||||||
|
'secondary_effort': str(motion.ergonomics + local.effort)}
|
||||||
for event in span.events:
|
for event in span.events:
|
||||||
|
placed_events.append((event.start, event.end, (grip, hand)))
|
||||||
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
|
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
|
||||||
for lane in lanes:
|
for lane in lanes:
|
||||||
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
|
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
|
||||||
attacks.append((lane, event.start, event.end, positions, span.chord))
|
attacks.append((lane, event.start, event.end, positions, span.chord))
|
||||||
if grip:
|
if grip:
|
||||||
previous = grip
|
previous = (grip, hand)
|
||||||
key = self.key(inputs, limit)
|
key = self.key(inputs, limit)
|
||||||
if self.part == 'acoustic':
|
if self.part == 'acoustic':
|
||||||
key = (key, rhythm.signature)
|
key = (key, rhythm.signature)
|
||||||
|
measure_hands, cursor = [], fractions.Fraction()
|
||||||
|
for m, _, _ in inputs:
|
||||||
|
end = cursor + generator.model.fraction(m['duration'])
|
||||||
|
played = sorted((a, b, p) for a, b, p in placed_events if a < end and b > cursor)
|
||||||
|
measure_hands.append(played[-1][2] if played else None)
|
||||||
|
cursor = end
|
||||||
self._reports[key] = rows
|
self._reports[key] = rows
|
||||||
voices = []
|
voices = []
|
||||||
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
|
||||||
@@ -456,4 +494,4 @@ class Canonical:
|
|||||||
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
|
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
|
||||||
cursor += duration
|
cursor += duration
|
||||||
self._rhythm_reports[key] = reports
|
self._rhythm_reports[key] = reports
|
||||||
return result
|
return result, tuple(measure_hands)
|
||||||
|
|||||||
@@ -134,9 +134,12 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
|
|||||||
elif role.endswith('_accompaniment'):
|
elif role.endswith('_accompaniment'):
|
||||||
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
|
canonical = generator.comping.Canonical(document, part, seed, composition=composition, plan=plan)
|
||||||
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
|
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'accompaniment'}, accompaniment=canonical, composition=composition)
|
||||||
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
|
details = {'canonical_comping': canonical.report, 'guitar_placement': canonical.placement_report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
|
||||||
else:
|
else:
|
||||||
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition)
|
placement_report = {}
|
||||||
|
result = generator.arrange.generate._arrange_document(document, part, plan, owned={'vocal' if part == 'vocal' else 'melody'}, composition=composition, placement_report=placement_report)
|
||||||
|
if placement_report:
|
||||||
|
details['guitar_placement'] = placement_report
|
||||||
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
|
allowed = tuple((a, b) for a, b, r in plan.windows if r == role)
|
||||||
generator.arrange.assembly.mask(result, allowed)
|
generator.arrange.assembly.mask(result, allowed)
|
||||||
if role == "acoustic_accompaniment":
|
if role == "acoustic_accompaniment":
|
||||||
|
|||||||
+65
-87
@@ -10,10 +10,10 @@ import typing
|
|||||||
import generator.chord_families
|
import generator.chord_families
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.model
|
import generator.model
|
||||||
|
import generator.hand_position
|
||||||
import generator.pitch
|
import generator.pitch
|
||||||
import generator.vocabulary
|
import generator.vocabulary
|
||||||
|
|
||||||
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):
|
||||||
@@ -23,13 +23,16 @@ class VoicingCost(typing.NamedTuple):
|
|||||||
covering_tones: int = 0
|
covering_tones: int = 0
|
||||||
string_shortfall: int = 0
|
string_shortfall: int = 0
|
||||||
family_penalty: int = 0
|
family_penalty: int = 0
|
||||||
|
travel: int = 0
|
||||||
|
peak_position: int = 0
|
||||||
effort: fractions.Fraction = fractions.Fraction()
|
effort: fractions.Fraction = fractions.Fraction()
|
||||||
missing_tones: int = 0
|
missing_tones: int = 0
|
||||||
|
register: fractions.Fraction = fractions.Fraction()
|
||||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
return VoicingCost(*(max(a, b) if field == "peak_position" else a + b for field, a, b in zip(self._fields, self, other)))
|
||||||
@functools.lru_cache(maxsize=16384)
|
@functools.lru_cache(maxsize=16384)
|
||||||
def moved(self, distance: int) -> VoicingCost:
|
def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost:
|
||||||
return self._replace(effort=self.effort + distance)
|
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
|
||||||
|
|
||||||
def voicing_preference(role, center=None, *, composition=None):
|
def voicing_preference(role, center=None, *, composition=None):
|
||||||
"""Immutable role settings for both selection and scored-retrieval caches."""
|
"""Immutable role settings for both selection and scored-retrieval caches."""
|
||||||
@@ -37,19 +40,20 @@ def voicing_preference(role, center=None, *, composition=None):
|
|||||||
settings = composition.role(role)
|
settings = composition.role(role)
|
||||||
policy = settings.get('voicing', {})
|
policy = settings.get('voicing', {})
|
||||||
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
|
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
|
||||||
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9)))
|
if set(policy) - {'register_weight'}:
|
||||||
if result[1] < 0 or result[2] < 0 or result[3] < 1:
|
raise ValueError('unknown voicing preference; hand position is ranked across the passage')
|
||||||
raise ValueError('voicing preference requires nonnegative weights and a positive fret threshold')
|
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1)))
|
||||||
|
if result[1] < 0:
|
||||||
|
raise ValueError('voicing preference requires a nonnegative register tie weight')
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=8192)
|
@functools.lru_cache(maxsize=8192)
|
||||||
def voicing_preferences(positions, preference):
|
def voicing_preferences(positions, preference):
|
||||||
if not positions or not preference:
|
if not positions or not preference:
|
||||||
return fractions.Fraction(), fractions.Fraction()
|
return fractions.Fraction()
|
||||||
center, register_weight, position_weight, threshold = preference
|
center, register_weight = preference
|
||||||
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
|
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
|
||||||
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold)
|
return register_weight * register
|
||||||
return register_weight * register, position_weight * position
|
|
||||||
|
|
||||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
|
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
@@ -64,12 +68,12 @@ def _voicing_cost(positions, profile, allowed, melody, families, preference):
|
|||||||
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
|
||||||
span = compactness(positions)
|
span = compactness(positions)
|
||||||
if profile == 'electric':
|
if profile == 'electric':
|
||||||
effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
|
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||||
else:
|
else:
|
||||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
|
effort = span
|
||||||
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
|
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)
|
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))
|
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), register=voicing_preferences(positions, preference))
|
||||||
|
|
||||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
@@ -96,9 +100,8 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
|||||||
|
|
||||||
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)}
|
||||||
# A lower source melody can be the top of a three-note adaptation on
|
# Every electric chord includes string 1, even with a lower melody inside.
|
||||||
# strings 2-4. It need not be buried beneath an added first-string tone.
|
for strings in ELECTRIC_STRINGS:
|
||||||
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
|
||||||
@@ -107,29 +110,13 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
|||||||
result.append(selected)
|
result.append(selected)
|
||||||
else:
|
else:
|
||||||
visit(0, (), set())
|
visit(0, (), set())
|
||||||
if not prune:
|
# Equal centers no longer imply equal future movement. Keep concrete grips;
|
||||||
return tuple(sorted(result))
|
# only identical positions can be merged before contextual placement.
|
||||||
# Independent attacks with equal centers have identical movement costs.
|
return tuple(sorted(set(result)))
|
||||||
# Held strings require the full candidate set instead of this reduction.
|
|
||||||
best = {}
|
|
||||||
for positions in result:
|
|
||||||
center = position_center(positions)
|
|
||||||
rank = (_voicing_cost(positions, profile, requirements.allowed, melody, families, preference), positions)
|
|
||||||
if center not in best or rank < best[center][0]:
|
|
||||||
best[center] = (rank, positions)
|
|
||||||
return tuple(sorted(item[1] for item in best.values()))
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=16384)
|
@functools.lru_cache(maxsize=16384)
|
||||||
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int:
|
def movement(previous, current, old_hand, hand) -> int:
|
||||||
if not previous or not current:
|
return generator.hand_position.motion(previous, current, old_hand, hand).shift
|
||||||
return 0
|
|
||||||
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=4096)
|
|
||||||
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
|
|
||||||
frets = [p.fret for p in positions] or [0]
|
|
||||||
strings = [p.string_number for p in positions]
|
|
||||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
|
||||||
|
|
||||||
def compactness(positions) -> int:
|
def compactness(positions) -> int:
|
||||||
frets = [p.fret for p in positions]
|
frets = [p.fret for p in positions]
|
||||||
@@ -151,38 +138,7 @@ class Placement:
|
|||||||
active: tuple
|
active: tuple
|
||||||
previous: Placement | None
|
previous: Placement | None
|
||||||
choices: tuple
|
choices: tuple
|
||||||
|
hand: int = 0
|
||||||
def nearest_positions(states: dict) -> list[list[tuple | None]]:
|
|
||||||
"""Exact Manhattan distance transform on the small fret/string position grid."""
|
|
||||||
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
|
|
||||||
grid = [[initial for _ in range(11)] for _ in range(2 * generator.fretboard.MAX_FRET + 1)]
|
|
||||||
for index, previous in enumerate(states.values()):
|
|
||||||
if not previous.active:
|
|
||||||
continue
|
|
||||||
x, y = position_center(tuple(p for _, p in previous.active))
|
|
||||||
candidate = (previous.cost, index, previous)
|
|
||||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
|
||||||
grid[x][y] = candidate
|
|
||||||
|
|
||||||
def relax(x, y, a, b):
|
|
||||||
source = grid[a][b]
|
|
||||||
if source is not None:
|
|
||||||
cost, index, previous = source
|
|
||||||
candidate = (cost.moved(1), index, previous)
|
|
||||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
|
||||||
grid[x][y] = candidate
|
|
||||||
|
|
||||||
for y in range(11):
|
|
||||||
for x in range(1, len(grid)):
|
|
||||||
relax(x, y, x - 1, y)
|
|
||||||
for x in range(len(grid) - 2, -1, -1):
|
|
||||||
relax(x, y, x + 1, y)
|
|
||||||
for x in range(len(grid)):
|
|
||||||
for y in range(1, 11):
|
|
||||||
relax(x, y, x, y - 1)
|
|
||||||
for y in range(9, -1, -1):
|
|
||||||
relax(x, y, x, y + 1)
|
|
||||||
return grid
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class GuitarTone:
|
class GuitarTone:
|
||||||
@@ -318,16 +274,37 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
|||||||
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
|
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
|
||||||
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
||||||
|
|
||||||
|
def keep_position(result, candidate, previous, fresh=True):
|
||||||
|
old_shape = tuple(p for _, p in previous.active)
|
||||||
|
hands = [previous.hand]
|
||||||
|
if fresh:
|
||||||
|
hands = generator.hand_position.regions(candidate.shape)
|
||||||
|
for hand in hands:
|
||||||
|
if candidate.shape and not all(hand <= p.fret <= hand + 3 for p in candidate.shape):
|
||||||
|
continue
|
||||||
|
motion = generator.hand_position.motion(old_shape, candidate.shape, previous.hand, hand) if fresh else generator.hand_position.Motion()
|
||||||
|
local = candidate.cost._replace(peak_position=hand)
|
||||||
|
cost = previous.cost.plus(local).moved(motion.shift, motion.ergonomics)
|
||||||
|
key = (candidate.active, hand, cost.peak_position)
|
||||||
|
value = Placement(cost, candidate.active, previous, candidate.choices, hand)
|
||||||
|
if key not in result or cost < result[key].cost:
|
||||||
|
result[key] = value
|
||||||
|
|
||||||
|
def reduce_positions(result):
|
||||||
|
# Future effort depends on the concrete held strings and hand, not on the
|
||||||
|
# earlier route. A strictly worse primary cost is dominated even if lower.
|
||||||
|
best = {}
|
||||||
|
for (active, hand, _), state in result.items():
|
||||||
|
key = (active, hand)
|
||||||
|
best[key] = min(best.get(key, state.cost[:7]), state.cost[:7])
|
||||||
|
return {key: state for key, state in result.items() if state.cost[:7] == best[key[:2]]}
|
||||||
|
|
||||||
def advance_attacks(candidates, states: dict) -> dict:
|
def advance_attacks(candidates, states: dict) -> dict:
|
||||||
nearest = nearest_positions(states)
|
|
||||||
result = {}
|
result = {}
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
x, y = position_center(candidate.shape)
|
for previous in states.values():
|
||||||
cost, _, previous = nearest[x][y]
|
keep_position(result, candidate, previous)
|
||||||
cost = cost.plus(candidate.cost)
|
return reduce_positions(result)
|
||||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
|
||||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
|
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
@@ -335,12 +312,6 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
|||||||
fresh = has_attack(notes)
|
fresh = has_attack(notes)
|
||||||
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
|
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
def keep(candidate, previous, cost):
|
|
||||||
cost = cost.plus(candidate.cost)
|
|
||||||
if candidate.active not in result or cost < result[candidate.active].cost:
|
|
||||||
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
|
|
||||||
|
|
||||||
if not held_ids and not continued:
|
if not held_ids and not continued:
|
||||||
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
|
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
|
||||||
else:
|
else:
|
||||||
@@ -348,15 +319,14 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
|||||||
active = dict(previous.active)
|
active = dict(previous.active)
|
||||||
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
|
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
|
||||||
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
|
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback, composition=composition):
|
||||||
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
|
keep_position(result, candidate, previous, fresh)
|
||||||
keep(candidate, previous, previous.cost.moved(distance))
|
|
||||||
if not result:
|
if not result:
|
||||||
if not fallback and any(n.get('_harmonize') for n in notes):
|
if not fallback and any(n.get('_harmonize') for n in notes):
|
||||||
return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
|
return advance_positions(notes, onset, tones, states, preserve_strings, True, composition=composition)
|
||||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
|
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
|
||||||
return result
|
return reduce_positions(result)
|
||||||
|
|
||||||
def place_guitar(document: dict, *, composition=None) -> None:
|
def place_guitar(document: dict, *, composition=None) -> dict:
|
||||||
"""Choose electric voicings or exact/held melody placements."""
|
"""Choose electric voicings or exact/held melody placements."""
|
||||||
composition = composition or generator.vocabulary.current()
|
composition = composition or generator.vocabulary.current()
|
||||||
events, tones = guitar_events(document)
|
events, tones = guitar_events(document)
|
||||||
@@ -367,8 +337,15 @@ def place_guitar(document: dict, *, composition=None) -> None:
|
|||||||
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)
|
||||||
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
|
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
|
||||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||||
|
trace = []
|
||||||
|
final_cost = selected.cost
|
||||||
skipped = []
|
skipped = []
|
||||||
while selected.previous is not None:
|
while selected.previous is not None:
|
||||||
|
fresh = has_attack([c.note for c in selected.choices])
|
||||||
|
motion = generator.hand_position.motion(tuple(p for _, p in selected.previous.active), tuple(p for _, p in selected.active), selected.previous.hand, selected.hand) if fresh else generator.hand_position.Motion()
|
||||||
|
trace.append({'hand_position': selected.hand, 'events': [c.note['id'] for c in selected.choices],
|
||||||
|
'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings,
|
||||||
|
'effort': str(selected.cost.effort - selected.previous.cost.effort)})
|
||||||
for choice in selected.choices:
|
for choice in selected.choices:
|
||||||
note = choice.note
|
note = choice.note
|
||||||
for tone, position in choice.exact:
|
for tone, position in choice.exact:
|
||||||
@@ -388,3 +365,4 @@ def place_guitar(document: dict, *, composition=None) -> None:
|
|||||||
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
||||||
document['staves'][0]['tablature'] = True
|
document['staves'][0]['tablature'] = True
|
||||||
generator.fretboard.validate_document(document)
|
generator.fretboard.validate_document(document)
|
||||||
|
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in trace), 'peak_position': final_cost.peak_position, 'cost': {k: str(v) for k, v in final_cost._asdict().items()}, 'positions': list(reversed(trace))}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Four-fret hand windows shared by chord and single-note placement.
|
||||||
|
|
||||||
|
This describes playable regions, not an anatomical fingering solver. Chord
|
||||||
|
and single tones may occupy any frets within the window. Only shifting the
|
||||||
|
window is movement. Actual sounded-note limits remain in fretboard.py.
|
||||||
|
"""
|
||||||
|
import functools
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import generator.fretboard
|
||||||
|
|
||||||
|
VERSION = 'hand-window-2'
|
||||||
|
|
||||||
|
class Motion(typing.NamedTuple):
|
||||||
|
shift: int = 0
|
||||||
|
fingers: int = 0
|
||||||
|
strings: int = 0
|
||||||
|
@property
|
||||||
|
def ergonomics(self):
|
||||||
|
return self.fingers + self.strings
|
||||||
|
|
||||||
|
def base(shape):
|
||||||
|
return min((p.fret for p in shape), default=0)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=4096)
|
||||||
|
def regions(shape):
|
||||||
|
if not shape:
|
||||||
|
return tuple([0])
|
||||||
|
return tuple(range(max(1, max(p.fret for p in shape) - 3), min(base(shape), generator.fretboard.MAX_ANCHOR_FRET) + 1))
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=32768)
|
||||||
|
def motion(previous, current, old_hand, hand, acoustic=False):
|
||||||
|
if not previous or not current:
|
||||||
|
return Motion()
|
||||||
|
if len(previous) == len(current) == 1:
|
||||||
|
pairs = ((previous[0], current[0]),)
|
||||||
|
unmatched = 0
|
||||||
|
else:
|
||||||
|
|
||||||
|
def voices(shape):
|
||||||
|
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
|
||||||
|
|
||||||
|
a, b = voices(previous), voices(current)
|
||||||
|
pairs = tuple((a[s], b[s]) for s in a.keys() & b.keys())
|
||||||
|
unmatched = len(a.keys() ^ b.keys())
|
||||||
|
return Motion(abs(hand - old_hand),
|
||||||
|
unmatched + sum(abs((a.fret - old_hand) - (b.fret - hand)) for a, b in pairs),
|
||||||
|
sum(abs(a.string_number - b.string_number) for a, b in pairs))
|
||||||
|
|
||||||
|
def summary(shifts):
|
||||||
|
shifts = tuple(shifts)
|
||||||
|
return {'total_travel': sum(shifts), 'shift_count': sum(s > 0 for s in shifts), 'largest_shift': max(shifts, default=0)}
|
||||||
@@ -41,15 +41,17 @@ def layout_part(document, fonts):
|
|||||||
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
|
header_style = dataclasses.replace(style, width=style.width // 2, margin_x=style.margin_x / 2, margin_y=style.margin_y / 2)
|
||||||
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
|
header = tuple(p.scaled(2) for p in generator.engraving.layout.part_header(document, fonts, header_style))
|
||||||
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
top = generator.engraving.types.bounds(header).bottom + style.page_clearance
|
||||||
bottom = style.height - style.margin_y - 20 - style.page_clearance
|
# Reserve the measured outline for every possible page number, just as
|
||||||
|
# screen pagination does. A nominal font size is not its actual ascent.
|
||||||
|
footers = tuple(fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
|
||||||
|
for number in range(1, len(systems) + 1))
|
||||||
|
bottom = min(p.box.top for p in footers) - style.page_clearance
|
||||||
pages, placed, cursor = [], [], top
|
pages, placed, cursor = [], [], top
|
||||||
|
|
||||||
def finish():
|
def finish():
|
||||||
number = len(pages) + 1
|
number = len(pages) + 1
|
||||||
# Include clearance for numeric descenders and JPEG block-edge ringing.
|
|
||||||
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y - 8, 20, 'page_number', (), True)
|
|
||||||
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
|
pages.append(generator.engraving.types.Page(number, style.width, style.height, style.supersampling, tuple(placed),
|
||||||
(*header, *(p for system in placed for p in system.primitives), footer)))
|
(*header, *(p for system in placed for p in system.primitives), footers[number - 1])))
|
||||||
|
|
||||||
for system in systems:
|
for system in systems:
|
||||||
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
|
system = generator.engraving.layout.make_system(index, system.start, system.end, fonts, style, justify=system.end < index.end, staff_width=style.width - 2 * style.margin_x - 2)
|
||||||
|
|||||||
+69
-40
@@ -5,77 +5,106 @@ import fractions
|
|||||||
import functools
|
import functools
|
||||||
|
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
|
import generator.hand_position
|
||||||
import generator.solo.types
|
import generator.solo.types
|
||||||
import generator.solo.profiles
|
import generator.solo.profiles
|
||||||
import generator.solo.policy
|
import generator.solo.policy
|
||||||
|
|
||||||
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
|
|
||||||
if previous is None:
|
|
||||||
return 0
|
|
||||||
return transition(previous.position, current.position, current.start - previous.start, tempo)
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=16384)
|
|
||||||
def transition(previous, current, duration, tempo):
|
|
||||||
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
|
|
||||||
seconds = float(duration * 60 / tempo)
|
|
||||||
# Movement is a preference, not an unsupported claim about a player's limits.
|
|
||||||
return round(100 * distance / max(seconds, 0.05))
|
|
||||||
|
|
||||||
def hand_positions(position):
|
def hand_positions(position):
|
||||||
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
|
return generator.hand_position.regions((position,))
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=512)
|
@functools.lru_cache(maxsize=512)
|
||||||
def position_rank(position):
|
def position_rank(position):
|
||||||
return generator.solo.policy.stable_rank('guitar-position', position)
|
return generator.solo.policy.stable_rank('guitar-position', position)
|
||||||
|
|
||||||
def stable_path(path):
|
def seconds(duration, tempo):
|
||||||
return tuple(position_rank(n.position) for n in path)
|
return max(fractions.Fraction(1, 20), duration * 60 / tempo)
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=4096)
|
@dataclasses.dataclass(frozen=True)
|
||||||
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
class Path:
|
||||||
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
|
travel: int = 0
|
||||||
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
|
peak: int = 0
|
||||||
|
effort: fractions.Fraction = fractions.Fraction()
|
||||||
|
notes: tuple = ()
|
||||||
|
hands: tuple = ()
|
||||||
|
ties: tuple = ()
|
||||||
|
@property
|
||||||
|
def rank(self):
|
||||||
|
return self.travel, self.peak, self.effort, self.ties
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=512)
|
||||||
|
def paths(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED, fixed=False):
|
||||||
|
"""Complete TAB paths: hand travel first, then maximum hand position.
|
||||||
|
|
||||||
|
Preserve different peaks at a common endpoint. No barline or tied fragment
|
||||||
|
resets the hand or shortens the elapsed time from the previous attack.
|
||||||
|
"""
|
||||||
|
states = [(Path(peak=h), h) for h in hand_positions(previous.position)] if previous and previous.position else [(Path(), 0)]
|
||||||
|
last_attack = previous.start if previous else None
|
||||||
for note in notes:
|
for note in notes:
|
||||||
following = {}
|
following = {}
|
||||||
for position in positions(note.pitch.number(), profile):
|
options = (note.position,) if fixed and note.position else positions(note.pitch.number(), profile)
|
||||||
|
for position in options:
|
||||||
current = dataclasses.replace(note, position=position)
|
current = dataclasses.replace(note, position=position)
|
||||||
for hand in hand_positions(position):
|
for hand in hand_positions(position):
|
||||||
best = None
|
for path, old_hand in states:
|
||||||
for cost, path, old_hand in states:
|
prior = path.notes[-1] if path.notes else previous
|
||||||
prior = path[-1] if path else previous
|
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start or hand != old_hand):
|
||||||
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
|
|
||||||
continue
|
continue
|
||||||
movement = 0
|
motion = generator.hand_position.Motion()
|
||||||
|
effort = fractions.Fraction()
|
||||||
if prior and not note.continuation:
|
if prior and not note.continuation:
|
||||||
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
|
motion = generator.hand_position.motion((prior.position,), (position,), old_hand, hand)
|
||||||
distance = abs(hand - old_hand) if old_hand is not None else 0
|
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo)
|
||||||
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
|
candidate = Path(path.travel + motion.shift, max(path.peak, hand), path.effort + effort, (*path.notes, current), (*path.hands, hand), (*path.ties, (position_rank(position), hand)))
|
||||||
movement = round(100 * distance / seconds)
|
key = (position, hand, candidate.peak)
|
||||||
candidate = (cost + movement, (*path, current), hand)
|
if key not in following or candidate.rank < following[key].rank:
|
||||||
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
|
following[key] = candidate
|
||||||
best = candidate
|
|
||||||
if best is not None:
|
|
||||||
following[position, hand] = best
|
|
||||||
if not following:
|
if not following:
|
||||||
return ()
|
return ()
|
||||||
states = list(following.values())
|
# A strictly worse hand travel at the identical endpoint/hand cannot recover.
|
||||||
# Endpoints survive so the following phrase can choose a better transition.
|
best = {}
|
||||||
|
for (position, hand, _), path in following.items():
|
||||||
|
best[position, hand] = min(best.get((position, hand), path.travel), path.travel)
|
||||||
|
states = [(path, hand) for (position, hand, _), path in following.items() if path.travel == best[position, hand]]
|
||||||
|
if not note.continuation:
|
||||||
|
last_attack = note.start
|
||||||
|
return tuple(sorted((path for path, _ in states), key=lambda path: path.rank))
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=512)
|
||||||
|
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
|
||||||
selected, seen = [], set()
|
selected, seen = [], set()
|
||||||
for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
|
for path in paths(notes, tempo, previous, profile):
|
||||||
endpoint = path[-1].position if path else None
|
endpoint = (path.notes[-1].position, path.hands[-1]) if path.notes else None
|
||||||
if endpoint in seen:
|
if endpoint in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(endpoint)
|
seen.add(endpoint)
|
||||||
selected.append((cost, path))
|
selected.append((path.travel, path.notes))
|
||||||
if len(selected) == limit:
|
if len(selected) == limit:
|
||||||
break
|
break
|
||||||
return tuple(selected)
|
return tuple(selected)
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=4096)
|
@functools.lru_cache(maxsize=512)
|
||||||
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
|
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
|
||||||
result = alternatives(notes, tempo, previous, 1, profile)
|
result = alternatives(notes, tempo, previous, 1, profile)
|
||||||
return result[0] if result else None
|
return result[0] if result else None
|
||||||
|
|
||||||
|
def describe(notes, tempo):
|
||||||
|
candidates = paths(notes, tempo, fixed=True)
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError('cannot describe an invalid guitar path')
|
||||||
|
path = candidates[0]
|
||||||
|
result, prior, old_hand, last_attack = [], None, 0, None
|
||||||
|
for note, hand in zip(path.notes, path.hands):
|
||||||
|
motion = generator.hand_position.motion((prior.position,), (note.position,), old_hand, hand) if prior and not note.continuation else generator.hand_position.Motion()
|
||||||
|
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo) if prior and not note.continuation else 0
|
||||||
|
result.append({'start': str(note.start), 'end': str(note.end), 'hand_position': hand, 'string': note.position.string_number,
|
||||||
|
'fret': note.position.fret, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings, 'secondary_effort': str(effort)})
|
||||||
|
if not note.continuation:
|
||||||
|
last_attack = note.start
|
||||||
|
prior, old_hand = note, hand
|
||||||
|
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in result), 'secondary_effort': str(path.effort), 'peak_position': path.peak, 'positions': result}
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=2048)
|
@functools.lru_cache(maxsize=2048)
|
||||||
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
|
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
|
||||||
profile = profile or generator.solo.profiles.UNRESTRICTED
|
profile = profile or generator.solo.profiles.UNRESTRICTED
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ def structure(solo, *, composition=None):
|
|||||||
ns = solo.notes()
|
ns = solo.notes()
|
||||||
duration = solo.end - solo.start
|
duration = solo.end - solo.start
|
||||||
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
return {'schema': 'composer-report-3', 'selection_trace': plain({k: v for k, v in solo.diagnostics.items() if not k.endswith('_seconds')}),
|
||||||
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo),
|
'instrument_profile': plain(solo.profile), 'phrases': phrases, 'expressions': plain(contracts), 'fretboard': fretboard_report(solo), 'guitar_placement': generator.solo.placement.describe(solo.notes(), solo.tempo),
|
||||||
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
|
'phrasing': {'attacks': len(ns), 'allocation_beats': str(duration),
|
||||||
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
|
'attacks_per_four_beats': 4 * len(ns) / float(duration) if duration else 0,
|
||||||
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},
|
'occupancy': sum(float(n.end - n.start) for n in ns) / float(duration) if duration else 0},
|
||||||
|
|||||||
Vendored
+16
-16
@@ -10655,8 +10655,8 @@
|
|||||||
"letter": "c",
|
"letter": "c",
|
||||||
"octave": 5,
|
"octave": 5,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 1,
|
"fret": 5,
|
||||||
"string": 2
|
"string": 3
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -10966,8 +10966,8 @@
|
|||||||
"letter": "d",
|
"letter": "d",
|
||||||
"octave": 5,
|
"octave": 5,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 7,
|
"fret": 3,
|
||||||
"string": 3
|
"string": 2
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -11007,8 +11007,8 @@
|
|||||||
"letter": "e",
|
"letter": "e",
|
||||||
"octave": 5,
|
"octave": 5,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 9,
|
"fret": 5,
|
||||||
"string": 3
|
"string": 2
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -11048,8 +11048,8 @@
|
|||||||
"letter": "f",
|
"letter": "f",
|
||||||
"octave": 5,
|
"octave": 5,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 10,
|
"fret": 6,
|
||||||
"string": 3
|
"string": 2
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -13379,8 +13379,8 @@
|
|||||||
"letter": "d",
|
"letter": "d",
|
||||||
"octave": 4,
|
"octave": 4,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 10,
|
"fret": 5,
|
||||||
"string": 6
|
"string": 5
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -13439,8 +13439,8 @@
|
|||||||
"letter": "c",
|
"letter": "c",
|
||||||
"octave": 4,
|
"octave": 4,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 8,
|
"fret": 3,
|
||||||
"string": 6
|
"string": 5
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -13624,8 +13624,8 @@
|
|||||||
"letter": "d",
|
"letter": "d",
|
||||||
"octave": 4,
|
"octave": 4,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 10,
|
"fret": 5,
|
||||||
"string": 6
|
"string": 5
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
@@ -13684,8 +13684,8 @@
|
|||||||
"letter": "c",
|
"letter": "c",
|
||||||
"octave": 4,
|
"octave": 4,
|
||||||
"tablature": {
|
"tablature": {
|
||||||
"fret": 8,
|
"fret": 3,
|
||||||
"string": 6
|
"string": 5
|
||||||
},
|
},
|
||||||
"written": {
|
"written": {
|
||||||
"dots": 0,
|
"dots": 0,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Independent exhaustive oracle for small grip/hand placement examples."""
|
||||||
|
import itertools
|
||||||
|
|
||||||
|
def regions(shape):
|
||||||
|
return tuple(h for h in range(1, 13) if all(h <= p.fret <= h + 3 for p in shape)) if shape else tuple([0])
|
||||||
|
|
||||||
|
def movement(a, b, ha, hb, acoustic=False):
|
||||||
|
if not a or not b:
|
||||||
|
return 0, 0
|
||||||
|
if len(a) == len(b) == 1:
|
||||||
|
pairs, unmatched = [(a[0], b[0])], 0
|
||||||
|
else:
|
||||||
|
|
||||||
|
def voices(shape):
|
||||||
|
return {('bass' if acoustic and p.string_number in (5, 6) else p.string_number): p for p in shape}
|
||||||
|
|
||||||
|
x, y = voices(a), voices(b)
|
||||||
|
pairs = [(x[s], y[s]) for s in x.keys() & y.keys()]
|
||||||
|
unmatched = len(x.keys() ^ y.keys())
|
||||||
|
return abs(ha - hb), unmatched + sum(abs((p.fret - ha) - (q.fret - hb)) + abs(p.string_number - q.string_number) for p, q in pairs)
|
||||||
|
|
||||||
|
def cost(path, costs, indices, *, acoustic=False, left=(), right=(), close=False, hands=None, left_hand=None, right_hand=None):
|
||||||
|
travel, peak, effort = indices
|
||||||
|
base = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(len(costs[0][path[0]]))]
|
||||||
|
results = []
|
||||||
|
for positions in [hands] if hands is not None else itertools.product(*(regions(v) for v in path)):
|
||||||
|
for lh in [left_hand] if left_hand is not None else regions(left):
|
||||||
|
for rh in [positions[0]] if close else [right_hand] if right_hand is not None else regions(right):
|
||||||
|
points = [(left, lh), *zip(path, positions), (path[0] if close else right, rh)]
|
||||||
|
total = base.copy()
|
||||||
|
total[peak] = max(h for _, h in points)
|
||||||
|
for (a, ha), (b, hb) in zip(points, points[1:]):
|
||||||
|
shift, ergonomics = movement(a, b, ha, hb, acoustic)
|
||||||
|
total[travel] += shift
|
||||||
|
total[effort] += ergonomics
|
||||||
|
results.append(tuple(total))
|
||||||
|
return min(results)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import generator.hand_position
|
||||||
import generator.validation
|
import generator.validation
|
||||||
import copy
|
import copy
|
||||||
import fractions
|
import fractions
|
||||||
@@ -13,6 +14,7 @@ import generator.harmony
|
|||||||
import generator.model
|
import generator.model
|
||||||
import tests.test_acoustic
|
import tests.test_acoustic
|
||||||
import tests.test_accompaniment
|
import tests.test_accompaniment
|
||||||
|
import tests.position_fixtures
|
||||||
|
|
||||||
def planned_document(harmonies, attacks=None):
|
def planned_document(harmonies, attacks=None):
|
||||||
if attacks is None:
|
if attacks is None:
|
||||||
@@ -173,19 +175,15 @@ class AcousticVoicingTests(unittest.TestCase):
|
|||||||
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
|
dense = planned_document(harmonies, [([(i, 1) for i in range(4)], [(i, 1) for i in range(4)])] * 3)
|
||||||
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
|
selected = [tuple(tuple(sorted({p for n in span.notes for p in shape(n)})) for span in self.place(document)) for document in (sparse, dense)]
|
||||||
self.assertEqual(*selected)
|
self.assertEqual(*selected)
|
||||||
def test_bass_switch_counts_both_string_and_fret_travel(self):
|
def test_bass_switch_and_finger_changes_do_not_move_the_hand(self):
|
||||||
previous = voicing((7, 8, 7, 8))
|
previous = voicing((7, 8, 7, 8))
|
||||||
for current, expected in ((voicing((7, 8, 7, 8), 6), 1), (voicing((8, 8, 7, 8), 6), 2), (voicing((9, 8, 7, 8), 6), 3), (voicing((6, 8, 7, 8)), 1)):
|
for current in (voicing((7, 8, 7, 8), 6), voicing((8, 8, 7, 8), 6), voicing((9, 8, 7, 8), 6), voicing((6, 8, 7, 8))):
|
||||||
self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected)
|
self.assertEqual(generator.acoustic_voicing.movement(previous, current, 6, 6), 0)
|
||||||
self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected)
|
self.assertEqual(generator.acoustic_voicing.movement(previous, previous, 5, 7), 2)
|
||||||
self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0)
|
def test_each_upper_string_is_tracked_as_secondary_finger_effort(self):
|
||||||
def test_each_upper_string_is_tracked_not_just_the_grip_center(self):
|
|
||||||
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
|
before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5))
|
||||||
self.assertEqual(generator.acoustic_voicing.movement(before, after), 8)
|
self.assertEqual(generator.acoustic_voicing.movement(before, after, 5, 5), 0)
|
||||||
for index in (1, 2, 3):
|
self.assertEqual(generator.hand_position.motion(before, after, 5, 5, True).fingers, 8)
|
||||||
frets = [5, 7, 5, 7]
|
|
||||||
frets[index] += 1
|
|
||||||
self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1)
|
|
||||||
def test_fret_and_register_limits_are_hard_constraints(self):
|
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), 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)):
|
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):
|
with self.subTest(frets=frets):
|
||||||
@@ -205,7 +203,8 @@ class AcousticVoicingTests(unittest.TestCase):
|
|||||||
options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14)))
|
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())]
|
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||||
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
result = generator.acoustic_voicing.advance(options, generator.fretboard.ChordRequirements(), states)
|
||||||
self.assertEqual([p.cost for p in result], [generator.acoustic_voicing.Cost()] * len(options))
|
self.assertTrue(all(p.cost.travel == 0 for p in result))
|
||||||
|
self.assertEqual({(p.voicing, p.hand, p.cost.peak_position) for p in result}, {(v, h, h) for v in options for h in tests.position_fixtures.regions(v)})
|
||||||
def test_optional_tones_break_equal_movement_costs_only(self):
|
def test_optional_tones_break_equal_movement_costs_only(self):
|
||||||
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
|
requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7'))
|
||||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||||
@@ -217,13 +216,16 @@ class AcousticVoicingTests(unittest.TestCase):
|
|||||||
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
|
def test_dynamic_program_matches_exhaustive_paths_and_is_deterministic(self):
|
||||||
harmonies = ['FM7', 'Gm7', 'C7']
|
harmonies = ['FM7', 'Gm7', 'C7']
|
||||||
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
|
spans = generator.acoustic.harmony_spans(planned_document([[(0, s)] for s in harmonies]))
|
||||||
options = [candidates(s) for s in harmonies]
|
options = [candidates(s)[:4] for s in harmonies]
|
||||||
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans]
|
||||||
|
costs = [{v: generator.acoustic_voicing.Cost(extended_grips=generator.fretboard.span_penalty(v), missing_tones=len(set(r.allowed) - {p.midi % 12 for p in v})) for v in layer} for r, layer in zip(requirements, options)]
|
||||||
def cost(path):
|
def cost(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)))
|
return tests.position_fixtures.cost(path, costs, (1, 2, 3), acoustic=True)
|
||||||
selected = generator.acoustic_voicing.select(spans)
|
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())]
|
||||||
self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options)))
|
for layer, r in zip(options, requirements):
|
||||||
self.assertEqual(selected, generator.acoustic_voicing.select(spans))
|
states = generator.acoustic_voicing.advance(layer, r, states)
|
||||||
|
self.assertEqual(min(s.cost for s in states), min(cost(path) for path in itertools.product(*options)))
|
||||||
|
self.assertEqual(generator.acoustic_voicing.select(spans), generator.acoustic_voicing.select(spans))
|
||||||
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
|
def test_complete_triads_and_doubling_use_only_authored_chord_tones(self):
|
||||||
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
|
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'):
|
||||||
with self.subTest(chord=symbol):
|
with self.subTest(chord=symbol):
|
||||||
|
|||||||
@@ -78,11 +78,13 @@ class ChordFamilies(unittest.TestCase):
|
|||||||
return min(p.string_number for p in v if p.midi == midi)
|
return min(p.string_number for p in v if p.midi == midi)
|
||||||
tier = generator.fretboard.span_penalty(choices[0])
|
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(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(tuple(p.string_number for p in v) in ((3, 2, 1), (4, 3, 2, 1)) for v in choices))
|
||||||
self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v))
|
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))
|
self.assertTrue(all(midi in {p.midi for p in v} for v in choices))
|
||||||
low = tests.test_voicing.ranked('C7', (64,))[0]
|
low = tests.test_voicing.ranked('C7', (64,))[0]
|
||||||
self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2))
|
self.assertIn(1, {p.string_number for p in low})
|
||||||
|
self.assertIn(64, {p.midi for p in low})
|
||||||
|
self.assertGreater(max(p.midi for p in low), 64)
|
||||||
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
|
def test_acoustic_policy_change_does_not_reseed_electric_roles(self):
|
||||||
resources = generator.vocabulary.load()
|
resources = generator.vocabulary.load()
|
||||||
changed = copy.deepcopy(resources)
|
changed = copy.deepcopy(resources)
|
||||||
|
|||||||
@@ -155,7 +155,9 @@ class CompingMeters(unittest.TestCase):
|
|||||||
measures = source['staves'][0]['measures']
|
measures = source['staves'][0]['measures']
|
||||||
carried = generator.accompaniment.carried_chords(measures)
|
carried = generator.accompaniment.carried_chords(measures)
|
||||||
full = canonical(measures[3], carried[3], 3)
|
full = canonical(measures[3], carried[3], 3)
|
||||||
|
full_position = canonical.last_position
|
||||||
partial = canonical(measures[3], carried[3], 3, bounds=(3, 10))
|
partial = canonical(measures[3], carried[3], 3, bounds=(3, 10))
|
||||||
|
self.assertEqual(canonical.last_position, full_position)
|
||||||
# Rest engraving can split differently at a sliced boundary. Sounded
|
# Rest engraving can split differently at a sliced boundary. Sounded
|
||||||
# onsets, releases, voicings and tie relationships must remain equal.
|
# onsets, releases, voicings and tie relationships must remain equal.
|
||||||
self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]])
|
self.assertEqual([n for n in tests.test_comping_cycles.signature(full) if n[-1]], [n for n in tests.test_comping_cycles.signature(partial) if n[-1]])
|
||||||
|
|||||||
@@ -15,21 +15,20 @@ import generator.comping
|
|||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.guitar
|
import generator.guitar
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
|
import generator.hand_position
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.score
|
import generator.score
|
||||||
import generator.vocabulary
|
import generator.vocabulary
|
||||||
import tests.solo_fixtures
|
import tests.solo_fixtures
|
||||||
import tests.test_acoustic_voicing
|
import tests.test_acoustic_voicing
|
||||||
import tests.test_voicing
|
import tests.test_voicing
|
||||||
|
import tests.position_fixtures
|
||||||
|
|
||||||
def options(symbol, part):
|
def options(symbol, part):
|
||||||
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(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):
|
def path_cost(path, costs, part, left=(), right=(), close=False, **kwargs):
|
||||||
total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)]
|
return tests.position_fixtures.cost(path, costs, (2, 3, 4), acoustic=part == 'acoustic', left=left, right=right, close=close, **kwargs)
|
||||||
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):
|
class CompingVoicings(unittest.TestCase):
|
||||||
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
|
def test_electric_prefers_lower_grip_but_can_avoid_a_large_shift(self):
|
||||||
@@ -39,23 +38,23 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
costs = {v: generator.comping.local_cost('electric', key, v, 60) for v in (low, high)}
|
costs = {v: generator.comping.local_cost('electric', key, v, 60) for v in (low, high)}
|
||||||
self.assertEqual(costs[low][:2], costs[high][:2])
|
self.assertEqual(costs[low][:2], costs[high][:2])
|
||||||
self.assertLess(costs[low], costs[high])
|
self.assertLess(costs[low], costs[high])
|
||||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
|
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower')
|
||||||
self.assertEqual(path, (low,))
|
self.assertEqual(path, (low,))
|
||||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
|
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'electric', 'lower', left=high, right=high)
|
||||||
self.assertEqual(path, (high,))
|
self.assertEqual(path, (high,))
|
||||||
def test_electric_voicing_policy_does_not_reseed_rhythm(self):
|
def test_electric_voicing_policy_does_not_reseed_rhythm(self):
|
||||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
|
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=4, chords=('C7', 'FM7', 'Dm7', 'G7')))
|
||||||
root = pathlib.Path('data')
|
root = pathlib.Path('data')
|
||||||
current = generator.composition.generate_role(source, 'electric_accompaniment', root)
|
current = generator.composition.generate_role(source, 'electric_accompaniment', root)
|
||||||
with tempfile.TemporaryDirectory() as cache:
|
with tempfile.TemporaryDirectory() as cache:
|
||||||
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0}}
|
settings = {'voicing': {'register_weight': 0}}
|
||||||
other = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
other = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||||
warm = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
warm = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache)
|
||||||
self.assertTrue(warm['cache_hit'])
|
self.assertTrue(warm['cache_hit'])
|
||||||
self.assertEqual(other['document'], warm['document'])
|
self.assertEqual(other['document'], warm['document'])
|
||||||
self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key'])
|
self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key'])
|
||||||
self.assertEqual(current['details']['accompaniment_rhythm'], other['details']['accompaniment_rhythm'])
|
self.assertEqual(current['details']['accompaniment_rhythm'], other['details']['accompaniment_rhythm'])
|
||||||
def test_lower_progression_can_outweigh_extra_boundary_travel(self):
|
def test_connected_progression_uses_actual_windows_and_neighboring_grips(self):
|
||||||
symbols = ('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')
|
symbols = ('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')
|
||||||
low = tuple(tests.test_acoustic_voicing.voicing(f) for f in ((7, 8, 7, 8), (7, 8, 6, 8), (5, 7, 5, 6), (5, 5, 4, 6), (3, 5, 3, 4), (3, 3, 2, 4)))
|
low = tuple(tests.test_acoustic_voicing.voicing(f) for f in ((7, 8, 7, 8), (7, 8, 6, 8), (5, 7, 5, 6), (5, 5, 4, 6), (3, 5, 3, 4), (3, 3, 2, 4)))
|
||||||
high = tuple(tests.test_acoustic_voicing.voicing(f, 6) for f in ((12, 12, 12, 11), (12, 11, 12, 11), (10, 10, 10, 10), (10, 9, 10, 8), (8, 8, 8, 8), (8, 7, 8, 6)))
|
high = tuple(tests.test_acoustic_voicing.voicing(f, 6) for f in ((12, 12, 12, 11), (12, 11, 12, 11), (10, 10, 10, 10), (10, 9, 10, 8), (8, 8, 8, 8), (8, 7, 8, 6)))
|
||||||
@@ -66,12 +65,11 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer))
|
self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer))
|
||||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol(symbol))
|
key = generator.comping.semantic_key(generator.harmony.parse_symbol(symbol))
|
||||||
costs.append({v: generator.comping.local_cost('acoustic', key, v, 53) for v in layer})
|
costs.append({v: generator.comping.local_cost('acoustic', key, v, 53) for v in layer})
|
||||||
def travel(path):
|
# The high path travels less from these particular neighbors; a lower
|
||||||
return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right)))
|
# sounding register cannot purchase extra hand movement.
|
||||||
self.assertEqual((travel(low), travel(high)), (43, 35))
|
self.assertGreater(path_cost(low, costs, 'acoustic', left, right)[2], path_cost(high, costs, 'acoustic', left, right)[2])
|
||||||
self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right))
|
path, cost, hands = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
|
||||||
path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
|
self.assertEqual(cost, path_cost(path, costs, 'acoustic', left, right, hands=hands))
|
||||||
self.assertEqual(path, low)
|
|
||||||
self.assertEqual(cost, min(path_cost(p, costs, 'acoustic', left, right) for p in itertools.product(*layers)))
|
self.assertEqual(cost, min(path_cost(p, costs, 'acoustic', left, right) for p in itertools.product(*layers)))
|
||||||
def test_large_round_trip_can_retain_a_high_position(self):
|
def test_large_round_trip_can_retain_a_high_position(self):
|
||||||
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
|
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
|
||||||
@@ -79,26 +77,24 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
||||||
costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, high)}
|
costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, high)}
|
||||||
self.assertLess(costs[low], costs[high])
|
self.assertLess(costs[low], costs[high])
|
||||||
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
|
path, _, hands = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
|
||||||
self.assertEqual(path, (high,))
|
self.assertEqual(path, (high,))
|
||||||
def test_acoustic_preferences_are_exact_and_weights_are_operational(self):
|
def test_register_weight_is_an_exact_subordinate_tiebreak(self):
|
||||||
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
|
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
|
||||||
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
|
|
||||||
resources = generator.vocabulary.load()
|
resources = generator.vocabulary.load()
|
||||||
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
|
||||||
self.assertEqual(generator.comping.voicing_preferences('acoustic', high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
|
|
||||||
self.assertEqual(generator.comping.voicing_preferences('acoustic', low, 53)[1], 0)
|
|
||||||
baseline = generator.comping.local_cost('acoustic', key, high, 53)
|
baseline = generator.comping.local_cost('acoustic', key, high, 53)
|
||||||
for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)):
|
self.assertEqual(baseline.register, fractions.Fraction(39, 4))
|
||||||
policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing'])
|
self.assertEqual(baseline.effort, 0)
|
||||||
policy[field] += 1
|
self.assertEqual(baseline.peak_position, 0)
|
||||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}):
|
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': 2}}):
|
||||||
cost = generator.comping.local_cost('acoustic', key, high, 53)
|
cost = generator.comping.local_cost('acoustic', key, high, 53)
|
||||||
self.assertEqual(cost.effort - baseline.effort, expected, field)
|
self.assertEqual(cost.register, 2 * baseline.register)
|
||||||
self.assertEqual(cost.extended_grips, baseline.extended_grips)
|
self.assertEqual(cost[:-1], baseline[:-1])
|
||||||
self.assertEqual(cost.family, baseline.family)
|
|
||||||
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
|
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
|
||||||
generator.comping.local_cost('acoustic', key, high, 53)
|
generator.comping.local_cost('acoustic', key, high, 53)
|
||||||
|
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'high_position_weight': 1}}), self.assertRaisesRegex(ValueError, 'unknown voicing'):
|
||||||
|
generator.comping.local_cost('acoustic', key, high, 53)
|
||||||
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
|
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
|
||||||
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
|
source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
|
||||||
def rhythm(result):
|
def rhythm(result):
|
||||||
@@ -115,12 +111,13 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
data['roles']['acoustic_accompaniment']['voicing'] = policy
|
data['roles']['acoustic_accompaniment']['voicing'] = policy
|
||||||
role_path.write_text(json.dumps(data))
|
role_path.write_text(json.dumps(data))
|
||||||
current = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
|
current = generator.composition.generate_role(source, 'acoustic_accompaniment', root)
|
||||||
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
|
settings = {'voicing': {'register_weight': 0}}
|
||||||
old = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
old = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||||
warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
|
||||||
self.assertTrue(warm['cache_hit'])
|
self.assertTrue(warm['cache_hit'])
|
||||||
self.assertEqual(old['document'], warm['document'])
|
self.assertEqual(old['document'], warm['document'])
|
||||||
self.assertNotEqual(current['document'], old['document'])
|
# A tie weight is not required to change a winner with better movement.
|
||||||
|
self.assertEqual(current['details']['guitar_placement']['cost']['effort'], old['details']['guitar_placement']['cost']['effort'])
|
||||||
self.assertNotEqual(current['trace']['cache_key'], old['trace']['cache_key'])
|
self.assertNotEqual(current['trace']['cache_key'], old['trace']['cache_key'])
|
||||||
self.assertNotEqual(legacy['trace']['cache_key'], current['trace']['cache_key'])
|
self.assertNotEqual(legacy['trace']['cache_key'], current['trace']['cache_key'])
|
||||||
self.assertEqual(legacy['document'], current['document'])
|
self.assertEqual(legacy['document'], current['document'])
|
||||||
@@ -133,8 +130,10 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
cost = row['voicing_cost']
|
cost = row['voicing_cost']
|
||||||
self.assertEqual(cost['movement'], row['movement'])
|
self.assertEqual(cost['movement'], row['movement'])
|
||||||
self.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position')))
|
self.assertGreaterEqual(fractions.Fraction(cost['secondary_effort']), fractions.Fraction(cost['grip_effort']))
|
||||||
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) for r in rows))
|
self.assertEqual(canonical.path_cost.peak_position, max(r['hand_position'] for r in rows))
|
||||||
|
self.assertEqual(canonical.path_cost.travel, sum(r['movement'] for r in rows))
|
||||||
|
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['secondary_effort']) for r in rows))
|
||||||
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
|
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
|
||||||
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
|
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
|
||||||
with self.subTest(chord=symbol):
|
with self.subTest(chord=symbol):
|
||||||
@@ -187,21 +186,21 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C')
|
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C')
|
||||||
layers = tuple(options(s, part) for s in chords)
|
layers = tuple(options(s, part) for s in chords)
|
||||||
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
|
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
|
||||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test')
|
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test')
|
||||||
greedy = []
|
greedy = []
|
||||||
for i, layer in enumerate(layers):
|
for i, layer in enumerate(layers):
|
||||||
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
|
greedy.append(min(layer, key=lambda v: (path_cost((*greedy, v), costs[:i + 1], part), v)))
|
||||||
self.assertLess(cost, path_cost(greedy, costs, part))
|
self.assertLessEqual(cost, path_cost(greedy, costs, part))
|
||||||
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
|
self.assertEqual(cost, min(path_cost(p, costs, part) for p in itertools.product(*layers)))
|
||||||
self.assertEqual(cost, path_cost(path, costs, part))
|
self.assertEqual(cost, path_cost(path, costs, part))
|
||||||
self.assertEqual((path, cost), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
|
self.assertEqual((path, cost, hands), generator.comping.select_grips(tuple(tuple(reversed(l)) for l in layers), costs, part, 'test'))
|
||||||
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
|
def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self):
|
||||||
for part in ('acoustic', 'electric'):
|
for part in ('acoustic', 'electric'):
|
||||||
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
|
layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7'))
|
||||||
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers)
|
costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v), peak_position=generator.hand_position.base(v)) for v in layer} for layer in layers)
|
||||||
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
|
for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}):
|
||||||
with self.subTest(part=part, endpoints=endpoints):
|
with self.subTest(part=part, endpoints=endpoints):
|
||||||
path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test', **endpoints)
|
||||||
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
|
self.assertEqual(cost, min(path_cost(p, costs, part, **endpoints) for p in itertools.product(*layers)))
|
||||||
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
|
self.assertEqual(cost, path_cost(path, costs, part, **endpoints))
|
||||||
def test_rest_does_not_reset_the_previous_hand_position(self):
|
def test_rest_does_not_reset_the_previous_hand_position(self):
|
||||||
@@ -210,7 +209,7 @@ class CompingVoicings(unittest.TestCase):
|
|||||||
for part in ('acoustic', 'electric'):
|
for part in ('acoustic', 'electric'):
|
||||||
canonical = generator.comping.Canonical(source, part, 'rest')
|
canonical = generator.comping.Canonical(source, part, 'rest')
|
||||||
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
|
self.assertEqual({i for i, _, _ in canonical.order}, {0, 2})
|
||||||
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2])
|
expected = generator.comping.movement(part, canonical.order[0][2], canonical.order[1][2], *canonical.hand_order[:2])
|
||||||
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
|
self.assertEqual(next(r['movement'] for r in canonical.report if r['source_index'] == 2), expected)
|
||||||
def test_harmony_variants_are_independent_of_request_order(self):
|
def test_harmony_variants_are_independent_of_request_order(self):
|
||||||
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))
|
source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7'))
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class FretPolicyTests(unittest.TestCase):
|
|||||||
costs = {v: generator.comping.local_cost('acoustic', key, v, 55) 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.
|
# Movement/family wins cannot purchase entry into the four-fret tier.
|
||||||
costs[primary] = costs[primary]._replace(effort=100000)
|
costs[primary] = costs[primary]._replace(effort=100000)
|
||||||
path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
path, cost, hands = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback)
|
||||||
self.assertEqual(path, (primary,))
|
self.assertEqual(path, (primary,))
|
||||||
self.assertEqual(cost.extended_grips, 0)
|
self.assertEqual(cost.extended_grips, 0)
|
||||||
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
|
states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)]
|
||||||
@@ -68,7 +68,8 @@ class FretPolicyTests(unittest.TestCase):
|
|||||||
best = choices[0]
|
best = choices[0]
|
||||||
self.assertEqual(generator.fretboard.span_penalty(best), 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(min(p.string_number for p in best if p.midi == 65), 2)
|
||||||
self.assertEqual(max(p.midi for p in best), 65)
|
self.assertIn(65, {p.midi for p in best})
|
||||||
|
self.assertIn(1, {p.string_number for p in best})
|
||||||
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))
|
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)
|
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)))
|
self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v)))
|
||||||
|
|||||||
+10
-15
@@ -15,6 +15,7 @@ import generator.acoustic_voicing
|
|||||||
import generator.arrange.generate
|
import generator.arrange.generate
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.guitar
|
import generator.guitar
|
||||||
|
import generator.hand_position
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.parts
|
import generator.parts
|
||||||
@@ -43,7 +44,7 @@ class GuitarTests(unittest.TestCase):
|
|||||||
positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(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.fretboard.within_fret_span(positions), valid)
|
||||||
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets))
|
||||||
self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets))
|
self.assertEqual(generator.hand_position.base(positions), min(frets))
|
||||||
self.assertTrue(generator.fretboard.within_fret_span(()))
|
self.assertTrue(generator.fretboard.within_fret_span(()))
|
||||||
def test_held_notes_obey_the_shared_four_fret_limit(self):
|
def test_held_notes_obey_the_shared_four_fret_limit(self):
|
||||||
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
held = {'held': generator.fretboard.TabPosition(4, 7, 57)}
|
||||||
@@ -210,20 +211,14 @@ class GuitarTests(unittest.TestCase):
|
|||||||
generator.guitar.place_guitar(source)
|
generator.guitar.place_guitar(source)
|
||||||
tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)]
|
tabs = [n['pitches'][0]['tablature'] for n in tests.test_musicxml.notes(source)]
|
||||||
self.assertEqual(tabs, [{'string': 1, 'fret': 10}, {'string': 1, 'fret': 12}])
|
self.assertEqual(tabs, [{'string': 1, 'fret': 10}, {'string': 1, 'fret': 12}])
|
||||||
def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self):
|
def test_equal_old_centers_can_have_different_finger_adjustments(self):
|
||||||
randomizer = random.Random(17)
|
def grip(frets):
|
||||||
states = {}
|
return tuple(generator.fretboard.TabPosition(s, f, 0) for s, f in zip((4, 3, 2, 1), frets))
|
||||||
for index in range(40):
|
first, second = grip((3, 5, 3, 5)), grip((5, 3, 5, 3))
|
||||||
positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3))
|
self.assertEqual(generator.hand_position.base(first), generator.hand_position.base(second))
|
||||||
states[index] = generator.guitar.Placement(generator.guitar.VoicingCost(*(randomizer.randrange(8) for _ in generator.guitar.VoicingCost._fields)), tuple((str(i), p) for i, p in enumerate(positions)), None, ())
|
self.assertEqual(generator.guitar.movement(first, first, 3, 3), 0)
|
||||||
grid = generator.guitar.nearest_positions(states)
|
self.assertEqual(generator.guitar.movement(first, second, 3, 3), 0)
|
||||||
for x, row in enumerate(grid):
|
self.assertEqual(generator.hand_position.motion(first, second, 3, 3).fingers, 8)
|
||||||
for y, actual in enumerate(row):
|
|
||||||
candidates = []
|
|
||||||
for index, state in enumerate(states.values()):
|
|
||||||
a, b = generator.guitar.position_center(tuple(p for _, p in state.active))
|
|
||||||
candidates.append((state.cost.moved(abs(x - a) + abs(y - b)), index))
|
|
||||||
self.assertEqual(actual[:2], min(candidates))
|
|
||||||
def test_generated_chord_retains_strings_when_a_melody_enters(self):
|
def test_generated_chord_retains_strings_when_a_melody_enters(self):
|
||||||
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='<voice>2</voice>')
|
content = tests.test_musicxml.xml_note() + '<backup><duration>4</duration></backup><forward><duration>2</duration></forward>' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='<voice>2</voice>')
|
||||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content))
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Whole-path position objectives and ties, with independently checked examples."""
|
||||||
|
import dataclasses
|
||||||
|
import fractions
|
||||||
|
import itertools
|
||||||
|
import unittest
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
|
import generator.comping
|
||||||
|
import generator.fretboard
|
||||||
|
import generator.guitar
|
||||||
|
import generator.hand_position
|
||||||
|
import generator.solo.placement
|
||||||
|
import tests.solo_fixtures
|
||||||
|
import tests.test_acoustic_voicing
|
||||||
|
import tests.test_comping_voicings
|
||||||
|
|
||||||
|
def position(string, fret):
|
||||||
|
return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret)
|
||||||
|
|
||||||
|
class HandPositionTests(unittest.TestCase):
|
||||||
|
def test_single_notes_can_share_a_four_fret_hand_region(self):
|
||||||
|
self.assertEqual(set.intersection(*(set(generator.hand_position.regions((position(1, f),))) for f in range(7, 11))), {7})
|
||||||
|
self.assertEqual(generator.hand_position.regions((position(1, 1),)), (1,))
|
||||||
|
self.assertIn(12, generator.hand_position.regions((position(1, 12),)))
|
||||||
|
self.assertTrue(all(p.fret <= 12 for n in range(41, 89) for p in generator.solo.placement.positions(n)))
|
||||||
|
def test_chords_share_the_same_window_as_single_notes(self):
|
||||||
|
self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 8), position(3, 7), position(2, 8))), (5, 6, 7))
|
||||||
|
self.assertEqual(generator.hand_position.regions((position(5, 7), position(4, 10))), (7,))
|
||||||
|
a, b = (position(5, 7),), (position(3, 9),)
|
||||||
|
motion = generator.hand_position.motion(a, b, 7, 7)
|
||||||
|
self.assertEqual(motion.shift, 0)
|
||||||
|
self.assertGreater(motion.ergonomics, 0)
|
||||||
|
def test_future_peak_can_equalize_histories_at_the_same_endpoint(self):
|
||||||
|
low, high, join, final = ((position(1, f),) for f in (1, 4, 2, 8))
|
||||||
|
layers = ((low, high), (join,), (final,))
|
||||||
|
costs = ({low: generator.comping.VoicingCost(register=10), high: generator.comping.VoicingCost()},
|
||||||
|
{join: generator.comping.VoicingCost()}, {final: generator.comping.VoicingCost()})
|
||||||
|
# Isolate peak propagation: a higher earlier peak must remain available
|
||||||
|
# when a common later maximum exposes its better subordinate tie-break.
|
||||||
|
with unittest.mock.patch('generator.hand_position.motion', return_value=generator.hand_position.Motion()):
|
||||||
|
path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'peak')
|
||||||
|
self.assertEqual(path, (high, join, final))
|
||||||
|
self.assertEqual((cost.travel, cost.peak_position, cost.register), (0, 5, 0))
|
||||||
|
def test_complete_path_avoids_a_locally_lower_position_trap(self):
|
||||||
|
low, high, middle, end = ((position(1, f),) for f in (1, 4, 5, 8))
|
||||||
|
layers = ((low, high), (middle,), (end,))
|
||||||
|
costs = tuple({v: generator.comping.VoicingCost(register=int(v == high)) for v in layer} for layer in layers)
|
||||||
|
first, _, _ = generator.comping.select_grips(layers[:1], costs[:1], 'electric', 'lookahead')
|
||||||
|
path, cost, hands = generator.comping.select_grips(layers, costs, 'electric', 'lookahead')
|
||||||
|
self.assertEqual(first, (low,))
|
||||||
|
self.assertEqual(path, (high, middle, end))
|
||||||
|
self.assertEqual(cost.travel, 1)
|
||||||
|
self.assertEqual(cost, tests.test_comping_voicings.path_cost(path, costs, 'electric', hands=hands))
|
||||||
|
def test_lower_travel_beats_easier_finger_changes(self):
|
||||||
|
notes = tuple(tests.solo_fixtures.note(i, i + 1, pitch, octave=4) for i, pitch in enumerate(('e', 'g', 'f', 'e')))
|
||||||
|
result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0]
|
||||||
|
self.assertEqual(result.travel, 0)
|
||||||
|
self.assertEqual(len(set(result.hands)), 1)
|
||||||
|
self.assertGreater(result.effort, 0)
|
||||||
|
def test_solo_matches_exhaustive_pitch_and_hand_paths(self):
|
||||||
|
notes = (tests.solo_fixtures.note(0, 1, 'e', octave=4), tests.solo_fixtures.note(1, 2, 'g', octave=4), tests.solo_fixtures.note(2, 3, 'f', octave=4))
|
||||||
|
layers = [tuple((p, h) for p in generator.solo.placement.positions(n.pitch.number()) for h in range(max(1, p.fret - 3), p.fret + 1)) for n in notes]
|
||||||
|
expected = []
|
||||||
|
for path in itertools.product(*layers):
|
||||||
|
effort = sum(200 * (abs(h - k) + abs((p.fret - h) - (q.fret - k)) + abs(p.string_number - q.string_number)) for (p, h), (q, k) in zip(path, path[1:]))
|
||||||
|
expected.append((sum(abs(h - k) for (_, h), (_, k) in zip(path, path[1:])), max(h for _, h in path), effort))
|
||||||
|
result = generator.solo.placement.paths(notes, fractions.Fraction(120))[0]
|
||||||
|
self.assertEqual((result.travel, result.peak, result.effort), min(expected))
|
||||||
|
self.assertEqual([(n.pitch, n.start, n.end) for n in result.notes], [(n.pitch, n.start, n.end) for n in notes])
|
||||||
|
report = generator.solo.placement.describe(result.notes, fractions.Fraction(120))
|
||||||
|
self.assertEqual(fractions.Fraction(report['secondary_effort']), sum(fractions.Fraction(r['secondary_effort']) for r in report['positions']))
|
||||||
|
def test_tie_split_never_creates_a_shift_or_shorter_travel_time(self):
|
||||||
|
merged = (tests.solo_fixtures.note(0, 3, 'e', octave=4), tests.solo_fixtures.note(3, 4, 'g', octave=4))
|
||||||
|
split = (dataclasses.replace(merged[0], end=fractions.Fraction(1)), dataclasses.replace(merged[0], start=fractions.Fraction(1), continuation=True), merged[1])
|
||||||
|
a, b = (generator.solo.placement.paths(notes, fractions.Fraction(120))[0] for notes in (merged, split))
|
||||||
|
self.assertEqual((a.travel, a.peak, a.effort), (b.travel, b.peak, b.effort))
|
||||||
|
self.assertEqual([n.position for n in a.notes], [b.notes[0].position, b.notes[2].position])
|
||||||
|
self.assertEqual((b.notes[0].position, b.hands[0]), (b.notes[1].position, b.hands[1]))
|
||||||
|
report = generator.solo.placement.describe(b.notes, fractions.Fraction(120))
|
||||||
|
self.assertEqual(report['positions'][1]['secondary_effort'], '0')
|
||||||
|
def test_guitar_cost_composes_peak_with_maximum(self):
|
||||||
|
first = generator.guitar.VoicingCost(effort=3, peak_position=4, register=fractions.Fraction(1, 2))
|
||||||
|
second = generator.guitar.VoicingCost(effort=2, peak_position=7, register=fractions.Fraction(1, 3))
|
||||||
|
result = first.plus(second)
|
||||||
|
self.assertEqual((result.effort, result.peak_position, result.register), (5, 7, fractions.Fraction(5, 6)))
|
||||||
@@ -4,6 +4,7 @@ import copy
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import pathlib
|
import pathlib
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -63,6 +64,18 @@ class PrintableParts(unittest.TestCase):
|
|||||||
broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie'))
|
broken = dataclasses.replace(page, primitives=tuple(p for p in page.primitives if p.role != 'tie'))
|
||||||
with self.assertRaisesRegex(ValueError, 'tie was omitted'):
|
with self.assertRaisesRegex(ValueError, 'tie was omitted'):
|
||||||
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None)
|
generator.engraving.audit.audit_layout(source, dataclasses.replace(layout, pages=(broken,)), self.fonts, systems_per_page=None)
|
||||||
|
def test_tight_page_uses_the_actual_footer_outline(self):
|
||||||
|
source = tests.solo_fixtures.song(count=32, chords=('C7',))
|
||||||
|
original = generator.printing.layout_part(source, self.fonts)
|
||||||
|
style = generator.printing.STYLE
|
||||||
|
# Two systems just fit the old nominal 20-unit footer reservation. The
|
||||||
|
# page-number outline needs more space than that arithmetic assumes.
|
||||||
|
height = math.ceil(original.pages[0].systems[1].box.bottom + style.margin_y + 20 + style.page_clearance)
|
||||||
|
with unittest.mock.patch('generator.printing.STYLE', dataclasses.replace(style, height=height)):
|
||||||
|
layout = generator.printing.layout_part(source, self.fonts)
|
||||||
|
for page in layout.pages:
|
||||||
|
footer = next(p for p in page.primitives if p.role == 'page_number')
|
||||||
|
self.assertGreaterEqual(footer.box.top - page.systems[-1].box.bottom, style.page_clearance - .01)
|
||||||
def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self):
|
def test_guitar_tab_pickups_and_final_three_bar_hold_are_included(self):
|
||||||
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True)
|
source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True)
|
||||||
source = source.replace('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
|
source = source.replace('</measure></part>', '</measure><measure number="2"><harmony><root><root-step>C</root-step></root><kind>major</kind></harmony>' + tests.test_musicxml.xml_note() + '</measure></part>')
|
||||||
|
|||||||
+40
-55
@@ -6,6 +6,8 @@ import unittest
|
|||||||
import generator.arrange.generate
|
import generator.arrange.generate
|
||||||
import generator.fretboard
|
import generator.fretboard
|
||||||
import generator.guitar
|
import generator.guitar
|
||||||
|
import generator.hand_position
|
||||||
|
import itertools
|
||||||
import generator.harmony
|
import generator.harmony
|
||||||
import generator.model
|
import generator.model
|
||||||
import generator.musicxml
|
import generator.musicxml
|
||||||
@@ -13,6 +15,7 @@ import generator.score
|
|||||||
import generator.vocabulary
|
import generator.vocabulary
|
||||||
import tests.test_musicxml
|
import tests.test_musicxml
|
||||||
import tests.test_parts
|
import tests.test_parts
|
||||||
|
import tests.position_fixtures
|
||||||
|
|
||||||
def grip(frets, strings=(4, 3, 2, 1)):
|
def grip(frets, strings=(4, 3, 2, 1)):
|
||||||
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
tuning = dict(generator.fretboard.STANDARD_TUNING)
|
||||||
@@ -27,7 +30,7 @@ def ranked(symbol, melody=(), profile='electric', prune=False):
|
|||||||
class VoicingTests(unittest.TestCase):
|
class VoicingTests(unittest.TestCase):
|
||||||
def test_melody_preferences_cannot_purchase_a_lower_priority_string(self):
|
def test_melody_preferences_cannot_purchase_a_lower_priority_string(self):
|
||||||
resources = generator.vocabulary.load()
|
resources = generator.vocabulary.load()
|
||||||
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 1000, 'high_position_weight': 1000}}):
|
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 1000}}):
|
||||||
for symbol, melody in (('C7', (67,)), ('FM7', (69,)), ('Cm7', (70,))):
|
for symbol, melody in (('C7', (67,)), ('FM7', (69,)), ('Cm7', (70,))):
|
||||||
candidates = ranked(symbol, melody)
|
candidates = ranked(symbol, melody)
|
||||||
primary = [c for c in candidates if generator.fretboard.span_penalty(c) == generator.fretboard.span_penalty(candidates[0])]
|
primary = [c for c in candidates if generator.fretboard.span_penalty(c) == generator.fretboard.span_penalty(candidates[0])]
|
||||||
@@ -38,17 +41,15 @@ class VoicingTests(unittest.TestCase):
|
|||||||
shape = grip((10, 12, 11, 12))
|
shape = grip((10, 12, 11, 12))
|
||||||
original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
|
original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
|
||||||
preference = generator.guitar.voicing_preferences(shape, generator.guitar.voicing_preference('electric_melody'))
|
preference = generator.guitar.voicing_preferences(shape, generator.guitar.voicing_preference('electric_melody'))
|
||||||
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0, 'high_position_weight': 0}}):
|
with generator.vocabulary.using(resources, 'electric_melody', {'voicing': {'register_weight': 0}}):
|
||||||
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
|
plain = generator.guitar.voicing_cost(shape, 'electric', melody=(76,))
|
||||||
self.assertEqual(original.effort - plain.effort, sum(preference))
|
self.assertEqual(original.register - plain.register, preference)
|
||||||
|
self.assertEqual(original.effort, plain.effort)
|
||||||
self.assertEqual(original[:6], plain[:6])
|
self.assertEqual(original[:6], plain[:6])
|
||||||
self.assertEqual(original, generator.guitar.voicing_cost(shape, 'electric', melody=(76,)))
|
self.assertEqual(original, generator.guitar.voicing_cost(shape, 'electric', melody=(76,)))
|
||||||
for center in (45, 76, 45):
|
for center in (45, 76, 45):
|
||||||
with generator.vocabulary.using(resources, 'electric_melody', {'center': center}):
|
with generator.vocabulary.using(resources, 'electric_melody', {'center': center}):
|
||||||
best = {}
|
self.assertEqual(set(ranked('C7', (72,), prune=True)), set(ranked('C7', (72,), prune=False)))
|
||||||
for candidate in ranked('C7', (72,)):
|
|
||||||
best.setdefault(generator.guitar.position_center(candidate), candidate)
|
|
||||||
self.assertEqual(set(ranked('C7', (72,), prune=True)), set(best.values()))
|
|
||||||
def test_electric_chords_use_the_shared_four_fret_limit(self):
|
def test_electric_chords_use_the_shared_four_fret_limit(self):
|
||||||
for prune in (False, True):
|
for prune in (False, True):
|
||||||
candidates = ranked('CM7', prune=prune)
|
candidates = ranked('CM7', prune=prune)
|
||||||
@@ -120,11 +121,12 @@ class VoicingTests(unittest.TestCase):
|
|||||||
self.assertNotIn(grip((17, 17, 17, 19)), candidates)
|
self.assertNotIn(grip((17, 17, 17, 19)), candidates)
|
||||||
self.assertFalse(generator.fretboard.has_fret_anchor(grip((13, 13, 13, 13))))
|
self.assertFalse(generator.fretboard.has_fret_anchor(grip((13, 13, 13, 13))))
|
||||||
self.assertFalse(generator.fretboard.has_fret_anchor(()))
|
self.assertFalse(generator.fretboard.has_fret_anchor(()))
|
||||||
def test_exact_melody_height_scoring_remains_unchanged(self):
|
def test_height_is_a_passage_peak_not_an_additive_effort_penalty(self):
|
||||||
for fret in range(1, 21):
|
for fret in range(1, 21):
|
||||||
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
|
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 0)
|
||||||
|
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).peak_position, 0)
|
||||||
crossing = grip((12, 13, 14, 14))
|
crossing = grip((12, 13, 14, 14))
|
||||||
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 6)
|
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 2)
|
||||||
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10)
|
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 10)
|
||||||
def test_high_melody_fails_when_neither_anchored_chord_nor_single_note_fits(self):
|
def test_high_melody_fails_when_neither_anchored_chord_nor_single_note_fits(self):
|
||||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6)))
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(tests.test_musicxml.xml_note('C', octave=6)))
|
||||||
@@ -134,13 +136,14 @@ class VoicingTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
|
with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'):
|
||||||
generator.guitar.place_guitar(document)
|
generator.guitar.place_guitar(document)
|
||||||
self.assertEqual(document, before)
|
self.assertEqual(document, before)
|
||||||
def test_shape_and_movement_share_one_effort_cost(self):
|
def test_finger_complexity_is_subordinate_to_hand_travel(self):
|
||||||
shape = grip((1, 2, 3, 3))
|
shape = grip((1, 2, 3, 3))
|
||||||
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2)
|
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2)
|
||||||
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
|
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10)
|
||||||
for profile in ('', 'electric'):
|
for profile in ('', 'electric'):
|
||||||
cost = generator.guitar.voicing_cost(shape, profile)
|
cost = generator.guitar.voicing_cost(shape, profile)
|
||||||
self.assertEqual(cost.moved(5).effort, cost.effort + 5)
|
self.assertEqual(cost.moved(5).travel, 5)
|
||||||
|
self.assertEqual(cost.moved(5).effort, cost.effort)
|
||||||
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), cost)
|
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), cost)
|
||||||
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
|
||||||
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
|
||||||
@@ -150,28 +153,19 @@ class VoicingTests(unittest.TestCase):
|
|||||||
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
|
def test_nearby_position_beats_lower_frets_within_comfort_range(self):
|
||||||
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
|
low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9))
|
||||||
for profile in ('', 'electric'):
|
for profile in ('', 'electric'):
|
||||||
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low)))
|
self.assertLess(generator.guitar.voicing_cost(nearby, profile).moved(generator.guitar.movement(previous, nearby, 7, 7)), generator.guitar.voicing_cost(low, profile).moved(generator.guitar.movement(previous, low, 7, 3)))
|
||||||
def test_exact_melody_threshold_remains_a_soft_cost(self):
|
def test_exact_melody_threshold_remains_a_soft_cost(self):
|
||||||
low, high = (grip((f,) * 4) for f in (3, 13))
|
low, high = (grip((f,) * 4) for f in (3, 13))
|
||||||
self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low)))
|
self.assertLess(generator.guitar.voicing_cost(high), generator.guitar.voicing_cost(low).moved(generator.guitar.movement(high, low, 12, 3)))
|
||||||
def test_anchor_is_checked_before_equal_center_pruning(self):
|
def test_anchor_is_checked_before_candidate_retention(self):
|
||||||
# A permissive pitch set lets the ineligible fret-13 barre compete at center 26.
|
|
||||||
requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,))
|
requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,))
|
||||||
unpruned = generator.guitar.chord_positions(requirements, prune=False, profile='electric')
|
for prune in (True, False):
|
||||||
ineligible = grip((13, 13, 13, 13))
|
candidates = generator.guitar.chord_positions(requirements, prune=prune, profile='electric')
|
||||||
center = generator.guitar.position_center(ineligible)
|
self.assertTrue(candidates)
|
||||||
eligible = [c for c in unpruned if generator.guitar.position_center(c) == center]
|
self.assertTrue(all(generator.fretboard.has_fret_anchor(c) for c in candidates))
|
||||||
self.assertTrue(eligible)
|
def test_candidate_retention_preserves_concrete_fingerings(self):
|
||||||
self.assertTrue(all(generator.guitar.voicing_cost(ineligible, 'electric', requirements.allowed) < generator.guitar.voicing_cost(c, 'electric', requirements.allowed) for c in eligible))
|
for symbol in ('C7', 'FM7', 'Gm7'):
|
||||||
best = min(eligible, key=lambda c: (generator.guitar.voicing_cost(c, 'electric', requirements.allowed), c))
|
self.assertEqual(set(ranked(symbol, prune=True)), set(ranked(symbol, prune=False)))
|
||||||
self.assertIn(best, generator.guitar.chord_positions(requirements, profile='electric'))
|
|
||||||
self.assertNotIn(ineligible, unpruned)
|
|
||||||
def test_candidate_pruning_uses_full_cost_and_preserves_each_center(self):
|
|
||||||
for symbol, profile, melody in (('FM7', 'electric', (69,)), ('Gm7', 'electric', (67,))):
|
|
||||||
best = {}
|
|
||||||
for shape in ranked(symbol, melody, profile):
|
|
||||||
best.setdefault(generator.guitar.position_center(shape), shape)
|
|
||||||
self.assertEqual(set(ranked(symbol, melody, profile, prune=True)), set(best.values()))
|
|
||||||
def test_tie_continuations_do_not_charge_a_second_voicing_cost(self):
|
def test_tie_continuations_do_not_charge_a_second_voicing_cost(self):
|
||||||
note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="stop"/>')
|
note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="start"/>') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='<tie type="stop"/>')
|
||||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(note))
|
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(note))
|
||||||
@@ -180,32 +174,23 @@ class VoicingTests(unittest.TestCase):
|
|||||||
first = generator.guitar.advance_positions([events[0][3]], events[0][0], tones, states, False)
|
first = generator.guitar.advance_positions([events[0][3]], events[0][0], tones, states, False)
|
||||||
second = generator.guitar.advance_positions([events[1][3]], events[1][0], tones, first, False)
|
second = generator.guitar.advance_positions([events[1][3]], events[1][0], tones, first, False)
|
||||||
self.assertEqual(sorted(p.cost for p in first.values()), sorted(p.cost for p in second.values()))
|
self.assertEqual(sorted(p.cost for p in first.values()), sorted(p.cost for p in second.values()))
|
||||||
def test_empty_and_positioned_states_are_compared_by_the_same_grid_cost(self):
|
def test_empty_and_positioned_states_use_the_same_physical_objective(self):
|
||||||
empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ())
|
empty = generator.guitar.Placement(generator.guitar.VoicingCost(effort=20), (), None, ())
|
||||||
placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', grip((3,), (1,))[0]),), None, ())
|
shape = grip((3,), (1,))
|
||||||
grid = generator.guitar.nearest_positions({(): empty, 1: placed})
|
placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', shape[0]),), None, (), 1)
|
||||||
self.assertIs(grid[6][0][2], placed)
|
candidate = generator.guitar.AttackCandidate((('next', shape[0]),), (), shape, generator.guitar.VoicingCost())
|
||||||
self.assertIs(grid[40][10][2], empty)
|
result = generator.guitar.advance_attacks([candidate], {0: empty, 1: placed})
|
||||||
def test_optimized_passage_search_matches_exhaustive_candidate_ranking(self):
|
self.assertIs(min(result.values(), key=lambda p: p.cost).previous, placed)
|
||||||
contents = ''.join(tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5) for _ in range(4))
|
def test_passage_search_matches_exhaustive_concrete_paths(self):
|
||||||
document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(contents))
|
layers = [ranked(symbol)[:4] for symbol in ('FM7', 'Gm7', 'C7b9', 'FM7')]
|
||||||
measure = document['staves'][0]['measures'][0]
|
costs = [{v: generator.guitar.voicing_cost(v, 'electric') for v in layer} for layer in layers]
|
||||||
for note, symbol in zip(measure['voices'][0]['notes'], ('FM7', 'Gm7', 'C7b9', 'FM7')):
|
def total(path):
|
||||||
note.update(pitches=[], _harmony=generator.harmony.parse_symbol(symbol), _guitar_profile='electric')
|
return tests.position_fixtures.cost(path, costs, (6, 7, 8))
|
||||||
events, tones = generator.guitar.guitar_events(document)
|
|
||||||
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())}
|
||||||
for onset, _, _, note in events:
|
for i, layer in enumerate(layers):
|
||||||
expected = {}
|
candidates = [generator.guitar.AttackCandidate(tuple((str(p.string_number), p) for p in v), (), v, costs[i][v]) for v in layer]
|
||||||
for candidate in generator.guitar.attack_candidates([note], tones, {}, {}, False):
|
states = generator.guitar.advance_attacks(candidates, states)
|
||||||
choices = []
|
self.assertEqual(min(s.cost for s in states.values()), min(total(p) for p in itertools.product(*layers)))
|
||||||
for index, previous in enumerate(states.values()):
|
|
||||||
distance = generator.guitar.movement(tuple(p for _, p in previous.active), candidate.shape)
|
|
||||||
choices.append((previous.cost.moved(distance).plus(candidate.cost), index))
|
|
||||||
expected[candidate.active] = min(choices)
|
|
||||||
actual = generator.guitar.advance_positions([note], onset, tones, states, False)
|
|
||||||
predecessors = {id(p): i for i, p in enumerate(states.values())}
|
|
||||||
self.assertEqual({key: (p.cost, predecessors[id(p.previous)]) for key, p in actual.items()}, expected)
|
|
||||||
states = actual
|
|
||||||
def test_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self):
|
def test_real_autumn_leaves_melody_has_expected_shapes_and_unchanged_notes(self):
|
||||||
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
|
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn_leaves.xml'))
|
||||||
before = copy.deepcopy(source)
|
before = copy.deepcopy(source)
|
||||||
|
|||||||
Reference in New Issue
Block a user