Skip to content

Commit

Permalink
Add temperament notation page
Browse files Browse the repository at this point in the history
  • Loading branch information
jangler committed Apr 3, 2024
1 parent 49ede2c commit 2932455
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 9 deletions.
1 change: 1 addition & 0 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ <h1>Microtonal web utilities</h1>
<li><a href="grid-layout/">Isomorphic grid layout viewer</a></li>
<li><a href="layout-heatmaps/">Isomorphic note layout heatmaps</a></li>
<li><a href="scala-to-sunvox/">Scala to SunVox</a></li>
<li><a href="temperament-notation/">Temperament notation finder</a></li>
</ul>
</main>

Expand Down
39 changes: 34 additions & 5 deletions src/lib/limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, string> {
export function integerLimitIntervals(limit: number, subgroup: number[] | undefined = undefined): Map<number, string> {
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;
Expand Down
6 changes: 3 additions & 3 deletions src/lib/udn.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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][] = [];
Expand Down
6 changes: 5 additions & 1 deletion src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
53 changes: 53 additions & 0 deletions src/temperament-notation/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en-US">

<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="description" content="Show diatonic notation for tempered intervals">
<title>Temperament notation finder</title>
<link rel="stylesheet" href="../normalize.css">
<link rel="stylesheet" href="../styles.css">
<link rel="icon" href="../favicon.png">
<script src="script.js" type="module" defer></script>
</head>

<body>

<header>
<h1>Temperament notation finder</h1>
</header>

<main>
<section class="parameters">
<label for="edos">EDOs:</label>
<input type="text" id="edos" size="10">
<label for="subgroup">Optional prime subgroup:</label>
<input type="text" id="subgroup" size="10">
<label for="integerLimit">Integer limit:</label>
<input type="number" id="integerLimit" size="5" value="16">
<label for="arrowRatio">Arrow ratio:</label>
<input type="text" id="arrowRatio" size="10" value="81/80">
</section>
<p id="alert"></p>
<table class="hidden">
<thead>
<tr>
<th>Notation</th>
<th>Intervals</th>
</tr>
</thead>
<tbody></tbody>
</table>
</main>

<footer>
<ul>
<li><a href="..">Index</a></li>
<li><a href="https://github.com/jangler/tuning">GitHub</a></li>
</ul>
</footer>

</body>

</html>
113 changes: 113 additions & 0 deletions src/temperament-notation/script.ts
Original file line number Diff line number Diff line change
@@ -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<string, number[][]>();
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`
<tr><td>${k}</td><td>${v.map(r => r.join('/')).join(', ')}</td></tr>
`)
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);

0 comments on commit 2932455

Please sign in to comment.