Skip to content

Instantly share code, notes, and snippets.

@crdrost
Last active September 4, 2019 22:15
Show Gist options
  • Save crdrost/8c8a1b7e9206c0d19e0dedcd08eb8d6b to your computer and use it in GitHub Desktop.
Save crdrost/8c8a1b7e9206c0d19e0dedcd08eb8d6b to your computer and use it in GitHub Desktop.
Good numbers of musical notes

I wanted to know what sorts of equal temperament systems were objectively better than others as 12-TET occupies a very distinctive place in music theory right now.

More description to come later.

Feel free to use/modify the below code under the MPLv2 license.

const factors = [3, 5, 7, 11, 13, 17, 19, 23, 29, 31].map(x => ({
factor: x,
// the following polynomial was justified more by experiment than anything else,
// the goal is that a 1/n^3 progression should mean that we converge on the
// basics quickly and only get weak influence from the 11:2 or 13:2 ratios.
weight: 4 / ((x - 3) ** 3 + 24) // 1/3, 1/4, 1/11, 1/67, 1/128...
}));
/**
* @type
* a combo like [1, 1, -2, 0, 0] means that we want 3*5/(7**2), powers of the
* digits. we try to assemble all combos where sum(map(abs)(seq)) <= 5. We use
* a set of strings so that we do not have to worry about counting the same
* combo twice. The choice of 5 is to get the syntonic comma 81/80 in there,
* hopefully.
*/
const comboStrings = new Set([]);
/**
* To do this we use "choosers" which choose an index and add +/- 1 to it.
* @returns {Iterable<[number, number]>}
*/
function* chooser() {
for (let i = 0; i < factors.length; i++) {
yield [i, 1];
yield [i, -1];
}
}
const blankCombo = factors.map(() => 0);
for (const [n_a, k_a] of chooser()) {
for (const [n_b, k_b] of chooser()) {
for (const [n_c, k_c] of chooser()) {
for (const [n_d, k_d] of chooser()) {
const combo = blankCombo.slice(0);
combo[n_a] += k_a;
combo[n_b] += k_b;
combo[n_c] += k_c;
combo[n_d] += k_d;
comboStrings.add(combo.join(" "));
}
}
}
}
for (const [n_a, k_a] of chooser()) {
for (const [n_b, k_b] of chooser()) {
for (const [n_c, k_c] of chooser()) {
for (const [n_d, k_d] of chooser()) {
for (const [n_e, k_e] of chooser()) {
const combo = blankCombo.slice(0);
combo[n_a] += k_a;
combo[n_b] += k_b;
combo[n_c] += k_c;
combo[n_d] += k_d;
combo[n_e] += k_e;
comboStrings.add(combo.join(" "));
}
}
}
}
}
// if this got in there who cares
comboStrings.delete(blankCombo.join(" "));
// Now we want to bring these combinations to within one octave, and we want to
// also not double-count our errors: log(a/b) = -log(b/a) and then when we add
// to get it to live within the interval (0, 1) we will find that b/a is just as
// far away from its best rational approximant as a/b was. So we can solve both
// problems at once by just only considering the fractions that start out
// positive. I would also like to formally bound this by the weight of the 3^5
// term, keeping only 4th-order terms with higher weight than that.
/**
* @type {Array<{factor: number, weight: number, frac: [number, number], prob: number, logFrac: number}>}
*/
const combinations = [];
const ignoreWeight = factors[0].weight ** 6;
for (const comboString of comboStrings) {
/**
* @type {number[]}
*/
const combos = comboString.split(" ").map(x => Number(x));
let numer = 1,
denom = 1;
let totalWeight = 1;
for (let i = 0; i < factors.length; i++) {
const { factor, weight } = factors[i];
const n = combos[i];
if (n > 0) {
numer *= factor ** n;
totalWeight *= weight ** n;
} else if (n < 0) {
const m = -n;
denom *= factor ** m;
totalWeight *= weight ** m;
}
}
if (totalWeight > ignoreWeight && numer > denom) {
let factor = numer / denom;
while (factor > 2) {
factor /= 2;
denom *= 2;
}
combinations.push({
factor,
weight: totalWeight,
fraction: [numer, denom],
prob: 0,
note: Math.log(numer / denom) / Math.log(2)
});
}
}
combinations.sort((x, y) => y.weight - x.weight);
const sumWeights = combinations.reduce((acc, x) => acc + x.weight, 0);
for (const combo of combinations) {
combo.prob = combo.weight / sumWeights;
}
function centsOff(notesPerOctave, note) {
const est = Math.round(note * notesPerOctave) / notesPerOctave;
return 1200 * (est - note);
}
function error(notesPerOctave, noteProbs) {
let squareSum = 0;
for (const { note, prob } of noteProbs) {
squareSum += prob * centsOff(notesPerOctave, note) ** 2;
}
return squareSum ** 0.5;
}
function roundedString(n, color) {
let rounded = Math.round(n * 100) / 100;
let out = String(rounded);
if (out.indexOf(".") === -1) {
out += ".0";
}
const withLastDigit = /\.\d$/.exec(out) ? out + "0" : out;
if (color && Math.abs(rounded) <= 6) {
return `*${withLastDigit}*`
}
if (color && Math.abs(rounded) > 12) {
return `**${withLastDigit}**`
}
return withLastDigit;
}
// n is the number of notes per octave in an equal temperament
const output = [
["notes", "expect-err", "actual-err"].concat(
combinations.slice(0, 12).map(x => x.fraction.join(":"))
)
];
output.push(output[0].map(() => "---"));
const expect = [...Array(1e7)].map((_, i) => ({
note: (i + 0.5) / 1e7,
prob: 1e-7
}));
for (let notesPerOctave = 6; notesPerOctave < 50; notesPerOctave++) {
const expectedError = error(notesPerOctave, expect);
const actualError = error(notesPerOctave, combinations);
if (actualError > expectedError) {
continue;
}
output.push(
[
String(notesPerOctave),
roundedString(expectedError),
roundedString(actualError)
].concat(
combinations
.slice(0, 12)
.map(x => roundedString(centsOff(notesPerOctave, x.note), true))
)
);
}
const colLengths = output[0].map(() => 0);
for (let i = 0; i < output.length; i++) {
for (let c = 0; c < colLengths.length; c++) {
colLengths[c] = Math.max(
colLengths[c],
output[i][c].length + (i > 0 ? 1 : 0)
);
}
}
for (let i = 0; i < output.length; i++) {
const row = output[i];
console.log('|', row.join(" | "), '|');
}

