Skip to content

Commit

Permalink
Implement algorithms for finding concordance shells
Browse files Browse the repository at this point in the history
ref #471
  • Loading branch information
frostburn committed Nov 13, 2023
1 parent 4dedb5f commit b3eb6b7
Show file tree
Hide file tree
Showing 3 changed files with 346 additions and 3 deletions.
86 changes: 84 additions & 2 deletions src/__tests__/analysis.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import { describe, it, expect } from "vitest";
import { arraysEqual } from "xen-dev-utils";
import { arraysEqual, valueToCents } from "xen-dev-utils";

import { otonalFundamental, utonalFundamental } from "../analysis";
import {
alignValues,
concordanceShell,
minimaxConcordanceShells,
misalignment,
otonalFundamental,
utonalFundamental,
} from "../analysis";

const EPSILON = 1e-4;

describe("Otonal balancer", () => {
it("can figure out that the major chord in 12edo approximates 4:5:6", () => {
Expand All @@ -28,3 +37,76 @@ describe("Utonal balancer", () => {
).toBeTruthy();
});
});

describe("Equal-division deviation minimizer", () => {
it("can figure out the optimal alignment of 4:5:6 on 12edo", () => {
const minimumAchievableError = alignValues([4, 5, 6], 100.0).error;
expect(minimumAchievableError).closeTo(7.8206, EPSILON);

// Attempt (and fail) to find a counter-example
const pitches = [4, 5, 6].map(valueToCents);
for (let i = 0; i < 100; ++i) {
const offset = Math.random() * 1200;
const candidate = pitches.map((pitch) => pitch + offset);
expect(misalignment(candidate, 100.0)).toBeGreaterThanOrEqual(
minimumAchievableError
);
}
});
});

describe("Concordance shell finder", () => {
it("finds the 37 : 44 : 66 : 70 : 83 : 93 : 99 : 111 : 157 : 187 : 197 : 209 : 235 : 249 shell in 12edo", () => {
const shell = concordanceShell(37, 255, 5.0, 12);
expect(shell.harmonics).toEqual([
37, 44, 66, 70, 83, 93, 99, 111, 157, 187, 197, 209, 235, 249,
]);
});
});

describe("Minimaxing concordance shell finder", () => {
it("finds more variants for the shell rooted at 37 in 12edo", () => {
const shells = minimaxConcordanceShells(37, 127, 3, 12);

// Just some way to create a deterministic order.
function cmp(a: number[], b: number[]) {
if (!a.length && b.length) {
return -1;
} else if (a.length && !b.length) {
return +1;
} else if (!a.length && !b.length) {
return 0;
}
if (a[0] < b[0]) {
return -1;
} else if (a[0] > b[0]) {
return +1;
}
return cmp(a.slice(1), b.slice(1));
}
shells.sort((a, b) => cmp(a.harmonics, b.harmonics));
for (const shell of shells) {
shell.error = Math.round(shell.error * 1000) / 1000;
}
expect(shells).toEqual([
{
harmonics: [37, 44, 66, 70, 83, 99, 111],
scaleDegrees: new Set([0, 2, 3, 5, 7, 10, 11]),
error: 2.59,
gridPitches: [0, 300, 1000, 1100, 1400, 1700, 1900],
},
{
harmonics: [37, 44, 66, 70, 99, 105, 111],
scaleDegrees: new Set([0, 3, 5, 6, 7, 10, 11]),
error: 2.888,
gridPitches: [0, 300, 1000, 1100, 1700, 1800, 1900],
},
{
harmonics: [37, 44, 83, 93],
scaleDegrees: new Set([0, 2, 3, 4]),
error: 2.177,
gridPitches: [0, 300, 1400, 1600],
},
]);
});
});
260 changes: 260 additions & 0 deletions src/analysis.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Scale } from "scale-workshop-core";
import { arraysEqual, mmod, valueToCents } from "xen-dev-utils";

const EPSILON = 1e-6;

Expand Down Expand Up @@ -112,3 +113,262 @@ export function intervalMatrix(scale: Scale) {
}
return result;
}

