Data Labels
ci / checks (push) Failing after 3h12m18s

This commit is contained in:
khodowany
2026-09-20 02:07:26 -05:00
parent 5fc2103810
commit 7bb1ed2b13
24 changed files with 2334 additions and 2563 deletions
+90 -28
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
import re
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name, rational
# Semantic degrees are independent of guitar voicing omissions.
KINDS = {
@@ -20,11 +22,27 @@ for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('min
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', '-')):
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:
@@ -50,7 +68,7 @@ def degrees(harmony: dict) -> dict[int, int]:
raise ValueError(f'unsupported degree operation: {operation}')
if kind != 'none' and not result:
if kind == 'other':
raise ValueError("harmony kind 'other' requires explicit added degrees; use a standard <kind> or add <degree> elements; display text alone does not define pitches")
raise ValueError("harmony kind 'other' requires explicit degrees or a recognized chord symbol")
raise ValueError('harmony contains no pitch information')
return result
@@ -67,43 +85,87 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
values.append((NATURAL[bass['letter']] + 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 = 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 = 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'])
suffix = chord_suffix(harmony)
result = pitch_name(harmony['root']) + suffix
if bass := harmony.get('bass'):
result += '/' + pitch_name(bass)
return result
return '(' + result + ')' if harmony.get('parenthesized') else result
def parse_symbol(text: str) -> dict:
import re
match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip())
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()
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:
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}')
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}
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'] = parse_pitch_name(root)
for change in result['degrees']:
change['alter'] = rational(change['alter'])
if bass:
result['bass'] = 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(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'] != 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)