Source code for subsequence.chords

"""Chord definitions and pitch class utilities.

This module provides chord quality definitions, pitch class mappings, and the ``Chord`` class
for representing and manipulating chords.

Module-level constants:

- ``NOTE_NAME_TO_PC``: Maps note names (e.g., ``"C"``, ``"F#"``, ``"Bb"``) to pitch classes (0-11)
- ``PC_TO_NOTE_NAME``: Maps pitch classes to note names
- ``CHORD_INTERVALS``: Maps chord quality names to interval lists (semitones from root)
- ``CHORD_SUFFIX``: Maps chord quality names to human-readable suffixes (e.g., ``"m"``, ``"7"``)

Module-level helpers:

- ``key_name_to_pc(key_name)``: Validate a key name and return its pitch class (0–11).
  Raises ``ValueError`` for unknown names. This is the canonical key validation function
  used by ``harmony.py``, ``pattern_builder.snap_to_scale()``, and ``chord_graphs.validate_key_name()``.

Chord qualities: ``"major"``, ``"minor"``, ``"diminished"``, ``"augmented"``, ``"dominant_7th"``,
``"major_7th"``, ``"minor_7th"``, ``"half_diminished_7th"``, ``"sus2"``, ``"sus4"``
"""

import dataclasses
import typing

import subsequence.voicings


NOTE_NAME_TO_PC: typing.Dict[str, int] = {
	"C": 0,
	"C#": 1,
	"Db": 1,
	"D": 2,
	"D#": 3,
	"Eb": 3,
	"E": 4,
	"F": 5,
	"F#": 6,
	"Gb": 6,
	"G": 7,
	"G#": 8,
	"Ab": 8,
	"A": 9,
	"A#": 10,
	"Bb": 10,
	"B": 11,
}

PC_TO_NOTE_NAME: typing.List[str] = [
	"C",
	"C#",
	"D",
	"D#",
	"E",
	"F",
	"F#",
	"G",
	"G#",
	"A",
	"A#",
	"B",
]


