57 lines
3.2 KiB
Python
57 lines
3.2 KiB
Python
"""Transposable guitar families, derived from the written chord and legal grips."""
|
|
import generator.fretboard
|
|
import generator.harmony
|
|
import generator.model
|
|
import generator.vocabulary
|
|
|
|
def acoustic(chord, grip, *, composition=None):
|
|
composition = composition or generator.vocabulary.current()
|
|
degrees = generator.harmony.degrees(chord)
|
|
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
|
|
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
|
|
guide = next((d for d in (3, 4, 2) if d in degrees), None)
|
|
terminal = next((d for d in (7, 6) if d in degrees), None)
|
|
color = max((d for d in degrees if d > 7), default=None)
|
|
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
|
|
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
|
|
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
|
|
policy = composition.accompaniment('acoustic')['voicing_families']
|
|
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
|
|
upper = tuple(p.midi % 12 for p in grip[1:])
|
|
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
|
|
# The physical/harmonic enumerator remains authoritative: an explicit bass
|
|
# or an unrepresented extension can use another valid compact voicing.
|
|
return {'id': f'{kind}/s{grip[0].string_number}/bass{bass or "written"}/' + '-'.join(str(next((d for d in sorted(classes) if pc == classes[d]), 'bass')) for pc in upper),
|
|
'preferred': preferred, 'bass_degree': bass, 'bass_string': grip[0].string_number,
|
|
'relative_frets': [p.fret - grip[0].fret for p in grip]}
|
|
|
|
def electric(grip):
|
|
"""Classify actual voicing geometry, without inventing chord permissions."""
|
|
pitches = sorted(p.midi for p in grip)
|
|
if len(grip) == 3:
|
|
return 'three-note-shell'
|
|
if len(set(p % 12 for p in pitches)) < len(pitches):
|
|
return 'compact-doubled'
|
|
if len(pitches) == 4:
|
|
lifted = sorted([pitches[0] + 12, *pitches[1:]])
|
|
if lifted[-1] - lifted[0] < 12 and lifted[2] == pitches[0] + 12:
|
|
return 'drop-two'
|
|
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
|
|
|
|
def coverage(chord, options, part, *, composition=None):
|
|
composition = composition or generator.vocabulary.current()
|
|
if part == 'electric':
|
|
return {name: sum(electric(v) == name for v in options) for name in composition.accompaniment('electric')['voicing_families']['rank']}
|
|
families = [acoustic(chord, v, composition=composition) for v in options]
|
|
result = []
|
|
for string in (5, 6):
|
|
for degree in (1, 5):
|
|
found = [f for f in families if f['bass_degree'] == degree and f['bass_string'] == string]
|
|
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)}
|