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 Interval JSON (de)serialization #290

Merged
merged 1 commit into from
May 5, 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
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
Loading