Created
December 23, 2023 19:43
-
-
Save arseniiv/ab128927adc3955867c1cc1bfe6b4cd7 to your computer and use it in GitHub Desktop.
Approximate a scale with a periodic scale with specific step pattern
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
Fitting a scale to have a given pattern of steps between notes. | |
Install `sympy` and `more_itertools`. | |
""" | |
from typing import Sequence | |
from collections import defaultdict | |
from dataclasses import dataclass | |
from fractions import Fraction | |
from math import log2, isclose, sqrt | |
from more_itertools import difference | |
import sympy as sp | |
__all__ = ('scale_from_str', 'match_pattern', 'ScaleFit', 'fit_scale') | |
def _cents(ratio: float | str) -> float: | |
if isinstance(ratio, str): | |
ratio = float(Fraction(ratio)) | |
return 1200. * log2(ratio) | |
def scale_from_str(intervals: str) -> tuple[float, ...]: | |
"""Make a scale of pitches in cents from intervals delimited by spaces.""" | |
return tuple(map(_cents, intervals.split())) | |
def match_pattern(scale: Sequence[float], pattern: str) -> dict[str, float] | None: | |
"""Check if the scale matches a given pattern of steps like 'LMsMs'.""" | |
if len(scale) != len(pattern): | |
raise ValueError('scale and pattern should have the same length') | |
step_sizes = defaultdict(set) | |
for letter, size in zip(pattern, difference(scale), strict=True): | |
step_sizes[letter].add(size) | |
res = dict() | |
for letter, sizes in step_sizes.items(): | |
mean_size = sum(sizes) / len(sizes) | |
if any(not isclose(size, mean_size) for size in sizes): | |
return None | |
res[letter] = mean_size | |
return res | |
@dataclass | |
class ScaleFit: | |
"""Results of a scale fit. RMS error is what’s minimized.""" | |
pattern: str | |
pitches: list[float] | |
step_sizes: dict[str, float] | |
errors: list[float] | |
rms_error: float | |
avg_error: float | |
max_error: float | |
def fit_scale(scale: Sequence[float], pattern: str) -> ScaleFit: | |
"""Fit this scale to a given pattern of steps like 'LMsMs'.""" | |
SHIFT, LAGRANGE = 'SHIFT', 'LAMBDA' | |
assert set(pattern).isdisjoint([SHIFT, LAGRANGE]) | |
vars_ = {letter: sp.Symbol(letter) | |
for letter in set(pattern) | {SHIFT, LAGRANGE}} | |
note_errors = [vars_[SHIFT]] | |
sym_pitches = [sp.sympify(0)] | |
for letter, pitch in zip(pattern, scale): | |
last_pitch = sym_pitches[-1] + vars_[letter] | |
sym_pitches.append(last_pitch) | |
note_errors.append(vars_[SHIFT] + last_pitch - pitch) | |
periodic_constraint = note_errors[-1] - note_errors[0] | |
note_errors.pop() | |
sym_pitches.pop(0) | |
assert len(note_errors) == len(scale) | |
error_sqr_sum = sum(error ** 2 for error in note_errors) | |
cost = error_sqr_sum - vars_[LAGRANGE] * periodic_constraint | |
equations = tuple(cost.diff(x) for x in vars_) | |
steps_sol = sp.solve(equations, vars_) | |
actual_errors = [float(error.subs(steps_sol)) for error in note_errors] | |
rms_error = sqrt(sum(error ** 2 for error in actual_errors)) / len(actual_errors) | |
avg_error = sum(abs(error) for error in actual_errors) / len(actual_errors) | |
max_error = max(abs(error) for error in actual_errors) | |
new_scale = [float(pitch.subs(steps_sol)) for pitch in sym_pitches] | |
assert match_pattern(new_scale, pattern) | |
return ScaleFit( | |
pattern=pattern, | |
pitches=new_scale, | |
step_sizes={letter: steps_sol[vars_[letter]] for letter in set(pattern)}, | |
errors=actual_errors, | |
rms_error=rms_error, | |
avg_error=avg_error, | |
max_error=max_error) | |
def _example() -> None: | |
from pprint import pprint | |
initial_scale = scale_from_str('33/32 9/8 11/9 4/3 11/8 3/2 44/27 27/16 11/6 2/1') | |
initial_pattern = 'sLMLsLMzML' | |
assert match_pattern(initial_scale, initial_pattern) | |
print('Equalizing small steps s == z:') | |
pprint(fit_scale(initial_scale, initial_pattern.replace('z', 's'))) | |
print() | |
print('Equalizing large steps L == M:') | |
pprint(fit_scale(initial_scale, initial_pattern.replace('M', 'L'))) | |
print() | |
print('Equalizing both small and large steps in pairs, ending with a MOS:') | |
new_pattern = initial_pattern.replace('z', 's').replace('M', 'L') | |
fit = fit_scale(initial_scale, new_pattern) | |
pprint(fit) | |
print('\nPrinting scale data usable in Scala and related contexts:') | |
print('\n'.join(map(str, fit.pitches))) | |
if __name__ == '__main__': | |
_example() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment