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