diff --git a/package-lock.json b/package-lock.json index 0f2aa056..ddaf82ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "moment-of-symmetry": "^0.4.2", - "xen-dev-utils": "^0.6.0" + "xen-dev-utils": "^0.6.1" }, "bin": { "sonic-weave": "bin/sonic-weave.js" @@ -4444,9 +4444,9 @@ } }, "node_modules/xen-dev-utils": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.6.0.tgz", - "integrity": "sha512-pBxjTVl6i+eNIkVpRY/mt4fat+4x1jYnAVX+S98rX3sIrJwDO1dcwviFzm8R5a4R3vC49yaQdOP7inkM6Bq4gA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.6.1.tgz", + "integrity": "sha512-smdLvCIYnAuwGiVdj67kW8kS37gwtUFBF253ukTa4V7BfVglt7HYR9r1EQTCB8FC1J8rdNY++M8TRID+YNsH4A==", "engines": { "node": ">=10.6.0" }, diff --git a/package.json b/package.json index 34c833a3..8e81ab0b 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ }, "dependencies": { "moment-of-symmetry": "^0.4.2", - "xen-dev-utils": "^0.6.0" + "xen-dev-utils": "^0.6.1" }, "engines": { "node": ">=12.0.0" diff --git a/src/__tests__/interval.spec.ts b/src/__tests__/interval.spec.ts index ecc6b2fe..3fa0b266 100644 --- a/src/__tests__/interval.spec.ts +++ b/src/__tests__/interval.spec.ts @@ -1,7 +1,8 @@ import {describe, it, expect} from 'vitest'; import {TimeMonzo} from '../monzo'; -import {intervalValueAs} from '../interval'; +import {Interval, intervalValueAs} from '../interval'; import {FractionLiteral, NedjiLiteral} from '../expression'; +import {sw} from '../parser'; describe('Idempontent formatting', () => { it('has stable ratios (common factor)', () => { @@ -42,3 +43,72 @@ describe('Idempontent formatting', () => { expect(node.denominator).toBe(12); }); }); + +const SERIALIZED = + '["hello",{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":2,"d":1},{"n":1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"linear","steps":0,"label":"","node":{"type":"IntegerLiteral","value":"12"},"trackingIds":[]},12,{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":1,"d":1},{"n":1,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"linear","steps":0,"label":"","node":{"type":"DecimalLiteral","sign":"","whole":"1","fractional":"2","exponent":null,"flavor":"e"},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":0,"d":1},{"n":-1,"d":1},{"n":1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"linear","steps":0,"label":"","node":{"type":"FractionLiteral","numerator":"5","denominator":"3"},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":1,"d":3},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"linear","steps":0,"label":"","node":{"type":"RadicalLiteral","argument":{"n":2,"d":1},"exponent":{"n":1,"d":3}},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":46797,"d":80000}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"type":"CentsLiteral","sign":"","whole":"701","fractional":"955","exponent":null,"real":false},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-4,"d":1},{"n":4,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"type":"SquareSuperparticular","start":"9","end":null},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":3,"label":"","node":{"type":"StepLiteral","count":3},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":0,"d":1},{"n":6,"d":13},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"type":"NedjiLiteral","numerator":6,"denominator":13,"equaveNumerator":3,"equaveDenominator":null},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-2,"d":1},{"n":0,"d":1},{"n":1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"ups":0,"lifts":0,"type":"FJS","pythagorean":{"type":"Pythagorean","quality":{"fraction":"","quality":"M"},"degree":{"negative":false,"base":3,"octaves":0,"imperfect":true}},"superscripts":[[5,""]],"subscripts":[]},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":1,"d":1},{"n":-2,"d":1},{"n":0,"d":1},{"n":1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"ups":0,"lifts":0,"type":"AbsoluteFJS","pitch":{"type":"AbsolutePitch","nominal":"A","accidentals":[{"fraction":"","accidental":"b"}],"octave":4},"superscripts":[[7,""]],"subscripts":[]},"trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":-1,"d":1},"primeExponents":[{"n":0,"d":1},{"n":1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":37,"d":1}},"domain":"linear","steps":0,"label":"","trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":1,"d":1},"primeExponents":[{"n":-3,"d":1},{"n":1,"d":1},{"n":-3,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},"domain":"linear","steps":0,"label":"","trackingIds":[]},{"type":"Interval","value":{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-3,"d":1},{"n":1,"d":1},{"n":1,"d":1}],"residual":{"n":1,"d":1}},"domain":"logarithmic","steps":0,"label":"","node":{"ups":0,"lifts":0,"type":"MonzoLiteral","components":[{"sign":"-","left":3,"separator":"","right":"","exponent":null},{"sign":"","left":1,"separator":"","right":"","exponent":null},{"sign":"","left":1,"separator":"","right":"","exponent":null}],"basis":[]},"trackingIds":[]}]'; + +describe('Interval JSON serialization', () => { + it('can be serialized alongside other data', () => { + const data: any = [ + 'hello', + sw`12`, + 12, + sw`1.2e`, + sw`5/3`, + sw`radical(2 /^ 3)`, + sw`701.955`, + sw`S9`, + sw`3°`, + sw`6\\13<3>`, + sw`M3^5`, + sw`Ab4^7`, + sw`111 Hz`, + sw`3ms`, + sw`[-3 1 1>`, + ]; + const serialized = JSON.stringify(data); + expect(serialized).toBe(SERIALIZED); + }); + + it('can be deserialized alongside other data', () => { + const data = JSON.parse(SERIALIZED, Interval.reviver); + expect(data).toHaveLength(15); + expect(data[0]).toBe('hello'); + expect(data[2]).toBe(12); + expect(data.map(datum => datum.toString())).toEqual([ + 'hello', + '12', + '12', + '1.2e', + '5/3', + '2^1/3', + '701.955', + 'S9', + '3°', + '6\\13<3>', + 'M3^5', + 'Ab4^7', + '111 Hz', + '2^-3*3*5^-3*(1s)^1', + '[-3 1 1>', + ]); + + expect(data.map(datum => datum.valueOf())).toEqual([ + 'hello', + 12, + 12, + 1.2, + 1.666666666666667, + 1.2599210498948732, + 1.499999999250199, + 1.0124999999999997, + 1, + 1.6603888560010867, + 1.25, + 1.5555555555555558, + 111, + 0.003, + 1.875, + ]); + }); +}); diff --git a/src/__tests__/monzo.spec.ts b/src/__tests__/monzo.spec.ts index ba232f66..7a05f2cb 100644 --- a/src/__tests__/monzo.spec.ts +++ b/src/__tests__/monzo.spec.ts @@ -460,16 +460,17 @@ describe('JSON serialization', () => { new TimeReal(-1, 777), 3.5, TimeMonzo.fromFraction('81/80'), + null, ]; const serialized = JSON.stringify(data); expect(serialized).toBe( - '["Hello, world!",{"n":10,"d":7},{"type":"TimeReal","timeExponent":-1,"value":777},3.5,{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-4,"d":1},{"n":4,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}}]' + '["Hello, world!",{"n":10,"d":7},{"type":"TimeReal","timeExponent":-1,"value":777},3.5,{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-4,"d":1},{"n":4,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},null]' ); }); it('can deserialize an array of primitives, fractions and monzos', () => { const serialized = - '["Hello, world!",{"n":10,"d":7},{"type":"TimeReal","timeExponent":-1,"value":777},3.5,{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-4,"d":1},{"n":4,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}}]'; + '["Hello, world!",{"n":10,"d":7},{"type":"TimeReal","timeExponent":-1,"value":777},3.5,{"type":"TimeMonzo","timeExponent":{"n":0,"d":1},"primeExponents":[{"n":-4,"d":1},{"n":4,"d":1},{"n":-1,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1},{"n":0,"d":1}],"residual":{"n":1,"d":1}},null]'; function reviver(key: string, value: any) { return TimeMonzo.reviver( key, @@ -477,7 +478,7 @@ describe('JSON serialization', () => { ); } const data = JSON.parse(serialized, reviver); - expect(data).toHaveLength(5); + expect(data).toHaveLength(6); expect(data[0]).toBe('Hello, world!'); @@ -492,5 +493,7 @@ describe('JSON serialization', () => { expect(data[4]).toBeInstanceOf(TimeMonzo); expect(data[4].toFraction().toFraction()).toBe('81/80'); + + expect(data[5]).toBeNull(); }); }); diff --git a/src/expression.ts b/src/expression.ts index 9de8cf02..5e3df0c2 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -1046,3 +1046,104 @@ export function integerToVectorComponent(num: number): VectorComponent { exponent: null, }; } + +export function literalToJSON(literal?: IntervalLiteral) { + if (!literal) { + return undefined; + } + const type = literal.type; + switch (literal.type) { + case 'IntegerLiteral': + return {type, value: literal.value.toString()}; + case 'DecimalLiteral': + return {...literal, whole: literal.whole.toString()}; + case 'FractionLiteral': + return { + type, + numerator: literal.numerator.toString(), + denominator: literal.denominator.toString(), + }; + case 'RadicalLiteral': + return { + type, + argument: literal.argument.toJSON(), + exponent: literal.exponent.toJSON(), + }; + case 'CentsLiteral': + return { + ...literal, + whole: literal.whole.toString(), + }; + case 'SquareSuperparticular': + return { + type, + start: literal.start.toString(), + end: literal.end && literal.end.toString(), + }; + case 'StepLiteral': + case 'NedjiLiteral': + case 'CentLiteral': + case 'ReciprocalCentLiteral': + case 'FJS': + case 'AspiringFJS': + case 'AbsoluteFJS': + case 'AspiringAbsoluteFJS': + case 'HertzLiteral': + case 'SecondLiteral': + case 'ReciprocalLogarithmicHertzLiteral': + case 'MonzoLiteral': + case 'ValLiteral': + case 'SparseOffsetVal': + case 'WartsLiteral': + return literal; + } +} + +export function literalFromJSON(object: any): IntervalLiteral | undefined { + if (object === undefined) { + return undefined; + } + const type: IntervalLiteral['type'] = object.type; + switch (type) { + case 'IntegerLiteral': + return {type, value: BigInt(object.value)}; + case 'DecimalLiteral': + return {...object, whole: BigInt(object.whole)}; + case 'FractionLiteral': + return { + type, + numerator: BigInt(object.numerator), + denominator: BigInt(object.denominator), + }; + case 'RadicalLiteral': + return { + type, + argument: Fraction.reviver('argument', object.argument), + exponent: Fraction.reviver('exponent', object.exponent), + }; + case 'CentsLiteral': + return {...object, whole: BigInt(object.whole)}; + case 'SquareSuperparticular': + return { + type, + start: BigInt(object.start), + end: object.end && BigInt(object.end), + }; + case 'StepLiteral': + case 'NedjiLiteral': + case 'CentLiteral': + case 'ReciprocalCentLiteral': + case 'FJS': + case 'AspiringFJS': + case 'AbsoluteFJS': + case 'AspiringAbsoluteFJS': + case 'HertzLiteral': + case 'SecondLiteral': + case 'ReciprocalLogarithmicHertzLiteral': + case 'MonzoLiteral': + case 'ValLiteral': + case 'SparseOffsetVal': + case 'WartsLiteral': + return object; + } +} diff --git a/src/interval.ts b/src/interval.ts index 626c41a8..03253d5e 100644 --- a/src/interval.ts +++ b/src/interval.ts @@ -23,6 +23,8 @@ import { inferFJSFlavor, integerToVectorComponent, MonzoLiteral, + literalToJSON, + literalFromJSON, } from './expression'; import {TimeMonzo, TimeReal} from './monzo'; import {asAbsoluteFJS, asFJS} from './fjs'; @@ -217,6 +219,61 @@ export class Interval { return new Interval(real, 'linear', 0, real.asDecimalLiteral(), convert); } + /** + * Revive an {@link Interval} instance produced by `Interval.toJSON()`. Return everything else as is. + * + * Intended usage: + * ```ts + * const data = JSON.parse(serializedData, Interval.reviver); + * ``` + * + * @param key Property name. + * @param value Property value. + * @returns Deserialized {@link Interval} instance or other data without modifications. + */ + static reviver(key: string, value: any) { + if ( + typeof value === 'object' && + value !== null && + value.type === 'Interval' + ) { + let monzo: TimeMonzo | TimeReal; + if (value.value.type === 'TimeMonzo') { + monzo = TimeMonzo.reviver('value', value.value); + } else { + monzo = TimeReal.reviver('value', value.value); + } + const result = new Interval( + monzo, + value.domain, + value.steps, + literalFromJSON(value.node) + ); + result.label = value.label; + result.color = value.color && new Color(value.color); + result.trackingIds = new Set(value.trackingIds); + return result; + } + return value; + } + + /** + * Serialize the time monzo to a JSON compatible object. + * @returns The serialized object with property `type` set to `'TimeMonzo'`. + */ + toJSON() { + return { + type: 'Interval', + value: this.value.toJSON(), + domain: this.domain, + steps: this.steps, + label: this.label, + color: this.color && this.color.value, + node: literalToJSON(this.node), + trackingIds: Array.from(this.trackingIds), + }; + } + /** * Clone this {@link Interval} instance without deeply copying any of the parts. * @returns An interval like this one but replacing any of the parts won't change the original. diff --git a/src/monzo.ts b/src/monzo.ts index 62aa1112..2e7154d6 100644 --- a/src/monzo.ts +++ b/src/monzo.ts @@ -222,7 +222,11 @@ export class TimeReal { * @returns Deserialized {@link TimeReal} instance or other data without modifications. */ static reviver(key: string, value: any) { - if (typeof value === 'object' && value.type === 'TimeReal') { + if ( + typeof value === 'object' && + value !== null && + value.type === 'TimeReal' + ) { return new TimeReal(value.timeExponent, value.value); } return value; @@ -1153,7 +1157,11 @@ export class TimeMonzo { * @returns Deserialized {@link TimeMonzo} instance or other data without modifications. */ static reviver(key: string, value: any) { - if (typeof value === 'object' && value.type === 'TimeMonzo') { + if ( + typeof value === 'object' && + value !== null && + value.type === 'TimeMonzo' + ) { const timeExponent = Fraction.reviver('timeExponent', value.timeExponent); const primeExponents = (value.primeExponents as UnsignedFraction[]).map( (component, i) => Fraction.reviver(i.toString(), component)