Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement constant structure checkers #17

Merged
merged 1 commit into from
Apr 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
Loading