Skip to content

Commit

Permalink
Implement constant structure checkers
Browse files Browse the repository at this point in the history
One for equal temperaments and one with a margin of equivalence.
  • Loading branch information
frostburn committed Apr 5, 2024
1 parent 2e50ba1 commit d80bd3c
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 0 deletions.
66 changes: 66 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,
hasConstantStructure,
hasMarginConstantStructure,
iteratedEuclid,
norm,
valueToCents,
Expand Down Expand Up @@ -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);
});
});
96 changes: 96 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number>();

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

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

0 comments on commit d80bd3c

Please sign in to comment.