diff --git a/src/__tests__/cli.spec.ts b/src/__tests__/cli.spec.ts new file mode 100644 index 00000000..0b331867 --- /dev/null +++ b/src/__tests__/cli.spec.ts @@ -0,0 +1,29 @@ +import {describe, it, expect} from 'vitest'; +import {toSonicWeaveInterchange} from '../cli'; + +describe('Interchange format', () => { + it('uses plain monzos up to 23-limit', () => { + const result = toSonicWeaveInterchange('23/16 "test"'); + expect(result).toContain('[-4 0 0 0 0 0 0 0 1> "test"'); + }); + + it('has representation for infinity', () => { + const result = toSonicWeaveInterchange('Infinity'); + expect(result).toContain('Infinity'); + }); + + it('has representation for NaN', () => { + const result = toSonicWeaveInterchange('NaN'); + expect(result).toContain('NaN'); + }); + + it('has representation for Infinity Hz', () => { + const result = toSonicWeaveInterchange('Infinity * 1 Hz'); + expect(result).toContain('Infinity * 1Hz'); + }); + + it('has representation for NaN Hz (normalizes)', () => { + const result = toSonicWeaveInterchange('NaN * 1 Hz'); + expect(result).toContain('NaN'); + }); +}); diff --git a/src/__tests__/interval.spec.ts b/src/__tests__/interval.spec.ts index 52d6aed8..b32040b4 100644 --- a/src/__tests__/interval.spec.ts +++ b/src/__tests__/interval.spec.ts @@ -115,7 +115,11 @@ describe('Interchange format', () => { expect(interval.toString()).toBe('[0.>@rc'); }); - // Real zero skipped + it('has an expression for real zero', () => { + const interval = new Interval(TimeReal.fromValue(0), 'linear'); + interval.node = interval.asMonzoLiteral(true); + expect(interval.toString()).toBe('[1 0.>@0.rc'); + }); it('has an expression for real -2', () => { const interval = new Interval(TimeReal.fromValue(-2), 'linear'); diff --git a/src/cli.ts b/src/cli.ts index 4b191793..530072d4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import type {REPLServer, ReplOptions} from 'repl'; import type {Context} from 'node:vm'; import {parse as parenCounter} from './parser/paren-counter'; import {literalToString} from './expression'; +import {TimeReal} from './monzo'; const {version} = require('../package.json'); /** diff --git a/src/interval.ts b/src/interval.ts index 67833f06..5736bf28 100644 --- a/src/interval.ts +++ b/src/interval.ts @@ -1078,7 +1078,7 @@ export class Interval { * @param interchange Boolean flag to format everything explicitly. * @returns A virtual monzo literal. */ - asMonzoLiteral(interchange = false): MonzoLiteral { + asMonzoLiteral(interchange = false): MonzoLiteral | undefined { let node: MonzoLiteral; if ( interchange && @@ -1089,7 +1089,11 @@ export class Interval { clone.numberOfComponents = NUM_INTERCHANGE_COMPONENTS; node = clone.asMonzoLiteral(); } else { - node = this.value.asMonzoLiteral(); + const maybeNode = this.value.asMonzoLiteral(); + if (maybeNode === undefined) { + return undefined; + } + node = maybeNode; } if ( interchange && @@ -1097,7 +1101,7 @@ export class Interval { node.components.length > NUM_INTERCHANGE_COMPONENTS || this.steps) ) { - node = this.value.asInterchangeLiteral(); + node = this.value.asInterchangeLiteral()!; } if (this.steps) { if (!node.basis.length && node.components.length) { @@ -1124,7 +1128,12 @@ export class Interval { if (this.isPureSteps()) { result = `${this.steps}°`; } else { - result = literalToString(this.asMonzoLiteral()); + const node = this.asMonzoLiteral(); + if (node) { + result = literalToString(node); + } else { + result = this.value.toString(); + } } if (this.domain === 'linear') { return `linear(${result})`; diff --git a/src/monzo.ts b/src/monzo.ts index 8fc1656f..25dd03bd 100644 --- a/src/monzo.ts +++ b/src/monzo.ts @@ -180,6 +180,10 @@ export class TimeReal { * @param value Multiplier of the time unit. */ constructor(timeExponent: number, value: number) { + // Intentional normalization for 0 and NaN. + if (!value) { + timeExponent = 0; + } this.timeExponent = timeExponent; this.value = value; } @@ -386,7 +390,7 @@ export class TimeReal { return new TimeReal(this.timeExponent * exponent, this.value ** exponent); } if (typeof other === 'number') { - if (!other) { + if (other === 0) { return new TimeMonzo(ZERO, []); } return new TimeReal(this.timeExponent * other, this.value ** other); @@ -450,10 +454,17 @@ export class TimeReal { * @returns The linear sum of the time reals. */ add(other: TimeMonzo | TimeReal) { + if (this.value === 0) { + return other.clone(); + } + const otherValue = other.valueOf(); + if (otherValue === 0) { + return this.clone(); + } if (other.timeExponent.valueOf() !== this.timeExponent) { throw new Error('Time exponents must match in addition.'); } - return new TimeReal(this.timeExponent, this.value + other.valueOf()); + return new TimeReal(this.timeExponent, this.value + otherValue); } /** @@ -462,10 +473,17 @@ export class TimeReal { * @returns The linear difference of the time reals. */ sub(other: TimeMonzo | TimeReal) { + if (this.value === 0) { + return other.neg(); + } + const otherValue = other.valueOf(); + if (otherValue === 0) { + return this.clone(); + } if (other.timeExponent.valueOf() !== this.timeExponent) { throw new Error('Time exponents must match in subtraction.'); } - return new TimeReal(this.timeExponent, this.value - other.valueOf()); + return new TimeReal(this.timeExponent, this.value - otherValue); } /** @hidden */ @@ -589,6 +607,9 @@ export class TimeReal { * @returns This modulo the other. */ mmod(other: TimeMonzo | TimeReal, ceiling = false) { + if (this.value === 0) { + return ceiling ? other.clone() : this.clone(); + } if (other.timeExponent.valueOf() !== this.timeExponent) { throw new Error('Time exponents must match in modulo.'); } @@ -699,69 +720,48 @@ export class TimeReal { /** * Obtain an AST node representing the time real as a monzo literal. + * @param interchange Boolean flag to use Hz basis for the absolute echelon. * @returns Monzo literal. */ - asMonzoLiteral(): MonzoLiteral { - const components: VectorComponent[] = []; - const basis: BasisElement[] = []; - if (this.timeExponent === -1) { - basis.push('Hz'); - components.push({sign: '', left: 1, right: '', exponent: null}); - } else if (this.timeExponent) { - basis.push('s'); - const {sign, whole, fractional, exponent} = numberToDecimalLiteral( - this.timeExponent, - 'r' - ); - components.push({ - sign, - left: Number(whole), - separator: '.', - right: fractional, - exponent, - }); - } - if (this.value < 0) { - basis.push({numerator: -1, denominator: null, radical: false}); - components.push({sign: '', left: 1, right: '', exponent: null}); - } - if (this.value !== 0) { - basis.push('rc'); - const {sign, whole, fractional, exponent} = numberToDecimalLiteral( - this.totalCents(true), - 'r' - ); - components.push({ - sign, - left: Number(whole), - separator: '.', - right: fractional, - exponent, - }); + asMonzoLiteral(interchange = false): MonzoLiteral | undefined { + if (!isFinite(this.value)) { + return undefined; } - return {type: 'MonzoLiteral', components, ups: 0, lifts: 0, basis}; - } - - /** - * Obtain an AST node representing the time monzo as a monzo literal suitable for interchange between programs. - * @returns Monzo literal. - */ - asInterchangeLiteral(): MonzoLiteral { const components: VectorComponent[] = []; const basis: BasisElement[] = []; - if (this.timeExponent) { - basis.push('Hz'); - const {sign, whole, fractional, exponent} = numberToDecimalLiteral( - -this.timeExponent, - 'r' - ); - components.push({ - sign, - left: Number(whole), - separator: '.', - right: fractional, - exponent, - }); + if (interchange) { + if (this.timeExponent) { + basis.push('Hz'); + const {sign, whole, fractional, exponent} = numberToDecimalLiteral( + -this.timeExponent, + 'r' + ); + components.push({ + sign, + left: Number(whole), + separator: '.', + right: fractional, + exponent, + }); + } + } else { + if (this.timeExponent === -1) { + basis.push('Hz'); + components.push({sign: '', left: 1, right: '', exponent: null}); + } else if (this.timeExponent) { + basis.push('s'); + const {sign, whole, fractional, exponent} = numberToDecimalLiteral( + this.timeExponent, + 'r' + ); + components.push({ + sign, + left: Number(whole), + separator: '.', + right: fractional, + exponent, + }); + } } if (this.value < 0) { basis.push({numerator: -1, denominator: null, radical: false}); @@ -769,6 +769,15 @@ export class TimeReal { } else if (this.value === 0) { basis.push({numerator: 0, denominator: null, radical: false}); components.push({sign: '', left: 1, right: '', exponent: null}); + basis.push('rc'); + components.push({ + sign: '', + left: 0, + separator: '.', + right: '', + exponent: null, + }); + return {type: 'MonzoLiteral', components, ups: 0, lifts: 0, basis}; } basis.push('rc'); const {sign, whole, fractional, exponent} = numberToDecimalLiteral( @@ -785,6 +794,29 @@ export class TimeReal { return {type: 'MonzoLiteral', components, ups: 0, lifts: 0, basis}; } + /** + * Obtain an AST node representing the time monzo as a monzo literal suitable for interchange between programs. + * @returns Monzo literal. + */ + asInterchangeLiteral(): MonzoLiteral | undefined { + return this.asMonzoLiteral(true); + } + + /** @hidden */ + formatTimeExponent() { + if (!this.timeExponent) { + return ''; + } else if (this.timeExponent === -1) { + return 'Hz'; + } else if (this.timeExponent === 1) { + return 's'; + } else { + return `s^${literalToString( + numberToDecimalLiteral(this.timeExponent, 'r') + )}`; + } + } + /** * Faithful string representation of the time real. * @param domain Domain of representation. @@ -803,7 +835,10 @@ export class TimeReal { } } if (!isFinite(this.value)) { - const value = this.value < 0 ? '-Infinity' : 'Infinity'; + let value = this.value < 0 ? '-Infinity' : 'Infinity'; + if (this.timeExponent) { + value = `${value} * 1${this.formatTimeExponent()}`; + } switch (domain) { case 'linear': return value; @@ -815,18 +850,7 @@ export class TimeReal { const scalar = this.clone(); scalar.timeExponent = 0; const value = literalToString(scalar.asDecimalLiteral()!); - if (!this.timeExponent) { - return value; - } else if (this.timeExponent === -1) { - return `${value}Hz`; - } else if (this.timeExponent === 1) { - return `${value}s`; - } else { - return `${value}s^${literalToString( - numberToDecimalLiteral(this.timeExponent, 'r') - )}`; - } - return value; + return `${value}${this.formatTimeExponent()}`; } if (!this.timeExponent) { const node = this.asCentsLiteral(); @@ -834,7 +858,11 @@ export class TimeReal { return literalToString(node); } } - return literalToString(this.asMonzoLiteral()); + const node = this.asMonzoLiteral(); + if (!node) { + throw new Error('Unable to represent real quantity as a string.'); + } + return literalToString(node); } /** @@ -1031,6 +1059,11 @@ export class TimeMonzo { if (residual === undefined) { residual = new Fraction(1); } + if (!residual.n) { + timeExponent = new Fraction(0); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + primeExponents = primeExponents.map(_ => new Fraction(0)); + } this.timeExponent = timeExponent; this.primeExponents = primeExponents; this.residual = residual; @@ -1632,6 +1665,12 @@ export class TimeMonzo { if (other instanceof TimeReal) { return other.add(this); } + if (!this.residual.n) { + return other.clone(); + } + if (!other.residual.n) { + return this.clone(); + } if (!this.timeExponent.equals(other.timeExponent)) { throw new Error( `Cannot add time monzos with disparate units. Have s^${this.timeExponent.toFraction()} + s^${other.timeExponent.toFraction()}.` @@ -1660,6 +1699,12 @@ export class TimeMonzo { if (other instanceof TimeReal) { return other.lsub(this); } + if (!this.residual.n) { + return other.neg(); + } + if (!other.residual.n) { + return this.clone(); + } if (this.timeExponent.compare(other.timeExponent)) { throw new Error( `Cannot subtract time monzos with disparate units. Have s^${this.timeExponent.toFraction()} + s^${other.timeExponent.toFraction()}.` @@ -2131,6 +2176,9 @@ export class TimeMonzo { if (other instanceof TimeReal) { return other.lmmod(this, ceiling); } + if (!this.residual.n) { + return ceiling ? other.clone() : this.clone(); + } if (!this.timeExponent.equals(other.timeExponent)) { throw new Error( `Cannot mod time monzos with disparate units. Have s^${this.timeExponent.toFraction()} mod s^${other.timeExponent.toFraction()}.` diff --git a/src/parser/__tests__/expression.spec.ts b/src/parser/__tests__/expression.spec.ts index 5bec3566..cda0d7c5 100644 --- a/src/parser/__tests__/expression.spec.ts +++ b/src/parser/__tests__/expression.spec.ts @@ -1,7 +1,7 @@ import {describe, it, expect} from 'vitest'; import {evaluateExpression} from '..'; import {Color, Interval, Val} from '../../interval'; -import {TimeMonzo} from '../../monzo'; +import {TimeMonzo, TimeReal} from '../../monzo'; function parseSingle(source: string) { const interval = evaluateExpression(source, false); @@ -2372,4 +2372,29 @@ describe('Poor grammar / Fun with "<"', () => { const product = evaluateExpression('PI(E)', false) as Interval; expect(product.valueOf()).toBeCloseTo(8.5397); }); + + it('normalizes zero (frequency)', () => { + const {interval, fraction} = parseSingle('0 Hz'); + expect(interval.isAbsolute()).toBe(false); + expect(fraction).toBe('0'); + }); + + it("doesn't normalize real zero to rational", () => { + const interval = evaluate('0r') as Interval; + expect(interval.isAbsolute()).toBe(false); + expect(interval.valueOf()).toBe(0); + expect(interval.value).toBeInstanceOf(TimeReal); + }); + + it('can add zero to anything (frequency)', () => { + const {interval} = parseSingle('440 Hz + 0'); + expect(interval.isAbsolute()).toBe(true); + expect(interval.valueOf()).toBe(440); + }); + + it('can add zero to anything (real)', () => { + const interval = evaluate('PI + 0') as Interval; + expect(interval.isAbsolute()).toBe(false); + expect(interval.valueOf()).toBeCloseTo(Math.PI); + }); });