The raw results of the above script are:

notes expect-err actual-err 3:2 5:4 7:4 9:8 15:8 5:3 25:16 21:16 7:6 11:8 35:32 7:5
7 49.49 42.30 -16.24 -43.46 59.75 -32.48 -59.70 -27.22 84.52 43.50 75.99 -37.03 16.29 -68.23
9 38.49 34.58 -35.29 13.69 -35.49 62.76 -21.60 48.97 27.37 62.55 -0.20 -17.98 -21.81 -49.18
10 34.64 27.24 18.04 -26.31 -8.83 36.09 -8.27 -44.36 -52.63 9.22 -26.87 48.68 -35.14 17.49
12 28.87 18.98 -1.96 13.69 31.17 -3.91 11.73 15.64 27.37 29.22 33.13 48.68 44.86 17.49
15 23.09 19.71 18.04 13.69 -8.83 36.09 31.73 -4.36 27.37 9.22 -26.87 8.68 4.86 -22.51
16 21.65 21.48 -26.96 -11.31 6.17 21.09 36.73 15.64 -22.63 -20.78 33.13 -26.32 -5.14 17.49
19 18.23 13.06 -7.22 -7.37 -21.46 -14.44 -14.58 -0.15 -14.73 -28.68 -14.24 17.10 -28.82 -14.09
21 16.50 16.21 -16.24 13.69 2.60 24.66 -2.55 -27.22 27.37 -13.64 18.84 20.11 16.29 -11.08
22 15.75 10.01 7.14 -4.50 12.99 14.27 2.64 -11.63 -8.99 20.13 5.86 -5.86 8.50 17.49
24 14.43 12.07 -1.96 13.69 -18.83 -3.91 11.73 15.64 -22.63 -20.78 -16.87 -1.32 -5.14 17.49
25 13.86 13.81 18.04 -2.31 -8.83 -11.91 15.73 -20.36 -4.63 9.22 21.13 -23.32 -11.14 -6.51
26 13.32 13.11 -9.65 -17.08 0.40 -19.29 19.42 -7.44 11.99 -9.24 10.05 2.53 -16.68 17.49
27 12.83 12.60 9.16 13.69 8.95 18.31 -21.60 4.53 -17.07 18.11 -0.20 -17.98 -21.81 -4.73
29 11.95 11.12 1.49 -13.90 -17.10 2.99 -12.41 -15.39 13.58 -15.61 -18.60 -13.39 10.38 -3.20
31 11.17 5.58 -5.18 0.78 -1.08 -10.36 -4.40 5.96 1.57 -6.26 4.10 -9.38 -0.30 -1.87
34 10.19 7.62 3.93 1.92 -15.88 7.85 5.85 -2.01 3.84 -11.96 15.48 13.39 -13.96 17.49
36 9.62 9.06 -1.96 13.69 -2.16 -3.91 11.73 15.64 -5.96 -4.11 -0.20 15.35 11.53 -15.85
37 9.36 8.83 11.56 2.88 4.15 -9.32 14.43 -8.68 5.75 15.71 -7.41 0.03 7.02 1.27
41 8.45 4.97 0.48 -5.83 -2.97 0.97 -5.34 -6.31 -11.65 -2.49 -3.46 4.78 -8.80 2.85
43 8.06 6.30 -4.28 4.38 7.92 -8.56 0.10 8.66 8.77 3.64 12.20 6.82 12.30 3.53
46 7.53 4.88 2.39 4.99 -3.61 4.79 7.38 2.60 9.98 -1.22 -6.00 -3.49 1.38 -8.60

