Skip to content

Commit

Permalink
Implement constant structure checkers
Browse files Browse the repository at this point in the history
One for equal temperaments producing a counter-example and one with a margin of equivalence.
  • Loading branch information
frostburn committed Apr 7, 2024
1 parent 2e50ba1 commit e1da747
Show file tree
Hide file tree
Showing 2 changed files with 220 additions and 0 deletions.
113 changes: 113 additions & 0 deletions src/__tests__/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
fareyInterior,
fareySequence,
gcd,
falsifyConstantStructure,
hasMarginConstantStructure,
iteratedEuclid,
norm,
valueToCents,
Expand Down Expand Up @@ -269,3 +271,114 @@ describe('Farey interior generator', () => {
expect(farey.next().done).toBe(true);
});
});

describe('Constant structure falsifier', () => {
it('Rejects diatonic in 12-tone equal temperament with F-to-B against B-to-F', () => {
const steps = [2, 4, 5, 7, 9, 11, 12];
const [[lowAug4, highAug4], [lowDim5, highDim5]] =
falsifyConstantStructure(steps)!;
// C = -1
// D = 0
// E = 1
expect(lowAug4).toBe(2); // F
expect(highAug4).toBe(5); // B

expect(lowDim5).toBe(5); // B
expect(highDim5 % 7).toBe(2); // F
});

it('Accepts diatonic in 19-tone equal temperament', () => {
const steps = [3, 6, 8, 11, 14, 17, 19];
expect(falsifyConstantStructure(steps)).toBe(null);
});

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 (falsifyConstantStructure(zarlino) === null) {
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]);
});

it('Accepts the empty scale', () => {
expect(falsifyConstantStructure([])).toBe(null);
});

it('Accepts the trivial scale', () => {
expect(falsifyConstantStructure([1])).toBe(null);
});

it('Rejects a scale with a repeated step (early)', () => {
expect(falsifyConstantStructure([0, 1200])).toEqual([
[-1, 1],
[0, 1],
]);
});

it('Rejects a scale with a repeated step (late)', () => {
expect(falsifyConstantStructure([1200, 1200])).toEqual([
[-1, 1],
[-1, 2],
]);
});
});

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);
});

it('Accepts the empty scale', () => {
expect(hasMarginConstantStructure([], 0)).toBe(true);
});

it('Accepts the trivial scale', () => {
expect(hasMarginConstantStructure([1200], 0)).toBe(true);
});

it('Rejects a scale with a comma step (early)', () => {
expect(hasMarginConstantStructure([1, 1200], 2)).toBe(false);
});

it('Rejects a scale with a comma step (late)', () => {
expect(hasMarginConstantStructure([1199, 1200], 2)).toBe(false);
});
});
107 changes: 107 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,110 @@ 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 A pair of pairs of indices that have the same stepspan but different subtension. `null` if the scale has constant structure.
*/
export function falsifyConstantStructure(
steps: number[]
): [[number, number], [number, number]] | null {
const n = steps.length;
if (!n) {
return null;
}
const period = steps[n - 1];
const scale = [...steps];
for (const step of steps) {
scale.push(period + step);
}

// Map from interval sizes to pairs of [index, subtension]
const subtensions = new Map<number, [number, number]>();

// Against implicit unison
for (let i = 0; i < n; i++) {
if (subtensions.has(scale[i])) {
return [subtensions.get(scale[i])!, [-1, i + 1]];
}
subtensions.set(scale[i], [-1, 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)) {
const [k, l] = subtensions.get(width)!;
if (j !== l) {
return [
[k, k + l],
[i, i + j],
];
}
}
// Add the observed width to the collection
subtensions.set(width, [i, j]);
}
}
return null;
}

/**
* 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 true;
}
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<number, number>();

// Against unison
for (let i = 0; i < n; i++) {
// Check for margin equivalence
for (const existing of subtensions.keys()) {
if (Math.abs(existing - scale[i]) <= margin) {
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;
}

0 comments on commit e1da747

Please sign in to comment.