93 lines
2.6 KiB
Python
93 lines
2.6 KiB
Python
|
|
#!/usr/bin/env python
|
||
|
|
"""
|
||
|
|
Pitch class - a point in harmonic space.
|
||
|
|
|
||
|
|
Represented as an array of exponents on prime dimensions.
|
||
|
|
Example: (0, 1, 0, 0) represents 3/2 (perfect fifth) in CHS_7
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
from fractions import Fraction
|
||
|
|
from math import log
|
||
|
|
from operator import add
|
||
|
|
from typing import Iterator
|
||
|
|
|
||
|
|
DIMS_8 = (2, 3, 5, 7, 11, 13, 17, 19)
|
||
|
|
DIMS_7 = (2, 3, 5, 7, 11, 13, 17)
|
||
|
|
DIMS_5 = (2, 3, 5, 7, 11)
|
||
|
|
DIMS_4 = (2, 3, 5, 7)
|
||
|
|
|
||
|
|
|
||
|
|
class Pitch:
|
||
|
|
def __init__(self, hs_array: tuple[int, ...], dims: tuple[int, ...] | None = None):
|
||
|
|
self.hs_array = hs_array
|
||
|
|
self.dims = dims if dims is not None else DIMS_7
|
||
|
|
|
||
|
|
def __hash__(self) -> int:
|
||
|
|
return hash(self.hs_array)
|
||
|
|
|
||
|
|
def __eq__(self, other: object) -> bool:
|
||
|
|
if not isinstance(other, Pitch):
|
||
|
|
return NotImplemented
|
||
|
|
return self.hs_array == other.hs_array
|
||
|
|
|
||
|
|
def __repr__(self) -> str:
|
||
|
|
return f"Pitch({self.hs_array})"
|
||
|
|
|
||
|
|
def __iter__(self):
|
||
|
|
return iter(self.hs_array)
|
||
|
|
|
||
|
|
def __len__(self) -> int:
|
||
|
|
return len(self.hs_array)
|
||
|
|
|
||
|
|
def __getitem__(self, index: int) -> int:
|
||
|
|
return self.hs_array[index]
|
||
|
|
|
||
|
|
def to_fraction(self) -> Fraction:
|
||
|
|
"""Convert to frequency ratio (e.g., 3/2)."""
|
||
|
|
from math import prod
|
||
|
|
|
||
|
|
return Fraction(
|
||
|
|
prod(pow(self.dims[d], self.hs_array[d]) for d in range(len(self.dims)))
|
||
|
|
)
|
||
|
|
|
||
|
|
def to_cents(self) -> float:
|
||
|
|
"""Convert to cents (relative to 1/1 = 0 cents)."""
|
||
|
|
fr = self.to_fraction()
|
||
|
|
return 1200 * log(float(fr), 2)
|
||
|
|
|
||
|
|
def collapse(self) -> Pitch:
|
||
|
|
"""
|
||
|
|
Collapse pitch so frequency ratio is in [1, 2).
|
||
|
|
|
||
|
|
This removes octave information, useful for pitch classes.
|
||
|
|
"""
|
||
|
|
collapsed = list(self.hs_array)
|
||
|
|
fr = self.to_fraction()
|
||
|
|
|
||
|
|
if fr < 1:
|
||
|
|
while fr < 1:
|
||
|
|
fr *= 2
|
||
|
|
collapsed[0] += 1
|
||
|
|
elif fr >= 2:
|
||
|
|
while fr >= 2:
|
||
|
|
fr /= 2
|
||
|
|
collapsed[0] -= 1
|
||
|
|
|
||
|
|
return Pitch(tuple(collapsed), self.dims)
|
||
|
|
|
||
|
|
def project(self) -> Pitch:
|
||
|
|
"""Project pitch to [1, 2) range - same as collapse."""
|
||
|
|
return self.collapse()
|
||
|
|
|
||
|
|
def transpose(self, trans: Pitch) -> Pitch:
|
||
|
|
"""Transpose by another pitch (add exponents element-wise)."""
|
||
|
|
return Pitch(tuple(map(add, self.hs_array, trans.hs_array)), self.dims)
|
||
|
|
|
||
|
|
def pitch_difference(self, other: Pitch) -> Pitch:
|
||
|
|
"""Calculate the pitch difference (self - other)."""
|
||
|
|
return Pitch(
|
||
|
|
tuple(self.hs_array[d] - other.hs_array[d] for d in range(len(self.dims))),
|
||
|
|
self.dims,
|
||
|
|
)
|