diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 0a5c3f0..31c077d 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -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. -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. -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. @@ -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. -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/`. diff --git a/data/composition/roles.json b/data/composition/roles.json index fef8fbb..8898203 100644 --- a/data/composition/roles.json +++ b/data/composition/roles.json @@ -10,9 +10,7 @@ "bass": "primary", "center": 53, "voicing": { - "register_weight": 1, - "high_position_weight": 1, - "comfortable_max_fret": 9 + "register_weight": 1 } }, "electric_accompaniment": { @@ -20,18 +18,14 @@ "bass": "essential", "center": 60, "voicing": { - "register_weight": 1, - "high_position_weight": 1, - "comfortable_max_fret": 9 + "register_weight": 1 } }, "electric_melody": { "seed": "head", "center": 60, "voicing": { - "register_weight": 1, - "high_position_weight": 1, - "comfortable_max_fret": 9 + "register_weight": 1 } }, "vocal_melody": { diff --git a/generator/acoustic_voicing.py b/generator/acoustic_voicing.py index 27a181c..a5b2f2d 100644 --- a/generator/acoustic_voicing.py +++ b/generator/acoustic_voicing.py @@ -8,6 +8,7 @@ import typing import generator.acoustic import generator.fretboard import generator.harmony +import generator.hand_position class Voicing(typing.NamedTuple): bass: generator.fretboard.TabPosition @@ -35,12 +36,14 @@ def candidates(requirements: generator.fretboard.ChordRequirements, complete_tri return tuple(sorted(result)) @functools.lru_cache(maxsize=16384) -def movement(previous: Voicing, current: Voicing) -> int: - return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number) +def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int: + return generator.hand_position.motion(previous, current, old_hand, hand, True).shift class Cost(typing.NamedTuple): extended_grips: int = 0 travel: int = 0 + peak_position: int = 0 + effort: int = 0 missing_tones: int = 0 @dataclasses.dataclass @@ -48,14 +51,20 @@ class Placement: cost: Cost voicing: Voicing | None = None previous: Placement | None = None + hand: int = 0 def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]: - result = [] + result = {} for voicing in options: 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)) - result.append(Placement(cost, voicing, states[index])) - return result + for hand in generator.hand_position.regions(voicing): + for previous in states: + 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]: states = [Placement(Cost())] diff --git a/generator/arrange/ending.py b/generator/arrange/ending.py index 4951379..f6b3e18 100644 --- a/generator/arrange/ending.py +++ b/generator/arrange/ending.py @@ -17,7 +17,7 @@ def chord(source): prior = generator.accompaniment.carried_chords(effective)[-1] return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior) -def append(source, output, part, *, sounding=False, center=55, composition=None): +def append(source, output, part, *, sounding=False, center=55, composition=None, incoming=None): composition = composition or generator.vocabulary.current() if not source or not output: return @@ -41,7 +41,13 @@ def append(source, output, part, *, sounding=False, center=55, composition=None) if part == 'acoustic': bass = next((recent[s] for s in (5, 6) if s in recent), None) previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else () - grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center, 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']) previous = None for index in range(generator.arrange.ending_plan.BARS): diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index cf3490e..55ad278 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -39,7 +39,7 @@ def section_source(source: list[dict], section, next_section=None) -> list[dict] generator.arrange.pickup.harmonies(destination, result[0]) 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() if part_type not in generator.parts.PART_TYPES: 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: generator.arrange.ending.append(source, output, part_type, 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) 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) @@ -112,9 +112,11 @@ def _arrange_document(document: dict, part_type: str, plan=None, *, owned=None, generator.acoustic.combine(result) elif part_type == 'electric' and (owned is None or 'melody' in owned): 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): - 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 # the complete performance, including its lead-in and repeated choruses. for number, measure in enumerate(output, 1): diff --git a/generator/comping.py b/generator/comping.py index ef271bd..beea07f 100644 --- a/generator/comping.py +++ b/generator/comping.py @@ -16,6 +16,7 @@ import generator.comping_rhythm import generator.fretboard import generator.guitar import generator.harmony +import generator.hand_position import generator.model import generator.parts 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') return tuple(sorted(options)) -def movement(part, previous, current): - if not previous or not current: - return 0 - if part == 'acoustic': - return generator.acoustic_voicing.movement(previous, current) - return generator.guitar.movement(previous, current) +def movement(part, previous, current, old_hand, hand): + return generator.hand_position.motion(previous, current, old_hand, hand, part == 'acoustic').shift class VoicingCost(typing.NamedTuple): extended_grips: int = 0 family: int = 0 + travel: int = 0 + peak_position: int = 0 effort: fractions.Fraction = fractions.Fraction() missing_tones: int = 0 register: fractions.Fraction = fractions.Fraction() def plus(self, other): - return VoicingCost(*(a + b for a, b in zip(self, other))) - def moved(self, distance): - return self._replace(effort=self.effort + distance) - -def 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)) + 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, ergonomics=0): + return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics) def local_cost(part, key, grip, center, *, composition=None): composition = composition or generator.vocabulary.current() required = requirements(json.loads(key), part) - register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center) - preference = sum(voicing_preferences(part, grip, center, composition=composition)) + register = generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center, composition=composition)) if part == 'electric': 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) - # Familiar degree orders on either bass string, then balanced whole-form - # movement, sounding register and hand position. Neither is an absolute ban. + 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) + # Keep the familiar families, then compare physical effort before position. + # Sounding register only breaks otherwise equal placement decisions. 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): - """Exact small-state dynamic programming; never prune by hand center. +def select_grips(layers, costs, part, seed, *, left=(), right=(), left_hand=None, right_hand=None, close=False, fixed_hands=None): + """Return complete grips, cost and hand windows, including repeat travel. - Both roles balance register/position with travel. Electric includes its - base grip effort; acoustic follows all four voices and bass-string changes. - Compact span and family remain higher tiers. - Fixing the first grip permits an exact repeat-join cost without a beam. + A concrete grip may occupy several windows. Preserve distinct earlier peaks + at a common grip/hand, and fix both starting values for a cyclic path. """ if not layers: - return (), VoicingCost() - # Every accompaniment transition is feasible (travel is a cost). A wider - # option cannot beat a primary grip at the same span, regardless of travel. - # This exact dominance reduction avoids growing the cubic repeat search. + return (), VoicingCost(), () + + def hands(grip, fixed=None): + options = generator.hand_position.regions(grip) + if fixed is not None: + return tuple(h for h in options if h == fixed) + return options + retained = [] for i, layer in enumerate(layers): tier = min(costs[i][v].extended_grips for v in layer) - retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier)) - layers = tuple(retained) - ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)] + fixed = fixed_hands[i] if fixed_hands is not None else None + options = tuple((v, h) for v in layer if costs[i][v].extended_grips == tier for h in hands(v, fixed)) + 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 = [] - for first in layers[0] if close else [None]: - states = {left: (VoicingCost(), (), ())} - for i, layer in enumerate(layers): + for first in retained[0] if close else [None]: + states = {(left, h, h): (VoicingCost(peak_position=h), (), (), ()) for h in hands(left, left_hand)} + for i, layer in enumerate(retained): following = {} - for grip in [first] if i == 0 and first is not None else layer: - local = costs[i][grip] - choices = [] - for previous, (cost, ties, path) in states.items(): - total = cost.plus(local).moved(movement(part, previous, grip)) - choices.append((total, (*ties, ranks[i][grip]), (*path, grip))) - following[grip] = min(choices) - states = following - for grip, (cost, ties, path) in states.items(): - travel = movement(part, grip, first if close else right) - winners.append((cost.moved(travel), ties, path)) - cost, _, path = min(winners) - return path, cost + for grip, hand in [first] if i == 0 and first is not None else layer: + local = costs[i][grip]._replace(peak_position=hand) + for (previous, old_hand, _), (cost, ties, path, positions) in states.items(): + motion = generator.hand_position.motion(previous, grip, old_hand, hand, part == 'acoustic') + total = cost.plus(local).moved(motion.shift, motion.ergonomics) + candidate = (total, (*ties, (ranks[i][grip], hand)), (*path, grip), (*positions, hand)) + key = (grip, hand, total.peak_position) + if key not in following or candidate < following[key]: + following[key] = candidate + # Future travel is identical at a common concrete grip and hand. + best = {} + for (grip, hand, _), value in following.items(): + 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) class HarmonyChoice: @@ -143,6 +154,7 @@ class HarmonyChoice: events: tuple figure: dict options: tuple + hand: int | None = None @property def key(self): 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.policy = self.composition.role(part + '_accompaniment') self.cache, self._reports, self._rhythm_reports = {}, {}, {} + self.cache_positions = {} + self.last_position = ((), 0) self.measures = generator.score.source_staff(document)['measures'] # A held accompaniment chord may continue into the bar containing the # 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)] 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)} - 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.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.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): - 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) - 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() for m, _, i in group: length = generator.model.fraction(m['duration']) @@ -250,6 +265,17 @@ class Canonical: def report(self): return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]] @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): return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]] @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 spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options)) 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) 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): group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),)) original_group = group @@ -371,23 +397,27 @@ class Canonical: for span in spans: 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) - 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) - before = [v for i, _, v in self.order if i < start] - after = [v for i, _, v in self.order if i > end] - left = before[-1] if before else self.order[-1][2] if self.closed and self.order else () - right = after[0] if after else self.order[0][2] if self.closed and self.order else () - selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right) - self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit) + before = [(v, h) for (i, _, v), h in zip(self.order, self.hand_order) if i < start] + 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], self.hand_order[-1]) if self.closed and self.order else ((), 0) + right = after[0] if after else (self.order[0][2], self.hand_order[0]) if self.closed and self.order else ((), 0) + 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.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]]) def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None): flat = generator.comping_rhythm.flatten(inputs) length = generator.model.fraction(flat['duration']) index = inputs[0][2] - attacks, rows = [], [] + attacks, rows, placed_events = [], [], [] choices = iter(selected) 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 degrees = generator.harmony.degrees(span.chord) 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], '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, - 'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)}) + 'movement': motion.shift, 'voicing_candidates': len(span.options)}) if grip: - register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55), composition=self.composition) - travel = movement(self.part, previous, grip) - effort = generator.guitar.voicing_cost(grip, 'electric', composition=self.composition).effort if self.part == 'electric' else 0 - rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)} + local = local_cost(self.part, semantic_key(span.chord), grip, self.policy.get('center', 55), composition=self.composition) + rows[-1]['hand_position'] = hand + rows[-1]['voicing_cost'] = {'movement': motion.shift, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, + '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: + placed_events.append((event.start, event.end, (grip, hand))) lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane] for lane in lanes: positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip attacks.append((lane, event.start, event.end, positions, span.chord)) if grip: - previous = grip + previous = (grip, hand) key = self.key(inputs, limit) if self.part == 'acoustic': 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 voices = [] 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]}) cursor += duration self._rhythm_reports[key] = reports - return result + return result, tuple(measure_hands) diff --git a/generator/composition.py b/generator/composition.py index b1a2fa0..4e8d58f 100644 --- a/generator/composition.py +++ b/generator/composition.py @@ -134,9 +134,12 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None): elif role.endswith('_accompaniment'): 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) - 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: - 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) generator.arrange.assembly.mask(result, allowed) if role == "acoustic_accompaniment": diff --git a/generator/guitar.py b/generator/guitar.py index 59d985e..305fc16 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -10,10 +10,10 @@ import typing import generator.chord_families import generator.fretboard import generator.model +import generator.hand_position import generator.pitch import generator.vocabulary -PREFERRED_MAX_FRET = 12 ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1)) class VoicingCost(typing.NamedTuple): @@ -23,13 +23,16 @@ class VoicingCost(typing.NamedTuple): covering_tones: int = 0 string_shortfall: int = 0 family_penalty: int = 0 + travel: int = 0 + peak_position: int = 0 effort: fractions.Fraction = fractions.Fraction() missing_tones: int = 0 + register: fractions.Fraction = fractions.Fraction() 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) - def moved(self, distance: int) -> VoicingCost: - return self._replace(effort=self.effort + distance) + def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost: + return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics) def voicing_preference(role, center=None, *, composition=None): """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) policy = settings.get('voicing', {}) 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 result[1] < 0 or result[2] < 0 or result[3] < 1: - raise ValueError('voicing preference requires nonnegative weights and a positive fret threshold') + if set(policy) - {'register_weight'}: + raise ValueError('unknown voicing preference; hand position is ranked across the passage') + 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 @functools.lru_cache(maxsize=8192) def voicing_preferences(positions, preference): if not positions or not preference: - return fractions.Fraction(), fractions.Fraction() - center, register_weight, position_weight, threshold = preference + return fractions.Fraction() + center, register_weight = preference 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, position_weight * position + return register_weight * register 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() @@ -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 span = compactness(positions) 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: - 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 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, ...], ...]: composition = composition or generator.vocabulary.current() @@ -96,9 +100,8 @@ def _chord_positions(requirements, prune, profile, melody, families, preference) if profile: 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 - # strings 2-4. It need not be buried beneath an added first-string tone. - for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS: + # Every electric chord includes string 1, even with a lower melody inside. + for strings in ELECTRIC_STRINGS: for selected in itertools.product(*(by_string[s] for s in strings)): if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}: continue @@ -107,29 +110,13 @@ def _chord_positions(requirements, prune, profile, melody, families, preference) result.append(selected) else: visit(0, (), set()) - if not prune: - return tuple(sorted(result)) - # Independent attacks with equal centers have identical movement costs. - # 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())) + # Equal centers no longer imply equal future movement. Keep concrete grips; + # only identical positions can be merged before contextual placement. + return tuple(sorted(set(result))) @functools.lru_cache(maxsize=16384) -def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int: - if not previous or not current: - 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 movement(previous, current, old_hand, hand) -> int: + return generator.hand_position.motion(previous, current, old_hand, hand).shift def compactness(positions) -> int: frets = [p.fret for p in positions] @@ -151,38 +138,7 @@ class Placement: active: tuple previous: Placement | None choices: tuple - -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 + hand: int = 0 @dataclasses.dataclass(frozen=True) 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: 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: - nearest = nearest_positions(states) result = {} for candidate in candidates: - x, y = position_center(candidate.shape) - cost, _, previous = nearest[x][y] - 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) - return result + for previous in states.values(): + keep_position(result, candidate, previous) + return reduce_positions(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: 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) held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)} 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: result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states) else: @@ -348,15 +319,14 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[ active = dict(previous.active) 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): - distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0 - keep(candidate, previous, previous.cost.moved(distance)) + keep_position(result, candidate, previous, fresh) if not result: if not fallback and any(n.get('_harmonize') for n in notes): 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') - 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.""" composition = composition or generator.vocabulary.current() 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) states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition) selected = min(states.values(), key=lambda s: (s.cost, s.active)) + trace = [] + final_cost = selected.cost skipped = [] 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: note = choice.note 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]) document['staves'][0]['tablature'] = True 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))} diff --git a/generator/hand_position.py b/generator/hand_position.py new file mode 100644 index 0000000..c5009bf --- /dev/null +++ b/generator/hand_position.py @@ -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)} diff --git a/generator/printing.py b/generator/printing.py index 11bfc26..1b50633 100644 --- a/generator/printing.py +++ b/generator/printing.py @@ -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 = 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 - 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 def finish(): 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), - (*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: 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) diff --git a/generator/solo/placement.py b/generator/solo/placement.py index 0e81a4d..b09ceea 100644 --- a/generator/solo/placement.py +++ b/generator/solo/placement.py @@ -5,77 +5,106 @@ import fractions import functools import generator.fretboard +import generator.hand_position import generator.solo.types import generator.solo.profiles 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): - 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) def position_rank(position): return generator.solo.policy.stable_rank('guitar-position', position) -def stable_path(path): - return tuple(position_rank(n.position) for n in path) +def seconds(duration, tempo): + return max(fractions.Fraction(1, 20), duration * 60 / tempo) -@functools.lru_cache(maxsize=4096) -def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED): - """Phrase paths with a four-fret hand region and distinct endpoint choices.""" - states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)] +@dataclasses.dataclass(frozen=True) +class Path: + travel: int = 0 + 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: 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) for hand in hand_positions(position): - best = None - for cost, path, old_hand in states: - 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): + for path, old_hand in states: + prior = path.notes[-1] if path.notes 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): continue - movement = 0 + motion = generator.hand_position.Motion() + effort = fractions.Fraction() if prior and not note.continuation: - seconds = max(.05, float((note.start - prior.start) * 60 / tempo)) - distance = abs(hand - old_hand) if old_hand is not None else 0 - distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number) - movement = round(100 * distance / seconds) - candidate = (cost + movement, (*path, current), hand) - if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]): - best = candidate - if best is not None: - following[position, hand] = best + motion = generator.hand_position.motion((prior.position,), (position,), old_hand, hand) + effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo) + 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))) + key = (position, hand, candidate.peak) + if key not in following or candidate.rank < following[key].rank: + following[key] = candidate if not following: return () - states = list(following.values()) - # Endpoints survive so the following phrase can choose a better transition. + # A strictly worse hand travel at the identical endpoint/hand cannot recover. + 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() - for cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))): - endpoint = path[-1].position if path else None + for path in paths(notes, tempo, previous, profile): + endpoint = (path.notes[-1].position, path.hands[-1]) if path.notes else None if endpoint in seen: continue seen.add(endpoint) - selected.append((cost, path)) + selected.append((path.travel, path.notes)) if len(selected) == limit: break return tuple(selected) -@functools.lru_cache(maxsize=4096) +@functools.lru_cache(maxsize=512) def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED): result = alternatives(notes, tempo, previous, 1, profile) 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) def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED): profile = profile or generator.solo.profiles.UNRESTRICTED diff --git a/generator/solo/report.py b/generator/solo/report.py index 4922cb8..9359cdb 100644 --- a/generator/solo/report.py +++ b/generator/solo/report.py @@ -32,7 +32,7 @@ def structure(solo, *, composition=None): ns = solo.notes() 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')}), - '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), '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}, diff --git a/tests/fixtures/role_outputs.json b/tests/fixtures/role_outputs.json index c1e8da8..07b3203 100644 --- a/tests/fixtures/role_outputs.json +++ b/tests/fixtures/role_outputs.json @@ -10655,8 +10655,8 @@ "letter": "c", "octave": 5, "tablature": { - "fret": 1, - "string": 2 + "fret": 5, + "string": 3 }, "written": { "dots": 0, @@ -10966,8 +10966,8 @@ "letter": "d", "octave": 5, "tablature": { - "fret": 7, - "string": 3 + "fret": 3, + "string": 2 }, "written": { "dots": 0, @@ -11007,8 +11007,8 @@ "letter": "e", "octave": 5, "tablature": { - "fret": 9, - "string": 3 + "fret": 5, + "string": 2 }, "written": { "dots": 0, @@ -11048,8 +11048,8 @@ "letter": "f", "octave": 5, "tablature": { - "fret": 10, - "string": 3 + "fret": 6, + "string": 2 }, "written": { "dots": 0, @@ -13379,8 +13379,8 @@ "letter": "d", "octave": 4, "tablature": { - "fret": 10, - "string": 6 + "fret": 5, + "string": 5 }, "written": { "dots": 0, @@ -13439,8 +13439,8 @@ "letter": "c", "octave": 4, "tablature": { - "fret": 8, - "string": 6 + "fret": 3, + "string": 5 }, "written": { "dots": 0, @@ -13624,8 +13624,8 @@ "letter": "d", "octave": 4, "tablature": { - "fret": 10, - "string": 6 + "fret": 5, + "string": 5 }, "written": { "dots": 0, @@ -13684,8 +13684,8 @@ "letter": "c", "octave": 4, "tablature": { - "fret": 8, - "string": 6 + "fret": 3, + "string": 5 }, "written": { "dots": 0, diff --git a/tests/position_fixtures.py b/tests/position_fixtures.py new file mode 100644 index 0000000..94135b8 --- /dev/null +++ b/tests/position_fixtures.py @@ -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) diff --git a/tests/test_acoustic_voicing.py b/tests/test_acoustic_voicing.py index 3483018..9b4031d 100644 --- a/tests/test_acoustic_voicing.py +++ b/tests/test_acoustic_voicing.py @@ -1,3 +1,4 @@ +import generator.hand_position import generator.validation import copy import fractions @@ -13,6 +14,7 @@ import generator.harmony import generator.model import tests.test_acoustic import tests.test_accompaniment +import tests.position_fixtures def planned_document(harmonies, attacks=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) 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) - 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)) - 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)): - self.assertEqual(generator.acoustic_voicing.movement(previous, current), expected) - self.assertEqual(generator.acoustic_voicing.movement(current, previous), expected) - self.assertEqual(generator.acoustic_voicing.movement(previous, previous), 0) - def test_each_upper_string_is_tracked_not_just_the_grip_center(self): + 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, 6, 6), 0) + self.assertEqual(generator.acoustic_voicing.movement(previous, previous, 5, 7), 2) + def test_each_upper_string_is_tracked_as_secondary_finger_effort(self): before, after = voicing((5, 7, 5, 7)), voicing((7, 5, 7, 5)) - self.assertEqual(generator.acoustic_voicing.movement(before, after), 8) - for index in (1, 2, 3): - frets = [5, 7, 5, 7] - frets[index] += 1 - self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1) + self.assertEqual(generator.acoustic_voicing.movement(before, after, 5, 5), 0) + self.assertEqual(generator.hand_position.motion(before, after, 5, 5, True).fingers, 8) 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)): 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))) states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())] 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): requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C7')) 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): harmonies = ['FM7', 'Gm7', 'C7'] 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] + 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): - return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path))) - selected = generator.acoustic_voicing.select(spans) - self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options))) - self.assertEqual(selected, generator.acoustic_voicing.select(spans)) + return tests.position_fixtures.cost(path, costs, (1, 2, 3), acoustic=True) + states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())] + for layer, r in zip(options, requirements): + 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): for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F', 'Dm7no7'): with self.subTest(chord=symbol): diff --git a/tests/test_chord_families.py b/tests/test_chord_families.py index 8ebdf1e..0b5efea 100644 --- a/tests/test_chord_families.py +++ b/tests/test_chord_families.py @@ -78,11 +78,13 @@ class ChordFamilies(unittest.TestCase): return min(p.string_number for p in v if p.midi == midi) tier = generator.fretboard.span_penalty(choices[0]) self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier)) - self.assertEqual(max(p.midi for p in choices[0]), midi) + self.assertTrue(all(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(midi in {p.midi for p in v} for v in choices)) low = tests.test_voicing.ranked('C7', (64,))[0] - self.assertEqual(tuple(p.string_number for p in low), (4, 3, 2)) + 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): resources = generator.vocabulary.load() changed = copy.deepcopy(resources) diff --git a/tests/test_comping_meters.py b/tests/test_comping_meters.py index bd3db57..0fa90ea 100644 --- a/tests/test_comping_meters.py +++ b/tests/test_comping_meters.py @@ -155,7 +155,9 @@ class CompingMeters(unittest.TestCase): measures = source['staves'][0]['measures'] carried = generator.accompaniment.carried_chords(measures) full = canonical(measures[3], carried[3], 3) + full_position = canonical.last_position 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 # 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]]) diff --git a/tests/test_comping_voicings.py b/tests/test_comping_voicings.py index 19d6bbc..8f3ebef 100644 --- a/tests/test_comping_voicings.py +++ b/tests/test_comping_voicings.py @@ -15,21 +15,20 @@ import generator.comping import generator.fretboard import generator.guitar import generator.harmony +import generator.hand_position import generator.model import generator.score import generator.vocabulary import tests.solo_fixtures import tests.test_acoustic_voicing import tests.test_voicing +import tests.position_fixtures def options(symbol, part): return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part) -def path_cost(path, costs, part, left=(), right=(), close=False): - total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)] - total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path)) - total[2] += generator.comping.movement(part, path[-1], path[0] if close else right) - return tuple(total) +def path_cost(path, costs, part, left=(), right=(), close=False, **kwargs): + return tests.position_fixtures.cost(path, costs, (2, 3, 4), acoustic=part == 'acoustic', left=left, right=right, close=close, **kwargs) class CompingVoicings(unittest.TestCase): 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)} self.assertEqual(costs[low][:2], costs[high][:2]) 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,)) - 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,)) 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'))) root = pathlib.Path('data') current = generator.composition.generate_role(source, 'electric_accompaniment', root) 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) warm = generator.composition.generate_role(source, 'electric_accompaniment', root, settings, cache) self.assertTrue(warm['cache_hit']) self.assertEqual(other['document'], warm['document']) self.assertNotEqual(current['trace']['cache_key'], other['trace']['cache_key']) 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') 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))) @@ -66,12 +65,11 @@ class CompingVoicings(unittest.TestCase): self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer)) 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}) - def travel(path): - return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right))) - self.assertEqual((travel(low), travel(high)), (43, 35)) - self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right)) - path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right) - self.assertEqual(path, low) + # The high path travels less from these particular neighbors; a lower + # sounding register cannot purchase extra hand movement. + self.assertGreater(path_cost(low, costs, 'acoustic', left, right)[2], path_cost(high, costs, 'acoustic', left, right)[2]) + path, cost, hands = 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(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): 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')) costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, 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,)) - 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) - low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8)) resources = generator.vocabulary.load() 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) - for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)): - policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing']) - policy[field] += 1 - with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}): - cost = generator.comping.local_cost('acoustic', key, high, 53) - self.assertEqual(cost.effort - baseline.effort, expected, field) - self.assertEqual(cost.extended_grips, baseline.extended_grips) - self.assertEqual(cost.family, baseline.family) + self.assertEqual(baseline.register, fractions.Fraction(39, 4)) + self.assertEqual(baseline.effort, 0) + self.assertEqual(baseline.peak_position, 0) + with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': 2}}): + cost = generator.comping.local_cost('acoustic', key, high, 53) + self.assertEqual(cost.register, 2 * baseline.register) + self.assertEqual(cost[:-1], baseline[:-1]) with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError): 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): source = generator.source.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7'))) def rhythm(result): @@ -115,12 +111,13 @@ class CompingVoicings(unittest.TestCase): data['roles']['acoustic_accompaniment']['voicing'] = policy role_path.write_text(json.dumps(data)) 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') warm = generator.composition.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache') self.assertTrue(warm['cache_hit']) 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(legacy['trace']['cache_key'], current['trace']['cache_key']) self.assertEqual(legacy['document'], current['document']) @@ -133,8 +130,10 @@ class CompingVoicings(unittest.TestCase): for row in rows: cost = row['voicing_cost'] self.assertEqual(cost['movement'], row['movement']) - self.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position'))) - self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) for r in rows)) + self.assertGreaterEqual(fractions.Fraction(cost['secondary_effort']), fractions.Fraction(cost['grip_effort'])) + 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): for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'): with self.subTest(chord=symbol): @@ -187,21 +186,21 @@ class CompingVoicings(unittest.TestCase): chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'FM7', 'C') 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)) - path, cost = generator.comping.select_grips(layers, costs, part, 'test') + path, cost, hands = generator.comping.select_grips(layers, costs, part, 'test') greedy = [] for i, layer in enumerate(layers): 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, 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): for part in ('acoustic', 'electric'): 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]}): 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, path_cost(path, costs, part, **endpoints)) def test_rest_does_not_reset_the_previous_hand_position(self): @@ -210,7 +209,7 @@ class CompingVoicings(unittest.TestCase): for part in ('acoustic', 'electric'): canonical = generator.comping.Canonical(source, part, 'rest') 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) def test_harmony_variants_are_independent_of_request_order(self): source = tests.solo_fixtures.song(count=3, chords=('C7', 'FM7', 'G7')) diff --git a/tests/test_fret_policy.py b/tests/test_fret_policy.py index 425301a..c9b5bc1 100644 --- a/tests/test_fret_policy.py +++ b/tests/test_fret_policy.py @@ -56,7 +56,7 @@ class FretPolicyTests(unittest.TestCase): costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)} # Movement/family wins cannot purchase entry into the four-fret tier. costs[primary] = costs[primary]._replace(effort=100000) - path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback) + path, cost, hands = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback) self.assertEqual(path, (primary,)) self.assertEqual(cost.extended_grips, 0) states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)] @@ -68,7 +68,8 @@ class FretPolicyTests(unittest.TestCase): best = choices[0] self.assertEqual(generator.fretboard.span_penalty(best), 0) self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2) - self.assertEqual(max(p.midi for p in best), 65) + self.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)) 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))) diff --git a/tests/test_guitar.py b/tests/test_guitar.py index e57b400..b96fc83 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -15,6 +15,7 @@ import generator.acoustic_voicing import generator.arrange.generate import generator.fretboard import generator.guitar +import generator.hand_position import generator.harmony import generator.model 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)) self.assertEqual(generator.fretboard.within_fret_span(positions), valid) self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets)) - self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets)) + self.assertEqual(generator.hand_position.base(positions), min(frets)) self.assertTrue(generator.fretboard.within_fret_span(())) def test_held_notes_obey_the_shared_four_fret_limit(self): held = {'held': generator.fretboard.TabPosition(4, 7, 57)} @@ -210,20 +211,14 @@ class GuitarTests(unittest.TestCase): generator.guitar.place_guitar(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}]) - def test_grid_search_matches_exhaustive_costs_and_tie_breaks(self): - randomizer = random.Random(17) - states = {} - for index in range(40): - positions = tuple(generator.fretboard.TabPosition(s, randomizer.randrange(21), 0) for s in randomizer.sample(range(1, 7), 3)) - 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, ()) - grid = generator.guitar.nearest_positions(states) - for x, row in enumerate(grid): - 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_equal_old_centers_can_have_different_finger_adjustments(self): + def grip(frets): + return tuple(generator.fretboard.TabPosition(s, f, 0) for s, f in zip((4, 3, 2, 1), frets)) + first, second = grip((3, 5, 3, 5)), grip((5, 3, 5, 3)) + self.assertEqual(generator.hand_position.base(first), generator.hand_position.base(second)) + self.assertEqual(generator.guitar.movement(first, first, 3, 3), 0) + self.assertEqual(generator.guitar.movement(first, second, 3, 3), 0) + self.assertEqual(generator.hand_position.motion(first, second, 3, 3).fingers, 8) def test_generated_chord_retains_strings_when_a_melody_enters(self): content = tests.test_musicxml.xml_note() + '42' + tests.test_musicxml.xml_note('E', 2, 'half', octave=6, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) diff --git a/tests/test_hand_position.py b/tests/test_hand_position.py new file mode 100644 index 0000000..056b720 --- /dev/null +++ b/tests/test_hand_position.py @@ -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))) diff --git a/tests/test_printing.py b/tests/test_printing.py index 4ac4b17..ce0e4f5 100644 --- a/tests/test_printing.py +++ b/tests/test_printing.py @@ -4,6 +4,7 @@ import copy import dataclasses import io import json +import math import pathlib import tempfile 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')) with self.assertRaisesRegex(ValueError, 'tie was omitted'): 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): source = tests.test_musicxml.xml_score(tests.test_musicxml.xml_note(duration=1, kind='quarter'), implicit=True) source = source.replace('', 'Cmajor' + tests.test_musicxml.xml_note() + '') diff --git a/tests/test_voicing.py b/tests/test_voicing.py index 06f600c..61f8894 100644 --- a/tests/test_voicing.py +++ b/tests/test_voicing.py @@ -6,6 +6,8 @@ import unittest import generator.arrange.generate import generator.fretboard import generator.guitar +import generator.hand_position +import itertools import generator.harmony import generator.model import generator.musicxml @@ -13,6 +15,7 @@ import generator.score import generator.vocabulary import tests.test_musicxml import tests.test_parts +import tests.position_fixtures def grip(frets, strings=(4, 3, 2, 1)): tuning = dict(generator.fretboard.STANDARD_TUNING) @@ -27,7 +30,7 @@ def ranked(symbol, melody=(), profile='electric', prune=False): class VoicingTests(unittest.TestCase): def test_melody_preferences_cannot_purchase_a_lower_priority_string(self): 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,))): candidates = ranked(symbol, melody) 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)) original = generator.guitar.voicing_cost(shape, 'electric', melody=(76,)) 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,)) - 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, generator.guitar.voicing_cost(shape, 'electric', melody=(76,))) for center in (45, 76, 45): with generator.vocabulary.using(resources, 'electric_melody', {'center': center}): - best = {} - 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())) + self.assertEqual(set(ranked('C7', (72,), prune=True)), set(ranked('C7', (72,), prune=False))) def test_electric_chords_use_the_shared_four_fret_limit(self): for prune in (False, True): candidates = ranked('CM7', prune=prune) @@ -120,11 +121,12 @@ class VoicingTests(unittest.TestCase): 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(())) - 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): - 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)) - 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) 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))) @@ -134,13 +136,14 @@ class VoicingTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'single notes need frets 1-12'): generator.guitar.place_guitar(document) 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)) self.assertEqual(generator.guitar.voicing_cost(shape).effort, 2) self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 10) for profile in ('', 'electric'): 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) def test_distinct_frets_add_difficulty_even_with_the_same_span(self): 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): low, nearby, previous = (grip((f,) * 4) for f in (3, 8, 9)) 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): 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))) - def test_anchor_is_checked_before_equal_center_pruning(self): - # A permissive pitch set lets the ineligible fret-13 barre compete at center 26. + 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_candidate_retention(self): requirements = generator.fretboard.ChordRequirements(allowed=tuple(range(12)), foundation=(0,)) - unpruned = generator.guitar.chord_positions(requirements, prune=False, profile='electric') - ineligible = grip((13, 13, 13, 13)) - center = generator.guitar.position_center(ineligible) - eligible = [c for c in unpruned if generator.guitar.position_center(c) == center] - self.assertTrue(eligible) - self.assertTrue(all(generator.guitar.voicing_cost(ineligible, 'electric', requirements.allowed) < generator.guitar.voicing_cost(c, 'electric', requirements.allowed) for c in eligible)) - best = min(eligible, key=lambda c: (generator.guitar.voicing_cost(c, 'electric', requirements.allowed), c)) - 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())) + for prune in (True, False): + candidates = generator.guitar.chord_positions(requirements, prune=prune, profile='electric') + self.assertTrue(candidates) + self.assertTrue(all(generator.fretboard.has_fret_anchor(c) for c in candidates)) + def test_candidate_retention_preserves_concrete_fingerings(self): + for symbol in ('C7', 'FM7', 'Gm7'): + self.assertEqual(set(ranked(symbol, prune=True)), set(ranked(symbol, prune=False))) def test_tie_continuations_do_not_charge_a_second_voicing_cost(self): note = tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') + tests.test_musicxml.xml_note('E', 2, 'half', octave=5, extra='') 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) 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())) - 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, ()) - placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', grip((3,), (1,))[0]),), None, ()) - grid = generator.guitar.nearest_positions({(): empty, 1: placed}) - self.assertIs(grid[6][0][2], placed) - self.assertIs(grid[40][10][2], empty) - def test_optimized_passage_search_matches_exhaustive_candidate_ranking(self): - contents = ''.join(tests.test_musicxml.xml_note('E', 1, 'quarter', octave=5) for _ in range(4)) - document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(contents)) - measure = document['staves'][0]['measures'][0] - for note, symbol in zip(measure['voices'][0]['notes'], ('FM7', 'Gm7', 'C7b9', 'FM7')): - note.update(pitches=[], _harmony=generator.harmony.parse_symbol(symbol), _guitar_profile='electric') - events, tones = generator.guitar.guitar_events(document) + shape = grip((3,), (1,)) + placed = generator.guitar.Placement(generator.guitar.VoicingCost(), (('tone', shape[0]),), None, (), 1) + candidate = generator.guitar.AttackCandidate((('next', shape[0]),), (), shape, generator.guitar.VoicingCost()) + result = generator.guitar.advance_attacks([candidate], {0: empty, 1: placed}) + self.assertIs(min(result.values(), key=lambda p: p.cost).previous, placed) + def test_passage_search_matches_exhaustive_concrete_paths(self): + layers = [ranked(symbol)[:4] for symbol in ('FM7', 'Gm7', 'C7b9', 'FM7')] + costs = [{v: generator.guitar.voicing_cost(v, 'electric') for v in layer} for layer in layers] + def total(path): + return tests.position_fixtures.cost(path, costs, (6, 7, 8)) states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} - for onset, _, _, note in events: - expected = {} - for candidate in generator.guitar.attack_candidates([note], tones, {}, {}, False): - choices = [] - 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 + for i, layer in enumerate(layers): + candidates = [generator.guitar.AttackCandidate(tuple((str(p.string_number), p) for p in v), (), v, costs[i][v]) for v in layer] + states = generator.guitar.advance_attacks(candidates, states) + self.assertEqual(min(s.cost for s in states.values()), min(total(p) for p in itertools.product(*layers))) 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')) before = copy.deepcopy(source)