from fractions import Fraction
from typing import Optional, Union, List, Sequence
from ..pitch import Pitch
from ..pitch.pitch_collections import (
EquaveCyclicMixin,
IntervalType,
DegreeList,
RelativePitchCollection,
RootedPitchCollection,
PitchCollectionBase,
_parse_equave,
_convert_degree,
)
from ..utils.interval_normalization import equave_reduce
import numpy as np
[docs]
class Scale(EquaveCyclicMixin, RelativePitchCollection):
"""
A musical scale with automatic sorting, deduplication, and equave reduction.
Scale represents a collection of pitch intervals that form a musical scale.
It automatically sorts degrees, removes duplicates, equave-reduces intervals,
and ensures the unison (1/1 or 0 cents) is present. Scales always use
equave-cyclic indexing for accessing pitches 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 scale is instanced at this pitch.
Examples
--------
>>> scale = Scale(["1/1", "9/8", "5/4", "4/3", "3/2", "5/3", "15/8"])
>>> scale.degrees
[Fraction(1, 1), Fraction(9, 8), Fraction(5, 4), Fraction(4, 3), Fraction(3, 2), Fraction(5, 3), Fraction(15, 8)]
>>> scale[7]
Fraction(2, 1)
>>> c_major = scale.root("C4")
>>> c_major[0]
Pitch(C4, 261.63 Hz)
"""
[docs]
def __init__(self, degrees: DegreeList = ["1/1", "9/8", "5/4", "4/3", "3/2", "5/3", "15/8"],
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_scale_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
self._mode_cache = {}
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_scale_intervals()
def _process_scale_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()
if not unique or abs(unique[0]) >= 1e-6:
unique.insert(0, 0.0)
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()
if not unique or abs(unique[0] - 1.0) >= 1e-9:
unique.insert(0, 1.0)
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)))
if not unique or unique[0] != Fraction(1, 1):
unique.insert(0, Fraction(1, 1))
return unique
def _compute_scale_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])
final = self._equave - self._degrees[-1]
result.append(final)
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)
final = self._equave / self._degrees[-1]
result.append(final)
return result
@property
def intervals(self) -> List[IntervalType]:
"""list : Successive step intervals including the closing interval to the equave."""
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) -> 'Scale':
"""
Return a rootless copy retaining only the interval structure.
Returns
-------
Scale
"""
if not self.is_instanced:
return self
return Scale(
list(self._degrees),
self._interval_type_mode,
self._equave,
None
)
[docs]
def root(self, pitch: Union[Pitch, str]) -> 'Scale':
"""
Return a copy of this scale rooted at the given pitch.
Parameters
----------
pitch : Pitch or str
The reference pitch.
Returns
-------
Scale
"""
return Scale(
list(self._degrees),
self._interval_type_mode,
self._equave,
pitch
)
[docs]
def mode(self, mode_number: int) -> 'Scale':
"""
Return a modal rotation of this scale.
Parameters
----------
mode_number : int
Zero-based mode index. ``0`` returns the original scale,
``1`` starts from the second degree, etc.
Returns
-------
Scale
A new Scale whose degrees are rotated to begin on the
specified degree of the original.
"""
if mode_number in self._mode_cache:
cached = self._mode_cache[mode_number]
if self._reference_pitch:
return cached.root(self._reference_pitch)
return cached
if mode_number == 0:
return self
size = len(self._degrees)
if size == 0:
return Scale([], self._interval_type_mode, self._equave, self._reference_pitch)
start_index = mode_number % size
if start_index < 0:
start_index += size
first_degree = self._degrees[start_index]
modal_degrees = []
if self._interval_type_mode == "cents":
for i in range(size):
current_idx = (start_index + i) % size
if i == 0:
modal_degrees.append(0.0)
else:
interval = self._degrees[current_idx] - first_degree
if current_idx < start_index:
equave_value = self._equave if isinstance(self._equave, float) else 1200.0
interval += equave_value
modal_degrees.append(interval)
else:
for i in range(size):
current_idx = (start_index + i) % size
if i == 0:
modal_degrees.append(Fraction(1, 1))
else:
interval = self._degrees[current_idx] / first_degree
if current_idx < start_index:
equave_value = self._equave if isinstance(self._equave, Fraction) else Fraction.from_float(2 ** (self._equave / 1200))
interval *= equave_value
modal_degrees.append(interval)
result = Scale(modal_degrees, self._interval_type_mode, self._equave, None)
self._mode_cache[mode_number] = result
if self._reference_pitch:
return result.root(self._reference_pitch)
return result
def __invert__(self) -> 'Scale':
if self._interval_type_mode == "cents":
inverted = [0.0 if abs(d) < 1e-6 else self._equave - d for d in self._degrees]
else:
inverted = [Fraction(1, 1) if d == Fraction(1, 1) else Fraction(d.denominator * 2, d.numerator) for d in self._degrees]
return Scale(sorted(inverted), self._interval_type_mode, self._equave, self._reference_pitch)
def __neg__(self) -> 'Scale':
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_scale(index)
if hasattr(index, '__iter__') and not isinstance(index, str):
flat_indices = self._flatten_indices(index)
return self._getitem_sequence_scale(flat_indices)
if not isinstance(index, int):
raise TypeError("Index must be an integer, slice, or sequence of integers")
return self._getitem_single_scale(index)
def _getitem_single_scale(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_scale(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))
else:
indices = list(range(start, stop, step))
selected_degrees = [
self._calculate_degree_with_shift(*self._get_cyclic_index(i))
if use_cyclic
else self._degrees[i]
for i in (indices if use_cyclic else 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_scale(self, indices: Sequence[int]) -> PitchCollectionBase:
selected_degrees = []
for i in indices:
idx = int(i) if not isinstance(i, int) else i
equave_shift, wrapped_index = self._get_cyclic_index(idx)
degree = self._calculate_degree_with_shift(equave_shift, wrapped_index)
selected_degrees.append(degree)
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 n_edo(cls, n: int = 12, equave: float = 1200.0, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
"""
Construct an equal-division-of-the-octave (EDO) scale.
Parameters
----------
n : int, optional
Number of equal divisions. Default is 12.
equave : float, optional
Size of the equave in cents. Default is 1200.0.
reference_pitch : Pitch, str, or None, optional
Optional root pitch.
Returns
-------
Scale
"""
step_size = equave / n
degrees = [i * step_size for i in range(n)]
return cls(degrees, 'cents', equave, reference_pitch)
[docs]
@classmethod
def ionian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls(["1/1", "9/8", "5/4", "4/3", "3/2", "5/3", "15/8"], reference_pitch=reference_pitch)
[docs]
@classmethod
def dorian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(1).root(reference_pitch) if reference_pitch else cls.ionian().mode(1)
[docs]
@classmethod
def phrygian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(2).root(reference_pitch) if reference_pitch else cls.ionian().mode(2)
[docs]
@classmethod
def lydian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(3).root(reference_pitch) if reference_pitch else cls.ionian().mode(3)
[docs]
@classmethod
def mixolydian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(4).root(reference_pitch) if reference_pitch else cls.ionian().mode(4)
[docs]
@classmethod
def aeolian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(5).root(reference_pitch) if reference_pitch else cls.ionian().mode(5)
[docs]
@classmethod
def locrian(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls.ionian().mode(6).root(reference_pitch) if reference_pitch else cls.ionian().mode(6)
[docs]
@classmethod
def bagpipes(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls(
['1/1', '9/8', '5/4', '4/3', '27/20', '3/2', '5/3', '7/4', '16/9', '9/5'],
reference_pitch=reference_pitch
)
[docs]
@classmethod
def janus(cls, reference_pitch: Union[Pitch, str, None] = None) -> 'Scale':
return cls(
['1/1', '33/32', '9/8', '7/6', '5/4', '21/16', '11/8', '3/2', '99/64', '5/3', '7/4', '15/8'],
reference_pitch=reference_pitch
)
InstancedScale = Scale