From 8423f3d788e2a66085ae2dbe3fbee95137962726 Mon Sep 17 00:00:00 2001 From: khodowany Date: Tue, 6 Oct 2026 03:18:02 -0500 Subject: [PATCH] Chord Families --- README.md | 2 +- TECHNICAL_GUIDE.md | 14 ++++-- generator/acoustic_voicing.py | 3 +- generator/chord_families.py | 5 ++ generator/comping.py | 38 ++++++++++++--- generator/fretboard.py | 16 ++++-- generator/guitar.py | 3 +- tests/test_acoustic_voicing.py | 23 +++++---- tests/test_chord_families.py | 17 +++---- tests/test_comping_voicings.py | 12 ++--- tests/test_fret_policy.py | 89 +++++++++++++++++++++++++++++++++- tests/test_guitar.py | 15 ++++-- tests/test_guitar_parts.py | 8 +-- tests/test_voicing.py | 8 +-- 14 files changed, 196 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 630cc5c..9c89e17 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ To retrain existing pairs, run `./modulus.py python -m generator.train`; add `-- The build-time pipeline is MusicXML -> source analysis -> independent roles -> assembled parts -> semantic JSON, supplied recordings, engraved PNG pages, Pico title artwork, and a read-only catalog database. Each source produces the six-section performance form as Acoustic, Electric, and Vocal parts in its authored key, without key variants or a key-selection menu. Performance measure numbers run continuously across the complete arrangement, starting at 1 in the lead-in; they do not restart with a new section or chorus. Pickups occupy the preceding measure. Standalone comparison XMLs use local measure numbers and retain the original arrangement numbers in `manifest.solo`. -All generated monophonic guitar material uses frets 1–12 without open strings. Accompaniment uses all legal compact positions; a chord can extend to fret 20 while retaining at least one sounding anchor at fret 12 or below, within three consecutive fret positions (e.g. 7–9, not 7–10). Acoustic bass remains within frets 1–12 and upper chords need their own anchor. Some chord/key combinations have no legal inversion under these combined constraints; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded. +All generated monophonic guitar material uses frets 1–12 without open strings. Chords use two span tiers: **one to three consecutive fret positions share the primary tier; exactly four positions are a fallback** (e.g. 7–9 is primary, 7–10 is fallback). Five or more positions are forbidden. Selection minimizes fallback use before family, melody-string, movement or register preferences. A chord can extend to fret 20 while retaining at least one sounding anchor on frets 1–12 throughout its duration. Acoustic bass remains within frets 1–12 and upper chords need their own anchor when played separately. Some chord/key combinations still have no legal inversion; generation reports them instead of removing required tones. Electric source-melody harmonization retains its chord-change reattack policy. Import preserves exact source pitches and ties; unplayable or unsupported input fails explicitly rather than being silently octave-folded. There are no genre presets or runtime composition. The complete arranged score is played in reading order. The source-defined form and continuous whole-arrangement measure numbering remain unchanged. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 17fa560..48f8ef9 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -123,11 +123,13 @@ 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. Its lowest feasible string number and foreground position precede chord size in selection. Short monophonic melody notes retain their movement-based placement; Electric Solo is unchanged. These electric roles use the same written chord requirements without taking responsibility for the slash bass. Acoustic accompaniment uses `acoustic_voicing.candidates`: exactly one bass on string 5/6 and one upper note each on strings 4/3/2. Without an explicit slash bass or inversion, its bass is a root or written fifth (including an altered fifth); plain major/minor triads also permit the third. Plain triads retain all three chord members. Explicit bass/inversion requirements override this choice. +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. -Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Exhaustive checks across C, F# and Bb cover 35 common chord qualities. Under the existing constraints, Bb9, BbM9, Bbm9, Bb7#9 and Bb6add9 have no acoustic variant: for example, the low Bb9 root grip needs an open string, its octave needs bass fret 13, and fifth-bass frets 8/10/7/9 span four consecutive positions. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones. +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. Electric accompaniment costs prioritize chord size and shared family rank, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic costs prefer declared degree-order families on either bass string, then travel of the bass and all three upper voices, optional-tone coverage and register preference. Other valid compact grips remain available. Stable role-specific keys resolve equal costs. Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement. +The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank, then grip effort plus hand-center travel, missing optional tones and register preference. Acoustic prefers declared degree-order families on either bass string, then travel of the bass and all three upper voices, optional-tone coverage and register preference. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier. + +Silent spans retain the preceding hand position. A continuous accompaniment repeat includes the last-to-first transition. Source-harmony variants, such as relocated pickup harmony, are solved between fixed canonical neighbors without mutating a shared previous-grip state; request order cannot change them. Per-span composition reports include candidate counts, selected family, bass/string family availability, selected positions, bass degree and movement, plus `fret_span`, `span_group` and `span_candidates` (primary/fallback counts before dominance reduction). These are diagnostic sidecar fields; the score/runtime format is unchanged. `comping_rhythm.py` constructs whole-measure rhythms from version 3 of `composition/accompaniment.json`, with instrument-specific duration and voicing families. Normal full-measure ranges are 0–2 electric chord attacks, 1–2 acoustic bass attacks and 1–3 acoustic upper-chord attacks. Bass starts on beat one whenever harmony is available there. An absent harmony or N.C. remains silent. Acoustic attacks last exactly a quarter or eighth note, with same-layer attacks at least an eighth apart. Electric retains short stabs, quarters, longer holds and alternating durations. Exact chord offsets constrain sounding intervals; short partial bars have bounded gestures. A required acoustic attack with less than an eighth of valid harmony fails explicitly rather than creating a sixteenth or silently quantizing the change. Rapid harmonic changes can authorize one extra attack per layer, recorded as an exception, without assigning a fresh density budget to every chord. @@ -135,7 +137,7 @@ The original head supplies entrances, moving-note intervals and space; the actua Acoustic bass and upper events are planned as two layers, then `acoustic.combine` converts their sounding intervals to one rhythmic voice before assembly. It splits notes at attack/release boundaries and ties only continuing pitches; repeated attacks stay separate. Staff and TAB share the result, with no competing rests or opposing layer stems. This preserves exact sounding durations instead of truncating overlaps. The older truncating `prepare` helper serves only the noncanonical legacy/test path. The product build audits canonical accompaniment reuse across arrangement iterations, allowing only source-derived changes in harmony, pickups and role handoffs. The `.roles` sidecars report per-measure families, attack counts, overlaps, ties and exceptions. Their `selection_cost` records local rhythm-candidate contributions before cross-bar joining; the form selector also applies the documented transition cost. Accompaniment dependency keys contain only that instrument's policy; electric melody consumes the shared electric voicing families. Solos retain their existing learned source-melody/harmony conditioning and registered exact performances. -Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20 with a sounding anchor at fret 12 or below and the existing inclusive three-fret span. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing. +Monophonic solos use strings 1–6 and frets 1–12, with no open strings or acoustic/electric string restrictions. Placement compares complete paths through legal positions, accounting for hand-region changes, travel time and string crossings. Ties retain their position. Chord placement retains its separate policy: frets 1–20, a sounding anchor on frets 1–12, primary spans of up to three consecutive positions and fallback spans of exactly four. The span is inclusive: 7–9 is three positions, 7–10 is four. Five positions are always invalid. The anchor must remain sounding, including after shorter notes release. Acoustic accompaniment additionally keeps its bass within frets 1–12 and requires an independent anchor in its upper three-note cluster, preserving the option of separate bass/upper attacks. These costs approximate physical feasibility; they do not assign fingers or guarantee comfortable playing. ### Recording Playback and Page Timing @@ -219,6 +221,10 @@ Chord-family and unified-acoustic verification on October 6, 2026: `unittest dis The full checked rebuild with two composition and two engraving workers took 13.76 seconds: 4.68 seconds for all parts and 8.63 seconds engraving. All role caches missed after the code change; two unchanged vocal engravings were reused. Serial composition-only generation into a disposable directory also passed in 5.58 seconds. The subsequent CMake asset build passed and reproduced every role document from warm caches. Both registered solos and vocal documents are exactly unchanged from the saved prechange baseline; the electric melody's original pitches/onsets/durations also match. Reuse audits retain the same 103/62 and 102/62 repeated-bar counts. Acoustic upper rhythms now select one to three attacks in Autumn Leaves and one or two in All of Me. Acoustic and electric melody pages were visually inspected; this is notation/constraint verification, not an ear-based audition. Only product artifacts and normal caches were written under `build/`; diagnostic logs and baseline comparisons remain under `/tmp/`. +Four-fret fallback verification on October 6, 2026: `unittest discover -s tests -p 'test_*.py'` passed 392 tests in 85.65 seconds. This includes primary-tier precedence over family, arbitrary movement savings and melody-string height; a complete held-note path that needs an earlier four-fret option; tied-fragment cost invariance; and anchor release checks. The first full run exposed one obsolete C5 rejection expectation, now replaced by a test verifying its legal four-fret voicings contain only C/G. Coverage tests check 35 qualities in 12 keys for each instrument: 418/420 acoustic contexts and 420/420 electric contexts have a legal grip. Acoustic Bbm9/Bm9 remain explicitly unsupported; this is measured coverage, not a guarantee for every written extension or slash chord. + +The checked six-score rebuild with `--composition-jobs 2 --jobs 2 --export-solo-xml` passed in 9.21 seconds (4.58 seconds composing all 12 roles, 3.90 seconds processing six cached engravings). All composition caches missed after the code change. Every resulting role document and accompaniment rhythm report exactly matches the saved baseline: current production chords already have primary grips, so none needed fallback substitutions. New sidecar span diagnostics identify the tiers and available candidates. The subsequent `cmake --build build/build_x86_debug --target vamp_generated_assets` passed with all role caches reused and identical per-role musical hashes. Acoustic and electric score/TAB pages were visually checked; supplied recordings are unchanged. Formatter and diff checks passed. Temporary verification files remain under `/tmp/`. + The optional exact comparison confirmed all four exports: 137/141 attacks for All of Me and 121/115 for Autumn Leaves, with no pitch, onset, release or rest differences. All four now pass contextual harmony validation as well. Removing 69 explanatory annotations left the complete event streams and learned musical count tables identical to the pre-edit baseline; only model provenance hashes changed. This verifies authored reuse rather than learned reproduction. Commands used the ordinary asset generator with `--export-solo-xml --check` and `python -m tests.solo_compare` with four explicit `--reference` pairs, `--reference-pitch guitar-written` and `--require-exact`. No ear-based audition or claim of transfer quality was made. Forced prediction, without reading the training XMLs, completed all four source/role combinations with valid harmony. These were composition-only runs on the training-source songs, not held-out transfer tests. Their notes intentionally differ from registered playback and were not published as replacements. The expanded treatment candidates initially took 99.03–120.33 seconds per role with one worker and no concrete-result cache. Removing repeated validation of identical resolution endpoints reduced the isolated All of Me acoustic check from 104.30 to 60.13 seconds, with an identical complete output hash. Six additional contexts retained all 32,566 ordered action candidates exactly. These expanded prediction choices remain slower than the earlier 21.81–27.16-second decoder; registered playback is unaffected. Training the pooled 2,860-context model took 0.57 seconds. Prediction remains the slower path, chiefly candidate construction and event-likelihood evaluation; further profiling is needed before expanding its choices again. diff --git a/generator/acoustic_voicing.py b/generator/acoustic_voicing.py index 8744533..27a181c 100644 --- a/generator/acoustic_voicing.py +++ b/generator/acoustic_voicing.py @@ -39,6 +39,7 @@ def movement(previous: Voicing, current: Voicing) -> int: return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number) class Cost(typing.NamedTuple): + extended_grips: int = 0 travel: int = 0 missing_tones: int = 0 @@ -52,7 +53,7 @@ def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.Chor result = [] for voicing in options: missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing}) - cost, index = min((Cost(p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states)) + cost, index = min((Cost(p.cost.extended_grips + generator.fretboard.span_penalty(voicing), p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states)) result.append(Placement(cost, voicing, states[index])) return result diff --git a/generator/chord_families.py b/generator/chord_families.py index 99477d5..7368aaa 100644 --- a/generator/chord_families.py +++ b/generator/chord_families.py @@ -1,5 +1,6 @@ """Transposable guitar families, derived from the written chord and legal grips.""" import generator.harmony +import generator.fretboard import generator.model import generator.vocabulary @@ -47,3 +48,7 @@ def coverage(chord, options, part): result.append({'bass_string': string, 'bass_degree': degree, 'candidates': len(found), 'preferred': sum(f['preferred'] for f in found), 'reason': '' if found else 'no legal grip for this bass under written harmony and current fret/span limits'}) return result + +def span_coverage(options): + return {'primary': sum(not generator.fretboard.span_penalty(v) for v in options), + 'fallback': sum(generator.fretboard.span_penalty(v) for v in options)} diff --git a/generator/comping.py b/generator/comping.py index 799f334..9fca0e6 100644 --- a/generator/comping.py +++ b/generator/comping.py @@ -5,6 +5,7 @@ import dataclasses import functools import json import fractions +import typing import generator.accompaniment import generator.comping_rhythm @@ -53,7 +54,7 @@ def voicings(key, part): options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v)) if not options: layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4' - raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and three consecutive frets remain binding') + raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding') return tuple(sorted(options)) def movement(part, previous, current): @@ -63,15 +64,26 @@ def movement(part, previous, current): return generator.acoustic_voicing.movement(previous, current) return generator.guitar.movement(previous, current) +class VoicingCost(typing.NamedTuple): + extended_grips: int = 0 + family: int = 0 + effort: int = 0 + missing_tones: int = 0 + register: fractions.Fraction = fractions.Fraction() + def plus(self, other): + return VoicingCost(*(a + b for a, b in zip(self, other))) + def moved(self, distance): + return self._replace(effort=self.effort + distance) + def local_cost(part, key, grip, center): required = requirements(json.loads(key), part) register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center) if part == 'electric': value = generator.guitar.voicing_cost(grip, 'electric', required.allowed) - return (3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register) + return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register) # Familiar degree orders on either bass string, then whole-form movement. family = generator.chord_families.acoustic(json.loads(key), grip) - return (int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register) + return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register) def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False): """Exact small-state dynamic programming; never prune by hand center. @@ -81,25 +93,32 @@ def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False): Fixing the first grip permits an exact repeat-join cost without a beam. """ if not layers: - return (), (0, 0, 0, 0) + return (), VoicingCost() + # Every accompaniment transition is feasible (travel is a cost). A wider + # option cannot beat a primary grip at the same span, regardless of travel. + # This exact dominance reduction avoids growing the cubic repeat search. + retained = [] + for i, layer in enumerate(layers): + tier = min(costs[i][v].extended_grips for v in layer) + retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier)) + layers = tuple(retained) ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)] winners = [] for first in layers[0] if close else [None]: - states = {left: ((0, 0, 0, 0), (), ())} + states = {left: (VoicingCost(), (), ())} for i, layer in enumerate(layers): following = {} for grip in [first] if i == 0 and first is not None else layer: local = costs[i][grip] choices = [] for previous, (cost, ties, path) in states.items(): - total = tuple(a + b for a, b in zip(cost, local)) - total = (total[0], total[1] + movement(part, previous, grip), *total[2:]) + total = cost.plus(local).moved(movement(part, previous, grip)) choices.append((total, (*ties, ranks[i][grip]), (*path, grip))) following[grip] = min(choices) states = following for grip, (cost, ties, path) in states.items(): travel = movement(part, grip, first if close else right) - winners.append(((cost[0], cost[1] + travel, *cost[2:]), ties, path)) + winners.append((cost.moved(travel), ties, path)) cost, _, path = min(winners) return path, cost @@ -212,6 +231,9 @@ class Canonical: 'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant, 'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None, 'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part), + 'fret_span': generator.fretboard.fret_span(grip), + 'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None, + 'span_candidates': generator.chord_families.span_coverage(span.options), 'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip], 'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None), 'bass_string': bass.string_number if bass else None, diff --git a/generator/fretboard.py b/generator/fretboard.py index 6002a20..4cf5633 100644 --- a/generator/fretboard.py +++ b/generator/fretboard.py @@ -12,7 +12,8 @@ STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) MIN_FRET = 1 MAX_FRET = 20 MAX_SINGLE_FRET = 12 -MAX_FRET_SPAN = 3 +PREFERRED_FRET_SPAN = 3 +MAX_FRET_SPAN = 4 MAX_ANCHOR_FRET = 12 WRITTEN_GUITAR_OCTAVE_OFFSET = 12 @@ -21,10 +22,17 @@ class TabPosition(typing.NamedTuple): fret: int midi: int -def within_fret_span(positions) -> bool: - """Count occupied fret positions inclusively: frets 1-3 fit, 1-4 do not.""" +def fret_span(positions) -> int: + """Inclusive occupied positions: frets 7-9 span three, 7-10 span four.""" frets = [position.fret for position in positions] - return not frets or max(frets) - min(frets) + 1 <= MAX_FRET_SPAN + return max(frets) - min(frets) + 1 if frets else 0 + +def span_penalty(positions) -> int: + """Two tiers for legal grips; all one-to-three-fret shapes share tier zero.""" + return int(fret_span(positions) > PREFERRED_FRET_SPAN) + +def within_fret_span(positions) -> bool: + return fret_span(positions) <= MAX_FRET_SPAN def has_fret_anchor(positions) -> bool: return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions) diff --git a/generator/guitar.py b/generator/guitar.py index 7877049..54b2c1d 100644 --- a/generator/guitar.py +++ b/generator/guitar.py @@ -17,6 +17,7 @@ PREFERRED_MAX_FRET = 12 ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1)) class VoicingCost(typing.NamedTuple): + extended_grips: int = 0 melody_string: int = 0 covered_melody: int = 0 covering_tones: int = 0 @@ -46,7 +47,7 @@ def _voicing_cost(positions, profile, allowed, melody, families): effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + 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(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)) def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]: families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else () diff --git a/tests/test_acoustic_voicing.py b/tests/test_acoustic_voicing.py index c487337..1ab25d8 100644 --- a/tests/test_acoustic_voicing.py +++ b/tests/test_acoustic_voicing.py @@ -68,7 +68,7 @@ class AcousticVoicingTests(unittest.TestCase): requirements = generator.fretboard.chord_requirements(span.harmony) if basses and uppers: self.assertTrue(generator.acoustic_voicing.playable(generator.acoustic_voicing.Voicing(*grip))) - self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 3) + self.assertLessEqual(max(p.fret for p in grip) - min(p.fret for p in grip) + 1, 4) self.assertTrue(requirements.covers({p.midi % 12 for p in grip})) self.assertTrue({p.midi % 12 for p in grip} <= set(requirements.allowed)) for note in span.notes: @@ -188,17 +188,20 @@ class AcousticVoicingTests(unittest.TestCase): frets[index] += 1 self.assertEqual(generator.acoustic_voicing.movement(before, voicing(frets)), 1) def test_fret_and_register_limits_are_hard_constraints(self): - for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), False), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), False), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)): + for frets, valid in (((1, 1, 1, 1), True), ((1, 3, 1, 3), True), ((1, 4, 1, 4), True), ((1, 5, 1, 4), False), ((0, 1, 1, 1), False), ((12, 14, 14, 12), True), ((12, 15, 14, 12), True), ((13, 13, 13, 13), False), ((12, 16, 14, 12), False), ((12, 21, 12, 12), False)): with self.subTest(frets=frets): self.assertEqual(generator.acoustic_voicing.playable(voicing(frets)), valid) for symbol in ('CM', 'C7b9', 'C13', 'Cdim7'): self.assertTrue(all(generator.acoustic_voicing.playable(v) for v in candidates(symbol))) - def test_autumn_leaves_cm7_and_f7_cannot_span_four_fret_positions(self): + def test_four_fret_cm7_and_f7_are_fallbacks_not_preferred_shapes(self): for symbol, frets in (('Cm7', (3, 1, 3, 4)), ('F7', (3, 1, 2, 4))): with self.subTest(symbol=symbol): - self.assertFalse(generator.acoustic_voicing.playable(voicing(frets))) - self.assertNotIn(voicing(frets), candidates(symbol)) - self.place(planned_document([[(0, symbol)]])) + self.assertTrue(generator.acoustic_voicing.playable(voicing(frets))) + self.assertIn(voicing(frets), candidates(symbol)) + document = planned_document([[(0, symbol)]]) + selected = generator.acoustic_voicing.select(generator.acoustic.harmony_spans(document)) + self.assertTrue(all(generator.fretboard.span_penalty(v) == 0 for v in selected)) + self.place(document) def test_acoustic_cost_has_no_register_span_or_shape_penalties(self): options = (voicing((1, 1, 1, 1)), voicing((1, 3, 2, 3)), voicing((12, 12, 12, 12)), voicing((12, 14, 13, 14))) states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost())] @@ -218,7 +221,7 @@ class AcousticVoicingTests(unittest.TestCase): options = [candidates(s) for s in harmonies] requirements = [generator.fretboard.chord_requirements(span.harmony) for span in spans] def cost(path): - return generator.acoustic_voicing.Cost(sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path))) + return generator.acoustic_voicing.Cost(sum(generator.fretboard.span_penalty(v) for v in path), sum(generator.acoustic_voicing.movement(a, b) for a, b in zip(path, path[1:])), sum(len(set(r.allowed) - {p.midi % 12 for p in v}) for r, v in zip(requirements, path))) selected = generator.acoustic_voicing.select(spans) self.assertEqual(cost(selected), min(cost(path) for path in itertools.product(*options))) self.assertEqual(selected, generator.acoustic_voicing.select(spans)) @@ -229,16 +232,16 @@ class AcousticVoicingTests(unittest.TestCase): options = candidates(symbol) self.assertTrue(options) self.assertTrue(all(len(v) == 4 and {p.midi % 12 for p in v} == classes for v in options)) - for symbol, omitted in (('C7no1', 0), ('C7no5', 7)): + for symbol, omitted in (('C7no1', 0), ('C7no5', 7), ('Dmno1', 2), ('CMno5', 7)): self.assertTrue(candidates(symbol)) self.assertTrue(all(omitted not in {p.midi % 12 for p in v} for v in candidates(symbol))) def test_unplayable_omission_is_not_restored_to_fill_upper_strings(self): - for symbol in ('Dmno5', 'Dmno1', 'CMno5'): + for symbol in ['Dmno5']: with self.subTest(symbol=symbol): self.assertFalse(candidates(symbol)) document = planned_document([[(0, symbol)]]) before = copy.deepcopy(document) - with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*3 consecutive fret positions'): + with self.assertRaisesRegex(ValueError, f'{symbol}: no acoustic grip.*4 consecutive fret positions'): generator.acoustic_voicing.place(document) self.assertEqual(document, before) def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self): diff --git a/tests/test_chord_families.py b/tests/test_chord_families.py index 400fd4d..f93f02d 100644 --- a/tests/test_chord_families.py +++ b/tests/test_chord_families.py @@ -22,16 +22,14 @@ import tests.test_voicing class ChordFamilies(unittest.TestCase): def test_common_jazz_qualities_in_multiple_keys(self): suffixes = ('', 'm', 'M7', 'm7', '7', 'mM7', '6', 'm6', 'dim', 'dim7', 'm7b5', 'aug', 'aug7', 'augM7', 'sus2', 'sus4', '7sus4', '9sus4', '9', 'M9', 'm9', '11', 'M11', 'm11', '13', 'M13', 'm13', '7b9', '7#9', '7b5', '7#5', '7#11', '7b13', '7b9#5', '6add9') - for root in ('C', 'F#', 'Bb'): + for root in ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'): for suffix in suffixes: chord = generator.harmony.parse_symbol(root + suffix) for part in ('acoustic', 'electric'): with self.subTest(chord=root + suffix, part=part): - # These transpositions have no inversion satisfying all of the - # three-consecutive-frets, bass <=12 and written-tone constraints. - # A familiar low Bb9 root grip needs fret zero; its octave needs - # bass fret 13. The fifth-bass grip 8/10/7/9 spans four positions. - if part == 'acoustic' and root == 'Bb' and suffix in ('9', 'M9', 'm9', '7#9', '6add9'): + # Even four positions cannot satisfy these written tones with + # a root/fifth bass <=12 and upper notes on strings 2/3/4. + if part == 'acoustic' and root in ('Bb', 'B') and suffix == 'm9': with self.assertRaisesRegex(ValueError, 'no legal acoustic voicing'): generator.comping.voicings(generator.comping.semantic_key(chord), part) continue @@ -65,8 +63,8 @@ class ChordFamilies(unittest.TestCase): self.assertNotIn(tests.test_voicing.grip((7, 8, 6, 8), (5, 4, 3, 2)), tests.test_comping_voicings.options('A7', 'acoustic')) self.assertNotIn(tests.test_voicing.grip((5, 7, 5, 6), (5, 4, 3, 2)), tests.test_comping_voicings.options('Dm', 'acoustic')) def test_impossible_family_is_explained_without_weakening_harmony(self): - chord = generator.harmony.parse_symbol('C9') - coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('C9', 'acoustic'), 'acoustic') + chord = generator.harmony.parse_symbol('Bb9') + coverage = generator.chord_families.coverage(chord, tests.test_comping_voicings.options('Bb9', 'acoustic'), 'acoustic') missing = [c for c in coverage if not c['candidates']] self.assertTrue(missing) self.assertTrue(all('fret/span' in c['reason'] for c in missing)) @@ -79,7 +77,8 @@ class ChordFamilies(unittest.TestCase): self.assertTrue(choices) def top_string(v): return min(p.string_number for p in v if p.midi == midi) - self.assertEqual(top_string(choices[0]), min(map(top_string, choices))) + tier = generator.fretboard.span_penalty(choices[0]) + self.assertEqual(top_string(choices[0]), min(top_string(v) for v in choices if generator.fretboard.span_penalty(v) == tier)) self.assertEqual(max(p.midi for p in choices[0]), midi) self.assertTrue(all(p.string_number in (1, 2, 3, 4) for v in choices for p in v)) self.assertTrue(all(midi in {p.midi for p in v} for v in choices)) diff --git a/tests/test_comping_voicings.py b/tests/test_comping_voicings.py index 293a505..9916d4a 100644 --- a/tests/test_comping_voicings.py +++ b/tests/test_comping_voicings.py @@ -21,9 +21,9 @@ def options(symbol, part): return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part) def path_cost(path, costs, part, left=(), right=(), close=False): - total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(4)] - total[1] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path)) - total[1] += generator.comping.movement(part, path[-1], path[0] if close else right) + total = [sum(costs[i][v][j] for i, v in enumerate(path)) for j in range(5)] + total[2] += sum(generator.comping.movement(part, a, b) for a, b in zip((left, *path), path)) + total[2] += generator.comping.movement(part, path[-1], path[0] if close else right) return tuple(total) class CompingVoicings(unittest.TestCase): @@ -69,9 +69,9 @@ class CompingVoicings(unittest.TestCase): # Bass responsibility belongs to the acoustic voicing, as in electric melody. self.assertEqual(options('C/E', 'electric'), options('C', 'electric')) def test_impossible_grip_reports_source_location_without_relaxation(self): - source = tests.solo_fixtures.song(count=1, chords=('C5',)) + source = tests.solo_fixtures.song(count=1, chords=('C7b9#11',)) for part in ('acoustic', 'electric'): - with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C5: no legal'): + with self.subTest(part=part), self.assertRaisesRegex(ValueError, 'source measure 1, beat 1: C7b9#11: no legal'): generator.comping.Canonical(source, part, 'test') def test_complete_path_beats_greedy_and_matches_exhaustive_search(self): for part in ('acoustic', 'electric'): @@ -90,7 +90,7 @@ class CompingVoicings(unittest.TestCase): def test_repeat_join_and_neighbor_boundaries_participate_in_selection(self): for part in ('acoustic', 'electric'): layers = tuple(options(s, part) for s in ('C', 'Dm7', 'G7')) - costs = tuple({v: (0, 0, 0, 0) for v in layer} for layer in layers) + costs = tuple({v: generator.comping.VoicingCost(generator.fretboard.span_penalty(v)) for v in layer} for layer in layers) for endpoints in ({'close': True}, {'left': options('Am7', part)[0], 'right': options('FM7', part)[-1]}): with self.subTest(part=part, endpoints=endpoints): path, cost = generator.comping.select_grips(layers, costs, part, 'test', **endpoints) diff --git a/tests/test_fret_policy.py b/tests/test_fret_policy.py index ce95af8..cfcaed9 100644 --- a/tests/test_fret_policy.py +++ b/tests/test_fret_policy.py @@ -7,6 +7,8 @@ import unittest.mock import generator.acoustic import generator.acoustic_voicing +import generator.chord_families +import generator.comping import generator.electric import generator.fretboard import generator.guitar @@ -18,6 +20,8 @@ import generator.solo.placement import tests.solo_fixtures import tests.test_acoustic_voicing import tests.test_musicxml +import tests.test_voicing +import tests.score_events def position(string, fret): return generator.fretboard.TabPosition(string, fret, dict(generator.fretboard.STANDARD_TUNING)[string] + fret) @@ -35,6 +39,89 @@ def document(positions): return result class FretPolicyTests(unittest.TestCase): + def test_span_tiers_group_one_two_and_three_positions(self): + for frets, span, tier, legal in (((7,), 1, 0, True), ((7, 8), 2, 0, True), ((7, 9), 3, 0, True), ((7, 10), 4, 1, True), ((7, 11), 5, 1, False)): + grip = tuple(position(s, f) for s, f in zip((2, 1), frets)) + self.assertEqual(generator.fretboard.fret_span(grip), span) + self.assertEqual(generator.fretboard.span_penalty(grip), tier) + self.assertEqual(generator.fretboard.within_fret_span(grip), legal) + self.assertEqual(generator.fretboard.fret_span(()), 0) + def test_primary_comping_grip_beats_family_and_arbitrarily_less_movement(self): + chord = generator.harmony.parse_symbol('C') + key = generator.comping.semantic_key(chord) + primary = tests.test_acoustic_voicing.voicing((7, 5, 5, 5)) + fallback = tests.test_acoustic_voicing.voicing((8, 5, 5, 5), 6) + self.assertFalse(generator.chord_families.acoustic(chord, primary)['preferred']) + self.assertTrue(generator.chord_families.acoustic(chord, fallback)['preferred']) + self.assertTrue(all(v in generator.comping.voicings(key, 'acoustic') for v in (primary, fallback))) + costs = {v: generator.comping.local_cost('acoustic', key, v, 55) for v in (primary, fallback)} + # Movement/family wins cannot purchase entry into the four-fret tier. + costs[primary] = costs[primary]._replace(effort=100000) + path, cost = generator.comping.select_grips(((primary, fallback),), (costs,), 'acoustic', 'tiers', left=fallback, right=fallback) + self.assertEqual(path, (primary,)) + self.assertEqual(cost.extended_grips, 0) + states = [generator.acoustic_voicing.Placement(generator.acoustic_voicing.Cost(), fallback)] + legacy = generator.acoustic_voicing.advance((primary, fallback), generator.comping.requirements(chord, 'acoustic'), states) + self.assertEqual(min(legacy, key=lambda s: s.cost).voicing, primary) + def test_primary_electric_grip_beats_highest_melody_string_and_pruning(self): + for prune in (False, True): + choices = tests.test_voicing.ranked('G7', (65,), prune=prune) + best = choices[0] + self.assertEqual(generator.fretboard.span_penalty(best), 0) + self.assertEqual(min(p.string_number for p in best if p.midi == 65), 2) + self.assertEqual(max(p.midi for p in best), 65) + self.assertTrue(any(generator.fretboard.span_penalty(v) and any(p.midi == 65 and p.string_number == 1 for p in v) for v in choices)) + primary_cost = generator.guitar.voicing_cost(best, 'electric', melody=(65,)).moved(100000) + self.assertTrue(all(primary_cost < generator.guitar.voicing_cost(v, 'electric', melody=(65,)) for v in choices if generator.fretboard.span_penalty(v))) + def test_previously_missing_chords_select_reported_fallbacks(self): + for symbol in ('Bb9', 'BbM9', 'Bb7#9', 'Bb6add9'): + with self.subTest(chord=symbol): + source = tests.solo_fixtures.song(count=1, chords=(symbol,)) + canonical = generator.comping.Canonical(source, 'acoustic', 'fallback') + report = canonical.report[0] + self.assertEqual(report['fret_span'], 4) + self.assertEqual(report['span_group'], 'fallback') + self.assertEqual(report['span_candidates']['primary'], 0) + self.assertGreater(report['span_candidates']['fallback'], 0) + self.assertEqual(canonical.path_cost.extended_grips, 1) + required = generator.comping.requirements(generator.harmony.parse_symbol(symbol), 'acoustic') + self.assertTrue(required.covers({p['sounding_midi'] % 12 for p in report['positions']})) + def test_future_held_notes_can_require_a_retained_four_fret_history(self): + source = document((position(4, 5), position(2, 2))) # G3, C#4 + measure = source['staves'][0]['measures'][0] + for p in tests.test_musicxml.notes(source)[0]['pitches']: + p.pop('tablature') + incoming = generator.model.make_event('incoming', fractions.Fraction(1), fractions.Fraction(3), 'note', [{'letter': 'a', 'alter': 0, 'octave': 3, 'tablature': {'string': 3, 'fret': 2}}]) + measure['voices'].append({'id': 'other', 'notes': [generator.model.make_event('rest', fractions.Fraction(), fractions.Fraction(1), 'rest'), incoming]}) + # G/C# also have primary placements. Neither permits this held A/string 3. + self.assertFalse(generator.fretboard.span_penalty((position(4, 5), position(3, 6)))) + before = tests.score_events.timeline(source) + generator.guitar.place_guitar(source) + generator.model.validate_document(source) + generator.fretboard.validate_document(source) + self.assertEqual(tests.score_events.timeline(source), before) + self.assertEqual([p['tablature'] for p in measure['voices'][0]['notes'][0]['pitches']], [{'string': 4, 'fret': 5}, {'string': 2, 'fret': 2}]) + def test_tied_four_fret_grip_is_charged_once_and_keeps_its_anchor(self): + source = document((position(2, 12), position(1, 15))) + original = tests.test_musicxml.notes(source)[0] + pieces = [generator.model.make_event(f'split_{i}', fractions.Fraction(2 * i), fractions.Fraction(2), 'note', original['pitches']) for i in range(2)] + for a, b in zip(pieces[0]['pitches'], pieces[1]['pitches']): + a['tie_to'], b['tie_from'] = b['id'], a['id'] + source['staves'][0]['measures'][0]['voices'][0]['notes'] = pieces + _, tones = generator.guitar.guitar_events(source) + states = {(): generator.guitar.Placement(generator.guitar.VoicingCost(), (), None, ())} + for i, n in enumerate(pieces): + states = generator.guitar.advance_positions([n], fractions.Fraction(2 * i), tones, states, True) + self.assertEqual(min(s.cost for s in states.values()).extended_grips, 1) + generator.guitar.place_guitar(source) + generator.model.validate_document(source) + generator.fretboard.validate_document(source) + self.assertEqual([p['tablature'] for p in pieces[0]['pitches']], [p['tablature'] for p in pieces[1]['pitches']]) + # Removing the low anchor early still leaves an illegal high single tail. + pieces[0]['pitches'][0].pop('tie_to') + pieces[1]['pitches'].pop(0) + with self.assertRaisesRegex(ValueError, 'held/tied tails'): + generator.fretboard.validate_document(source) def test_single_limits_and_alternate_string(self): for midi, expected in ((40, False), (41, True), (72, True), (76, True), (77, False), (84, False)): with self.subTest(midi=midi): @@ -59,7 +146,7 @@ class FretPolicyTests(unittest.TestCase): generator.guitar.place_guitar(source) self.assertEqual(source, before) def test_chord_anchor_and_span_are_hard_constraints(self): - for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), False), ((0, 2), False)): + for frets, valid in (((12, 14), True), ((13, 14), False), ((12, 15), True), ((12, 16), False), ((0, 2), False)): with self.subTest(frets=frets): source = document(tuple(position(s, f) for s, f in zip((2, 1), frets))) if valid: diff --git a/tests/test_guitar.py b/tests/test_guitar.py index 90d4009..649bf3d 100644 --- a/tests/test_guitar.py +++ b/tests/test_guitar.py @@ -32,20 +32,20 @@ class GuitarTests(unittest.TestCase): self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(85))) self.assertFalse(generator.fretboard.positions_for_pitch(fractions.Fraction(129, 2))) self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 13, 72)))) - self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73)))) + self.assertTrue(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 11, 61), generator.fretboard.TabPosition(3, 12, 67), generator.fretboard.TabPosition(2, 14, 73)))) self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(6, 0, 40), generator.fretboard.TabPosition(1, 20, 84)))) self.assertFalse(generator.fretboard.within_fret_span((generator.fretboard.TabPosition(4, 10, 60), generator.fretboard.TabPosition(1, 20, 84)))) def test_fretted_positions_keep_span_and_proximity_scoring(self): - for frets, valid in (((1, 2, 3), True), ((1, 4), False), ((1, 5), False), ((7, 9), True), ((7, 10), False), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)): + for frets, valid in (((1, 2, 3), True), ((1, 4), True), ((1, 5), False), ((7, 9), True), ((7, 10), True), ((7, 11), False), ((12, 1, 11), False), ((3, 3), True), ((18, 20), True)): with self.subTest(frets=frets): positions = tuple(generator.fretboard.TabPosition(6 - i, fret, 0) for i, fret in enumerate(frets)) self.assertEqual(generator.fretboard.within_fret_span(positions), valid) self.assertEqual(generator.guitar.compactness(positions), max(frets) - min(frets)) self.assertEqual(generator.guitar.position_center(positions)[0], min(frets) + max(frets)) self.assertTrue(generator.fretboard.within_fret_span(())) - def test_held_notes_obey_the_shared_three_fret_limit(self): + def test_held_notes_obey_the_shared_four_fret_limit(self): held = {'held': generator.fretboard.TabPosition(4, 7, 57)} - for fret, valid in ((9, True), (10, False), (11, False)): + for fret, valid in ((9, True), (10, True), (11, False)): with self.subTest(fret=fret): pending = [('attack', (generator.fretboard.TabPosition(2, fret, 59 + fret),))] self.assertEqual(bool(list(generator.guitar.extend_positions(held, pending))), valid) @@ -194,8 +194,13 @@ class GuitarTests(unittest.TestCase): content = tests.test_musicxml.xml_note('E', 1, 'quarter', octave=4, extra='') + tests.test_musicxml.xml_note('E', 3, 'half', octave=4, extra='') content += '41' + tests.test_musicxml.xml_note('F', 3, 'half', octave=4, extra='2') document = tests.test_musicxml.parse(tests.test_musicxml.xml_score(content)) + # These fixed E/F positions span five frets; a tie cannot move to a + # different E position to make the incoming F fit. + for note in tests.test_musicxml.notes(document): + for pitch in note['pitches']: + pitch['tablature'] = {'string': 2, 'fret': 5} if pitch['letter'] == 'e' else {'string': 1, 'fret': 1} before = copy.deepcopy(document) - with self.assertRaisesRegex(ValueError, '3 consecutive fret positions.*held/tied strings'): + with self.assertRaisesRegex(ValueError, '4 consecutive fret positions.*held/tied strings'): generator.guitar.place_guitar(document) self.assertEqual(document, before) def test_proximity_chooses_a_path_without_relocating_tied_notes(self): diff --git a/tests/test_guitar_parts.py b/tests/test_guitar_parts.py index d0aa078..cd1c230 100644 --- a/tests/test_guitar_parts.py +++ b/tests/test_guitar_parts.py @@ -41,11 +41,13 @@ class GuitarPartTests(unittest.TestCase): self.assertNotIn(0, generator.fretboard.chord_requirements(chord, respect_bass=False).required) candidates = generator.guitar.chord_positions(generator.fretboard.chord_requirements(chord, respect_bass=False), profile='electric') self.assertTrue(any(all(p.midi % 12 != 0 for p in c) for c in candidates)) - def test_power_chord_cannot_expand_span_or_invent_upper_tones(self): + def test_power_chord_uses_four_fret_fallback_without_inventing_tones(self): requirements = generator.fretboard.chord_requirements(generator.harmony.parse_symbol('C5')) self.assertEqual(set(requirements.allowed), {0, 7}) - self.assertFalse(generator.acoustic_voicing.candidates(requirements)) - self.assertFalse(generator.guitar.chord_positions(requirements, profile='electric')) + for candidates in (generator.acoustic_voicing.candidates(requirements), generator.guitar.chord_positions(requirements, profile='electric')): + self.assertTrue(candidates) + self.assertTrue(all(generator.fretboard.fret_span(c) == 4 for c in candidates)) + self.assertTrue(all({p.midi % 12 for p in c} == {0, 7} for c in candidates)) def test_acoustic_uses_bass_and_three_upper_notes(self): result = self.section(tests.test_parts.xml_harmony() + tests.test_musicxml.xml_note(), 'acoustic') generator.model.validate_document(result) diff --git a/tests/test_voicing.py b/tests/test_voicing.py index 4219d76..082e1fb 100644 --- a/tests/test_voicing.py +++ b/tests/test_voicing.py @@ -23,14 +23,14 @@ def ranked(symbol, melody=(), profile='electric', prune=False): return sorted(candidates, key=lambda shape: (generator.guitar.voicing_cost(shape, profile, requirements.allowed, melody), shape)) class VoicingTests(unittest.TestCase): - def test_electric_chords_use_the_shared_three_fret_limit(self): + def test_electric_chords_use_the_shared_four_fret_limit(self): for prune in (False, True): candidates = ranked('CM7', prune=prune) self.assertTrue(candidates) - self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 3 for c in candidates)) + self.assertTrue(all(generator.guitar.compactness(c) + 1 <= 4 for c in candidates)) candidates = ranked('CM7') self.assertIn(grip((5, 4, 5, 3)), candidates) - self.assertNotIn(grip((2, 4, 1, 3)), candidates) + self.assertIn(grip((2, 4, 1, 3)), candidates) self.assertNotIn(grip((5, 9, 8, 7)), candidates) def test_requested_shapes_win_without_a_shape_dictionary(self): for symbol, melody, expected in (('FM7', 69, (3, 5, 5, 5)), ('Gm7', 67, (3, 3, 3, 3))): @@ -55,7 +55,7 @@ class VoicingTests(unittest.TestCase): def test_wider_low_chord_cannot_displace_a_playable_melody_grip(self): candidates = ranked('CM7', (67,)) self.assertEqual(candidates[0], grip((5, 4, 5, 3))) - self.assertNotIn(grip((2, 4, 1, 3)), candidates) + self.assertIn(grip((2, 4, 1, 3)), candidates) self.assertEqual(max(p.midi for p in candidates[0]), 67) def test_essential_extensions_are_never_relaxed_to_add_the_root(self): for symbol in ('C7b9', 'FM7#11', 'C13', 'Cdim7'):