Files
product-vamp/generator/harmony.py
T
khodowany 1d1bd3e871
ci / checks (push) Successful in 43m19s
Music Notation
2026-09-15 01:30:42 -05:00

108 lines
5.5 KiB
Python

from __future__ import annotations
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
# 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': '', 'minor': '-', 'augmented': '+', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'maj7', 'minor-seventh': '-7', 'diminished-seventh': 'dim7', 'augmented-seventh': '+7', 'augmented-major-seventh': '+maj7', 'half-diminished': '-7b5', 'major-minor': '-maj7', 'major-sixth': '6', 'minor-sixth': '-6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus4', 'power': '5', 'pedal': '', 'other': ''}
for family, prefix in (('dominant', ''), ('major', 'maj'), ('minor', '-')):
for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')):
SUFFIXES[f'{family}-{suffix}'] = prefix + number
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'], 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:
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 = NATURAL[root['letter']] + 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((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
return tuple(dict.fromkeys(values))
def symbol(harmony: dict) -> str:
if harmony['kind'] == 'none':
return 'N.C.'
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
if 'kind_text' not in harmony:
for d in harmony.get('degrees', []):
amount = alteration(d['alter'])
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value'])
result = pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + pitch_name(bass)
return result
def parse_symbol(text: str) -> dict:
import re
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
if not match:
raise ValueError(f'invalid chord symbol: {text!r}')
root, suffix, bass = match.groups()
aliases = {'m': 'minor', 'min': 'minor', 'maj': 'major', 'M': 'major', 'm7': 'minor-seventh', 'm6': 'minor-sixth', 'm7b5': 'half-diminished', 'o': 'diminished', 'o7': 'diminished-seventh', 'aug': 'augmented', 'sus': 'suspended-fourth'}
aliases.update({v: k for k, v in SUFFIXES.items() if k not in ('other', 'pedal')})
core = suffix
changes = []
while core not in aliases:
m = re.search(r'(add|no)?([b#]?)(\d+)$', core)
if not m:
raise ValueError(f'unsupported chord spelling: {text!r}')
op, accidental, value = m.groups()
changes.insert(0, {'value': int(value), 'alter': 1 if accidental == '#' else -1 if accidental == 'b' else 0, 'type': 'subtract' if op == 'no' else 'add'})
core = core[:m.start()]
kind = aliases[core]
for d in changes:
if d['type'] == 'add' and d['value'] in KINDS[kind]:
d['type'] = 'alter'
result = {'root': parse_pitch_name(root), 'kind': kind, 'degrees': changes, 'kind_text': suffix}
if bass:
result['bass'] = parse_pitch_name(bass)
degrees(result)
return result