Fixed acoustic
ci / checks (push) Successful in 48m31s

This commit is contained in:
khodowany
2026-09-30 12:14:49 -05:00
parent 7683793052
commit 10d295abfe
5 changed files with 173 additions and 26 deletions
+2 -2
View File
@@ -107,7 +107,7 @@ Identical notation bodies share layout and body rasterization even when titles o
The build-time pipeline is Soundslice/MuseScore -> `.xml` -> musical model and rhythm plans -> part generation -> JSON, engraved PNG pages, generated-tone audio, Pico title artwork, and a read-only catalog database. Sources in `data/songs/` retain the seven-section performance form. Songs generate Acoustic, Electric, and Vocal parts in their authored key, without key variants or a key-selection menu.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable grip until the harmony changes. Its bass pitch, upper voicing, and TAB positions stay constant across rests, barlines, repeated chord symbols, and section labels while accompaniment continues. The bass can be the root or the chord's fifth, whichever makes the complete grip easier; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
Accompaniment uses deterministic four-bar phrase cycles: a two-bar motif repeats with a one-beat variation in the fourth bar when a suitable candidate exists. Acoustic has independently planned bass and upper-chord rhythms, including simultaneous attacks, sharing one fixed playable grip until the harmony changes. Its bass pitch, upper voicing, and TAB positions stay constant across rests, barlines, repeated chord symbols, and section labels while accompaniment continues. Any defined chord tone can be the bass, including thirds, sevenths, and extensions; explicit slash basses and inversions remain authoritative. The rhythms combine into one notated voice with no ties: each attack releases by the next attack or barline, simultaneous tones share a stem and duration, and rests appear only when the entire part is silent. Held notes are not reattacked. Electric accompaniment targets about two attacks per 4/4 bar, scaled for other meters, favoring gaps around the melody and acoustic rhythms. Its notes retain the selected rhythm's duration without a one-beat cap, stopping at chord changes or barlines and reusing the corresponding melody passage's chord shape when available. Both parts remain tie-free; clipped binary durations that need ties shorten to one conventional note followed by silence. Rhythm snippets supply attacks and rests; tied continuations do not create accompaniment attacks. Structured harmony supplies chord tones. N.C. and absent harmony remain silent. Solo slashes and the seven-section form retain their roles. Unsupported accompaniment meters and mid-measure meter changes fail explicitly.
There are no musical genre presets, genre defaults, or genre metadata. Obsolete `vamp:style` hints are rejected; visual styles, fonts, colors, and barline notation remain supported. Phrase cycles are written into the generated score and played once in reading order.
@@ -115,7 +115,7 @@ Guitar parts share a theory-based voicing and proximity search over standard tun
All parts use two music systems per page, with a single system only when the score ends on an odd system. Lines break only between measures and share consistent staff/TAB widths. A measure that cannot fit at readable spacing fails with a diagnostic instead of being split. Tall notation is scaled uniformly to fit the two rows; guitar notation and TAB stay together and aligned. TAB displays frets and ties without duplicate rests; standard notation retains the complete rhythm. The generated `.pages/*.png` files are the same page images displayed by the product.
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar chord requires the root, the fifth, or both; neither is preferred over the other. Acoustic favors simple grips, allowing bass plus two upper notes when that is easier than a four-note shape. Defining chord tones remain mandatory. Acoustic and electric chords share a combined effort score for shape difficulty, fret height, and movement, so a compact mid-neck grip can beat a stretched lower one. Shape difficulty adds three points per fret of span and two per additional distinct fret. Height adds one point per fret, plus two more per fret above 12. Acoustic transitions weight per-string movement by two; electric retains its doubled-center movement metric. Frets 13-20 remain available when the overall tradeoff justifies them. Exact melody pitches and rhythms remain unchanged, and unharmonized melody placement retains its existing proximity scoring.
Electric melody prioritizes four-string chords, then melody on top. Every generated guitar grip requires the root, the fifth, or both somewhere in the grip, not necessarily in the bass. Defining chord tones remain mandatory. Acoustic first prefers physical reach no wider than frets 7-10, accounting for narrower fret spacing higher on the neck. Wider grips are considered only if none meet that limit, using the least-reaching valid options with a diagnostic. Within that pool, complete major, minor, diminished, and augmented triads take priority when available; authored omissions and extended-chord omission rules remain intact. Movement, fret height, and distinct-fret count then choose the sequence, with no further reward for compressing an already comfortable grip. Acoustic per-string movement still has weight two, and both chord profiles retain the lower-fret preference and additional penalty above fret 12. Electric keeps its existing span and doubled-center movement costs. This is a geometric playability approximation, not a finger-assignment model. Exact melody pitches and rhythms remain unchanged.
Write all music in reading order: repeat bars, numbered endings, navigation jumps, and pass-specific ties are rejected. The seven-section performance arrangements remain fully written out. Chord labels use `M`, `m`, `aug`, `dim`, `sus`, and `mM7`, including explicit major triads such as `CM`. Recognized Soundslice text-only harmonies are normalized into structured chords; parenthesized chords mark optional turnarounds omitted from the final arrangement ending.
+6 -4
View File
@@ -135,7 +135,7 @@ All three parts use octave-transposing notation: written notes sound one octave
`pitch.py` resolves explicit MusicXML instrument transposition once before part generation. Explicit zero transposition is also authoritative. Octave-clef exports already encode sounding pitch in their note values, so they do not receive another implicit octave subtraction. Generated parts carry an explicit `transpose.octave_change: -1`; guitar placement and audio read that shared state. Internal concert-pitch normalization and written-pitch conversion cancel for ordinary source notation except for the intentional song electric melody octave increase. There is no automatic octave fitting, vocal-specific range shift, or key transposition.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 1-20 inclusive, with at most four frets between the lowest and highest positions. Open strings are excluded from chords and single-note melodies alike. A pitch that only an open string could produce fails generation rather than being octave-shifted or omitted. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. Root-bass and fifth-bass candidates compete equally; fifth choices are not merely fallbacks. Explicit acoustic slash basses and inversions override the default bass choices. There is no alternating-bass substitution.
`guitar.py` searches standard tuning E2 A2 D3 G3 B3 E4, frets 1-20 inclusive, with at most four frets between the lowest and highest positions. Open strings are excluded from chords and single-note melodies alike. A pitch that only an open string could produce fails generation rather than being octave-shifted or omitted. Exact melody tones are never dropped or replaced. Simultaneous tones occupy distinct strings, and sustained/tied tones retain their assignments. Acoustic grips use one bass on string 5 or 6 and two or three consecutive strings within 2-4, with zero or one unused string between bass and upper voices. Both rhythmic voices must fit one complete grip, even when only one attacks. Each continuous acoustic harmony span keeps the same bass pitch, upper voicing, and string/fret positions. Any defined chord tone can serve as the acoustic bass, including thirds, sevenths, and extensions. Explicit acoustic slash basses and inversions override these choices, and the bass remains the lowest sounding pitch. There is no alternating-bass substitution.
Shared `ChordRequirements` separate mandatory pitch classes, allowed pitch classes, root-or-fifth alternatives, and bass constraints. Every complete generated guitar grip must contain the root, the fifth, or both, with no preference for the root itself. This rule applies equally to acoustic accompaniment, electric accompaniment, and electric melody chords, not isolated bass/upper attacks or unharmonized melody notes. The fifth follows the chord's actual degree, including diminished or augmented fifths. Explicitly removed degrees are never restored, and a harmony removing both root and fifth cannot generate a guitar chord. Defining thirds/suspensions, sixths/sevenths, alterations, and required extensions remain mandatory; an altered fifth cannot be omitted merely because the root is present.
@@ -145,13 +145,15 @@ Electric chord blocks use strings 1-3 or 1-4, including string 1. Rootless voici
`melody.py` treats the full tied duration as one note when testing whether it exceeds one quarter-note beat. Qualifying notes receive a chord at their initial attack and at each subsequent changed harmony, even if a resulting fragment is less than one beat. Changed harmonies break ties and reattack the melody; repeated identical harmonies do not. N.C. releases the added voices while preserving the melody's sustain. Added tones through unchanged harmony retain their own ties. These melody rules are independent of accompaniment duration spelling. If no valid chord fits, the exact melody remains with a diagnostic; it is not octave-wrapped or omitted.
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, combined physical effort, and missing optional pitch classes. These costs accumulate over the whole passage, so physical effort cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness penalties: a simpler three-note grip can beat a more awkward four-note grip. Shape difficulty competes directly with fret height and movement, not as a later tie-breaker.
Placement uses the ordered `VoicingCost` components: electric string shortfall from four, melody covered, number of tones above the melody, combined effort, and missing optional pitch classes. These costs accumulate over the whole passage, so effort cannot outweigh the earlier electric musical priorities. Melody height compares sounding pitch; unison doubling does not cover it. Electric accompaniment uses the same ranking without the melody-height terms. Acoustic does not receive electric fullness penalties: a simpler three-note grip can beat a more awkward four-note grip.
Acoustic candidate selection separates harmonic validity from reach and sequence scoring. `physical_reach()` computes `round(1_000_000 * (2 ** (-lowest_fret / 12) - 2 ** (-highest_fret / 12)))`, normalized to scale length. The preferred limit is the reach from fret 7 to fret 10, inclusive. If any valid grip meets it, wider grips are excluded before movement optimization. Otherwise only the least-reaching valid grips remain, with a passage-specific warning. The hard four-fret difference and frets 1-20 still apply. Among the retained candidates, triads with exactly degrees 1, 3, and 5 prefer all three tones when feasible. Authored omissions are never restored; seventh, extended, and suspended chords retain their existing coverage rules. Complete triads do not justify exceeding the preferred reach when a reduced comfortable grip exists. Candidate filtering is cached independently of rhythmic events and does not alter electric placement.
`electric.py` links song accompaniment attacks to harmonized melody events using authored source positions, not displayed measure numbers or a song-wide chord dictionary. References stay inside the corresponding source harmony span and must match its structured chord. Within a span, an attack uses the latest available melody grip at or before its source position, or the first later grip if it precedes all melody chords. This allows different shapes for the same chord in different passages. After guitar placement and melody tie resolution, linked stabs copy the actual melody pitches and TAB positions while retaining their own IDs, timing, and written durations; melody ties and markings are not copied. A melody that remains a single note provides no grip. Passages without a matching playable three- or four-string melody chord, including altered endings without a corresponding reference, retain independently generated electric shapes. No reference metadata reaches runtime JSON.
Both generated chord profiles use the same shape-and-height cost: `highest_fret + 2 * max(0, highest_fret - 12) + 3 * fret_span + 2 * max(0, distinct_frets - 1)`. Electric adds its existing movement metric, the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic adds twice its per-string movement: fret changes on retained strings plus one unit per added or released string. The doubled weight balances transitions against shape difficulty so a lower isolated grip does not justify a large jump. An unchanged grip costs zero movement. Exact candidate-to-candidate dynamic programming retains all acoustic grips, including different grips with equal centers. Exact, unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`.
After candidate filtering, acoustic effort is `highest_fret + 2 * max(0, highest_fret - 12) + 2 * max(0, distinct_frets - 1)`. There is no raw-span penalty or continuing reward for smaller physical reach within the preferred range. Electric retains the same formula plus `3 * fret_span` and its existing movement metric, the Manhattan distance between doubled fret/string position centers, with the exact small-grid distance transform used for independent attacks. Acoustic still adds twice its per-string movement: fret changes on retained strings plus one unit per added or released string. An unchanged grip costs zero movement. Exact candidate-to-candidate dynamic programming retains all preferred acoustic grips, including different grips with equal centers. Exact, unharmonized melody placement retains `movement + 2 * max(0, highest_fret - 12) + fret_span`.
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Compact mid-neck grips can beat stretched lower ones, while lower positions still win when shape difficulty and movement are comparable. Autumn Leaves begins with Em7b5 at `5:7, 4:8, 3:7, 2:8` and A7b9 at `5:7, 4:8, 3:6, 2:8`; the nearby C7 at `5:10, 3:9, 2:11` remains preferred after Gm7 at `5:10, 3:10, 2:11`. These are regression examples, not a song-specific shape dictionary. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no automatic octave fitting or finger-assignment model. Stable position ordering resolves equal costs.
Generated chord height contributes one point per fret throughout the allowed range. The shared `PREFERRED_MAX_FRET` remains 12, above which each excess fret adds two more points. Lower positions still win when reach eligibility, shape simplicity, and movement are comparable. Autumn Leaves can use Em7b5/G at `6:3, 4:2, 3:3, 2:3` followed by A7b9/G at `6:3, 4:2, 3:3, 2:2`, moving only one note by one fret. The original mid-neck grips at `5:7, 4:8, 3:7, 2:8` and `5:7, 4:8, 3:6, 2:8` remain valid nearby alternatives. Dm/F at `5:8, 3:7, 2:10` meets the preferred reach limit exactly. These are regression examples, not mandatory song-specific shapes, and generated inversions do not rewrite the source chord labels. Acoustic shape costs are charged once per harmony span, independent of rhythm density, and transitions compare complete grips rather than whichever lane last sounded. Pure melody tie continuations add no fresh shape or movement cost. There is no automatic octave fitting or finger-assignment model. Stable position ordering resolves equal costs.
There is no finger assignment, barre logic, or predefined chord-shape dictionary. These are proximity and fretboard-feasibility constraints, not a guarantee of comfortable fingering. Nonstandard tuning, capos, microtonal frets, and unplayable passages fail with source/part/event context; there is no slash fallback for a failed chord. Existing assets remain untouched after failure.
+26 -3
View File
@@ -17,6 +17,8 @@ MIN_FRET = 1
MAX_FRET = 20
PREFERRED_MAX_FRET = 12
MAX_FRET_SPAN = 4
# Scale-length-normalized reach, in millionths, using frets 7-10 as the reference.
PREFERRED_ACOUSTIC_REACH = round(1_000_000 * (2 ** (-7 / 12) - 2 ** (-10 / 12)))
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
ACOUSTIC_STRINGS = ((6, 4, 3), (6, 4, 3, 2), (5, 4, 3), (5, 3, 2), (5, 4, 3, 2))
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
@@ -46,7 +48,9 @@ def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed:
span = compactness(positions)
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET)
if profile in ('acoustic', 'electric'):
effort += max(frets, default=0) + 3 * span + 2 * max(0, len(frets) - 1)
effort += max(frets, default=0) + 2 * max(0, len(frets) - 1)
if profile == 'electric':
effort += 3 * span
else:
effort += span
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
@@ -55,6 +59,12 @@ def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions]
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
def physical_reach(positions: tuple[TabPosition, ...]) -> int:
if not positions:
return 0
frets = [position.fret for position in positions]
return round(1_000_000 * (2 ** (-min(frets) / 12) - 2 ** (-max(frets) / 12)))
def positions_for_pitch(midi: fractions.Fraction) -> tuple[TabPosition, ...]:
if midi.denominator != 1:
return ()
@@ -103,7 +113,7 @@ def chord_requirements(definition: dict, profile: str = '') -> ChordRequirements
if bass_pc is not None:
allowed.add(bass_pc)
required.add(bass_pc)
basses = {bass_pc} if bass_pc is not None else foundation if profile == 'acoustic' else set()
basses = {bass_pc} if bass_pc is not None else allowed if profile == 'acoustic' else set()
return ChordRequirements(tuple(sorted(required)), tuple(sorted(allowed)), tuple(sorted(foundation)), tuple(sorted(basses)))
@functools.lru_cache(maxsize=256)
@@ -146,6 +156,16 @@ def chord_positions(requirements: ChordRequirements, prune: bool = True, profile
best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values()))
@functools.lru_cache(maxsize=256)
def acoustic_positions(requirements: ChordRequirements, complete_triad: bool = False) -> tuple[tuple[TabPosition, ...], ...]:
reaches = {shape: physical_reach(shape) for shape in chord_positions(requirements, profile='acoustic')}
limit = max(PREFERRED_ACOUSTIC_REACH, min(reaches.values(), default=PREFERRED_ACOUSTIC_REACH))
shapes = tuple(shape for shape, reach in reaches.items() if reach <= limit)
if complete_triad:
complete = tuple(shape for shape in shapes if set(requirements.allowed) <= {p.midi % 12 for p in shape})
shapes = complete or shapes
return shapes
def chord_pitches(definition: dict, positions: tuple[TabPosition, ...]) -> list[dict]:
spellings = {}
for degree, alter in generator.harmony.degrees(definition).items():
@@ -255,7 +275,10 @@ class EventPlacement:
def acoustic_candidates(span: generator.acoustic.HarmonySpan):
requirements = chord_requirements(span.harmony, 'acoustic')
for shape in chord_positions(requirements, profile='acoustic'):
shapes = acoustic_positions(requirements, set(generator.harmony.degrees(span.harmony)) == {1, 3, 5})
if shapes and physical_reach(shapes[0]) > PREFERRED_ACOUSTIC_REACH:
logging.getLogger(__name__).warning('%s: %s requires a wider acoustic reach; using the least-reaching valid grips', span.location, generator.harmony.symbol(span.harmony))
for shape in shapes:
active = tuple((str(p.string_number), p) for p in shape)
choices = tuple(EventPlacement(note, (), tuple(p for p in shape if (p.string_number >= 5) == (note['_guitar_lane'] == 'bass'))) for note in span.notes)
yield AttackCandidate(active, choices, shape, voicing_cost(shape, 'acoustic', requirements.allowed))
+106 -4
View File
@@ -1,6 +1,7 @@
import copy
import fractions
import unittest
import unittest.mock
import generator.accompaniment
import generator.acoustic
@@ -85,9 +86,9 @@ class AcousticVoicingTests(unittest.TestCase):
root_shapes = generator.guitar.chord_positions(generator.guitar.chord_requirements(generator.harmony.parse_symbol('Csus/C'), 'acoustic'), profile='acoustic')
selected = tuple(sorted({p for n in spans[0].notes for p in shape(n)}, reverse=True))
self.assertLessEqual(generator.guitar.voicing_cost(selected, 'acoustic'), min(generator.guitar.voicing_cost(s, 'acoustic') for s in root_shapes))
def test_bass_options_follow_defined_fifth_and_explicit_basses(self):
def test_bass_options_follow_all_chord_tones_and_explicit_basses(self):
inversion = generator.harmony.parse_symbol('Cdim7') | {'inversion': 0}
for chord, expected in (('C7', {0, 7}), ('Cdim7', {0, 6}), ('Caug', {0, 8}), ('Cno5', {0}), ('C7/E', {4}), (inversion, {0})):
for chord, expected in (('C7', {0, 4, 7, 10}), ('Cdim7', {0, 3, 6, 9}), ('Caug', {0, 4, 8}), ('Cno5', {0, 4}), ('C7b9', {0, 1, 4, 7, 10}), ('Dmno1', {5, 9}), ('C7/E', {4}), (inversion, {0})):
with self.subTest(chord=chord):
span = generator.acoustic.harmony_spans(planned_document([[(0, chord)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
@@ -116,8 +117,9 @@ class AcousticVoicingTests(unittest.TestCase):
spans = self.place(document)
self.assertEqual(len(spans), 4)
basses = [min(p.midi for n in span.notes for p in shape(n)) % 12 for span in spans]
self.assertIn(basses[0], (0, 7))
self.assertEqual(basses[1:], [4, 7, 6])
self.assertIn(basses[0], (0, 4, 7, 10))
self.assertEqual(basses[1:3], [4, 7])
self.assertIn(basses[3], (0, 4, 6, 10))
def test_non_accompaniment_passage_ends_the_span(self):
document = planned_document([[(0, 'C7')], [], []])
measure = document['staves'][0]['measures'][1]
@@ -189,3 +191,103 @@ class AcousticVoicingTests(unittest.TestCase):
candidates = generator.guitar.chord_positions(requirements, profile='acoustic')
self.assertEqual(candidates, generator.guitar.chord_positions(requirements, profile='acoustic', prune=False))
self.assertGreater(len(candidates), len({generator.guitar.position_center(p) for p in candidates}))
def test_physical_reach_uses_fret_spacing_and_an_inclusive_reference(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
low = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 3), (3, 2), (2, 5)))
reference = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 8), (3, 7), (2, 10)))
high = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((5, 9), (3, 8), (2, 11)))
self.assertEqual(generator.guitar.compactness(low), generator.guitar.compactness(reference))
self.assertGreater(generator.guitar.physical_reach(low), generator.guitar.physical_reach(reference))
self.assertGreater(generator.guitar.physical_reach(reference), generator.guitar.physical_reach(high))
self.assertEqual(generator.guitar.physical_reach(reference), generator.guitar.PREFERRED_ACOUSTIC_REACH)
self.assertEqual(generator.guitar.physical_reach(()), 0)
self.assertEqual(generator.guitar.physical_reach(reference[:1]), 0)
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Dm')]]))[0]
self.assertIn(reference, [c.shape for c in generator.guitar.acoustic_candidates(span)])
def test_acoustic_effort_does_not_reward_further_compression(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
shapes = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in zip((5, 4, 3, 2), frets)) for frets in ((5, 5, 7, 7), (6, 6, 7, 7))]
self.assertGreater(generator.guitar.physical_reach(shapes[0]), generator.guitar.physical_reach(shapes[1]))
self.assertTrue(all(generator.guitar.physical_reach(s) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for s in shapes))
self.assertEqual(*(generator.guitar.voicing_cost(s, 'acoustic').effort for s in shapes))
def test_comfortable_candidates_win_even_when_previous_grip_is_wider(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
wider = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in ((6, 6), (4, 5), (3, 3), (2, 3)))
active = tuple((str(p.string_number), p) for p in wider)
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
span = generator.acoustic.harmony_spans(planned_document([[(0, 'Em7b5')]]))[0]
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertIn(wider, generator.guitar.chord_positions(requirements, profile='acoustic'))
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertNotIn(wider, [c.shape for c in candidates])
result = generator.guitar.advance_acoustic(candidates, states)
chosen = min(result.values(), key=lambda p: (p.cost, p.active))
self.assertLessEqual(generator.guitar.physical_reach(tuple(p for _, p in chosen.active)), generator.guitar.PREFERRED_ACOUSTIC_REACH)
def test_complete_triads_are_preferred_within_comfortable_candidates(self):
for symbol in ('CM', 'Dm', 'Cdim', 'Caug', 'Dm/F'):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
classes = set(generator.harmony.pitch_classes(span.harmony))
self.assertTrue(candidates)
self.assertTrue(all(classes <= {p.midi % 12 for p in c.shape} for c in candidates))
self.assertTrue(all(generator.guitar.physical_reach(c.shape) <= generator.guitar.PREFERRED_ACOUSTIC_REACH for c in candidates))
def test_omissions_and_extended_chords_do_not_gain_complete_triad_requirement(self):
for symbol, omitted in (('Dmno1', 2), ('Dmno5', 9)):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
self.assertTrue(all(omitted not in {p.midi % 12 for p in c.shape} for c in candidates))
for symbol in ('C7', 'C7b9', 'C13'):
with self.subTest(chord=symbol):
span = generator.acoustic.harmony_spans(planned_document([[(0, symbol)]]))[0]
candidates = list(generator.guitar.acoustic_candidates(span))
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
self.assertTrue(any(0 not in {p.midi % 12 for p in c.shape} for c in candidates))
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) for c in candidates))
def test_complete_triad_preference_cannot_force_a_wider_grip(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
reduced, complete = [tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in pairs) for pairs in (((6, 1), (4, 3), (3, 2)), ((5, 5), (4, 3), (3, 2)))]
requirements = generator.guitar.chord_requirements(generator.harmony.parse_symbol('Dm'), 'acoustic')
self.assertTrue(all(requirements.covers({p.midi % 12 for p in s}) for s in (reduced, complete)))
self.assertLessEqual(generator.guitar.physical_reach(reduced), generator.guitar.PREFERRED_ACOUSTIC_REACH)
self.assertGreater(generator.guitar.physical_reach(complete), generator.guitar.PREFERRED_ACOUSTIC_REACH)
generator.guitar.acoustic_positions.cache_clear()
self.addCleanup(generator.guitar.acoustic_positions.cache_clear)
with unittest.mock.patch('generator.guitar.chord_positions', return_value=(reduced, complete)):
self.assertEqual(generator.guitar.acoustic_positions(requirements, complete_triad=True), (reduced,))
def test_explicit_inversions_and_nonchord_slash_basses_are_preserved(self):
for chord, bass in ((generator.harmony.parse_symbol('Dm') | {'inversion': 1}, 5), (generator.harmony.parse_symbol('C7') | {'inversion': 3}, 10), ('Dm/B', 11)):
with self.subTest(chord=chord):
document = planned_document([[(0, chord)]])
span = self.place(document)[0]
self.assertEqual({p.midi % 12 for n in span.notes for p in shape(n) if p.string_number >= 5}, {bass})
def test_wider_fallback_is_least_reaching_and_reported_with_context(self):
document = planned_document([[(0, 'C13/E')]])
span = generator.acoustic.harmony_spans(document)[0]
requirements = generator.guitar.chord_requirements(span.harmony, 'acoustic')
raw = generator.guitar.chord_positions(requirements, profile='acoustic')
minimum = min(generator.guitar.physical_reach(s) for s in raw)
self.assertGreater(minimum, generator.guitar.PREFERRED_ACOUSTIC_REACH)
with self.assertLogs('generator.guitar', level='WARNING') as logs:
candidates = list(generator.guitar.acoustic_candidates(span))
self.assertTrue(candidates)
self.assertEqual({generator.guitar.physical_reach(c.shape) for c in candidates}, {minimum})
self.assertTrue(all(requirements.covers({p.midi % 12 for p in c.shape}) and generator.guitar.within_fret_span(c.shape) for c in candidates))
self.assertIn(span.location, logs.output[0])
self.assertIn('C13/E requires a wider acoustic reach', logs.output[0])
with self.assertLogs('generator.guitar', level='WARNING'):
self.place(document)
def test_inversions_still_follow_nearby_full_grip_positions(self):
tuning = dict(generator.guitar.STANDARD_TUNING)
for previous, expected in ((((6, 3), (4, 2), (3, 3), (2, 3)), ((6, 3), (4, 2), (3, 3), (2, 2))), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
with self.subTest(previous=previous):
grip = tuple(generator.guitar.TabPosition(s, f, tuning[s] + f) for s, f in previous)
active = tuple((str(p.string_number), p) for p in grip)
states = {active: generator.guitar.Placement(generator.guitar.VoicingCost(), active, None, ())}
span = generator.acoustic.harmony_spans(planned_document([[(0, 'A7b9')]]))[0]
result = generator.guitar.advance_acoustic(generator.guitar.acoustic_candidates(span), states)
chosen = tuple(p for _, p in min(result.values(), key=lambda p: (p.cost, p.active)).active)
self.assertEqual(tuple((p.string_number, p.fret) for p in chosen), expected)
self.assertEqual(generator.guitar.acoustic_movement(grip, chosen), 1)
+33 -13
View File
@@ -82,22 +82,22 @@ class VoicingTests(unittest.TestCase):
self.assertEqual(generator.guitar.voicing_cost(grip((fret,) * 4)).effort, 2 * max(0, fret - 12))
crossing = grip((11, 12, 13, 14))
self.assertEqual(generator.guitar.voicing_cost(crossing).effort, 7)
for profile in ('electric', 'acoustic'):
self.assertEqual(generator.guitar.voicing_cost(crossing, profile).effort, 33)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'electric').effort, 33)
self.assertEqual(generator.guitar.voicing_cost(crossing, 'acoustic').effort, 24)
def test_chord_shape_and_height_share_one_effort_cost(self):
shape = grip((1, 3, 4, 5))
self.assertEqual(generator.guitar.voicing_cost(shape).effort, 4)
for profile in ('electric', 'acoustic'):
self.assertEqual(generator.guitar.voicing_cost(shape, profile).effort, 23)
self.assertEqual(generator.guitar.voicing_cost(shape, 'electric').effort, 23)
self.assertEqual(generator.guitar.voicing_cost(shape, 'acoustic').effort, 11)
for profile in ('', 'electric', 'acoustic'):
self.assertLess(generator.guitar.voicing_cost(grip((4, 4, 4, 4)), profile), generator.guitar.voicing_cost(grip((2, 4, 1, 3)), profile))
def test_distinct_frets_add_difficulty_even_with_the_same_span(self):
simple, varied = grip((3, 3, 5, 5)), grip((3, 4, 5, 5))
for profile in ('electric', 'acoustic'):
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, 13)
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, 15)
for profile, expected in (('electric', 13), ('acoustic', 7)):
self.assertEqual(generator.guitar.voicing_cost(simple, profile).effort, expected)
self.assertEqual(generator.guitar.voicing_cost(varied, profile).effort, expected + 2)
self.assertEqual(generator.guitar.voicing_cost(simple).effort, generator.guitar.voicing_cost(varied).effort)
def test_compact_mid_neck_acoustic_pair_beats_high_and_stretched_low_pairs(self):
def test_acoustic_reach_excludes_stretched_low_pair_before_scoring(self):
high = [grip((12, 12, 12, 11), (6, 4, 3, 2)), grip((12, 11, 12, 11), (6, 4, 3, 2))]
requested = [grip((7, 8, 7, 8), (5, 4, 3, 2)), grip((7, 8, 6, 8), (5, 4, 3, 2))]
low = [grip((6, 5, 3, 3), (6, 4, 3, 2)), grip((5, 5, 3, 2), (6, 4, 3, 2))]
@@ -108,20 +108,40 @@ class VoicingTests(unittest.TestCase):
self.assertIn(shape, candidates)
self.assertEqual({p.midi % 12 for p in upper}, {p.midi % 12 for p in middle})
self.assertEqual(min(p.midi for p in upper), min(p.midi for p in middle))
preferred = generator.guitar.acoustic_positions(requirements)
self.assertIn(middle, preferred)
self.assertIn(upper, preferred)
self.assertNotIn(lower, preferred)
costs = [sum(generator.guitar.voicing_cost(s, 'acoustic').effort for s in pair) + 2 * generator.guitar.acoustic_movement(*pair) for pair in (requested, high, low)]
self.assertEqual(costs, [33, 36, 41])
def test_real_autumn_leaves_lead_in_uses_requested_compact_grips(self):
self.assertEqual(costs, [24, 30, 23])
def test_real_autumn_leaves_cadences_use_comfortable_inversions_and_complete_triads(self):
source = generator.musicxml.load_musicxml(pathlib.Path('data/songs/autumn-leaves.xml'))
before = copy.deepcopy(source)
result = generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms')))
generator.model.validate_document(result)
measures = result['staves'][0]['measures'][:2]
measures = result['staves'][0]['measures']
self.assertEqual(measures[0]['section_label'], 'Rhythm Lead-in')
for measure, symbol, expected in zip(measures, ('Em7b5', 'A7b9'), (((5, 7), (4, 8), (3, 7), (2, 8)), ((5, 7), (4, 8), (3, 6), (2, 8)))):
with self.subTest(chord=symbol):
for index, symbol, expected in ((0, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (1, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2))), (4, 'Em7b5', ((6, 3), (4, 2), (3, 3), (2, 3))), (5, 'A7b9', ((6, 3), (4, 2), (3, 3), (2, 2)))):
measure = measures[index]
with self.subTest(measure=measure['number'], chord=symbol):
self.assertEqual(generator.harmony.symbol(measure['harmonies'][0]), symbol)
positions = {(p['tablature']['string'], p['tablature']['fret']) for v in measure['voices'] for n in v['notes'] for p in n['pitches']}
self.assertEqual(tuple(sorted(positions, reverse=True)), expected)
checked = set()
for measure in measures:
if measure['number'] not in ('26', '27', '30', '31', '32', '33'):
continue
pitches = [p for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
if not pitches:
continue
positions = tuple(generator.guitar.TabPosition(p['tablature']['string'], p['tablature']['fret'], int(generator.model.pitch_number(p)) - 12) for p in pitches)
self.assertLessEqual(generator.guitar.physical_reach(positions), generator.guitar.PREFERRED_ACOUSTIC_REACH, measure['id'])
self.assertTrue(all('tie_from' not in p and 'tie_to' not in p for p in pitches))
if measure['number'] in ('32', '33'):
self.assertEqual({p.midi % 12 for p in positions}, {2, 5, 9})
checked.add(measure['number'])
self.assertEqual(checked, {'26', '27', '30', '31', '32', '33'})
self.assertEqual(result, generator.arrange.generate.arrange_document(source, 'acoustic', rhythms=generator.rhythm.load_library(pathlib.Path('data/rhythms'))))
self.assertEqual(source, before)
def test_acoustic_simpler_three_note_shape_can_beat_four_notes(self):
candidates = ranked('Csus', profile='acoustic')