/**
* Calculate the maximum deviation of a set of pitches from an equal grid in pitch-space.
* @param pitches Array of pitches measured in cents.
* @param gridCents The distance in cents between two grid lines of the equal division e.g. `100.0`.
* @returns The maximum distance in cents from a grid "line" in the set.
*/
export function misalignment(pitches: number[], gridCents: number) {
const gridPitches = pitches.map(
(pitch) => Math.round(pitch / gridCents) * gridCents
);
let error = 0;
for (let i = 0; i < pitches.length; ++i) {
error = Math.max(error, Math.abs(pitches[i] - gridPitches[i]));
}
return error;
}

/**
* Align a set of pitches on an equal division of pitch-space such that the maximum absolute error is minimized.
* @param pitches An array of pitches measured in cents.
* @param gridCents The distance in cents between two grid lines of the equal division e.g. `100.0`.
* @returns The minimum misalignment achievable measured in cents and the pitches snapped to the grid.
*/
export function alignCents(pitches: number[], gridCents: number) {
// The error function we're trying to optimize is piecewise linear.

// Find the segment where the global optimum lies.
let optimalPitches: number[];
let minError = Infinity;
for (let i = 0; i < pitches.length; ++i) {
const aligned = pitches.map((pitch) => pitch - pitches[i]);
const error = misalignment(aligned, gridCents);
if (error < minError) {
optimalPitches = aligned;
minError = error;
}
}

// Calculate the shape of the segment.
const gridPitches = optimalPitches!.map(
(pitch) => Math.round(pitch / gridCents) * gridCents
);
let minOffset = Infinity;
let maxOffset = -Infinity;
let root = Infinity;
for (let i = 0; i < gridPitches.length; ++i) {
const offset = optimalPitches![i] - gridPitches[i];
minOffset = Math.min(minOffset, offset);
maxOffset = Math.max(maxOffset, offset);
if (gridPitches[i] < root) {
root = gridPitches[i];
}
}

// Calculate minimum achievable absolute error.
const error = 0.5 * Math.abs(minOffset) + 0.5 * Math.abs(maxOffset);
// Move root to grid origin.
for (let i = 0; i < gridPitches.length; ++i) {
gridPitches[i] -= root;
}
return {
error,
gridPitches,
};
}

/**
* Align a set of ratios in frequency-space on an equal division of pitch-space such that the maximum absolute error is minimized.
* @param ratios An array of frequency ratios.
* @param gridCents The distance in cents between two grid lines of the equal division e.g. `100.0`.
* @returns The minimum misalignment achievable measured in cents and the pitches snapped to the grid.
*/
export function alignValues(ratios: number[], gridCents: number) {
return alignCents(ratios.map(valueToCents), gridCents);
}

function circleEqual(
a: number,
b: number,
equaveCents: number,
tolerance = EPSILON
) {
return (
Math.abs(mmod(a - b + equaveCents * 0.5, equaveCents) - equaveCents * 0.5) <
tolerance
);
}

export type Shell = {
harmonics: number[];
gridPitches: number[];
error: number;
scaleDegrees: Set<number>;
};

/**
* Find the concordance shell centered around a root harmonic.
* @param rootHarmonic Root harmonic.
* @param maxHarmonic Maximum harmonic to consider for inclusion.
* @param tolerance Maximum deviation in cents from the equal division of pitch-space.
* @param division Number of equal divisions of the equave.
* @param equave The size of the interval of equivalence in cents.
* @returns An objects with
* harmonics of the shell as an array of integers,
* the harmonics snapped to the grid as an array of cents,
* the minimum achievable misalignment of the harmonics within the grid measured in cents.
* the shell as a reduced repeating scale of degrees (with only the zero endpoint included)
*/
export function concordanceShell(
rootHarmonic: number,
maxHarmonic: number,
tolerance: number,
divisions: number,
equaveCents = 1200.0
): Shell {
const gridCents = equaveCents / divisions;
const harmonics = [rootHarmonic];
const gridPitches = [0];
const scaleDegrees = new Set([0]);
const fingerprints = [0];
let error = 0;
search: for (
let harmonic = rootHarmonic + 1;
harmonic <= maxHarmonic;
++harmonic
) {
const pitch = valueToCents(harmonic / rootHarmonic);
const degree = Math.round(pitch / gridCents);
const scaleDegree = mmod(degree, divisions);
if (scaleDegrees.has(scaleDegree)) {
for (const existing of fingerprints) {
if (circleEqual(pitch, existing, equaveCents)) {
continue search;
}
}
}
const gridPitch = degree * gridCents;
const deviation = Math.abs(pitch - gridPitch);
if (deviation < tolerance) {
harmonics.push(harmonic);
gridPitches.push(gridPitch);
scaleDegrees.add(scaleDegree);
fingerprints.push(pitch);
error = Math.max(error, deviation);
}
}
return {
harmonics,
gridPitches,
error,
scaleDegrees,
};
}

