Source code for klotho.tonos.chords.chord

from fractions import Fraction
from typing import Optional, Union, List, Sequence
from ..pitch import Pitch
from ..pitch.pitch_collections import (
    AbsolutePitchCollection,
    EquaveCyclicMixin,
    IntervalType,
    DegreeList,
    RelativePitchCollection,
    RootedPitchCollection,
    PitchCollectionBase,
    _parse_equave,
    _convert_degree,
)
from ..utils.interval_normalization import equave_reduce
import numpy as np


[docs] class Chord(EquaveCyclicMixin, RelativePitchCollection): """ A musical chord with automatic sorting and deduplication. Chord represents a collection of pitch intervals that form a musical chord. It automatically sorts degrees, removes duplicates, and equave-reduces intervals, but unlike Scale, it does NOT enforce the presence of unison. Chords always use equave-cyclic indexing for accessing chord tones in different octaves. Parameters ---------- degrees : list of str, float, int, or Fraction Intervals as ratios (e.g., ``"5/4"``), decimals, or numbers. interval_type : str, optional ``"ratios"`` or ``"cents"``. Default is ``"ratios"``. equave : float, Fraction, int, or str, optional Interval of equivalence. Default is ``"2/1"`` (octave). reference_pitch : Pitch, str, or None, optional If provided, the chord is instanced at this pitch. Examples -------- >>> chord = Chord(["1/1", "5/4", "3/2"]) >>> chord.degrees [Fraction(1, 1), Fraction(5, 4), Fraction(3, 2)] >>> chord[3] Fraction(2, 1) >>> c_major = chord.root("C4") >>> c_major[0] Pitch(C4, 261.63 Hz) """
[docs] def __init__(self, degrees: DegreeList = ["1/1", "5/4", "3/2"], interval_type: str = "ratios", equave: Union[float, Fraction, int, str] = "2/1", reference_pitch: Union[Pitch, str, None] = None): if interval_type not in ["ratios", "cents"]: raise ValueError("interval_type must be 'ratios' or 'cents'") parsed_equave = _parse_equave(equave) processed_degrees = self._process_chord_degrees(degrees, interval_type, parsed_equave) if interval_type == "cents": if isinstance(parsed_equave, Fraction): parsed_equave = 1200.0 if parsed_equave == Fraction(2, 1) else float(parsed_equave) else: if isinstance(parsed_equave, float): parsed_equave = Fraction.from_float(2 ** (parsed_equave / 1200)) self._equave = parsed_equave self._equave_cyclic = True self._degrees = processed_degrees self._interval_type_mode = interval_type self._pitches = None if reference_pitch is not None: self._reference_pitch = Pitch(reference_pitch) if isinstance(reference_pitch, str) else reference_pitch else: self._reference_pitch = None self._intervals = self._compute_chord_intervals()
def _process_chord_degrees(self, degrees: DegreeList, interval_type: str, equave: Union[float, Fraction]) -> List[IntervalType]: if not degrees: return [] converted = [_convert_degree(d) for d in degrees] if interval_type == "cents": converted = [float(d) if isinstance(d, Fraction) else d for d in converted] equave_val = equave if isinstance(equave, float) else 1200.0 reduced = [] for d in converted: while d >= equave_val: d -= equave_val while d < 0: d += equave_val reduced.append(d) unique = [] for d in reduced: if not any(abs(d - existing) < 1e-6 for existing in unique): unique.append(d) unique.sort() else: converted = [d if isinstance(d, Fraction) else Fraction(d) if isinstance(d, int) else d for d in converted] has_float = any(isinstance(d, float) for d in converted) if has_float: equave_val = float(equave) if not isinstance(equave, float) else equave reduced = [] for d in converted: val = float(d) while val < 1: val *= equave_val while val >= equave_val: val /= equave_val reduced.append(val) unique = [] for d in reduced: if not any(abs(d - existing) < 1e-9 for existing in unique): unique.append(d) unique.sort() else: equave_val = equave if isinstance(equave, Fraction) else Fraction(2, 1) reduced = [equave_reduce(d, equave_val) for d in converted] unique = sorted(list(set(reduced))) return unique def _compute_chord_intervals(self) -> List[IntervalType]: if not self._degrees or len(self._degrees) <= 1: return [] result = [] if self._interval_type_mode == "cents": for i in range(1, len(self._degrees)): result.append(self._degrees[i] - self._degrees[i-1]) else: for i in range(1, len(self._degrees)): prev = self._degrees[i-1] if prev == 0 or (isinstance(prev, Fraction) and prev.numerator == 0): result.append(Fraction(0, 1)) else: result.append(self._degrees[i] / prev) return result @property def intervals(self) -> List[IntervalType]: """list : Successive intervals between adjacent chord tones.""" return self._intervals @property def degrees(self) -> List[Union[Pitch, IntervalType]]: """list : Cumulative degrees. Returns Pitch objects when instanced.""" if self.is_instanced: return [self._calculate_pitch(i) for i in range(len(self._degrees))] return list(self._degrees)
[docs] def relative(self) -> 'Chord': """ Return a rootless copy retaining only the interval structure. Returns ------- Chord """ if not self.is_instanced: return self return Chord( list(self._degrees), self._interval_type_mode, self._equave, None )
[docs] def root(self, pitch: Union[Pitch, str]) -> 'Chord': """ Return a copy of this chord rooted at the given pitch. Parameters ---------- pitch : Pitch or str The reference pitch. Returns ------- Chord """ return Chord( list(self._degrees), self._interval_type_mode, self._equave, pitch )
[docs] def normalized(self) -> 'Chord': """ Transpose the chord so the lowest degree is the unison (1/1 or 0 cents). Returns ------- Chord """ if not self._degrees: return Chord([], self._interval_type_mode, self._equave, self._reference_pitch) lowest = self._degrees[0] if self._interval_type_mode == "cents": normalized_degrees = [d - lowest for d in self._degrees] else: normalized_degrees = [d / lowest for d in self._degrees] return Chord(normalized_degrees, self._interval_type_mode, self._equave, self._reference_pitch)
[docs] def voicing(self, index: Union[int, slice, Sequence[int], np.ndarray]) -> 'Voicing': """ Extract a Voicing from this chord by selecting tones with cyclic indexing. Unlike slicing (which returns a ``RelativePitchCollection``), this preserves multi-octave spread by using cyclic degree lookup without equave reduction. Parameters ---------- index : int, slice, or sequence of int Indices into the chord (cyclic). Returns ------- Voicing """ if isinstance(index, slice): size = len(self._degrees) if size == 0: return Voicing([], self._interval_type_mode, self._equave, self._reference_pitch) start, stop, step = index.indices(size) use_cyclic = index.stop is not None and abs(index.stop) > size if use_cyclic: indices = list(range(index.start or 0, index.stop, step)) selected = [self._get_degree_cyclic(i) for i in indices] else: selected = [self._degrees[i] for i in range(start, stop, step)] return Voicing(selected, self._interval_type_mode, self._equave, self._reference_pitch) if hasattr(index, '__iter__') and not isinstance(index, str): selected = [self._get_degree_cyclic(int(i) if not isinstance(i, int) else i) for i in index] return Voicing(selected, self._interval_type_mode, self._equave, self._reference_pitch) if not isinstance(index, int): raise TypeError("Index must be an integer, slice, or sequence of integers") degree = self._get_degree_cyclic(index) return Voicing([degree], self._interval_type_mode, self._equave, self._reference_pitch)
def _get_degree_cyclic(self, index: int) -> IntervalType: equave_shift, wrapped_index = self._get_cyclic_index(index) return self._calculate_degree_with_shift(equave_shift, wrapped_index) def __invert__(self) -> 'Chord': if len(self._degrees) <= 1: return Chord(list(self._degrees), self._interval_type_mode, self._equave, self._reference_pitch) if self._interval_type_mode == "cents": new_degrees = [self._degrees[0]] for i in range(len(self._degrees) - 1, 0, -1): interval_diff = self._degrees[i] - self._degrees[i-1] new_degrees.append(new_degrees[-1] + interval_diff) else: new_degrees = [self._degrees[0]] for i in range(len(self._degrees) - 1, 0, -1): interval_ratio = self._degrees[i] / self._degrees[i-1] new_degrees.append(new_degrees[-1] * interval_ratio) return Chord(new_degrees, self._interval_type_mode, self._equave, self._reference_pitch) def __neg__(self) -> 'Chord': return self.__invert__() def __getitem__(self, index: Union[int, slice, Sequence[int], np.ndarray]) -> Union[Pitch, IntervalType, PitchCollectionBase]: if isinstance(index, slice): return self._getitem_slice_chord(index) if hasattr(index, '__iter__') and not isinstance(index, str): flat_indices = self._flatten_indices(index) return self._getitem_sequence_chord(flat_indices) if not isinstance(index, int): raise TypeError("Index must be an integer, slice, or sequence of integers") return self._getitem_single_chord(index) def _getitem_single_chord(self, index: int) -> Union[Pitch, IntervalType]: equave_shift, wrapped_index = self._get_cyclic_index(index) degree = self._calculate_degree_with_shift(equave_shift, wrapped_index) if self.is_instanced: return self._calculate_pitch(index) return degree def _getitem_slice_chord(self, index: slice) -> PitchCollectionBase: size = len(self._degrees) if size == 0: relative = RelativePitchCollection([], self._interval_type_mode, self._equave, self._reference_pitch) relative._equave_cyclic = False return relative start, stop, step = index.indices(size) use_cyclic = index.stop is not None and abs(index.stop) > size if use_cyclic: indices = list(range(index.start or 0, index.stop, step)) selected_degrees = [self._get_degree_cyclic(i) for i in indices] else: selected_degrees = [self._degrees[i] for i in range(start, stop, step)] if self.is_instanced: rooted = RootedPitchCollection(selected_degrees, self._interval_type_mode, self._equave, self._reference_pitch) rooted._equave_cyclic = False return rooted relative = RelativePitchCollection(selected_degrees, self._interval_type_mode, self._equave, self._reference_pitch) relative._equave_cyclic = False return relative def _getitem_sequence_chord(self, indices: Sequence[int]) -> PitchCollectionBase: selected_degrees = [self._get_degree_cyclic(int(i) if not isinstance(i, int) else i) for i in indices] if self.is_instanced: rooted = RootedPitchCollection(selected_degrees, self._interval_type_mode, self._equave, self._reference_pitch) rooted._equave_cyclic = False return rooted relative = RelativePitchCollection(selected_degrees, self._interval_type_mode, self._equave, None) relative._equave_cyclic = False return relative
[docs] @classmethod def from_collection(cls, collection: PitchCollectionBase, equave: Union[float, Fraction, int, str, None] = None) -> 'Chord': """ Construct a Chord from an existing relative pitch collection. Parameters ---------- collection : PitchCollectionBase A relative pitch collection to convert. equave : float, Fraction, int, str, or None, optional Override equave. Defaults to the collection's equave. Returns ------- Chord Raises ------ ValueError If the collection is absolute (not relative). """ if not collection.is_relative: raise ValueError("Cannot create Chord from absolute collection") target_equave = equave if equave is not None else (collection.equave if collection.equave is not None else Fraction(2, 1)) return cls( list(collection._degrees), collection._interval_type_mode, target_equave, collection._reference_pitch )
[docs] class Voicing(RelativePitchCollection): """ A musical voicing with no equave reduction, removing only exact duplicates. Voicing represents a "frozen" set of intervals that preserves exact pitch relationships without equave cycling. Unlike Chord, it does not reduce intervals to within an equave, allowing voicings that span multiple octaves. Exact duplicates are removed, but the same pitch-class in different octaves is allowed. Parameters ---------- degrees : list of str, float, int, or Fraction Intervals as ratios, decimals, or numbers. interval_type : str, optional ``"ratios"`` or ``"cents"``. Default is ``"ratios"``. equave : float, Fraction, int, or str, optional Interval of equivalence. Default is ``"2/1"``. reference_pitch : Pitch, str, or None, optional If provided, the voicing is instanced at this pitch. Examples -------- >>> voicing = Voicing(["1/2", "1/1", "3/2", "5/2"]) >>> voicing.degrees [Fraction(1, 2), Fraction(1, 1), Fraction(3, 2), Fraction(5, 2)] >>> voicing = Voicing(["1/1", "1/1", "3/2"]) >>> voicing.degrees [Fraction(1, 1), Fraction(3, 2)] """
[docs] def __init__(self, degrees: DegreeList = ["1/1", "5/4", "3/2"], interval_type: str = "ratios", equave: Union[float, Fraction, int, str] = "2/1", reference_pitch: Union[Pitch, str, None] = None): if interval_type not in ["ratios", "cents"]: raise ValueError("interval_type must be 'ratios' or 'cents'") parsed_equave = _parse_equave(equave) processed_degrees = self._process_sonority_degrees(degrees, interval_type, parsed_equave) if interval_type == "cents": if isinstance(parsed_equave, Fraction): parsed_equave = 1200.0 if parsed_equave == Fraction(2, 1) else float(parsed_equave) else: if isinstance(parsed_equave, float): parsed_equave = Fraction.from_float(2 ** (parsed_equave / 1200)) self._equave = parsed_equave self._equave_cyclic = False self._degrees = processed_degrees self._interval_type_mode = interval_type self._pitches = None if reference_pitch is not None: self._reference_pitch = Pitch(reference_pitch) if isinstance(reference_pitch, str) else reference_pitch else: self._reference_pitch = None self._intervals = self._compute_sonority_intervals()
def _process_sonority_degrees(self, degrees: DegreeList, interval_type: str, equave: Union[float, Fraction]) -> List[IntervalType]: if not degrees: return [] converted = [_convert_degree(d) for d in degrees] if interval_type == "cents": converted = [float(d) if isinstance(d, Fraction) else d for d in converted] unique = [] for d in converted: if not any(abs(d - existing) < 1e-6 for existing in unique): unique.append(d) unique.sort() else: converted = [d if isinstance(d, Fraction) else Fraction(d) if isinstance(d, int) else d for d in converted] has_float = any(isinstance(d, float) for d in converted) if has_float: converted = [float(d) for d in converted] unique = [] for d in converted: if not any(abs(d - existing) < 1e-9 for existing in unique): unique.append(d) unique.sort() else: unique = sorted(list(set(converted))) return unique def _compute_sonority_intervals(self) -> List[IntervalType]: if not self._degrees or len(self._degrees) <= 1: return [] result = [] if self._interval_type_mode == "cents": for i in range(1, len(self._degrees)): result.append(self._degrees[i] - self._degrees[i-1]) else: for i in range(1, len(self._degrees)): prev = self._degrees[i-1] if prev == 0 or (isinstance(prev, Fraction) and prev.numerator == 0): result.append(Fraction(0, 1)) else: result.append(self._degrees[i] / prev) return result @property def intervals(self) -> List[IntervalType]: """list : Successive intervals between adjacent voicing tones.""" return self._intervals @property def degrees(self) -> List[Union[Pitch, IntervalType]]: """list : The voicing degrees. Returns Pitch objects when instanced.""" if self.is_instanced: return [self._getitem_single_sonority(i) for i in range(len(self._degrees))] return list(self._degrees)
[docs] def relative(self) -> 'Voicing': """ Return a rootless copy retaining only the interval structure. Returns ------- Voicing """ if not self.is_instanced: return self return Voicing( list(self._degrees), self._interval_type_mode, self._equave, None )
[docs] def root(self, pitch: Union[Pitch, str]) -> 'Voicing': """ Return a copy rooted at the given pitch. Parameters ---------- pitch : Pitch or str The reference pitch. Returns ------- Voicing """ return Voicing( list(self._degrees), self._interval_type_mode, self._equave, pitch )
def __getitem__(self, index: Union[int, slice, Sequence[int], np.ndarray]) -> Union[Pitch, IntervalType, PitchCollectionBase]: if isinstance(index, slice): return self._getitem_slice_sonority(index) if hasattr(index, '__iter__') and not isinstance(index, str): flat_indices = self._flatten_indices(index) return self._getitem_sequence_sonority(flat_indices) if not isinstance(index, int): raise TypeError("Index must be an integer, slice, or sequence of integers") return self._getitem_single_sonority(index) def _getitem_single_sonority(self, index: int) -> Union[Pitch, IntervalType]: degree = self._degrees[index] if self.is_instanced: if self._interval_type_mode == "cents": freq = self._reference_pitch.freq * (2 ** (float(degree) / 1200)) partial = 2 ** (float(degree) / 1200) else: freq = self._reference_pitch.freq * float(degree) partial = degree return Pitch.from_freq(freq, partial) return degree def _getitem_slice_sonority(self, index: slice) -> PitchCollectionBase: selected_degrees = self._degrees[index] if self.is_instanced: rooted = RootedPitchCollection(list(selected_degrees), self._interval_type_mode, self._equave, self._reference_pitch) rooted._equave_cyclic = False return rooted relative = RelativePitchCollection(list(selected_degrees), self._interval_type_mode, self._equave, None) relative._equave_cyclic = False return relative def _getitem_sequence_sonority(self, indices: Sequence[int]) -> PitchCollectionBase: selected_degrees = [self._degrees[int(i) if not isinstance(i, int) else i] for i in indices] if self.is_instanced: rooted = RootedPitchCollection(selected_degrees, self._interval_type_mode, self._equave, self._reference_pitch) rooted._equave_cyclic = False return rooted relative = RelativePitchCollection(selected_degrees, self._interval_type_mode, self._equave, None) relative._equave_cyclic = False return relative
[docs] @classmethod def from_collection(cls, collection: PitchCollectionBase, equave: Union[float, Fraction, int, str, None] = None) -> 'Voicing': """ Construct a Voicing from an existing relative pitch collection. Parameters ---------- collection : PitchCollectionBase A relative pitch collection to convert. equave : float, Fraction, int, str, or None, optional Override equave. Defaults to the collection's equave. Returns ------- Voicing Raises ------ ValueError If the collection is absolute (not relative). """ if not collection.is_relative: raise ValueError("Cannot create Voicing from absolute collection") target_equave = equave if equave is not None else (collection.equave if collection.equave is not None else Fraction(2, 1)) return cls( list(collection._degrees), collection._interval_type_mode, target_equave, collection._reference_pitch )
[docs] class ChordSequence: """ An ordered sequence of Chord or Voicing objects. Provides a container for chord progressions or harmonic sequences. Parameters ---------- chords : list of Chord or Voicing, optional The chord objects in the sequence. Examples -------- >>> chord1 = Chord(["1/1", "5/4", "3/2"]) >>> chord2 = Chord(["1/1", "6/5", "3/2"]) >>> sequence = ChordSequence([chord1, chord2]) >>> len(sequence) 2 """
[docs] def __init__(self, chords: List[Union[Chord, Voicing]] = None): self._chords = chords if chords is not None else []
@property def chords(self) -> List[Union[Chord, Voicing]]: """list : A copy of the chord objects in this sequence.""" return self._chords.copy() def __len__(self) -> int: return len(self._chords) def __getitem__(self, index: Union[int, slice]) -> Union[Chord, Voicing, 'ChordSequence']: if isinstance(index, slice): return ChordSequence(self._chords[index]) return self._chords[index] def __iter__(self): return iter(self._chords) def __repr__(self) -> str: return f"ChordSequence({len(self._chords)} chords)" def __str__(self) -> str: return f"ChordSequence({len(self._chords)} chords)"
InstancedChord = Chord InstancedVoicing = Voicing