Files
product-vamp/generator/harmony.py
T
khodowany 9fd5abd075
ci / checks (push) Successful in 1h16m10s
Rhythm Selection & Cleanup
2026-09-26 02:50:22 -05:00

172 lines
8.9 KiB
Python

from __future__ import annotations
import re
import generator.model
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
'major': {1: 0, 3: 0, 5: 0}, 'minor': {1: 0, 3: -1, 5: 0},
'augmented': {1: 0, 3: 0, 5: 1}, 'diminished': {1: 0, 3: -1, 5: -1},
'dominant': {1: 0, 3: 0, 5: 0, 7: -1}, 'major-seventh': {1: 0, 3: 0, 5: 0, 7: 0},
'minor-seventh': {1: 0, 3: -1, 5: 0, 7: -1}, 'diminished-seventh': {1: 0, 3: -1, 5: -1, 7: -2},
'augmented-seventh': {1: 0, 3: 0, 5: 1, 7: -1}, 'augmented-major-seventh': {1: 0, 3: 0, 5: 1, 7: 0},
'half-diminished': {1: 0, 3: -1, 5: -1, 7: -1}, 'major-minor': {1: 0, 3: -1, 5: 0, 7: 0},
'major-sixth': {1: 0, 3: 0, 5: 0, 6: 0}, 'minor-sixth': {1: 0, 3: -1, 5: 0, 6: 0},
'suspended-second': {1: 0, 2: 0, 5: 0}, 'suspended-fourth': {1: 0, 4: 0, 5: 0},
'power': {1: 0, 5: 0}, 'pedal': {1: 0}, 'other': {}, 'none': {},
'Neapolitan': {1: 0, 3: 0, 5: 0}, 'Italian': {1: 0, 3: 0, 6: 1},
'French': {1: 0, 3: 0, 4: 1, 6: 1}, 'German': {1: 0, 3: 0, 5: 0, 6: 1},
}
for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('minor', 'minor-seventh')):
for suffix, highest in (('ninth', 9), ('11th', 11), ('13th', 13)):
KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i: 0 for i in range(9, highest + 1, 2)}}
SUFFIXES = {'major': 'M', 'minor': 'm', 'augmented': 'aug', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'M7', 'minor-seventh': 'm7', 'diminished-seventh': 'dim7', 'augmented-seventh': 'aug7', 'augmented-major-seventh': 'augM7', 'half-diminished': 'm7b5', 'major-minor': 'mM7', 'major-sixth': 'M6', 'minor-sixth': 'm6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus', 'power': '5', 'pedal': 'pedal', 'other': ''}
for family, prefix in (('dominant', ''), ('major', 'M'), ('minor', 'm')):
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
SUFFIXES[f'{family}-{suffix}'] = prefix + number
SPELLINGS = {suffix: kind for kind, suffix in SUFFIXES.items() if kind != 'other'}
SPELLINGS.update({'': 'major', 'maj': 'major', 'min': 'minor', '-': 'minor', '+': 'augmented', 'o': 'diminished', 'o7': 'diminished-seventh', '6': 'major-sixth', 'sus4': 'suspended-fourth', 'mM': 'major-minor', 'mMaj7': 'major-minor', 'mmaj7': 'major-minor', '-maj7': 'major-minor', '-7b5': 'half-diminished', '+7': 'augmented-seventh', '+maj7': 'augmented-major-seventh'})
for kind, suffix in SUFFIXES.items():
if suffix.startswith('M'):
SPELLINGS['maj' + suffix[1:]] = kind
if suffix.startswith('m') and not suffix.startswith('mM'):
SPELLINGS['-' + suffix[1:]] = kind
SPELLINGS['min' + suffix[1:]] = kind
EXTENSIONS = {f'mM{number}': ('major-minor', number) for number in (9, 11, 13)}
EXTENSIONS.update({f'{number}sus{suffix}': (kind, number) for number in (7, 9, 11, 13) for suffix, kind in (('', 'suspended-fourth'), ('2', 'suspended-second'), ('4', 'suspended-fourth'))})
def extended_chord(kind: str, highest: int) -> dict:
additions = [{'value': degree, 'alter': -1 if degree == 7 else 0, 'type': 'add'} for degree in range(7, highest + 1, 2) if degree not in KINDS[kind]]
return {'kind': kind, 'degrees': additions}
def degrees(harmony: dict) -> dict[int, int]:
kind = harmony['kind']
if kind not in KINDS:
raise ValueError(f'unsupported harmony kind: {kind}')
result = KINDS[kind].copy()
for change in harmony.get('degrees', []):
degree, alter, operation = change['value'], generator.model.alteration(change['alter']), change['type']
if type(degree) is not int or degree < 1:
raise ValueError('invalid harmony degree')
if operation == 'subtract':
if degree not in result:
raise ValueError(f'cannot subtract absent chord degree {degree}')
del result[degree]
elif operation == 'alter':
if degree not in result:
raise ValueError(f'cannot alter absent chord degree {degree}; use add')
result[degree] = alter
elif operation == 'add':
if degree in result:
raise ValueError(f'duplicate added chord degree {degree}')
result[degree] = alter
else:
raise ValueError(f'unsupported degree operation: {operation}')
if kind != 'none' and not result:
if kind == 'other':
raise ValueError("harmony kind 'other' requires explicit degrees or a recognized chord symbol")
raise ValueError('harmony contains no pitch information')
return result
def degree_semitones(degree: int, alter: int) -> int:
return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter
def pitch_classes(harmony: dict) -> tuple[int, ...]:
if harmony['kind'] == 'none':
return ()
root = harmony['root']
root_pc = generator.model.NATURAL[root['letter']] + generator.model.alteration(root['alter'])
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
if bass := harmony.get('bass'):
values.append((generator.model.NATURAL[bass['letter']] + generator.model.alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def chord_suffix(harmony: dict) -> str:
values = degrees(harmony)
kind = harmony['kind']
if values == KINDS[kind]:
return SUFFIXES.get(kind, kind)
for name, suffix in SUFFIXES.items():
if name != 'other' and values == KINDS[name]:
return suffix
for suffix, (name, highest) in EXTENSIONS.items():
if values == degrees(extended_chord(name, highest)):
return suffix
suffix = SUFFIXES.get(kind, kind)
for change in sorted(harmony.get('degrees', []), key=lambda change: change['value']):
amount = generator.model.alteration(change['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if change['type'] == 'subtract' else ('add' if change['type'] == 'add' and not sign else '') + sign) + str(change['value'])
return suffix
def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
suffix = chord_suffix(harmony)
result = generator.model.pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + generator.model.pitch_name(bass)
return '(' + result + ')' if harmony.get('parenthesized') else result
def parse_symbol(text: str) -> dict:
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/()]*)(?:/([A-Ga-g][#b]*))?', content)
if not match:
raise ValueError(f'invalid chord symbol: {text!r}')
root, suffix, bass = match.groups()
core = next(core for core in sorted(SPELLINGS.keys() | EXTENSIONS.keys(), key=lambda value: (-len(value), value)) if suffix.startswith(core))
result = extended_chord(*EXTENSIONS[core]) if core in EXTENSIONS else {'kind': SPELLINGS[core], 'degrees': []}
remainder = suffix[len(core):]
changed = set()
while remainder:
modifier = re.match(r'(add|no)?(b+|#+)?([1-9][0-9]*)', remainder)
if not modifier or not (modifier[1] or modifier[2]):
raise ValueError(f'unsupported chord spelling: {text!r}')
operation, accidental, value = modifier.groups()
accidental = accidental or ''
degree = int(value)
if degree in changed or operation == 'no' and accidental:
raise ValueError(f'contradictory chord degrees: {text!r}')
changed.add(degree)
operation = 'subtract' if operation == 'no' else 'alter' if degree in degrees(result) and operation != 'add' else 'add'
result['degrees'].append({'value': degree, 'alter': accidental.count('#') - accidental.count('b'), 'type': operation})
remainder = remainder[modifier.end():]
result['root'] = generator.model.parse_pitch_name(root)
for change in result['degrees']:
change['alter'] = generator.model.rational(change['alter'])
if bass:
result['bass'] = generator.model.parse_pitch_name(bass)
if parenthesized:
result['parenthesized'] = True
degrees(result)
return result
def normalize_text(harmony: dict, text: str) -> None:
text = text.strip()
parenthesized = text.startswith('(') and text.endswith(')')
content = text[1:-1] if parenthesized else text
try:
displayed = parse_symbol(generator.model.pitch_name(harmony['root']) + content)
except ValueError:
displayed = parse_symbol(content)
text_only = harmony['kind'] == 'other' and not harmony['degrees']
if displayed['root'] != harmony['root']:
if not text_only or harmony['root'] != generator.model.parse_pitch_name('C'):
raise ValueError(f'contradictory harmony root in {text!r}')
if 'bass' in displayed and 'bass' in harmony and displayed['bass'] != harmony['bass']:
raise ValueError(f'contradictory harmony bass in {text!r}')
if text_only:
harmony.update(displayed)
elif degrees(displayed) not in (degrees(harmony), KINDS[harmony['kind']]):
raise ValueError(f'contradictory harmony text {text!r} and structured chord degrees')
if 'bass' in displayed:
harmony['bass'] = displayed['bass']
if parenthesized:
harmony.update(parenthesized=True, chord_optional=True)