Skip to content

Commit

Permalink
Implement Interval JSON (de)serialization
Browse files Browse the repository at this point in the history
ref #288
  • Loading branch information
frostburn committed May 5, 2024
1 parent 5a5bde7 commit beb6843
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 11 deletions.
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
72 changes: 71 additions & 1 deletion src/__tests__/interval.spec.ts
Original file line number Diff line number Diff line change
@@ -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)', () => {
Expand Down Expand Up @@ -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,
]);
});
});
9 changes: 6 additions & 3 deletions src/__tests__/monzo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,24 +460,25 @@ 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,
TimeReal.reviver(key, Fraction.reviver(key, value))
);
}
const data = JSON.parse(serialized, reviver);
expect(data).toHaveLength(5);
expect(data).toHaveLength(6);

expect(data[0]).toBe('Hello, world!');

Expand All @@ -492,5 +493,7 @@ describe('JSON serialization', () => {

expect(data[4]).toBeInstanceOf(TimeMonzo);
expect(data[4].toFraction().toFraction()).toBe('81/80');

expect(data[5]).toBeNull();
});
});
101 changes: 101 additions & 0 deletions src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
57 changes: 57 additions & 0 deletions src/interval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
inferFJSFlavor,
integerToVectorComponent,
MonzoLiteral,
literalToJSON,
literalFromJSON,
} from './expression';
import {TimeMonzo, TimeReal} from './monzo';
import {asAbsoluteFJS, asFJS} from './fjs';
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions src/monzo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit beb6843

Please sign in to comment.