diff --git a/src/__tests__/analysis.spec.ts b/src/__tests__/analysis.spec.ts index 250a4f34..9468e13a 100644 --- a/src/__tests__/analysis.spec.ts +++ b/src/__tests__/analysis.spec.ts @@ -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", () => { @@ -28,3 +37,73 @@ 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, 100.0); + expect(shell).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, 100.0, 4); + + // 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], + error: 2.59, + gridPitches: [0, 300, 1000, 1100, 1400, 1700, 1900], + }, + { + harmonics: [37, 44, 66, 70, 99, 105, 111], // The harmonic 105 is novel here compared to the rooted error model. + error: 2.888, + gridPitches: [0, 300, 1000, 1100, 1700, 1800, 1900], + }, + { + harmonics: [37, 44, 83, 93], + error: 2.177, + gridPitches: [0, 300, 1400, 1600], + }, + ]); + }); +}); diff --git a/src/analysis.ts b/src/analysis.ts index 6f82dfb9..beaf3fa9 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -1,4 +1,5 @@ import type { Scale } from "scale-workshop-core"; +import { arraysEqual, valueToCents } from "xen-dev-utils"; const EPSILON = 1e-6; @@ -112,3 +113,226 @@ 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); +} + +/** + * 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 gridCents The distance in cents between two grid lines of the equal division e.g. `100.0`. + * @param exclusionEquave The interval of equivalence. If non-null equivalent harmonics are filtered out. + * @returns An enumerated chord as an array of integers. + */ +export function concordanceShell( + rootHarmonic: number, + maxHarmonic: number, + tolerance: number, + gridCents: number, + exclusionEquave: number | null = 2 +) { + const harmonics = [rootHarmonic]; + search: for ( + let harmonic = rootHarmonic + 1; + harmonic <= maxHarmonic; + ++harmonic + ) { + if (exclusionEquave !== null) { + for (const existing of harmonics) { + if (harmonic % existing === 0) { + let temp = harmonic; + while (temp > existing && temp % exclusionEquave === 0) { + temp /= exclusionEquave; + if (temp === existing) { + continue search; + } + } + } + } + } + const pitch = valueToCents(harmonic / rootHarmonic); + const gridPitch = Math.round(pitch / gridCents) * gridCents; + if (Math.abs(pitch - gridPitch) < tolerance) { + harmonics.push(harmonic); + } + } + return harmonics; +} + +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; +} + +export type Shell = { + harmonics: number[]; + gridPitches: number[]; + error: number; +}; + +/** + * 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 gridCents The distance in cents between two grid lines of the equal division e.g. `100.0`. + * @param minSize Minimum size of the shell to search for. + * @param maxCount Maximum number of shells to discover. + * @param exclusionEquave The interval of equivalence. If non-null equivalent harmonics are filtered out. + * @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. + */ +export function minimaxConcordanceShells( + rootHarmonic: number, + maxHarmonic: number, + tolerance: number, + gridCents: number, + minSize = 3, + maxCount = 100, + exclusionEquave: number | null = 2 +): Shell[] { + // Calculate the largest possible shell if everything aligns perfectly. + const supershell = concordanceShell( + rootHarmonic, + maxHarmonic, + 2 * tolerance, + gridCents, + exclusionEquave + ); + + if (supershell.length < minSize) { + return []; + } + + const superAlignment = alignValues(supershell, gridCents); + // Not gonna happen, but let's pretend to be hopeful. + if (superAlignment.error <= tolerance) { + return [{ harmonics: supershell, ...superAlignment }]; + } + + // Start breaking the super-shell into smaller and smaller sub-shells that fit within the tolerance. + const result: Shell[] = []; + const badShells = [supershell]; + 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) { + result.push({ + harmonics: subshell, + ...alignment, + }); + if (result.length >= maxCount) { + return result; + } + } else if (subshell.length > minSize) { + badShells.push(subshell); + } + } + } + + return result; +} diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index 9a79254b..52dd1a95 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -39,7 +39,8 @@ Lajos Mészáros - developer
Forrest Cahoon - developer
Videco - developer
- Kraig Grady - lattice advisor + Kraig Grady - lattice advisor
+ Ben Lampert - developer