Several rows are missing; they are rows where the actual error was greater than the expected error, so that they were just intrinsically bad choices for approximating intervals. You can see “howlers” (approximations more than 12 cents off) marked in bold and “sweet spots” (less than 6 cents off) marked in italics.

In terms of the best actual-err the standard base-12 scale starts us off with a standard error of 19 cents off its typical just interval, with a much lower score because it is based on a best rational approximation for that extremely important perfect fifth 3:2 interval (that is, log2(3/2) ≈ 1/2, 3/5, 7/12, 24/41, 31/53, 179/306...) and this also shows itself to be important for the success of 41-TET. This also gives them a really nice major second 9:8 which basically only has double the error of the 3:2 interval; that relationship of doubled-error is relatively common on this table, generally failing when the perfect fifth has been marked as a howler in bold.

But 12-TET has some really nasty problems including a somewhat bad 5:4 ratio and 5:3 ratio and then higher primes are out of the question, the 7:4 ratio is wickedly out-of-tune for example.

The next good equal temperament system is 19-TET, fitting 19 notes into the octave. This unsweetens the 3:2 ratio to the point where sensitive folks can hear it, but brings that major-third 5:4 ratio into order. The extremely good major-sixth 5:3 ratio hints at why this works objectively (that is, log2(5/3) ≈ 2/3, 3/4, 14/19, 311/422...), but the cost is that there has been more dissonance pushed onto the major second 9:8 interval—indeed it could be better described as a sharp 10:9 interval (by only 7 cents) rather than a flat 9:8 interval (by 14). What is very surprising is that while things might get slightly more sonorous and musicians might appreciate having more choices, in terms of fundamental frequencies very little has happened other than that 5:3 ratio. Listening to that C6sus chord of C-F-A-C in both tunings it actually sounds pretty good, but it does have a noticeable “wobble” in 12-TET that is nicely missing in 19-TET.

Surprisingly, after that we see another improvement where almost everything gets even sweeter when we go to 22-TET: everything but that major sixth is doing better, and kind of for no obvious mathematical reason: unlike the past few where some key just interval was uniquely well-approximated, 22-TET does not. It seems to have some nice properties to do with the number 15, where 11/10 is an okayish approximation for the major-seventh 15:8 interval and thus the 16:15 interval would be correspondingly nice in 22-TET.

After that 24-TET and 29-TET both have really nice perfect fifth approximations but suffer enough on all the other intervals to not do any better in my rankings, even despite the 1/n3 devaluation of each higher prime, and we get our first tuning with no howlers on the table, which is not too surprising since by this point just from adding sheer volume of notes onto the octave, the expected error has dipped below 12 cents. That tuning is 31-TET.

Packing two and a half times as many frets onto the guitar fretboard, 31-TET is a hard pill to stomache. Like 22-TET it appears to do many things equally well; unlike 12-TET and 19-TET it does not have one distinctive interval which it does masterfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment