Skip to content
This repository was archived by the owner on Dec 28, 2025. It is now read-only.

Commit 513050e

Browse files
committed
Scale builder WIP
1 parent cffa9dc commit 513050e

8 files changed

Lines changed: 312 additions & 1 deletion

File tree

12.9 KB
Binary file not shown.
6.95 KB
Binary file not shown.

scripts/scale_layout.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
DESCRIPTION = """
2+
Plot a heatmap of good step tunings for isomorphic note layouts.
3+
4+
Layouts are scored by the sum of the inverse Tenney height of intervals
5+
within an integer limit approximated within an error limit, taking the
6+
center pad as 1/1. Point values for each interval are scaled based on
7+
the square of the absolute error of the best approximation, down to 0
8+
at the error limit.
9+
10+
Computation takes a few minutes, especially for larger controllers.
11+
"""
12+
13+
from fractions import Fraction
14+
from itertools import chain
15+
from math import log
16+
from pprint import pprint
17+
from statistics import mean
18+
from typing import Callable, Iterable, NamedTuple
19+
20+
from tqdm import tqdm
21+
22+
def cents(r: Fraction) -> float:
23+
return 1200 * log(r) / log(2)
24+
25+
class Pad(NamedTuple):
26+
vector: tuple[int, int]
27+
cents: float
28+
29+
def closest(x: float, pads: list[Pad]) -> Pad:
30+
return min(pads, key=lambda p: abs(x - p.cents))
31+
32+
def exquis_vectors_from_rowfunc(row: Callable) -> list[tuple[int, int]]:
33+
return list(chain(row(4, 1), row(3, 1), row(3, 0), row(2, 0), row(2, -1),
34+
row(1, -1), row(1, -2), row(0, -2), row(0, -3),
35+
row(-1, -3), row(-1, -4)))
36+
37+
def exquis_vectors() -> list[tuple[int, int]]:
38+
def row(g1: int, g2: int) -> Iterable[tuple[int, int]]:
39+
length = 5 if (g1 + g2) % 2 == 0 else 6
40+
return ((g1 + 1 - i, g2 - 1 + i) for i in range(length))
41+
return exquis_vectors_from_rowfunc(row)
42+
43+
vectors = exquis_vectors()
44+
45+
class Result(NamedTuple):
46+
steps: tuple[float, float]
47+
error: float
48+
size: float
49+
step_size: float
50+
51+
def vector_distance(v1: tuple[int, int], v2: tuple[int, int]) -> tuple[int, int]:
52+
return v2[0] - v1[0], v2[1] - v1[0]
53+
54+
def layout_result(targets: list[float], steps: tuple[float, float]) -> Result:
55+
pads = [Pad(v, steps[0]*v[0] + steps[1]*v[1]) for v in vectors]
56+
matches = [closest(t, pads) for t in targets]
57+
error = max(abs(t - p.cents) for (t, p) in zip(targets, matches))
58+
size = max(sum(map(abs, p.vector)) for p in matches)
59+
step_size = max(sum(map(abs, vector_distance(matches[i].vector, matches[i+1].vector)))
60+
for i in range(len(matches) - 1))
61+
return Result(steps, error, size, step_size)
62+
63+
def compute(targets: list[float]) -> list[Result]:
64+
results = list(tqdm(layout_result(targets, (x, y))
65+
for x in range(20, 720)
66+
for y in range(x, 720)))
67+
results.sort(key=lambda x: x.error/2 + x.size + x.step_size)
68+
return results
69+
70+
def main():
71+
from argparse import ArgumentParser
72+
parser = ArgumentParser(description=DESCRIPTION)
73+
parser.add_argument('ratio', nargs='+')
74+
args = parser.parse_args()
75+
results = compute([cents(Fraction(s)) for s in args.ratio])
76+
pprint(results[:10])
77+
78+
if __name__ == '__main__':
79+
main()

src/chord-builder/script.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ function renderChord() {
119119
chordDiv);
120120
const nonChordIntervals = ratios(integerLimit, subgroup)
121121
.filter(r => !chordIntervals.some(x => x[0] == r[0] && x[1] == r[1]));
122-
console.log(nonChordIntervals);
123122
// TODO: Factor series height and critical band into this.
124123
const complexities = new Map(nonChordIntervals.map(r =>
125124
[r, dyadicComplexity(r, chordIntervals) + seriesComplexity([...chordIntervals, r]) + criticalBandPenalty(r, chordIntervals)]));

