Source code for klotho.tonos.pitch.contour

import numpy as np
from typing import Union, List, Sequence

[docs] class Contour: """ A pitch contour represented as a 1-D numpy array of integers. Contour provides element-wise arithmetic operations and an outer sum method for Cartesian sum (chord multiplication). All operations return new Contour instances (immutable design). A contour represents the shape of pitch motion as a sequence of scale degree indices. It can be used to index into pitch collections (Scale, Chord, etc.) to retrieve actual pitches. Parameters ---------- values : list of int, Sequence, numpy.ndarray, or Contour Sequence of integers representing the contour. Examples -------- >>> contour = Contour([6, 2, 4, 0]) >>> contour + 1 Contour([7, 3, 5, 1]) >>> contour * 2 Contour([12, 4, 8, 0]) >>> contour + Contour([1, 2, 3, 4]) Contour([7, 4, 7, 4]) >>> Contour.outer([6, 2, 4, 0], [0, 3, 1]) Contour([6, 9, 7, 2, 5, 3, 4, 7, 5, 0, 3, 1]) """
[docs] def __init__(self, values: Union[List[int], Sequence[int], np.ndarray, 'Contour']): if isinstance(values, Contour): self._values = values._values.copy() else: self._values = np.asarray(values, dtype=int).flatten()
[docs] @classmethod def outer(cls, a: Union['Contour', Sequence[int], np.ndarray], b: Union['Contour', Sequence[int], np.ndarray]) -> 'Contour': """ Compute the outer sum (Cartesian sum) of two sequences. Also known as "chord multiplication" in set-theoretic music theory (Boulez). For each element in *a*, adds all elements of *b*, returning a flattened result. Parameters ---------- a : Contour, list, or numpy.ndarray First sequence. b : Contour, list, or numpy.ndarray Second sequence. Returns ------- Contour New Contour containing the flattened outer sum. Examples -------- >>> Contour.outer([0, 1, 2], [4, 0]) Contour([4, 0, 5, 1, 6, 2]) >>> Contour.outer(Contour([6, 2, 4, 0]), [0, 3, 1]) Contour([6, 9, 7, 2, 5, 3, 4, 7, 5, 0, 3, 1]) """ a_arr = a._values if isinstance(a, cls) else np.asarray(a, dtype=int) b_arr = b._values if isinstance(b, cls) else np.asarray(b, dtype=int) return cls(np.add.outer(a_arr, b_arr).flatten())
[docs] @classmethod def concat(cls, contours: List['Contour']) -> 'Contour': """ Concatenate multiple contours into one flat contour. Parameters ---------- contours : list of Contour Contour instances to concatenate. Returns ------- Contour New Contour containing all values in sequence. Examples -------- >>> c1 = Contour([0, 2, 4]) >>> c2 = Contour([1, 3, 5]) >>> Contour.concat([c1, c2]) Contour([0, 2, 4, 1, 3, 5]) """ return cls([v for c in contours for v in c])
@property def values(self) -> List[int]: """list of int : The contour values as a Python list.""" return self._values.tolist() def __len__(self) -> int: return len(self._values) def __getitem__(self, index: Union[int, slice, Sequence[int], np.ndarray]) -> Union[int, 'Contour']: result = self._values[index] if isinstance(result, np.ndarray): return Contour(result) return int(result) def __iter__(self): return iter(self._values) def __add__(self, other: Union[int, float, 'Contour', Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, Contour): return Contour(self._values + other._values) elif isinstance(other, (int, float, np.integer, np.floating)): return Contour(self._values + int(other)) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(self._values + np.asarray(other, dtype=int)) return NotImplemented def __radd__(self, other: Union[int, float, Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, (int, float, np.integer, np.floating)): return Contour(int(other) + self._values) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(np.asarray(other, dtype=int) + self._values) return NotImplemented def __sub__(self, other: Union[int, float, 'Contour', Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, Contour): return Contour(self._values - other._values) elif isinstance(other, (int, float, np.integer, np.floating)): return Contour(self._values - int(other)) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(self._values - np.asarray(other, dtype=int)) return NotImplemented def __rsub__(self, other: Union[int, float, Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, (int, float, np.integer, np.floating)): return Contour(int(other) - self._values) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(np.asarray(other, dtype=int) - self._values) return NotImplemented def __mul__(self, other: Union[int, float, 'Contour', Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, Contour): return Contour(self._values * other._values) elif isinstance(other, (int, float, np.integer, np.floating)): return Contour((self._values * other).astype(int)) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(self._values * np.asarray(other, dtype=int)) return NotImplemented def __rmul__(self, other: Union[int, float, Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, (int, float, np.integer, np.floating)): return Contour((other * self._values).astype(int)) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(np.asarray(other, dtype=int) * self._values) return NotImplemented def __truediv__(self, other: Union[int, float, 'Contour', Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, Contour): if np.any(other._values == 0): raise ValueError("Cannot divide by zero") return Contour((self._values / other._values).astype(int)) elif isinstance(other, (int, float, np.integer, np.floating)): if other == 0: raise ValueError("Cannot divide by zero") return Contour((self._values / other).astype(int)) elif isinstance(other, (list, tuple, np.ndarray)): other_arr = np.asarray(other, dtype=int) if np.any(other_arr == 0): raise ValueError("Cannot divide by zero") return Contour((self._values / other_arr).astype(int)) return NotImplemented def __rtruediv__(self, other: Union[int, float, Sequence[int], np.ndarray]) -> 'Contour': if np.any(self._values == 0): raise ValueError("Cannot divide by zero") if isinstance(other, (int, float, np.integer, np.floating)): return Contour((other / self._values).astype(int)) elif isinstance(other, (list, tuple, np.ndarray)): return Contour((np.asarray(other, dtype=int) / self._values).astype(int)) return NotImplemented def __floordiv__(self, other: Union[int, float, 'Contour', Sequence[int], np.ndarray]) -> 'Contour': if isinstance(other, Contour): if np.any(other._values == 0): raise ValueError("Cannot floor divide by zero") return Contour(self._values // other._values) elif isinstance(other, (int, float, np.integer, np.floating)): if other == 0: raise ValueError("Cannot floor divide by zero") return Contour(self._values // int(other)) elif isinstance(other, (list, tuple, np.ndarray)): other_arr = np.asarray(other, dtype=int) if np.any(other_arr == 0): raise ValueError("Cannot floor divide by zero") return Contour(self._values // other_arr) return NotImplemented def __rfloordiv__(self, other: Union[int, float, Sequence[int], np.ndarray]) -> 'Contour': if np.any(self._values == 0): raise ValueError("Cannot floor divide by zero") if isinstance(other, (int, float, np.integer, np.floating)): return Contour(int(other) // self._values) elif isinstance(other, (list, tuple, np.ndarray)): return Contour(np.asarray(other, dtype=int) // self._values) return NotImplemented
[docs] def __mod__(self, other: Union[int, float]) -> 'Contour': """ Musical modulo operation that preserves sign. Unlike standard Python mod which always returns non-negative values, this preserves the sign of the input to maintain musical semantics where negative indices indicate pitches below the root. Examples -------- >>> Contour([13, -13, -1, -12, 0]) % 12 Contour([1, -1, -1, 0, 0]) """ if isinstance(other, (int, float, np.integer, np.floating)) and other != 0: mod_val = int(other) return Contour(np.sign(self._values) * (np.abs(self._values) % mod_val)) raise ValueError("Cannot modulo by zero or non-numeric value")
def __neg__(self) -> 'Contour': return Contour(-self._values) def __abs__(self) -> 'Contour': return Contour(np.abs(self._values)) def __eq__(self, other) -> bool: if isinstance(other, Contour): return np.array_equal(self._values, other._values) elif isinstance(other, (list, tuple, np.ndarray)): return np.array_equal(self._values, np.asarray(other)) return False def __repr__(self) -> str: return self.__str__() def __str__(self) -> str: values_str = ', '.join(str(v) for v in self._values) return f"Contour([{values_str}])"
[docs] def copy(self) -> 'Contour': """ Return a copy of this contour. Returns ------- Contour """ return Contour(self._values.copy())
[docs] def invert(self, axis: int = 0) -> 'Contour': """ Return an inverted copy reflected around *axis*. Uses the formula ``2 * axis - value``. With the default ``axis=0`` this is equivalent to negation. Parameters ---------- axis : int, optional The pitch value to reflect around. Default is 0. Returns ------- Contour New Contour with inverted values. """ return Contour(2 * axis - self._values)
[docs] def retrograde(self) -> 'Contour': """ Return the retrograde (reversed order) of this contour. Returns ------- Contour """ return Contour(self._values[::-1])
[docs] def rotate(self, n: int) -> 'Contour': """ Circular shift of contour values. Parameters ---------- n : int Number of positions to shift. Positive shifts right, negative shifts left. Returns ------- Contour New Contour with rotated values. Examples -------- >>> Contour([0, 1, 2, 3]).rotate(1) Contour([3, 0, 1, 2]) >>> Contour([0, 1, 2, 3]).rotate(-1) Contour([1, 2, 3, 0]) """ return Contour(np.roll(self._values, n))
[docs] def normalized(self, start: int = 0) -> 'Contour': """ Shift contour so the minimum value equals *start*. Parameters ---------- start : int, optional The value that the minimum should equal. Default is 0. Returns ------- Contour New Contour shifted so that its minimum equals *start*. Examples -------- >>> Contour([5, 3, 7, 4]).normalized() Contour([2, 0, 4, 1]) >>> Contour([5, 3, 7, 4]).normalized(1) Contour([3, 1, 5, 2]) """ return Contour(self._values - np.min(self._values) + start)
[docs] def zeroed(self, start: int = 0) -> 'Contour': """ Shift contour so the first value equals *start*. Parameters ---------- start : int, optional The value that the first element should equal. Default is 0. Returns ------- Contour New Contour with values shifted relative to the first element. Examples -------- >>> Contour([5, 3, 7, 4]).zeroed() Contour([0, -2, 2, -1]) >>> Contour([5, 3, 7, 4]).zeroed(1) Contour([1, -1, 3, 0]) """ return Contour(self._values - self._values[0] + start)
[docs] def to_numpy(self) -> np.ndarray: """ Return a copy of the internal numpy array. Returns ------- numpy.ndarray """ return self._values.copy()