[docs] def key_name_to_pc (key_name: str) -> int: """Validate a key name and return its pitch class (0–11). Parameters: key_name: Note name (e.g. ``"C"``, ``"F#"``, ``"Bb"``). Returns: Pitch class integer (0–11). Raises: ValueError: If the key name is not recognised. Example: ```python key_name_to_pc("C") # → 0 key_name_to_pc("F#") # → 6 key_name_to_pc("Bb") # → 10 ``` """ if key_name not in NOTE_NAME_TO_PC: raise ValueError( f"Unknown key name: {key_name!r}. Expected e.g. 'C', 'F#', 'Bb'." ) return NOTE_NAME_TO_PC[key_name]
CHORD_INTERVALS: typing.Dict[str, typing.List[int]] = { "major": [0, 4, 7], "minor": [0, 3, 7], "diminished": [0, 3, 6], "augmented": [0, 4, 8], "dominant_7th": [0, 4, 7, 10], "major_7th": [0, 4, 7, 11], "minor_7th": [0, 3, 7, 10], "half_diminished_7th": [0, 3, 6, 10], "diminished_7th": [0, 3, 6, 9], "sus2": [0, 2, 7], "sus4": [0, 5, 7], } CHORD_SUFFIX: typing.Dict[str, str] = { "major": "", "minor": "m", "diminished": "dim", "augmented": "+", "dominant_7th": "7", "major_7th": "maj7", "minor_7th": "m7", "half_diminished_7th": "m7b5", "diminished_7th": "dim7", "sus2": "sus2", "sus4": "sus4", } @dataclasses.dataclass(frozen=True)
[docs] class Chord: """ Represents a chord as a root pitch class and quality. """ root_pc: int quality: str
[docs] def intervals (self) -> typing.List[int]: """ Return the chord intervals for this chord quality. """ if self.quality not in CHORD_INTERVALS: raise ValueError(f"Unknown chord quality: {self.quality}") return CHORD_INTERVALS[self.quality]
[docs] def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: """Return MIDI note numbers for chord tones starting from a root. Finds the MIDI note corresponding to the chord's root pitch class that is closest to the provided ``root`` argument. Parameters: root: MIDI note number (e.g., 60 = middle C) to center the chord around. inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). Wraps around for values >= number of notes. count: Number of notes to return. When set, the chord intervals cycle into higher octaves until ``count`` notes are produced. When ``None`` (default), returns the natural chord tones. Returns: List of MIDI note numbers for chord tones Example: ```python chord = Chord(root_pc=0, quality="major") # C major chord.tones(root=60) # [60, 64, 67] - root position around C4 chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root ``` """ # Find the MIDI note for self.root_pc that is closest to the requested root. # This handles octaves automatically. offset = (self.root_pc - root) % 12 if offset > 6: offset -= 12 effective_root = root + offset intervals = self.intervals() if inversion != 0: intervals = subsequence.voicings.invert_chord(intervals, inversion) if count is not None: n = len(intervals) return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] return [effective_root + interval for interval in intervals]
[docs] def root_note (self, root_midi: int) -> int: """ Return the MIDI note number for the chord root nearest to *root_midi*. This is equivalent to ``self.tones(root_midi)[0]`` but makes intent explicit when you only need the single root pitch. Parameters: root_midi: Reference MIDI note number used to find the closest octave of this chord's root pitch class. Returns: MIDI note number of the chord root. Example: ```python chord = Chord(root_pc=4, quality="major") # E major chord.root_note(60) # → 64 (E4, nearest to C4) chord.root_note(69) # → 64 (E4, nearest to A4) ``` """ return self.tones(root_midi)[0]
[docs] def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: """ Return the chord root shifted by a number of octaves. Commonly used to produce a bass register note one or two octaves below the chord voicing. Parameters: root_midi: Reference MIDI note number (passed to :meth:`root_note`). octave_offset: Octaves to shift; negative moves down (default ``-1``). Returns: MIDI note number of the chord root in the target register. Example: ```python chord = Chord(root_pc=4, quality="major") # E major chord.bass_note(64) # → 52 (E3, one octave down from E4) chord.bass_note(64, -2) # → 40 (E2, two octaves down) ``` """ return self.root_note(root_midi) + (12 * octave_offset)
[docs] def name (self) -> str: """ Return a human-friendly chord name. A registered quality without a suffix prints as ``root(quality)`` (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. """ root_name = PC_TO_NOTE_NAME[self.root_pc % 12] if self.quality not in CHORD_SUFFIX: return f"{root_name}({self.quality})" return f"{root_name}{CHORD_SUFFIX[self.quality]}"
# Quality suffixes accepted by parse_chord(), including common alternates. The # canonical suffixes (the values of CHORD_SUFFIX) all round-trip, so a chord's # own name() always re-parses to the same chord. _SUFFIX_TO_QUALITY: typing.Dict[str, str] = { "": "major", "maj": "major", "M": "major", "m": "minor", "min": "minor", "-": "minor", "dim": "diminished", "o": "diminished", "°": "diminished", "aug": "augmented", "+": "augmented", "7": "dominant_7th", "dom7": "dominant_7th", "maj7": "major_7th", "M7": "major_7th", "m7": "minor_7th", "min7": "minor_7th", "-7": "minor_7th", "m7b5": "half_diminished_7th", "ø": "half_diminished_7th", "ø7": "half_diminished_7th", "halfdim": "half_diminished_7th", "dim7": "diminished_7th", "o7": "diminished_7th", "°7": "diminished_7th", "sus2": "sus2", "sus4": "sus4", "sus": "sus4", } # Snapshots of the shipped tables, taken before any register_chord_quality() # call: built-in qualities and suffixes can never be overwritten. _BUILTIN_QUALITY_NAMES: typing.FrozenSet[str] = frozenset(CHORD_INTERVALS) _BUILTIN_SUFFIXES: typing.FrozenSet[str] = frozenset(_SUFFIX_TO_QUALITY)
[docs] def register_chord_quality ( name: str, intervals: typing.List[int], suffix: typing.Optional[str] = None, ) -> None: """Register a custom chord quality for use everywhere chords are used. The counterpart to :func:`subsequence.intervals.register_scale` — it opens the quality table so quartal stacks, clusters, and extended chords become first-class symbolic chords: they work in progressions, graphs, voice leading, and ``describe()`` output. Built-in qualities (e.g. ``"minor"``) cannot be overwritten. Custom names may be re-registered freely — live reload re-runs registration on every save, so this must not raise. Parameters: name: Quality name (used as ``Chord(root_pc, quality=name)``). intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th). Must start with 0, ascend strictly, and stay within 0–24 (extensions reach past the octave). suffix: Optional chord-name suffix. When given, ``parse_chord()`` accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so ``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")`` makes ``"Am9"`` parse from then on. Must not collide with a built-in suffix. Example: ```python import subsequence subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4") subsequence.parse_chord("Dq4") # → Chord(root_pc=2, quality="quartal") ``` """ if name in _BUILTIN_QUALITY_NAMES: raise ValueError( f"Cannot overwrite built-in chord quality '{name}'. " "Choose a different name for your custom quality." ) if not intervals: raise ValueError("intervals must not be empty") if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals): raise ValueError("intervals must be whole numbers (semitone offsets)") if intervals[0] != 0: raise ValueError("intervals must start with 0 (the root)") if any(b <= a for a, b in zip(intervals, intervals[1:])): raise ValueError("intervals must be strictly ascending") if any(i < 0 or i > 24 for i in intervals): raise ValueError("intervals must contain values between 0 and 24") if suffix is not None: if suffix in _BUILTIN_SUFFIXES: raise ValueError( f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. " "Choose a different suffix for your custom quality." ) if not suffix or suffix[0] in "ABCDEFG#b0123456789": raise ValueError( f"Suffix {suffix!r} would be ambiguous in a chord name — " "it must not be empty or start with a note letter, accidental, or digit" ) # Re-registration: drop any suffix this quality registered previously, so # renaming a suffix on live reload does not leave a stale alias behind. for old_suffix in [s for s, q in _SUFFIX_TO_QUALITY.items() if q == name and s not in _BUILTIN_SUFFIXES]: del _SUFFIX_TO_QUALITY[old_suffix] CHORD_INTERVALS[name] = list(intervals) if suffix is not None: CHORD_SUFFIX[name] = suffix _SUFFIX_TO_QUALITY[suffix] = name else: CHORD_SUFFIX.pop(name, None)
[docs] def parse_chord (name: str) -> Chord: """Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`. The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished, ``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor 7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``. A few common alternates (``min``, ``-``, ``M7``, …) are accepted too. Raises ``ValueError`` for anything it can't read, so a typo surfaces at the call site rather than as a silently wrong chord. Example: ```python parse_chord("Cm7") # → Chord(root_pc=0, quality="minor_7th") parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th") parse_chord("F#") # → Chord(root_pc=6, quality="major") ``` """ stripped = name.strip() if not stripped or stripped[0] not in "ABCDEFG": raise ValueError(f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'") split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1 root_name = stripped[:split] suffix = stripped[split:] if root_name not in NOTE_NAME_TO_PC: raise ValueError(f"Cannot parse chord name {name!r} — unknown root {root_name!r}") if suffix not in _SUFFIX_TO_QUALITY: known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key) raise ValueError(f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}") return Chord(root_pc=NOTE_NAME_TO_PC[root_name], quality=_SUFFIX_TO_QUALITY[suffix])