diff --git a/src/__tests__/analysis.spec.ts b/src/__tests__/analysis.spec.ts index 250a4f34..564f6f1a 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,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], + }, + ]); + }); +}); diff --git a/src/analysis.ts b/src/analysis.ts index 6f82dfb9..8a7bc7db 100644 --- a/src/analysis.ts +++ b/src/analysis.ts @@ -1,4 +1,5 @@ import type { Scale } from "scale-workshop-core"; +import { arraysEqual, mmod, valueToCents } from "xen-dev-utils"; const EPSILON = 1e-6; @@ -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; +}; + +/** + * 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; +} diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 211f3628..aad0349e 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -769,6 +769,7 @@ function copyToClipboard() { " @cancel="showEqualizeModal = false" :scale="scale" + :centsFractionDigits="centsFractionDigits" /> -import { ref } from "vue"; +import { computed, ref } from "vue"; import Modal from "@/components/ModalDialog.vue"; -import type { Scale } from "scale-workshop-core"; +import { Scale } from "scale-workshop-core"; +import { alignCents, misalignment } from "@/analysis"; const props = defineProps<{ scale: Scale; + centsFractionDigits: number; }>(); const emit = defineEmits(["update:scale", "cancel"]); const divisions = ref(22); +const pitches = computed(() => + props.scale.intervals.map((i) => i.totalCents()) +); + +const gridCents = computed( + () => props.scale.equave.totalCents() / divisions.value +); + +const rootedError = computed(() => + misalignment(pitches.value, gridCents.value) +); + +const minimax = computed(() => alignCents(pitches.value, gridCents.value)); + function modify() { emit( "update:scale", props.scale.approximateEqualTemperament(divisions.value) ); } + +function modifyMinimax() { + emit( + "update:scale", + Scale.fromEqualTemperamentSubset( + minimax.value.degrees.slice(1).concat([divisions.value]), + props.scale.equave + ) + ); +} + 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