function* subshells(shell: number[]) {
if (shell.length <= 1) {
return;
}
for (let i = 1; i < shell.length; ++i) {
const subshell = [...shell];
subshell.splice(i, 1);
yield subshell;
}
}

function subsetOf(a: number[], b: number[]) {
for (const value of a) {
if (!b.includes(value)) {
return false;
}
}
return true;
}

/**
* Find concordance shells based on the given harmonic.
* @param rootHarmonic Root harmonic.
* @param maxHarmonic Maximum harmonic to consider for inclusion.
* @param tolerance Maximum deviation in cents from the equal division of pitch-space.
* @param division Number of equal divisions of the equave.
* @param equave The size of the interval of equivalence in cents.
* @param minSize Minimum size of the shell to search for.
* @param maxShells Maximum number of shells to discover.
* @returns An array of objects with
* harmonics of the shell as an array of integers,
* the harmonics snapped to the grid as an array of cents,
* the minimum achievable misalignment of the harmonics within the grid measured in cents.
* the shell as a reduced repeating scale of degrees (with only the zero endpoint included)
*/
export function minimaxConcordanceShells(
rootHarmonic: number,
maxHarmonic: number,
tolerance: number,
divisions: number,
equaveCents = 1200.0,
minSize = 3,
maxShells = 100
): Shell[] {
const gridCents = equaveCents / divisions;
// Calculate the largest possible shell if everything aligns perfectly.
const supershell = concordanceShell(
rootHarmonic,
maxHarmonic,
2 * tolerance,
divisions,
equaveCents
);

if (supershell.harmonics.length < minSize) {
return [];
}

const superAlignment = alignValues(supershell.harmonics, gridCents);
// Not gonna happen, but let's pretend to be hopeful.
if (superAlignment.error <= tolerance) {
return [{ ...supershell, ...superAlignment }];
}

// Start breaking the super-shell into smaller and smaller sub-shells that fit within the tolerance.
const result: Shell[] = [];
const badShells = [supershell.harmonics];
while (badShells.length) {
const shell = badShells.shift()!; // Break bigger shells first.
search: for (const subshell of subshells(shell)) {
for (const existing of result) {
if (subsetOf(subshell, existing.harmonics)) {
continue search;
}
}
for (const existing of badShells) {
if (arraysEqual(existing, subshell)) {
continue search;
}
}

const alignment = alignValues(subshell, gridCents);
if (alignment.error <= tolerance) {
const scaleDegrees = new Set(
alignment.gridPitches.map((p) =>
mmod(Math.round((p / equaveCents) * divisions), divisions)
)
);
result.push({
harmonics: subshell,
scaleDegrees,
...alignment,
});
if (result.length >= maxShells) {
return result;
}
} else if (subshell.length > minSize) {
badShells.push(subshell);
}
}
}

return result;
}
3 changes: 2 additions & 1 deletion src/views/AboutView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@
Lajos Mészáros - <i>developer</i><br />
Forrest Cahoon - <i>developer</i> <br />
Videco - <i>developer</i> <br />
Kraig Grady - <i>lattice advisor</i>
Kraig Grady - <i>lattice advisor</i> <br />
Ben Lampert - <i>developer</i>
</p>
</div>
</div>
Expand Down

0 comments on commit b3eb6b7

Please sign in to comment.