diff --git a/src/__tests__/index.spec.ts b/src/__tests__/index.spec.ts index fe0ef09..f24b4fd 100644 --- a/src/__tests__/index.spec.ts +++ b/src/__tests__/index.spec.ts @@ -14,6 +14,8 @@ import { fareyInterior, fareySequence, gcd, + hasConstantStructure, + hasMarginConstantStructure, iteratedEuclid, norm, valueToCents, @@ -269,3 +271,67 @@ describe('Farey interior generator', () => { expect(farey.next().done).toBe(true); }); }); + +describe('Constant structure checker', () => { + it('Rejects diatonic in 12-tone equal temperament', () => { + const steps = [2, 4, 5, 7, 9, 11, 12]; + expect(hasConstantStructure(steps)).toBe(false); + }); + + it('Accepts diatonic in 19-tone equal temperament', () => { + const steps = [3, 6, 8, 11, 14, 17, 19]; + expect(hasConstantStructure(steps)).toBe(true); + }); + + it("Produces Zarlino's sequence in 311-tone equal temperament", () => { + const sizes: number[] = []; + const gs: number[] = [100, 182]; + for (let i = 3; i < 60; ++i) { + const zarlino = [...gs]; + zarlino.push(311); + zarlino.sort((a, b) => a - b); + if (hasConstantStructure(zarlino)) { + sizes.push(i); + } + const last = gs[gs.length - 1]; + if (i & 1) { + gs.push((last + 100) % 311); + } else { + gs.push((last + 82) % 311); + } + } + expect(sizes).toEqual([3, 4, 7, 10, 17, 34, 58]); + }); +}); + +describe('Constant structure checker with a margin of equivalence', () => { + it('Rejects diatonic in 12-tone equal temperament (zero margin)', () => { + const scaleCents = [200, 400, 500, 700, 900, 1100, 1200]; + expect(hasMarginConstantStructure(scaleCents, 0)).toBe(false); + }); + + it('Accepts diatonic in 19-tone equal temperament (margin of 1 cent)', () => { + const scaleCents = [189.5, 378.9, 505.3, 694.7, 884.2, 1073.7, 1200]; + expect(hasMarginConstantStructure(scaleCents, 1)).toBe(true); + }); + + const zarlino: number[] = [386.313714, 701.955001]; + for (let i = 0; i < 31; ++i) { + const last = zarlino[zarlino.length - 1]; + if (i & 1) { + zarlino.push((last + 315.641287) % 1200); + } else { + zarlino.push((last + 386.313714) % 1200); + } + } + zarlino.sort((a, b) => a - b); + zarlino.push(1200); + + it('Accepts Zarlino[34] with a margin of 1 cent', () => { + expect(hasMarginConstantStructure(zarlino, 1)).toBe(true); + }); + + it('Rejects Zarlino[34] with a margin of 2 cents', () => { + expect(hasMarginConstantStructure(zarlino, 2)).toBe(false); + }); +}); diff --git a/src/index.ts b/src/index.ts index 8cd6684..ef18560 100644 --- a/src/index.ts +++ b/src/index.ts @@ -342,3 +342,99 @@ export function* fareyInterior( yield new Fraction(a, b); } } + +/** + * Determine if an equally tempered scale has constant structure i.e. you can tell the interval class from the size of an interval. + * @param steps Musical intervals measured in steps not including the implicit 0 at the start, but including the interval of repetition at the end. + * @returns `true` if the scale has constant structure `false` otherwise. + */ +export function hasConstantStructure(steps: number[]) { + const n = steps.length; + if (!n) { + return false; + } + const period = steps[n - 1]; + const scale = [...steps]; + for (const step of steps) { + scale.push(period + step); + } + + // Map from interval sizes to (zero-indexed) interval classes a.k.a. subtensions + const subtensions = new Map(); + + // Against unison + for (let i = 0; i < n; i++) { + if (subtensions.has(scale[i])) { + return false; + } + subtensions.set(scale[i], i + 1); + } + + // Against each other + for (let i = 0; i < n - 1; ++i) { + for (let j = 1; j < n; ++j) { + const width = scale[i + j] - scale[i]; + if (subtensions.has(width) && subtensions.get(width) !== j) { + return false; + } + // Add the observed width to the collection + subtensions.set(width, j); + } + } + return true; +} + +/** + * Determine if a scale has constant structure i.e. you can tell the interval class from the size of an interval. + * @param scaleCents Musical intervals measured in cents not including the implicit 0 at the start, but including the interval of repetition at the end. + * @param margin Margin of equivalence between two intervals measured in cents. + * @returns `true` if the scale definitely has constant structure. (A `false` result may convert to `true` using a smaller margin.) + */ +export function hasMarginConstantStructure( + scaleCents: number[], + margin: number +) { + const n = scaleCents.length; + if (!n) { + return false; + } + const period = scaleCents[n - 1]; + const scale = [...scaleCents]; + for (const cents of scaleCents) { + scale.push(period + cents); + } + + // Map from interval sizes to (zero-indexed) interval classes a.k.a. subtensions + const subtensions = new Map(); + + // Against unison + for (let i = 0; i < n; i++) { + if (subtensions.has(scale[i])) { + return false; + } + subtensions.set(scale[i], i + 1); + } + + // Against each other + for (let i = 0; i < n - 1; ++i) { + for (let j = 1; j < n; ++j) { + const width = scale[i + j] - scale[i]; + // Try to get lucky with an exact match + if (subtensions.has(width) && subtensions.get(width) !== j) { + return false; + } + // Check for margin equivalence + for (const [existing, subtension] of subtensions.entries()) { + if (subtension === j) { + continue; + } + if (Math.abs(existing - width) <= margin) { + return false; + } + } + // Add the observed width to the collection + subtensions.set(width, j); + } + } + return true; +}