Share scale
{{ exportTextClipboard }}
diff --git a/src/constants.ts b/src/constants.ts index 41a8acba..c5ad088e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,9 @@ import { Interval, TimeMonzo } from 'sonic-weave' import { version } from '../package.json' import { Fraction, PRIME_CENTS } from 'xen-dev-utils' +// .env config +export const API_URL: string | undefined = import.meta.env.VITE_API_URL + // GLOBALS export const APP_TITLE = `Scale Workshop ${version}` diff --git a/src/main.ts b/src/main.ts index c74443cb..1ec74768 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,32 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from '@/App.vue' import router from '@/router' +import { API_URL } from './constants' + +if (import.meta.env.DEV) { + if (API_URL) { + fetch(API_URL) + .then((res) => res.text()) + .then((body) => { + if (!body.includes('Scale Workshop server')) { + console.warn('VITE_API_URL responded with foreign data.') + console.log(body) + } else { + console.info('VITE_API_URL responded. Scale URLs should work.') + } + }) + .catch((err) => { + console.warn('VITE_API_URL did not respond. Is sw-server running?') + console.error(err) + }) + } else { + console.warn('VITE_API_URL not configured. Scale URLs will not work.') + } +} + +if (!localStorage.getItem('uuid')) { + localStorage.setItem('uuid', crypto.randomUUID()) +} const app = createApp(App) diff --git a/src/router/index.ts b/src/router/index.ts index cf64acc2..fc56b669 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -37,6 +37,11 @@ const router = createRouter({ // which is lazy-loaded when the route is visited. component: () => import('../views/AboutView.vue') }, + { + path: '/scale/:id', + name: 'load-scale', + component: () => import('../views/LoadScaleView.vue') + }, { path: '/analysis', name: 'analysis', diff --git a/src/scale.ts b/src/scale.ts index 6aab29ec..da0bd83e 100644 --- a/src/scale.ts +++ b/src/scale.ts @@ -38,6 +38,39 @@ export class Scale { return valueToCents(this.equaveRatio) } + /** + * Convert this {@link Scale} instance to JSON. + * @returns The serialized object with property `type` set to `'ScaleWorkshopScale'`. + */ + toJSON() { + return { + type: 'ScaleWorkshopScale', + intervalRatios: this.intervalRatios, + baseFrequency: this.baseFrequency, + baseMidiNote: this.baseMidiNote, + title: this.title + } + } + + /** + * Revive a {@link Scale} instance produced by `Scale.toJSON()`. Return everything else as is. + * + * Intended usage: + * ```ts + * const data = JSON.parse(serializedData, Scale.reviver); + * ``` + * + * @param key Property name. + * @param value Property value. + * @returns Deserialized {@link Scale} instance or other data without modifications. + */ + static reviver(key: string, value: any) { + if (typeof value === 'object' && value !== null && value.type === 'ScaleWorkshopScale') { + return new Scale(value.intervalRatios, value.baseFrequency, value.baseMidiNote, value.title) + } + return value + } + /** * Obtain the ratio agains the base MIDI note. * @param index MIDI index of a note. diff --git a/src/stores/scale.ts b/src/stores/scale.ts index b0cd55d4..a857f711 100644 --- a/src/stores/scale.ts +++ b/src/stores/scale.ts @@ -4,7 +4,8 @@ import { type AccidentalStyle, syncValues, isBlackMidiNote, - midiNoteNumberToName + midiNoteNumberToName, + randomId } from '@/utils' import { defineStore } from 'pinia' import { computed, ref, watch } from 'vue' @@ -94,6 +95,9 @@ export const useScaleStore = defineStore('scale', () => { const error = ref('') const warning = ref('') + // Isomorphic offsets don't couple to anything else here, but they're part of the shareable live state. + const isomorphicVertical = ref(5) + const isomorphicHorizontal = ref(1) // Keyboard mode affects both physical qwerty and virtual keyboards const keyboardMode = ref<'isomorphic' | 'piano'>('isomorphic') // QWERTY mapping is coupled to equave and degree shifts @@ -109,6 +113,9 @@ export const useScaleStore = defineStore('scale', () => { const middleAccidentalColor = ref('navy') const highAccidentalColor = ref('indigo') + const id = ref('000000000') + const uploadedId = ref('000000000') + // === Computed state === const sourcePrefix = computed(() => { const base = `numComponents(${DEFAULT_NUMBER_OF_COMPONENTS})\n` @@ -334,6 +341,10 @@ export const useScaleStore = defineStore('scale', () => { } // Methods + function rerollId() { + id.value = randomId() + } + function getUserScopeVisitor() { const globalVisitor = getGlobalScaleWorkshopVisitor() const visitor = new StatementVisitor(globalVisitor) @@ -435,27 +446,21 @@ export const useScaleStore = defineStore('scale', () => { } } - return { - // Live state + const LIVE_STATE = { name, baseMidiNote, userBaseFrequency, autoFrequency, autoColors, - baseFrequencyDisplay, - sourcePrefix, sourceText, scale, relativeIntervals, colors, labels, - latticePermutation, - inverseLatticePermutation, - latticeIntervals, - latticeColors, - latticeLabels, error, warning, + isomorphicVertical, + isomorphicHorizontal, keyboardMode, equaveShift, degreeShift, @@ -464,11 +469,76 @@ export const useScaleStore = defineStore('scale', () => { lowAccidentalColor, middleAccidentalColor, highAccidentalColor, + id, + uploadedId + } + + let skipNextRerollWatch = false + const watched = [] + for (const [key, value] of Object.entries(LIVE_STATE)) { + if (key === 'id' || key === 'uploadedId') { + continue + } + watched.push(value) + } + watch(watched, () => { + if (skipNextRerollWatch) { + skipNextRerollWatch = false + return + } + if (uploadedId.value === id.value) { + rerollId() + } + }) + + /** + * Convert live state to a format suitable for storing on the server. + */ + function toJSON() { + const result: any = { + scale: scale.value.toJSON(), + relativeIntervals: relativeIntervals.value.map((i) => i.toJSON()) + } + for (const [key, value] of Object.entries(LIVE_STATE)) { + if (key === 'id' || key === 'uploadedId') { + continue + } + if (key in result) { + continue + } + result[key] = value.value + } + return result + } + + /** + * Apply revived state to current state. + * @param data JSON revived through {@link Scale.reviver} and {@link Interval.reviver}. + */ + function fromJSON(data: any) { + skipNextRerollWatch = true + for (const key in LIVE_STATE) { + LIVE_STATE[key as keyof typeof LIVE_STATE].value = data[key] + } + } + + return { + // Live state + ...LIVE_STATE, // Presistent state accidentalPreference, hasLeftOfZ, gas, // Computed state + // With setters + baseFrequencyDisplay, + // Get only + sourcePrefix, + latticePermutation, + inverseLatticePermutation, + latticeIntervals, + latticeColors, + latticeLabels, frequencies, centss, qwertyMapping, @@ -476,10 +546,13 @@ export const useScaleStore = defineStore('scale', () => { whiteIndices, whiteModeOffset, // Methods + rerollId, getUserScopeVisitor, computeScale, getFrequency, colorForIndex, - labelForIndex + labelForIndex, + toJSON, + fromJSON } }) diff --git a/src/stores/state.ts b/src/stores/state.ts index a8a93c40..23463e62 100644 --- a/src/stores/state.ts +++ b/src/stores/state.ts @@ -4,8 +4,6 @@ import { UNIX_NEWLINE } from '@/constants' import { syncValues } from '@/utils' export const useStateStore = defineStore('state', () => { - const isomorphicVertical = ref(5) - const isomorphicHorizontal = ref(1) // Mapping from MIDI index to number of interfaces currently pressing the key down const heldNotes = reactive(new Map{{ text }}
+