Skip to content

Instantly share code, notes, and snippets.

@arseniiv
Created December 23, 2023 19:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arseniiv/ab128927adc3955867c1cc1bfe6b4cd7 to your computer and use it in GitHub Desktop.
Save arseniiv/ab128927adc3955867c1cc1bfe6b4cd7 to your computer and use it in GitHub Desktop.
Approximate a scale with a periodic scale with specific step pattern
"""
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