src/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ <h1>Microtonal web utilities</h1>
2424
<li><a href="exquis-scl-generator/">Exquis .scl generator</a></li>
2525
<li><a href="grid-layout/">Isomorphic grid layout viewer</a></li>
2626
<li><a href="scala-to-sunvox/">Scala to SunVox</a></li>
27+
<li><a href="scale-builder/">Scale builder</a></li>
2728
<li><a href="temperament-notation/">Temperament notation finder</a></li>
2829
</ul>
2930
</main>

src/scale-builder/index.html

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<!DOCTYPE html>
2+
<html lang="en-US">
3+
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width">
7+
<meta name="description" content="Interactive scale builder for equal temperaments">
8+
<title>Scale builder</title>
9+
<link rel="stylesheet" href="../normalize.css">
10+
<link rel="stylesheet" href="../styles.css">
11+
<link rel="stylesheet" href="styles.css">
12+
<link rel="icon" href="../favicon.png">
13+
<script src="script.js" type="module" defer></script>
14+
</head>
15+
16+
<body>
17+
18+
<header>
19+
<h1>Scale builder</h1>
20+
</header>
21+
22+
<main>
23+
<p>
24+
<label for="edo">Steps per octave:</label>
25+
<input type="number" id="edo" value="12" size="4">
26+
</p>
27+
<p>
28+
<button id="prevMode">Previous mode</button>
29+
<button id="nextMode">Next mode</button>
30+
</p>
31+
<p>
32+
Click intervals to move them into or out of the scale.
33+
</p>
34+
<h2>Scale</h2>
35+
<div id="scale" class="flex-row"></div>
36+
<h2>Non-scale tones</h2>
37+
<div id="suggestions" class="flex-row"></div>
38+
</main>
39+
40+
<footer>
41+
<ul>
42+
<li><a href="..">Index</a></li>
43+
<li><a href="https://github.com/jangler/tuning">GitHub</a></li>
44+
</ul>
45+
</footer>
46+
47+
</body>
48+
49+
</html>

