Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement algorithms for finding concordance shells #472

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 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,74 @@ 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;
delete shell.scaleDegrees;
}
expect(shells).toEqual([
{
harmonics: [37, 44, 66, 70, 83, 99, 111],
error: 2.59,
degrees: [0, 3, 10, 11, 14, 17, 19],
},
{
harmonics: [37, 44, 66, 70, 99, 105, 111],
error: 2.888,
degrees: [0, 3, 10, 11, 17, 18, 19],
},
{
harmonics: [37, 44, 83, 93],
error: 2.177,
degrees: [0, 3, 14, 16],
},
]);
});
});
255 changes: 255 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,257 @@ 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 degrees = optimalPitches!.map((pitch) => Math.round(pitch / gridCents));
let minOffset = Infinity;
let maxOffset = -Infinity;
let root = Infinity;
for (let i = 0; i < degrees.length; ++i) {
const offset = optimalPitches![i] - degrees[i] * gridCents;
minOffset = Math.min(minOffset, offset);
maxOffset = Math.max(maxOffset, offset);
if (degrees[i] < root) {
root = degrees[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 < degrees.length; ++i) {
degrees[i] -= root;
}
return {
error,
degrees,
};
}

/**
* 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[];
error: number;
degrees: 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 degrees = [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 deviation = Math.abs(pitch - degree * gridCents);
if (deviation < tolerance) {
harmonics.push(harmonic);
degrees.push(degree);
scaleDegrees.add(scaleDegree);
fingerprints.push(pitch);
error = Math.max(error, deviation);
}
}
return {
harmonics,
error,
degrees,
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.degrees.map((degree) => mmod(degree, divisions))
);
result.push({
harmonics: subshell,
scaleDegrees,
...alignment,
});
if (result.length >= maxShells) {
return result;
}
} else if (subshell.length > minSize) {
badShells.push(subshell);
}
}
}

return result;
}
1 change: 1 addition & 0 deletions src/components/ScaleBuilder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ function copyToClipboard() {
"
@cancel="showEqualizeModal = false"
:scale="scale"
:centsFractionDigits="centsFractionDigits"
/>

<TemperModal
Expand Down
Loading