Source code for subsequence.harmony

"""Harmony helpers — diatonic chords without the chord-graph engine.

Standalone convenience functions (``diatonic_chords``, ``diatonic_chord``,
``diatonic_chord_sequence``) for building chords from a key and mode, plus the
``ChordPattern`` view.  For generative progressions use ``progressions``.
"""

import logging
import typing

import subsequence.chords
import subsequence.constants.velocity
import subsequence.harmonic_state
import subsequence.intervals
import subsequence.pattern
import subsequence.voicings


logger = logging.getLogger(__name__)


[docs] def diatonic_chords (key: str, mode: str = "ionian") -> typing.List[subsequence.chords.Chord]: """Return the diatonic triads for a key and mode. This is a convenience function for generating chord sequences without using the chord graph engine. The returned ``Chord`` objects can be passed directly to ``p.chord()`` or ``chord.tones()`` inside a pattern. Parameters: key: Note name for the key (e.g., ``"C"``, ``"Eb"``, ``"F#"``). mode: A mode with chord qualities defined (e.g. ``"ionian"``, ``"dorian"``, ``"minor"``). Scales without chord qualities (e.g. ``"hirajoshi"``) will raise ``ValueError`` — use ``p.snap_to_scale()`` for pitch snapping instead. Returns: List of ``Chord`` objects, one per scale degree. Example: ```python from subsequence.harmony import diatonic_chords # All 7 chords in Eb Major chords = diatonic_chords("Eb") # Natural minor chords in A chords = diatonic_chords("A", mode="minor") # Dorian chords in D chords = diatonic_chords("D", mode="dorian") ``` """ if mode not in subsequence.intervals.SCALE_MODE_MAP: available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] if qualities is None: raise ValueError( f"Mode {mode!r} has no chord qualities defined. " "Use register_scale(..., qualities=[...]) to add them, " "or use p.snap_to_scale() for pitch snapping without harmony." ) key_pc = subsequence.chords.key_name_to_pc(key) scale_pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) return [ subsequence.chords.Chord(root_pc=root_pc, quality=quality) for root_pc, quality in zip(scale_pcs, qualities) ]
[docs] def diatonic_chord ( key: str, mode: str = "ionian", degree: int = 0, ) -> subsequence.chords.Chord: """Return a single diatonic chord by scale degree. Convenience wrapper around :func:`diatonic_chords` for the common case where only one chord is needed. Parameters: key: Root note name (e.g. ``"E"``, ``"Bb"``). mode: Scale mode (default ``"ionian"``). degree: Zero-indexed scale degree (0 = I/tonic, 4 = V/dominant, etc.). Raises: ValueError: If *degree* is out of range for the mode. Example: ```python tonic = diatonic_chord("E", "phrygian") # I dominant = diatonic_chord("E", "phrygian", degree=4) # V ``` """ chords = diatonic_chords(key, mode) if degree < 0 or degree >= len(chords): raise ValueError( f"degree {degree} out of range for {mode} (0\u2013{len(chords) - 1})" ) return chords[degree]
[docs] def diatonic_chord_sequence ( key: str, root_midi: int, count: int, mode: str = "ionian" ) -> typing.List[typing.Tuple[subsequence.chords.Chord, int]]: """Return a list of ``(Chord, midi_root)`` tuples stepping diatonically upward. Useful for mapping a continuous value (like altitude or brightness) to a chord, or for building explicit rising/falling progressions without using the chord graph engine. The returned list has ``count`` entries. Each entry contains the ``Chord`` object (quality and pitch class) and the exact MIDI note number to use as that chord's root. Pass both directly to ``p.chord(chord, root=midi_root)``. Counts larger than the number of scale degrees wrap into higher octaves automatically. The sequence always steps upward — reverse the list for a falling sequence. Parameters: key: Note name for the key (e.g., ``"D"``, ``"Eb"``, ``"F#"``). root_midi: MIDI note number for the first chord's root. Must fall on a scale degree of the chosen key and mode. count: Number of ``(Chord, midi_root)`` pairs to generate. mode: One of ``"ionian"`` (or ``"major"``), ``"dorian"``, ``"phrygian"``, ``"lydian"``, ``"mixolydian"``, ``"aeolian"`` (or ``"minor"``), ``"locrian"``, ``"harmonic_minor"``, ``"melodic_minor"``. Returns: List of ``(Chord, int)`` tuples, one per step. Raises: ValueError: If ``key`` or ``mode`` is not recognised, or if ``root_midi`` does not fall on a scale degree of the key. Example: ```python from subsequence.harmony import diatonic_chord_sequence # 7-step D Major ladder starting at D3 (MIDI 50) sequence = diatonic_chord_sequence("D", root_midi=50, count=7) # Map a 0-1 value to a chord (e.g. from ISS altitude) chord, root = sequence[int(ratio * (len(sequence) - 1))] p.chord(chord, root=root, sustain=True) # Falling sequence for chord, root in reversed(diatonic_chord_sequence("A", 57, 7, "minor")): ... ``` """ # Validate mode before looking up the scale key. diatonic_chords() also # validates internally, but diatonic_chord_sequence() is called directly # from user code so we give a clear error here without going deeper. if mode not in subsequence.intervals.SCALE_MODE_MAP: available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) raise ValueError(f"Unknown mode: {mode!r}. Available: {available}") scale_key, _ = subsequence.intervals.SCALE_MODE_MAP[mode] scale_ivs = subsequence.intervals.get_intervals(scale_key) key_pc = subsequence.chords.key_name_to_pc(key) start_pc = root_midi % 12 # Locate the scale degree that matches the starting MIDI note. start_degree: typing.Optional[int] = None for i, iv in enumerate(scale_ivs): if (key_pc + iv) % 12 == start_pc: start_degree = i break if start_degree is None: raise ValueError( f"MIDI note {root_midi} (pitch class {start_pc}) is not a scale " f"degree of {key!r} {mode!r}." ) all_chords = diatonic_chords(key, mode=mode) result: typing.List[typing.Tuple[subsequence.chords.Chord, int]] = [] num_degrees = len(scale_ivs) for i in range(count): degree = (start_degree + i) % num_degrees octave_bump = (start_degree + i) // num_degrees midi_root = ( root_midi + (scale_ivs[degree] - scale_ivs[start_degree]) + 12 * octave_bump ) result.append((all_chords[degree], midi_root)) return result
[docs] class ChordPattern (subsequence.pattern.Pattern): """ A repeating chord pattern that follows the shared harmonic state. """ def __init__ ( self, harmonic_state: subsequence.harmonic_state.HarmonicState, length: int = 4, root_midi: int = 52, velocity: int = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, reschedule_lookahead: int = 1, channel: typing.Optional[int] = None, voice_leading: bool = False ) -> None: """Initialize a chord pattern driven by composition-level harmony. Parameters: harmonic_state: Shared harmonic state that provides chord changes length: Pattern length in beats (default 4) root_midi: Base MIDI note number for the chord root (default 52) velocity: MIDI velocity 0-127 (default 90) reschedule_lookahead: Reschedule lookahead in beats (default 1) channel: MIDI channel (0-15, required) voice_leading: When True, each chord automatically picks the inversion closest to the previous chord for smooth movement """ if channel is None: # Decision path: channel is required so composition choices stay in demo.py. logger.error("ChordPattern requires an explicit MIDI channel") raise ValueError("ChordPattern requires an explicit MIDI channel") super().__init__( channel = channel, length = length, reschedule_lookahead = reschedule_lookahead ) self.harmonic_state = harmonic_state self.key_root_midi = root_midi self.velocity = velocity self.current_chord = self.harmonic_state.get_current_chord() self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = ( subsequence.voicings.VoiceLeadingState() if voice_leading else None ) self._build_current_chord() def _get_chord_root_midi (self, chord: subsequence.chords.Chord) -> int: """ Calculate the MIDI root for a chord relative to the key root. """ return self.harmonic_state.get_chord_root_midi(self.key_root_midi, chord) def _build_current_chord (self) -> None: """ Build the current chord as a sustained voicing. """ self.steps = {} chord_root_midi = self._get_chord_root_midi(self.current_chord) chord_intervals = self.current_chord.intervals() if self._voice_leading_state is not None: pitches = self._voice_leading_state.next(chord_intervals, chord_root_midi) else: pitches = [chord_root_midi + interval for interval in chord_intervals] for pitch in pitches: self.add_note_beats( beat_position = 0.0, pitch = pitch, velocity = self.velocity, duration_beats = float(self.length) )
[docs] def on_reschedule (self) -> None: """ Rebuild the chord pattern from the shared harmonic state. """ # Decision path: chord changes come from harmonic_state.step in the sequencer callback. self.current_chord = self.harmonic_state.get_current_chord() self._build_current_chord()