src/scale-builder/script.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// @ts-ignore
2+
import { html, render } from 'https://unpkg.com/htm/preact/standalone.module.js';
3+
import { gcd } from "../lib/limit.js";
4+
5+
const edoInput = document.querySelector('#edo') as HTMLInputElement;
6+
const scaleDiv = document.querySelector('#scale')!;
7+
const suggestionsDiv = document.querySelector('#suggestions')!;
8+
const prevModeButton = document.querySelector('#prevMode')!;
9+
const nextModeButton = document.querySelector('#nextMode')!;
10+
11+
const errorLimit = 18;
12+
13+
type Ratio = [number, number];
14+
15+
function ratios(limit: number): Ratio[] {
16+
const s = new Set<number>();
17+
const a = new Array<Ratio>();
18+
for (let n = 1; n <= limit; n++) {
19+
for (let d = Math.ceil(n / 2); d < n; d++) {
20+
const k = n/d;
21+
if (!s.has(k)) {
22+
s.add(k);
23+
a.push(simplify([n, d]));
24+
}
25+
}
26+
}
27+
return a;
28+
}
29+
30+
const ratioPool = ratios(27);
31+
let edo = edoInput.valueAsNumber;
32+
33+
function tenneyHeight(r: Ratio): number {
34+
return Math.log2(r[0] * r[1]);
35+
}
36+
37+
function bestMapping(r: Ratio, et: number): number {
38+
return Math.round(et * Math.log2(r[0] / r[1]) / Math.log2(2));
39+
}
40+
41+
function approximates(n: number, et: number, r: Ratio): boolean {
42+
const justCents = 1200 * Math.log2(r[0]/r[1]) / Math.log2(2);
43+
const edoCents = 1200 * n / et;
44+
return Math.abs(justCents - edoCents) < errorLimit;
45+
}
46+
47+
function detemper(n: number): Ratio | null {
48+
if (n === 0) {
49+
return [1, 1];
50+
}
51+
const matchedRatios = ratioPool
52+
.filter(r => bestMapping(r, edo) == n && approximates(n, edo, r));
53+
matchedRatios.sort((a, b) => tenneyHeight(a) - tenneyHeight(b));
54+
return matchedRatios[0]; // TODO
55+
}
56+
57+
function stepScore(n: number): number {
58+
const r = detemper(n);
59+
if (r) {
60+
return tenneyHeight(r);
61+
} else {
62+
return 100;
63+
}
64+
}
65+
66+
let stepScores: number[] = [...Array(edo).keys()].map(stepScore);
67+
let scaleIntervals: number[] = [0, bestMapping([3, 2], edo)];
68+
69+
// TODO: export to .scl
70+
71+
function simplify(r: Ratio): Ratio {
72+
const f = gcd(r);
73+
return [r[0] / f, r[1] / f];
74+
}
75+
76+
function addInterval(n: number) {
77+
scaleIntervals.push(n);
78+
scaleIntervals.sort((a, b) => a - b);
79+
renderScale();
80+
updateDiagnostics();
81+
}
82+
83+
function removeInterval(n: number) {
84+
if (n == 0) return;
85+
scaleIntervals = scaleIntervals.filter(x => x != n);
86+
renderScale();
87+
updateDiagnostics();
88+
}
89+
90+
function mean(xs: number[]): number {
91+
return xs.reduce((a, b) => a + b, 0) / xs.length;
92+
}
93+
94+
function complexity(n: number, scale: number[]): number {
95+
const result = mean(scale.map(x => Math.pow(stepScores[Math.abs(n - x)], 2)));
96+
return result;
97+
}
98+
99+
function formatRatio(r: Ratio | null): string {
100+
if (r) {
101+
return `${r[0]}/${r[1]}`;
102+
} else {
103+
return '?';
104+
}
105+
}
106+
107+
function renderScale() {
108+
render(html`${scaleIntervals.map(n => html`
109+
<button class="note" onClick=${() => removeInterval(n)}>
110+
${n}
111+
<div class="approx">${formatRatio(detemper(n))}</div>
112+
</button>`)
113+
}`,
114+
scaleDiv);
115+
const nonChordIntervals = [...Array(edo).keys()]
116+
.filter(x => !scaleIntervals.includes(x));
117+
nonChordIntervals.sort((a, b) =>
118+
complexity(a, scaleIntervals) - complexity(b, scaleIntervals));
119+
render(html`${nonChordIntervals.map(n => html`
120+
<button class="note" onClick=${() => addInterval(n)}>
121+
${n}
122+
<div class="approx">${formatRatio(detemper(n))}</div>
123+
</button>`)
124+
}`,
125+
suggestionsDiv);
126+
}
127+
128+
function updateDiagnostics() {
129+
// TODO
130+
}
131+
132+
renderScale();
133+
updateDiagnostics();
134+
135+
edoInput.addEventListener('change', () => {
136+
const newEdo = edoInput.valueAsNumber;
137+
scaleIntervals = [...new Set(scaleIntervals.map(x => {
138+
const r = detemper(x);
139+
if (r) {
140+
return bestMapping(r, newEdo);
141+
} else {
142+
return 0;
143+
}
144+
}))];
145+
edo = newEdo;
146+
stepScores = [...Array(edo).keys()].map(stepScore);
147+
renderScale();
148+
});
149+
150+
prevModeButton.addEventListener('click', () => {
151+
if (scaleIntervals.length > 1) {
152+
const root = edo - scaleIntervals.pop()!;
153+
scaleIntervals = scaleIntervals.map(x => x + root);
154+
scaleIntervals.unshift(0);
155+
renderScale();
156+
}
157+
});
158+
159+
nextModeButton.addEventListener('click', () => {
160+
if (scaleIntervals.length > 1) {
161+
const root = scaleIntervals[1];
162+
scaleIntervals = scaleIntervals.map(x => x - root);
163+
scaleIntervals.push(edo + scaleIntervals.shift()!);
164+
renderScale();
165+
}
166+
});

src/scale-builder/styles.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.note {
2+
border: 1px solid gray;
3+
border-radius: 0.1rem;
4+
margin: 0.2rem;
5+
padding: 0.2rem;
6+
background-color: white;
7+
width: 3rem;
8+
}
9+
10+
.note:hover {
11+
background-color: #f0f0f0;
12+
}
13+
14+
.approx {
15+
color: gray;
16+
font-size: small;
17+
}

0 commit comments

Comments
 (0)