Source code for klotho.tonos.scales.scale

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