From c17ed7e39730afaa73150f98cfa251eed5dd0072 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Wed, 31 Jan 2024 16:00:41 +0200 Subject: [PATCH] Unify generator search for temperaments of all ranks --- src/__tests__/monzo.spec.ts | 8 +- src/__tests__/subgroup.spec.ts | 6 +- src/__tests__/temperament.spec.ts | 78 ++++++++++----- src/temperament.ts | 151 ++++++++++++------------------ 4 files changed, 121 insertions(+), 122 deletions(-) diff --git a/src/__tests__/monzo.spec.ts b/src/__tests__/monzo.spec.ts index 942e0aa..a21b9c2 100644 --- a/src/__tests__/monzo.spec.ts +++ b/src/__tests__/monzo.spec.ts @@ -9,7 +9,7 @@ describe('Respeller', () => { }); it('knows that the major third is 9/7 in pajara', () => { - const third = new Fraction('3/2').div('7/5').pow(4); + const third = new Fraction('3/2').div('7/5').pow(4)!; const spelling = spell(third, ['50/49', '64/63']); expect(spelling.minBenedetti.equals('9/7')).toBeTruthy(); expect(spelling.noTwosMinNumerator.equals('32/25')).toBeTruthy(); @@ -17,19 +17,19 @@ describe('Respeller', () => { }); it('knows that the minor third is 7/6 is in pajara', () => { - const third = new Fraction('4/3').pow(3).div('10/7').div('10/7'); + const third = new Fraction('4/3').pow(3)!.div('10/7').div('10/7'); const spelling = spell(third, ['50/49', '64/63']); expect(spelling.minBenedetti.equals('7/6')).toBeTruthy(); }); it('knows that the minor third is 6/5 in srutal', () => { - const third = new Fraction('3/2').div('45/32').pow(3); + const third = new Fraction('3/2').div('45/32').pow(3)!; const spelling = spell(third, ['2048/2025', '4375/4374']); expect(spelling.minBenedetti.equals('6/5')).toBeTruthy(); }); it('knows that the major third is 5/4 is in srutal', () => { - const third = new Fraction('4/3').pow(2).div('45/32'); + const third = new Fraction('4/3').pow(2)!.div('45/32'); const spelling = spell(third, ['2048/2025', '4375/4374']); expect(spelling.minBenedetti.equals('5/4')).toBeTruthy(); }); diff --git a/src/__tests__/subgroup.spec.ts b/src/__tests__/subgroup.spec.ts index 27a9311..0912439 100644 --- a/src/__tests__/subgroup.spec.ts +++ b/src/__tests__/subgroup.spec.ts @@ -79,7 +79,7 @@ describe('Fractional just intonation subgroup', () => { expect(monzo[2]).toBe(1); expect( new Fraction(2) - .pow(monzo[0]) + .pow(monzo[0])! .mul(9 ** monzo[1]) .mul(5 ** monzo[2]) .equals(new Fraction(45, 32)) @@ -94,7 +94,7 @@ describe('Fractional just intonation subgroup', () => { expect(arraysEqual(password, [6, -5, 0, 1])).toBeTruthy(); expect( new Fraction(19, 5) - .pow(password[3]) + .pow(password[3])! .div(3 ** -password[1]) .mul(2 ** password[0]) .equals(new Fraction(1216, 1215)) @@ -105,7 +105,7 @@ describe('Fractional just intonation subgroup', () => { expect(arraysEqual(island, [2, -3, 2, 0])).toBeTruthy(); expect( new Fraction(13, 5) - .pow(island[2]) + .pow(island[2])! .div(3 ** -island[1]) .mul(2 ** island[0]) .equals(new Fraction(676, 675)) diff --git a/src/__tests__/temperament.spec.ts b/src/__tests__/temperament.spec.ts index 4dab931..99b59b5 100644 --- a/src/__tests__/temperament.spec.ts +++ b/src/__tests__/temperament.spec.ts @@ -247,7 +247,7 @@ describe('Temperament', () => { const monzo = [-5, 0, 7, -4, 0, 0, 0]; const subgroup = new Subgroup('2.5.7'); const temperament = Temperament.fromCommas([monzo], subgroup, true); - const [period, generator] = temperament.periodGenerator({ + const [period, generator] = temperament.periodGenerators({ temperEquaves: true, }); expect(period).toBeCloseTo(1200.3479); @@ -368,7 +368,7 @@ describe('Temperament', () => { it('can figure out the period and a generator for meantone', () => { const syntonicComma = toMonzo(new Fraction(81, 80)); const temperament = Temperament.fromCommas([syntonicComma]); - const [numPeriods, generator] = temperament.numPeriodsGenerator(); + const [numPeriods, [generator]] = temperament.numPeriodsGenerators(); expect(numPeriods).toBe(1); expect(generator.length).toBe(3); @@ -383,7 +383,7 @@ describe('Temperament', () => { it('can figure out the period and a generator for orgone', () => { const orgonisma = toMonzo(new Fraction(65536, 65219)); const temperament = Temperament.fromCommas([orgonisma]); - const [period, generator] = temperament.periodGenerator(); + const [period, generator] = temperament.periodGenerators(); expect(period).toBeCloseTo(1200); const poteGenerator = 323.372; @@ -394,7 +394,7 @@ describe('Temperament', () => { const limma = toMonzoAndResidual(new Fraction(256, 243), 3)[0]; const subgroup = new Subgroup(5); const temperament = Temperament.fromCommas([limma], subgroup); - const [period, generator] = temperament.periodGenerator(); + const [period, generator] = temperament.periodGenerators(); expect(period).toBeCloseTo(240); const poteGenerator = 399.594; @@ -405,7 +405,7 @@ describe('Temperament', () => { const diesis = toMonzo(new Fraction(128, 125)); const subgroup = new Subgroup(5); const temperament = Temperament.fromCommas([diesis], subgroup); - const [period, generator] = temperament.periodGenerator(); + const [period, generator] = temperament.periodGenerators(); expect(period).toBeCloseTo(400); const poteGenerator = 706.638; @@ -414,7 +414,7 @@ describe('Temperament', () => { it('can figure out the period and a generator for miracle', () => { const temperament = Temperament.fromCommas(['225/224', '1029/1024']); - const [period, generator] = temperament.periodGenerator(); + const [period, generator] = temperament.periodGenerators(); expect(period).toBeCloseTo(1200); const poteGenerator = 116.675; @@ -423,15 +423,15 @@ describe('Temperament', () => { it('can figure out the generators of whitewood', () => { const temperament = Temperament.fromCommas(['2187/2048'], 5); - const generators = temperament.generators(); + const [period, generator] = temperament.periodGenerators(); - expect(generators[0]).toBeCloseTo(1200 / 7); - expect(generators[1]).toBeCloseTo(mmod(374.469, 1200 / 7)); + expect(period).toBeCloseTo(1200 / 7); + expect(generator).toBeCloseTo(mmod(374.469, 1200 / 7)); }); it('can figure out the generators of kleismic', () => { const temperament = Temperament.fromCommas(['15625/15552'], 7); - const generators = temperament.generators(); + const generators = temperament.periodGenerators(); const mapping = temperament.getMapping(); @@ -442,32 +442,32 @@ describe('Temperament', () => { it('can figure out the generators of manwe', () => { const temperament = Temperament.fromCommas(['176/175', '1331/1323']); - const generators = temperament.generators(); + const generators = temperament.periodGenerators(); const mapping = temperament.getMapping(); expect(generators[0]).toBeCloseTo(mapping[0]); - expect(generators[1]).toBeCloseTo(mmod(-mapping[1], 1200)); - expect(generators[2]).toBeCloseTo(mmod(mapping[2], 1200)); + expect(generators[1]).toBeCloseTo(mmod(mapping[2], 1200)); + expect(generators[2]).toBeCloseTo(mmod(-mapping[1], 1200)); }); it('can figure out the generators of kalismic', () => { const temperament = Temperament.fromCommas(['9801/9800']); - const generators = temperament.generators(); + const generators = temperament.periodGenerators(); const mapping = temperament.getMapping(); expect(generators[0]).toBeCloseTo(600); expect(generators[0]).toBeCloseTo(temperament.tune('99/70')); - expect(generators[1]).toBeCloseTo(mmod(mapping[1], 600)); - expect(generators[2]).toBeCloseTo(mmod(-mapping[2], 600)); - expect(generators[3]).toBeCloseTo(mmod(-mapping[4], 600)); + expect(generators[1]).toBeCloseTo(mmod(-mapping[4], 600)); + expect(generators[2]).toBeCloseTo(mmod(mapping[1], 600)); + expect(generators[3]).toBeCloseTo(mmod(-mapping[3], 600)); }); it('can figure out the generators of xeimtionic', () => { const temperament = Temperament.fromCommas(['625/616']); - const generators = temperament.generators(); + const generators = temperament.periodGenerators(); const mapping = temperament.getMapping(); expect(generators[0]).toBeCloseTo(1200); @@ -477,15 +477,14 @@ describe('Temperament', () => { it('can figure out the generators of rank-2 xeimtionic', () => { const temperament = Temperament.fromCommas(['245/242', '625/616']); - const generators = temperament.generators(); - const periodGenerator = temperament.periodGenerator(); - - expect(generators[1]).toBeCloseTo(periodGenerator[1]); + const generators = temperament.periodGenerators(); + expect(generators[0]).toBeCloseTo(1200); + expect(generators[1]).toBeCloseTo(204.958); }); it('can figure out the generators of frostmic', () => { const temperament = Temperament.fromCommas(['245/242']); - const generators = temperament.generators(); + const generators = temperament.periodGenerators(); const mapping = temperament.getMapping(); expect(generators[0]).toBeCloseTo(1200); @@ -493,6 +492,33 @@ describe('Temperament', () => { expect(generators[2]).toBeCloseTo(mmod(mapping[3], 1200)); }); + it('can figure out the generators of altierran', () => { + const temperament = Temperament.fromCommas([ + '32805/32768', + '161280/161051', + ]); + const generators = temperament.periodGenerators(); + const mapping = temperament.getMapping(); + + expect(generators[0]).toBeCloseTo(1200); + expect(generators[1]).toBeCloseTo(mmod(-mapping[1], 1200)); + expect(generators[2]).toBeCloseTo(mmod(mapping[4], 1200)); + }); + + it('can figure out the generators of tenierian', () => { + const temperament = Temperament.fromCommas([ + '10985/10976', + '32805/32768', + '161280/161051', + ]); + const generators = temperament.periodGenerators(); + const mapping = temperament.getMapping(); + + expect(generators[0]).toBeCloseTo(1200); + // expect(generators[1]).toBeCloseTo(mmod(-mapping[1], 1200)); + // expect(generators[2]).toBeCloseTo(mmod(mapping[4], 1200)); + }); + it('can recover semaphore from its prefix', () => { const diesis = toMonzo(new Fraction(49, 48)); const temperament = Temperament.fromCommas([diesis]); @@ -557,7 +583,7 @@ describe('Temperament', () => { ); const pinkan = temperament.getMapping(); - const [d, g] = temperament.numPeriodsGenerator(); + const [d, [g]] = temperament.numPeriodsGenerators(); const semifourth = subgroup.toMonzoAndResidual(new Fraction(15, 13))[0]; const octave = [1, 0, 0, 0]; @@ -783,7 +809,7 @@ describe('Free Temperament', () => { const barbados = temperament.getMapping({units: 'nats'}); - const [period, generator] = temperament.periodGenerator(); + const [period, generator] = temperament.periodGenerators(); temperament.canonize(); const prefix = temperament.rankPrefix(2); @@ -820,7 +846,7 @@ describe('Free Temperament', () => { ); const pinkan = temperament.getMapping(); - const [d, g] = temperament.numPeriodsGenerator(); + const [d, [g]] = temperament.numPeriodsGenerators(); const semifourth_ = toMonzo(new Fraction(15, 13)); const semifourth = [semifourth_[0], semifourth_[1], semifourth_[5], 0]; diff --git a/src/temperament.ts b/src/temperament.ts index dac3f3d..2a8b515 100644 --- a/src/temperament.ts +++ b/src/temperament.ts @@ -138,61 +138,21 @@ abstract class BaseTemperament { * @param options Options determining how the temperament is interpreted as a tuning and the units of the result. * @returns A pair `[period, generator]` in cents (default) or the specified units. */ - periodGenerator(options?: TuningOptions): [number, number] { + periodGenerators(options?: TuningOptions): number[] { const mappingOptions = Object.assign({}, options || {}); mappingOptions.units = 'nats'; mappingOptions.primeMapping = false; const mapping = this.getMapping(mappingOptions); - const [numPeriods, generatorMonzo] = this.numPeriodsGenerator(); + const [numPeriods, generatorMonzos] = this.numPeriodsGenerators(); const period = mapping[0] / numPeriods; - let generator = dot(mapping, generatorMonzo); - generator = Math.min(mmod(generator, period), mmod(-generator, period)); - if (options?.units === 'nats') { - return [period, generator]; - } - if (options?.units === 'ratio') { - return [Math.exp(period), Math.exp(generator)]; - } - if (options?.units === 'semitones') { - return [natsToSemitones(period), natsToSemitones(generator)]; - } - return [natsToCents(period), natsToCents(generator)]; - } - - /** - * Obtain the generators of the temperament. - * @param options Options determining how the temperament is interpreted as a tuning and the units of the result. - * @param modPeriod Reduce the generators by the first one interpreted as the period. - * @returns An array of generators `[period, generatorA, generatorB, ...]` in cents (default) or the specified units. - */ - generators(options?: TuningOptions, modPeriod = true): number[] { - const mappingOptions = Object.assign({}, options || {}); - mappingOptions.units = 'nats'; - mappingOptions.primeMapping = false; - const mapping = this.getMapping(mappingOptions); - - const basisMonzosAndDivisions = this.fractionalGenerators(); - - if (!basisMonzosAndDivisions.length) { - return []; - } - const period = - dot(mapping, basisMonzosAndDivisions[0][0]) / - basisMonzosAndDivisions[0][1]; - const result = [period]; - basisMonzosAndDivisions.slice(1).map(([monzo, divisions]) => { - const generator = dot(mapping, monzo) / divisions; - if (modPeriod) { - result.push( - Math.min(mmod(generator, period), mmod(-generator, period)) - ); - } else { - result.push(generator); - } - }); - + let generators = generatorMonzos.map(g => dot(mapping, g)); + generators = generators.map(g => + Math.min(mmod(g, period), mmod(-g, period)) + ); + generators.sort((a, b) => a - b); + const result = [period].concat(generators); if (options?.units === 'nats') { return result; } @@ -254,56 +214,69 @@ abstract class BaseTemperament { } /** - * Obtain the number of periods per octave (or equave) and the generator in monzo form. - * The procedure assumes the temperament is of rank 2 and canonized. - * @returns A pair representing the number of periods per equave and the generator as a monzo of the temperament's subgroup. + * Obtain the number of periods per octave (or equave) and the generators in monzo form. + * The procedure assumes that the temperament is canonized. + * @returns A pair representing the number of periods per equave and the generators as monzos of the temperament's subgroup. */ - numPeriodsGenerator(): [number, Monzo] { + numPeriodsGenerators(): [number, Monzo[]] { + const rank = this.getRank(); const equaveUnit = this.algebra.basisBlade(0); - const equaveProj = equaveUnit.dot(this.value).vector(); - const generator = iteratedEuclid(equaveProj); - const numPeriods = Math.abs(dot(generator, equaveProj)); + const equaveOrthoComplement = equaveUnit.dotL(this.value); + const numPeriods = Math.abs(equaveOrthoComplement.reduce(gcd)); + if (rank === 1) { + return [numPeriods, []]; + } + if (rank === 2) { + const generator = iteratedEuclid(equaveOrthoComplement.vector()); + return [numPeriods, [generator]]; + } - return [numPeriods, generator]; - } + const algebra = this.algebra; + const dimensions = this.dimensions; + const value = this.value; - /** - * Obtain basis monzos and their divisions that generate the full limit alongside the temperament's comma basis. - * @returns Array of basis monzos and their divisions. - */ - fractionalGenerators() { - let hyperwedge = this.value.dual(); - - const basisMonzosAndDivisions: [number[], number][] = []; - for (let i = 0; i < this.algebra.dimensions; ++i) { - const multigen = this.algebra.basisBlade(i); - const multiwedge = hyperwedge.wedge(multigen); - if (multiwedge.ps) { - break; - } - const divisions = Math.abs(multiwedge.reduce(gcd)); - if ((i === 0 && divisions) || divisions === 1) { - basisMonzosAndDivisions.push([[...multigen.vector()], divisions]); - hyperwedge = multiwedge.scale(1 / divisions); + const unsplit = []; + for (let i = 1; i < dimensions; ++i) { + const blade = algebra.basisBlade(i); + if (Math.abs(blade.dotL(value).reduce(gcd)) === 1) { + unsplit.push(blade); } } - // XXX: I have no idea what I'm doing. - const params = []; - for (let i = 0; i < this.algebra.dimensions; ++i) { - params.push(hyperwedge.wedge(this.algebra.basisBlade(i)).ps); - } - const monzo = iteratedEuclid(params); - hyperwedge = hyperwedge.wedge(this.algebra.fromVector(monzo)); - const divisions = Math.abs(hyperwedge.reduce(gcd)); - basisMonzosAndDivisions.push([monzo, divisions]); - hyperwedge.rescale(1 / divisions); - - if (Math.abs(hyperwedge.ps) !== 1) { - throw new Error('Failed to extract generators'); + let generators = unsplit.slice(0, rank - 1); + + let measure = Math.abs( + equaveUnit.wedge(generators.reduce((a, b) => a.wedge(b))).star(value) + ); + + // TODO: Replace with a quaranteed deterministic solution. This is horrible! + for (let i = 0; i < 100; ++i) { + if (measure === numPeriods) { + return [numPeriods, generators.map(g => [...g.vector()])]; + } + + const candidates = [...generators]; + const i = Math.floor(Math.random() * candidates.length); + const j = Math.floor(Math.random() * unsplit.length); + if (Math.random() < 0.5) { + candidates[i] = candidates[i].add(unsplit[j]); + } else { + candidates[i] = candidates[i].sub(unsplit[j]); + } + + const candidateMeasure = Math.abs( + equaveUnit.wedge(candidates.reduce((a, b) => a.wedge(b))).star(value) + ); + if ( + (measure === 0 && candidateMeasure !== 0) || + candidateMeasure < measure + ) { + generators = candidates; + measure = candidateMeasure; + } } - return basisMonzosAndDivisions; + throw new Error('Failed to find generators'); } /**