Acoustic Comping

This commit is contained in:
khodowany
2026-10-06 21:50:59 -05:00
parent c7f6a30a13
commit 2f21ec1f16
6 changed files with 132 additions and 8 deletions
+2
View File
@@ -104,6 +104,8 @@ Electric accompaniment and electric melody share compact chord families on strin
Accompaniment uses independently authored two-bar jazz pattern families: Charleston and shifted Charleston, forward-motion syncopation, anticipated heavy beats, syncopated riffs, quarters, dotted spacing, shifting motifs, call-response and anticipated beat four. Acoustic distributes a single figure between bass and upper chord, normally 1–3 total attacks per full bar, without mandatory beat-one bass. Separate attacks are quarters/eighths and never overlap; a combined bass-plus-upper chord is allowed only when both attack and sustain together for more than one beat. Electric has distinct patterns with normally 0–2 attacks per bar, stabs, sustained chords and space. The source melody and harmonic rhythm guide complete-pattern selection. Half-beat upper/electric anticipations may establish the next written chord; bass stays on current harmony. Explicit cross-bar holds use ties, never automatic merging of repeated attacks. Canonical patterns repeat at corresponding form positions with documented harmonic/pickup exceptions, and changing only tempo leaves the music unchanged.
Acoustic voicing selection balances movement with lower sounding register and a soft cost for mean fret positions above nine; comfortable shifts can win over staying high. Compact spans and preferred chord families remain higher priorities. The same preference applies to the final sustained chord, without changing rhythm choices.
**Registered Solo 1 is notated exactly in the acoustic part and Solo 2 exactly in the electric part.** Unregistered song/slot pairs use deterministic prediction from one pooled statistical model trained on both slots. There is no learned acoustic/electric style distinction. Prediction uses up to four recent pitch/rhythm events, source-melody features, harmony, and metrical position; it does not plan complete themes or guarantee long-range phrase development. The current corpus is four authored solos from Autumn Leaves and All of Me.
Training, authored score reuse, and prediction share contextual harmony validation based on actual notes, timing, and chords. Explanatory labels and justification tags are not required. Unsupported relationships fail with a diagnostic rather than silently changing notes. Both solo roles use strings 1–6 and frets 1–12 without open strings. No dynamics, accents, velocity shaping, or random timing are added.
+9 -1
View File
@@ -127,7 +127,7 @@ Electric accompaniment shares the electric melody's `guitar.chord_positions(...,
Acoustic families are transposable degree orders for triads, tetrads and extended chords, with root/fifth alternatives on both bass strings. Slots resolve to the written third/suspension, sixth/seventh, fifth and extension, including alterations and omissions. They are not per-song grip tables. Only harmonically and physically valid realizations enter selection; unavailable bass/inversion variants are discarded. Coverage checks exercise 35 common qualities in all 12 keys. Four-fret fallbacks support previously unavailable acoustic Bb9, BbM9, Bb7#9 and Bb6add9; for example, Bb9 uses fifth-bass frets 8/10/7/9 on strings 5/4/3/2. Acoustic Bbm9 and Bm9 still have no grip meeting all constraints. Unsupported chords fail with source measure, beat and chord rather than silently omitting defining tones.
The active voicing selector compares complete paths with dynamic programming over concrete grips. The leading cost minimizes four-fret grip selections: every span of one, two or three positions has zero cost in this tier. Existing criteria then apply within the tier. Electric accompaniment prioritizes chord size and shared family rank, then 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.
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 a combined travel/register/position cost, optional-tone coverage and register tie-breaking. Stable role-specific keys resolve equal costs. Since accompaniment transitions have travel costs but no hard travel cutoff, four-fret candidates are safely removed from a span when any primary grip exists; this exact dominance reduction keeps the repeat search small. Electric placement retains distinct alternatives where future held/tied notes can constrain feasibility, and does not charge tied continuations as fresh grip choices. The remaining acoustic placement helper uses the same leading fallback tier.
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.
@@ -143,6 +143,14 @@ Acoustic events use temporary bass/upper notation lanes for projection; then `ac
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.
Acoustic voicing selection retains the lexicographic compact-span and preferred-family tiers. Within those tiers, the whole-form cost is finger travel plus `register_weight * abs(mean sounding MIDI - center)` plus `high_position_weight * max(0, mean fret - comfortable_max_fret)`, summed once per active harmonic span. The acoustic role's `voicing` policy defaults to weights 1 and 1, threshold 9, and the existing register center 53. All arithmetic is rational. Missing allowed tones and the original register distance remain later tie-breakers. Existing finger-travel costs include bass-string changes, source-form repeat joins and neighboring grips; high positions can still win when avoiding a costly shift. No preferred bass string or song-specific grip list is added. The ending uses the same local cost and its incoming grip.
Each selected acoustic span reports `voicing_cost` contributions for movement, weighted register, weighted position and their combined effort. These contributions describe the balanced tier, not the higher span/family priorities; repeat-join travel is assigned to the first canonical span. New acoustic `voicing` settings remain in concrete cache dependencies but are excluded from rhythmic choice seeds, including settings overrides, preserving the pre-policy rhythm choices. Other role policies and electric selection are unchanged.
Lower-position verification on October 6, 2026: all 400 Python tests and 124 native test cases passed. Native and ARM builds passed asset checks; all 139 score/page files matched byte for byte. Against the saved production output, acoustic rhythms and all other five role documents were identical for both songs. Mean fret across canonical active acoustic grips changed from 8.90 to 4.50 in Autumn Leaves and 5.19 to 4.29 in All of Me; movement costs rose from 144 to 190 and 128 to 140 respectively. In the requested six-chord passage, five suggested grips won; Em7b5 instead chose its valid fifth-bass shape on strings 6/4/3/2 at frets 6/5/7/5. Tests also verify that the complete suggested sequence beats the previous higher sequence with fixed neighbors, and that sufficiently costly travel can still retain a higher grip. These are score/selection facts, not an ear-based audition.
Isolated uncached acoustic role generation took 0.61–0.62 seconds per song, versus 0.56–0.58 seconds before this change; cache hits took about 0.048 seconds and matched clean output. With two composition and engraving workers, the native asset rebuild took approximately 11 seconds (4 composing, 6 engraving, 1 other); ARM reused caches and took approximately 6 seconds. These are individual local measurements, not a statistical benchmark. Supplied recordings remain byte-identical.
### Recording Playback and Page Timing
Vamp uses only supplied PCM16 WAVs. `package/assets/sounds/beat1.wav` and `beat2.wav` provide measure accents and ordinary beats. Backing files are `package/assets/backing/<normalized_source_id>_<bpm>.wav`. `generator/performance.py` checks containment, format, decoded length and checksums, then copies the recordings byte for byte into the staged output. A catalog `resources` row stores their paths, checksums, sample rates, channels, frame counts, song IDs and tempos. No note-event audio compiler, pitch synthesizer, release envelope or generated audition audio remains. Pitch mathematics remains for notation, TAB, composition and exact MusicXML comparisons.
+6 -1
View File
@@ -8,7 +8,12 @@
"acoustic_accompaniment": {
"seed": "acoustic_comp",
"bass": "primary",
"center": 53
"center": 53,
"voicing": {
"register_weight": 1,
"high_position_weight": 1,
"comfortable_max_fret": 9
}
},
"electric_accompaniment": {
"seed": "electric_comp",
+23 -4
View File
@@ -67,7 +67,7 @@ def movement(part, previous, current):
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
effort: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
@@ -75,21 +75,36 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance):
return self._replace(effort=self.effort + distance)
def acoustic_preferences(grip, center):
"""Soft register/hand-position costs, in the same units as finger travel."""
policy = generator.vocabulary.role('acoustic_accompaniment').get('voicing', {})
register_weight = fractions.Fraction(str(policy.get('register_weight', 1)))
position_weight = fractions.Fraction(str(policy.get('high_position_weight', 1)))
threshold = fractions.Fraction(str(policy.get('comfortable_max_fret', 9)))
if register_weight < 0 or position_weight < 0 or threshold < 1:
raise ValueError('acoustic voicing requires nonnegative weights and a positive fret threshold')
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in grip), len(grip)) - threshold)
return register_weight * register, position_weight * position
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 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.
# Familiar degree orders on either bass string, then balanced whole-form
# movement, sounding register and hand position. Neither is an absolute ban.
family = generator.chord_families.acoustic(json.loads(key), grip)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
effort = sum(acoustic_preferences(grip, center))
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), effort, 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.
Electric effort includes its melody grip cost and hand-center travel.
Acoustic effort follows all four voices, including a bass-string change.
Acoustic effort balances register/position with travel of all four voices,
including a bass-string change. Compact span and family remain higher tiers.
Fixing the first grip permits an exact repeat-join cost without a beam.
"""
if not layers:
@@ -304,6 +319,10 @@ class Canonical:
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
if grip and self.part == 'acoustic':
register, position = acoustic_preferences(grip, self.policy.get('center', 55))
travel = movement(self.part, previous, grip)
rows[-1]['voicing_cost'] = {'movement': travel, 'register': str(register), 'position': str(position), 'combined': str(travel + register + position)}
for event in span.events:
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
+8 -1
View File
@@ -213,7 +213,14 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
choice_data, choice_settings = inputs['vocabulary'], settings
if role == 'acoustic_accompaniment':
# Voicing weights invalidate the concrete cache, but must not reshuffle
# rhythm patterns. Removing this new field preserves the pre-policy seed.
choice_data = copy.deepcopy(choice_data)
choice_data['roles']['role'].pop('voicing', None)
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}
+84 -1
View File
@@ -2,7 +2,9 @@
import copy
import fractions
import itertools
import json
import pathlib
import shutil
import tempfile
import unittest
@@ -16,6 +18,7 @@ import generator.production
import generator.score
import generator.vocabulary
import tests.solo_fixtures
import tests.test_acoustic_voicing
def options(symbol, part):
return generator.comping.voicings(generator.comping.semantic_key(generator.harmony.parse_symbol(symbol)), part)
@@ -27,6 +30,86 @@ def path_cost(path, costs, part, left=(), right=(), close=False):
return tuple(total)
class CompingVoicings(unittest.TestCase):
def test_lower_progression_can_outweigh_extra_boundary_travel(self):
symbols = ('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')
low = tuple(tests.test_acoustic_voicing.voicing(f) for f in ((7, 8, 7, 8), (7, 8, 6, 8), (5, 7, 5, 6), (5, 5, 4, 6), (3, 5, 3, 4), (3, 3, 2, 4)))
high = tuple(tests.test_acoustic_voicing.voicing(f, 6) for f in ((12, 12, 12, 11), (12, 11, 12, 11), (10, 10, 10, 10), (10, 9, 10, 8), (8, 8, 8, 8), (8, 7, 8, 6)))
left, right = tests.test_acoustic_voicing.voicing((8, 10, 9, 10)), low[0]
layers = tuple(zip(low, high))
costs = []
for symbol, layer in zip(symbols, layers):
self.assertTrue(all(v in options(symbol, 'acoustic') for v in layer))
key = generator.comping.semantic_key(generator.harmony.parse_symbol(symbol))
costs.append({v: generator.comping.local_cost('acoustic', key, v, 53) for v in layer})
def travel(path):
return sum(generator.comping.movement('acoustic', a, b) for a, b in zip((left, *path), (*path, right)))
self.assertEqual((travel(low), travel(high)), (43, 35))
self.assertLess(path_cost(low, costs, 'acoustic', left, right), path_cost(high, costs, 'acoustic', left, right))
path, cost = generator.comping.select_grips(layers, costs, 'acoustic', 'lower-voicings', left=left, right=right)
self.assertEqual(path, low)
self.assertEqual(cost, min(path_cost(p, costs, 'acoustic', left, right) for p in itertools.product(*layers)))
def test_large_round_trip_can_retain_a_high_position(self):
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
costs = {v: generator.comping.local_cost('acoustic', key, v, 53) for v in (low, high)}
self.assertLess(costs[low], costs[high])
path, _ = generator.comping.select_grips(((low, high),), (costs,), 'acoustic', 'travel', left=high, right=high)
self.assertEqual(path, (high,))
def test_acoustic_preferences_are_exact_and_weights_are_operational(self):
high = tests.test_acoustic_voicing.voicing((12, 12, 12, 11), 6)
low = tests.test_acoustic_voicing.voicing((7, 8, 7, 8))
resources = generator.vocabulary.load()
key = generator.comping.semantic_key(generator.harmony.parse_symbol('Em7b5'))
self.assertEqual(generator.comping.acoustic_preferences(high, 53), (fractions.Fraction(39, 4), fractions.Fraction(11, 4)))
self.assertEqual(generator.comping.acoustic_preferences(low, 53)[1], 0)
baseline = generator.comping.local_cost('acoustic', key, high, 53)
for field, expected in (('register_weight', fractions.Fraction(39, 4)), ('high_position_weight', fractions.Fraction(11, 4)), ('comfortable_max_fret', -1)):
policy = dict(resources['roles']['roles']['acoustic_accompaniment']['voicing'])
policy[field] += 1
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': policy}):
cost = generator.comping.local_cost('acoustic', key, high, 53)
self.assertEqual(cost.effort - baseline.effort, expected, field)
self.assertEqual(cost.extended_grips, baseline.extended_grips)
self.assertEqual(cost.family, baseline.family)
with generator.vocabulary.using(resources, 'acoustic_accompaniment', {'voicing': {'register_weight': -1}}), self.assertRaises(ValueError):
generator.comping.local_cost('acoustic', key, high, 53)
def test_production_voicing_policy_changes_cache_not_rhythm_or_electric(self):
source = generator.production.SourceAnalysis.read(tests.solo_fixtures.song(count=6, chords=('Em7b5', 'A7b9', 'Dm7', 'G7', 'Cm7', 'F7')))
def rhythm(result):
return [[(n['onset'], n['duration'], n['written'], n['kind'], len(n['pitches']), tuple((bool(p.get('tie_from')), bool(p.get('tie_to'))) for p in n['pitches'])) for n in generator.score.measure_notes(m)] for m in result['document']['staves'][0]['measures']]
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory) / 'data'
shutil.copytree(pathlib.Path('data/composition'), root / 'composition')
role_path = root / 'composition/roles.json'
data = json.loads(role_path.read_text())
policy = data['roles']['acoustic_accompaniment'].pop('voicing')
role_path.write_text(json.dumps(data))
legacy = generator.production.generate_role(source, 'acoustic_accompaniment', root)
electric = generator.production.generate_role(source, 'electric_accompaniment', root)
data['roles']['acoustic_accompaniment']['voicing'] = policy
role_path.write_text(json.dumps(data))
current = generator.production.generate_role(source, 'acoustic_accompaniment', root)
settings = {'voicing': {'register_weight': 0, 'high_position_weight': 0, 'comfortable_max_fret': 9}}
old = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
warm = generator.production.generate_role(source, 'acoustic_accompaniment', root, settings, pathlib.Path(directory) / 'cache')
self.assertTrue(warm['cache_hit'])
self.assertEqual(old['document'], warm['document'])
self.assertNotEqual(current['document'], old['document'])
self.assertNotEqual(current['trace']['cache_key'], old['trace']['cache_key'])
self.assertNotEqual(legacy['trace']['cache_key'], current['trace']['cache_key'])
self.assertEqual(legacy['document'], current['document'])
self.assertEqual(rhythm(current), rhythm(old))
self.assertEqual(current['details']['accompaniment_rhythm'], old['details']['accompaniment_rhythm'])
self.assertEqual(electric['document'], generator.production.generate_role(source, 'electric_accompaniment', root)['document'])
def test_report_balanced_cost_matches_selected_path(self):
canonical = generator.comping.Canonical(tests.solo_fixtures.song(count=4, chords=('Em7b5', 'A7b9', 'Dm7', 'G7')), 'acoustic', 'report')
rows = [r for r in canonical.report if r['tones']]
for row in rows:
cost = row['voicing_cost']
self.assertEqual(cost['movement'], row['movement'])
self.assertEqual(fractions.Fraction(cost['combined']), sum(fractions.Fraction(cost[k]) for k in ('movement', 'register', 'position')))
self.assertEqual(canonical.path_cost.effort, sum(fractions.Fraction(r['voicing_cost']['combined']) for r in rows))
def test_acoustic_geometry_complete_triads_and_component_fret_limits(self):
for symbol in ('C', 'Cm', 'F#', 'Bbm', 'C7', 'Cm6', 'Em7b5', 'A7b9', 'C7/E', 'C/D'):
with self.subTest(chord=symbol):
@@ -76,7 +159,7 @@ class CompingVoicings(unittest.TestCase):
def test_complete_path_beats_greedy_and_matches_exhaustive_search(self):
for part in ('acoustic', 'electric'):
with self.subTest(part=part):
chords = ('C', 'C', 'Dm7')
chords = ('C', 'Dm7', 'A7b9') if part == 'acoustic' else ('C', 'C', 'Dm7')
layers = tuple(options(s, part) for s in chords)
costs = tuple({v: generator.comping.local_cost(part, generator.comping.semantic_key(generator.harmony.parse_symbol(s)), v, 55) for v in layer} for s, layer in zip(chords, layers))
path, cost = generator.comping.select_grips(layers, costs, part, 'test')