diff --git a/package-lock.json b/package-lock.json index 31062541..69e943d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", diff --git a/package.json b/package.json index 40eb8e46..6c97fb3f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.30", + "version": "3.0.0-beta.31", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", diff --git a/src/constants.ts b/src/constants.ts index 9ede2306..8c90c0c2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -26,6 +26,9 @@ export const MAX_INTERACTIVE_SUBGROUP_SIZE = 6 // Anything larger than this uses O(n²) methods (if available) instead of O(exp(n)) export const MAX_GEO_SUBGROUP_SIZE = 9 +// === Sanity limits for scale sharing === +export const MAX_NUMBER_OF_SHARED_INTERVALS = 255 + export const SEMITONE_12TET = 2 ** (1 / 12) export const TET12 = [...Array(12).keys()].map((i) => SEMITONE_12TET ** (i + 1)) diff --git a/src/scale.ts b/src/scale.ts index b346352a..7a2ab498 100644 --- a/src/scale.ts +++ b/src/scale.ts @@ -38,6 +38,10 @@ export class Scale { return valueToCents(this.equaveRatio) } + clone() { + return new Scale([...this.intervalRatios], this.baseFrequency, this.baseMidiNote, this.title) + } + /** * Convert this {@link Scale} instance to JSON. * @returns The serialized object with property `type` set to `'ScaleWorkshopScale'`. diff --git a/src/stores/__tests__/scale.spec.ts b/src/stores/__tests__/scale.spec.ts index 14aac5ae..085305f8 100644 --- a/src/stores/__tests__/scale.spec.ts +++ b/src/stores/__tests__/scale.spec.ts @@ -4,7 +4,7 @@ import { useScaleStore } from '../scale' import { Scale } from '../../scale' import { Interval } from 'sonic-weave' -const SERIALIZED = String.raw`{"scale":{"type":"ScaleWorkshopScale","intervalRatios":[1.148698354997035,1.3195079107728942,1.515716566510398,1.7411011265922482,2],"baseFrequency":261.6255653005987,"baseMidiNote":60,"title":""},"relativeIntervals":[{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[1,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":1,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[2,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":2,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[3,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":3,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[4,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":4,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[1,1],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":5,"d":5,"p":null,"q":null},"t":[]}],"latticeIntervals":null,"name":"","baseMidiNote":60,"userBaseFrequency":261.63,"autoFrequency":true,"autoColors":"silver","sourceText":"tet(5)","colors":["silver","silver","silver","silver","gray"],"labels":["1\\5","2\\5","3\\5","4\\5","5\\5"],"error":"","warning":"","isomorphicVertical":5,"isomorphicHorizontal":1,"keyboardMode":"isomorphic","equaveShift":0,"degreeShift":0,"pianoMode":"Asdf","accidentalColor":"black","lowAccidentalColor":"maroon","middleAccidentalColor":"navy","highAccidentalColor":"indigo"}` +const SERIALIZED = String.raw`{"scale":{"type":"ScaleWorkshopScale","intervalRatios":[1.148698354997035,1.3195079107728942,1.515716566510398,1.7411011265922482,2],"baseFrequency":261.6255653005987,"baseMidiNote":60,"title":""},"relativeIntervals":[{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[1,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":1,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[2,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":2,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[3,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":3,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[4,5],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":4,"d":5,"p":null,"q":null},"t":[]},{"type":"Interval","v":{"type":"TimeMonzo","t":{"n":0,"d":1},"p":[1,1],"r":{"n":1,"d":1}},"d":1,"s":0,"l":"","n":{"type":"n","n":5,"d":5,"p":null,"q":null},"t":[]}],"colors":["silver","silver","silver","silver","gray"],"labels":["1\\5","2\\5","3\\5","4\\5","5\\5"],"latticeIntervals":null,"name":"","baseMidiNote":60,"userBaseFrequency":261.63,"autoFrequency":true,"autoColors":"silver","sourceText":"tet(5)","error":"","warning":"","isomorphicVertical":5,"isomorphicHorizontal":1,"keyboardMode":"isomorphic","equaveShift":0,"degreeShift":0,"pianoMode":"Asdf","accidentalColor":"black","lowAccidentalColor":"maroon","middleAccidentalColor":"navy","highAccidentalColor":"indigo"}` describe('Scale store', () => { beforeEach(() => { @@ -40,4 +40,23 @@ describe('Scale store', () => { expect(centss[5]).toBeCloseTo(1200) expect(scale.latticeIntervals).toHaveLength(5) }) + + it('has a cap on the number of shareable intervals', () => { + const scale = useScaleStore() + scale.sourceText = '500::1000' + scale.computeScale() + const serialized = JSON.stringify(scale) + expect(serialized.length).toBeLessThan(80000) + + const data = JSON.parse(serialized, (key: string, value: any) => + Scale.reviver(key, Interval.reviver(key, value)) + ) + data.id = 'ABCabc123' + expect(data.latticeIntervals).toBeNull() + scale.fromJSON(data) + expect(scale.scale.size).toBe(255) + expect(scale.scale.equaveRatio).toBeCloseTo(2) + expect(scale.colorForIndex(scale.baseMidiNote)).toBe('gray') + expect(scale.colorForIndex(scale.baseMidiNote + 1)).toBe('silver') + }) }) diff --git a/src/stores/scale.ts b/src/stores/scale.ts index e03f75ca..83f722c6 100644 --- a/src/stores/scale.ts +++ b/src/stores/scale.ts @@ -29,6 +29,7 @@ import { APP_TITLE, DEFAULT_NUMBER_OF_COMPONENTS, INTERVALS_12TET, + MAX_NUMBER_OF_SHARED_INTERVALS, MIDI_NOTE_COLORS, MIDI_NOTE_NAMES, NUMBER_OF_NOTES, @@ -487,14 +488,52 @@ export const useScaleStore = defineStore('scale', () => { * Convert live state to a format suitable for storing on the server. */ function toJSON() { + let slicedScale = scale.value + let slicedIntervals = relativeIntervals.value + let slicedColors = colors.value + let slicedLabels = labels.value + if (slicedScale.intervalRatios.length > MAX_NUMBER_OF_SHARED_INTERVALS) { + slicedScale = slicedScale.clone() + slicedIntervals = [...slicedIntervals] + slicedColors = [...slicedColors] + slicedLabels = [...slicedLabels] + const equave = slicedScale.intervalRatios.pop()! + const equaveInterval = slicedIntervals.pop()! + const equaveColor = slicedColors.pop()! + const equaveLabel = slicedLabels.pop()! + slicedScale.intervalRatios = slicedScale.intervalRatios.slice( + 0, + MAX_NUMBER_OF_SHARED_INTERVALS - 1 + ) + slicedScale.intervalRatios.push(equave) + slicedIntervals = slicedIntervals.slice(0, MAX_NUMBER_OF_SHARED_INTERVALS - 1) + slicedIntervals.push(equaveInterval) + slicedColors = slicedColors.slice(0, MAX_NUMBER_OF_SHARED_INTERVALS - 1) + slicedColors.push(equaveColor) + slicedLabels = slicedLabels.slice(0, MAX_NUMBER_OF_SHARED_INTERVALS - 1) + slicedLabels.push(equaveLabel) + } const result: any = { - scale: scale.value.toJSON(), - relativeIntervals: relativeIntervals.value.map((i) => i.toJSON()) + scale: slicedScale.toJSON(), + relativeIntervals: slicedIntervals.map((i) => i.toJSON()), + colors: slicedColors, + labels: slicedLabels + } + if (result.colors.length) { + result.colors[result.colors.length - 1] = colors.value[colors.value.length - 1] + } + if (result.labels.length) { + result.labels[result.labels.length - 1] = labels.value[labels.value.length - 1] } if (relativeIntervals.value === latticeIntervals.value) { result.latticeIntervals = null } else { - result.latticeIntervals = latticeIntervals.value.map((i) => i.toJSON()) + if (latticeIntervals.value.length <= MAX_NUMBER_OF_SHARED_INTERVALS) { + result.latticeIntervals = latticeIntervals.value.map((i) => i.toJSON()) + } else { + // Give up. The lattice would be incomprehensible anyway. + result.latticeIntervals = null + } } for (const [key, value] of Object.entries(LIVE_STATE)) { if (key in result) {