diff --git a/src/index.html b/src/index.html
index 278e9f8..c527ef4 100644
--- a/src/index.html
+++ b/src/index.html
@@ -24,6 +24,7 @@
Microtonal web utilities
Isomorphic grid layout viewer
Isomorphic note layout heatmaps
Scala to SunVox
+ Temperament notation finder
diff --git a/src/lib/limit.ts b/src/lib/limit.ts
index 73fecaa..fbb85ef 100644
--- a/src/lib/limit.ts
+++ b/src/lib/limit.ts
@@ -6,13 +6,42 @@ function gcd(xs: number[]): number {
return 1;
}
+export function isPrime(n: number): boolean {
+ for (let i = 2; i <= Math.sqrt(n); i++) {
+ if (n % i == 0) return false;
+ }
+ return true;
+}
+
+export function primeFactors(n: number): number[] {
+ const factors = [];
+ while (n > 1) {
+ for (let i = 2; i <= n; i++) {
+ if (n % i == 0 && isPrime(i)) {
+ factors.push(i);
+ n /= i;
+ break;
+ }
+ }
+ }
+ return factors;
+}
+
+/** Return true if all prime factors of n and d are included in subgroup. */
+function inSubgroup(n: number, d: number, subgroup: number[]): boolean {
+ if (d == 1) return primeFactors(n).every(f => subgroup.includes(f));
+ return inSubgroup(n, 1, subgroup) && inSubgroup(d, 1, subgroup);
+}
+
/** Return a map of cents values to ratio strings. */
-export function integerLimitIntervals(limit: number): Map {
+export function integerLimitIntervals(limit: number, subgroup: number[] | undefined = undefined): Map {
const m = new Map();
- for (var n = 1; n <= limit; n++) {
- for (var d = 1; d <= limit; d++) {
- const f = gcd([n, d]);
- m.set(1200 * Math.log(n / d) / Math.log(2), `${n / f}/${d / f}`);
+ for (let n = 1; n <= limit; n++) {
+ for (let d = 1; d <= limit; d++) {
+ if (!subgroup || inSubgroup(n, d, subgroup)) {
+ const f = gcd([n, d]);
+ m.set(1200 * Math.log(n / d) / Math.log(2), `${n / f}/${d / f}`);
+ }
}
}
return m;
diff --git a/src/lib/udn.ts b/src/lib/udn.ts
index ba5ed94..51c00ff 100644
--- a/src/lib/udn.ts
+++ b/src/lib/udn.ts
@@ -1,4 +1,4 @@
-function mod(a: number, b: number): number {
+export function mod(a: number, b: number): number {
return a - Math.floor(a / b) * b;
}
@@ -11,10 +11,10 @@ export function udn(steps: number, edo: number): string[] {
const sharp = fifth * 7 - edo * 4;
var symbols = new Map(['F', 'C', 'G', 'D', 'A', 'E', 'B']
.map((v, i) => [v, mod(fifth * (i - 1), edo)]));
- const matches = [];
+ const matches: string[] = [];
while (matches.length == 0) {
for (const [s, n] of symbols) {
- if (n == stepClass) matches.push(s);
+ if (n == stepClass && !matches.includes(s)) matches.push(s);
}
symbols = new Map([...symbols.entries()].flatMap(([s, n]) => {
const a: [string, number][] = [];
diff --git a/src/styles.css b/src/styles.css
index 2cfd2fa..7e4dafa 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -38,11 +38,15 @@ img {
width: fit-content;
label {
- text-align: right;
margin-right: 0.5rem;
+ text-align: right;
}
}
+label {
+ margin-right: 0.2em;
+}
+
footer>ul {
padding-left: 0;
diff --git a/src/temperament-notation/index.html b/src/temperament-notation/index.html
new file mode 100644
index 0000000..1f118ec
--- /dev/null
+++ b/src/temperament-notation/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+ Temperament notation finder
+
+
+
+
+
+
+
+
+
+ Temperament notation finder
+
+
+
+
+
+
+
+
+ Notation |
+ Intervals |
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/temperament-notation/script.ts b/src/temperament-notation/script.ts
new file mode 100644
index 0000000..f275af8
--- /dev/null
+++ b/src/temperament-notation/script.ts
@@ -0,0 +1,113 @@
+// @ts-ignore
+import { html, render } from 'https://unpkg.com/htm/preact/standalone.module.js';
+import { integerLimitIntervals, primeFactors } from "../lib/limit.js";
+
+// TODO: Better page layout. The current layout is too long and narrow.
+// TODO: Neutral circle of fifths notation, with demisharps and demiflats.
+
+const maxAccidentals = 4;
+
+const edosInput = document.querySelector('#edos') as HTMLInputElement;
+const subgroupInput = document.querySelector('#subgroup') as HTMLInputElement;
+const integerLimitInput = document.querySelector('#integerLimit') as HTMLInputElement;
+const arrowRatioInput = document.querySelector('#arrowRatio') as HTMLInputElement;
+const alert = document.querySelector('#alert')!;
+const table = document.querySelector('table')!;
+const tbody = document.querySelector('tbody')!;
+
+let intervals: number[][];
+
+function edoMapping(prime: number, edo: number): number {
+ const stepSize = 1200/edo;
+ const cents = 1200 * Math.log(prime) / Math.log(2);
+ return Math.round(cents / stepSize);
+}
+
+function edoSteps(ratio: number[], edo: number): number {
+ return primeFactors(ratio[0]).map(p => edoMapping(p, edo)).reduce((a, b) => a + b, 0)
+ - primeFactors(ratio[1]).map(p => edoMapping(p, edo)).reduce((a, b) => a + b, 0);
+}
+
+function multiplyRatio(a: number[], b: number[]): number[] {
+ return [a[0] * b[0], a[1] * b[1]];
+}
+
+function notateInterval(ratio: number[], edos: number[], arrow: number[]): string {
+ let symbols = new Map([
+ ['1', [1, 1]],
+ ['5', [3, 2]],
+ ['4', [4, 3]],
+ ['2', [9, 8]],
+ ['7', [243, 128]],
+ ['6', [27, 16]],
+ ['3', [81, 64]],
+ ['8', [2, 1]],
+ ['9', [9, 4]],
+ ['-2', [8, 9]],
+ ]);
+ const matches: string[] = [];
+ for (let layer = 0; matches.length == 0 && layer <= maxAccidentals; layer++) {
+ for (const [k, v] of symbols) {
+ if (edos.every(edo => edoSteps(ratio, edo) == edoSteps(v, edo)) && !matches.includes(k))
+ matches.push(k);
+ }
+ symbols = new Map([...symbols.entries()].flatMap(([s, r]) => {
+ const a: [string, number[]][] = [];
+ if (!s.includes('♭')) a.push([s.slice(0, -1) + '♯' + s.slice(-1), multiplyRatio(r, [2187, 2048])]);
+ if (!s.includes('♯')) a.push([s.slice(0, -1) + '♭' + s.slice(-1), multiplyRatio(r, [2048, 2187])]);
+ if (!s.includes('v')) a.push(['^' + s, multiplyRatio(r, arrow)]);
+ if (!s.includes('^')) a.push(['v' + s, multiplyRatio(r, [arrow[1], arrow[0]])]);
+ return a;
+ }))
+ }
+ return matches.length > 0 ? matches.join(', ') : '?';
+}
+
+function maxFraction(rs: number[][]): number {
+ return Math.max(...rs.map(r => r[0]/r[1]));
+}
+
+function updateTable() {
+ if (!edosInput.value) return;
+ try {
+ const edos = edosInput.value.match(/\d+/g)?.map(s => parseInt(s));
+ if (!edos) throw Error('Could not parse EDOs');
+ const arrow = arrowRatioInput.value ? arrowRatioInput.value.match(/(\d+)[:/](\d+)/)?.slice(1).map(s => parseInt(s)) : [1, 1];
+ if (!arrow) throw Error('Could not parse arrow ratio');
+ const notation = new Map();
+ for (const interval of intervals) {
+ const n = notateInterval(interval, edos, arrow);
+ if (!notation.has(n)) notation.set(n, []);
+ notation.get(n)?.push(interval);
+ }
+ const rows = [...notation.entries()]
+ .sort(([k1, v1], [k2, v2]) => maxFraction(v1) - maxFraction(v2))
+ .map(([k, v]) => html`
+ ${k} | ${v.map(r => r.join('/')).join(', ')} |
+ `)
+ render(rows, tbody);
+ alert.textContent = '';
+ table.classList.remove('hidden');
+ } catch (e) {
+ if (e instanceof Error) {
+ alert.textContent = e.message;
+ table.classList.add('hidden');
+ }
+ }
+}
+
+function updateIntervals() {
+ const limit = integerLimitInput.valueAsNumber;
+ const subgroup = subgroupInput.value.match(/\d+/g)?.map(s => parseInt(s));
+ intervals = [...integerLimitIntervals(limit, subgroup).entries()]
+ .filter(([k, _]) => k > 0 && k < 1200)
+ .map(([_, v]) => v.match(/\d+/g)!.map(s => parseInt(s)));
+ updateTable();
+}
+
+updateIntervals();
+
+edosInput.addEventListener('change', updateTable);
+subgroupInput.addEventListener('change', updateIntervals);
+integerLimitInput.addEventListener('change', updateIntervals);
+arrowRatioInput.addEventListener('change', updateTable);
\ No newline at end of file