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

    +
    + +
    +
    + + + + + + + + +
    +

    + + + + + + + + + +
    + + + + + + \ 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