diff --git a/CHANGELOG.md b/CHANGELOG.md index 56598f5f..9b8258d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ * Feature: New "repeat" modifier [#406](https://github.com/xenharmonic-devs/scale-workshop/issues/406) * Feature: Implement multi-channel MIDI mode compatible with the Lumatone [#649](https://github.com/xenharmonic-devs/scale-workshop/pull/649) * Feature: Show labels, ratios, cents and frequencies on the tuning table [#534](https://github.com/xenharmonic-devs/scale-workshop/issues/534) + * Feature: Full width view dedicated to the MOS pyramid [#700](https://github.com/xenharmonic-devs/scale-workshop/issues/700) + * Feature: Import scale title from .scl files + * Bug fix: Fix handling of trailing comments when importing .scl files [#706](https://github.com/xenharmonic-devs/scale-workshop/issues/706) * Bug fix: Extreme ratios now only break parts of the tuning table that do not have IEEE floating point representation and format better when non-finite [#631](https://github.com/xenharmonic-devs/scale-workshop/issues/631), [#632](https://github.com/xenharmonic-devs/scale-workshop/issues/632) * Style fix: Make checkbox and radio button labels more consistent [#644](https://github.com/xenharmonic-devs/scale-workshop/issues/644) * Beta cycle issues: [#643](https://github.com/xenharmonic-devs/scale-workshop/issues/643), [#640](https://github.com/xenharmonic-devs/scale-workshop/issues/640), [#577](https://github.com/xenharmonic-devs/scale-workshop/issues/577), [#513](https://github.com/xenharmonic-devs/scale-workshop/issues/513), [#658](https://github.com/xenharmonic-devs/scale-workshop/issues/658), [#664](https://github.com/xenharmonic-devs/scale-workshop/issues/664), [#666](https://github.com/xenharmonic-devs/scale-workshop/issues/666) diff --git a/package-lock.json b/package-lock.json index 69e943d8..41aebab7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,28 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.31", + "version": "3.0.0-beta.41", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.31", + "version": "3.0.0-beta.41", "dependencies": { + "harmonic-entropy": "^0.2.0", "isomorphic-qwerty": "^0.0.2", - "ji-lattice": "^0.0.3", + "ji-lattice": "^0.2.0", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.5.2", + "moment-of-symmetry": "^0.8.1", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.2.0", + "sonic-weave": "0.4.3", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", "vue": "^3.3.4", "vue-router": "^4.3.0", "webmidi": "^3.1.8", - "xen-dev-utils": "^0.7.0", + "xen-dev-utils": "^0.9.0", "xen-midi": "^0.2.0" }, "devDependencies": { @@ -3161,6 +3162,15 @@ "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", "dev": true }, + "node_modules/frost-fft": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/frost-fft/-/frost-fft-0.2.0.tgz", + "integrity": "sha512-o4V+U5CtMv+UVJPlBf6G6+4Gm0+8ttUYpXoIot0maV6FtP9IhmRNX69zfOuhj6G1G5Ip+q+y/G1wc9B0fmFqvQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/frostburn" + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -3357,6 +3367,31 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/harmonic-entropy": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/harmonic-entropy/-/harmonic-entropy-0.2.0.tgz", + "integrity": "sha512-XuFwQqyEbDiIWSlZzJcLTh8L9PIoinodKZj7IuP/5x+hmssS4XAeGkwnIsWNYDgBBCeJda/0JlYfb6QKbhM+gA==", + "dependencies": { + "frost-fft": "^0.2.0", + "xen-dev-utils": "^0.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/frostburn" + } + }, + "node_modules/harmonic-entropy/node_modules/xen-dev-utils": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.7.0.tgz", + "integrity": "sha512-KECBCnnHD9sh4lY0Q6+KcgKVwMBWUOGjEJ/7S8sER2L3BKciy9kLhPFB+LR3z1oQiNS/OI7lv3L4rtPEX5SPUQ==", + "engines": { + "node": ">=10.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/frostburn" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3767,9 +3802,9 @@ } }, "node_modules/ji-lattice": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/ji-lattice/-/ji-lattice-0.0.3.tgz", - "integrity": "sha512-LAO8u0aO4qpDr7WucJmWRpXocy1pnIYlvDNjXmS4/VUhMc4xxMjTzLf4TN0kL/bWTPIhAxRd+0amTEjN1Mnptg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/ji-lattice/-/ji-lattice-0.2.0.tgz", + "integrity": "sha512-kX6Q598XiFTvEFuT+ySMC+YJcOC78aF04hT3VJGtHn6zGKjr6Mq8rFacjhkDgKV27c7vz1ma1x1C6geOT1QP6w==", "dependencies": { "xen-dev-utils": "^0.2.8" }, @@ -4381,11 +4416,11 @@ } }, "node_modules/moment-of-symmetry": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.5.3.tgz", - "integrity": "sha512-+CTxeGrJioy71uyYzNn7Kk5F73fqbSVIAXE69O+6/0sdNWxwgcVw7eCnjX5AHW4tJelhvHVeJyaAgP2CCLnzrg==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.8.1.tgz", + "integrity": "sha512-t8nR6DL4dpjv247WI7dIDbwmFrUhJZZHOguRNab1lw1TGWht0gEqNi1ux/uDxxCLAZRTivDUfM4MvXXmJUMb3A==", "dependencies": { - "xen-dev-utils": "^0.7.0" + "xen-dev-utils": "^0.9.0" }, "funding": { "type": "github", @@ -5455,12 +5490,12 @@ } }, "node_modules/sonic-weave": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.2.0.tgz", - "integrity": "sha512-dc9Gd0V5hSR2Xc1nXopLjuqas39UcDEfqRDl6GmQQ3riHW/eaLCSlSfhh2Xo91L2sZHnL1n9oJEbnEbxNSmQBw==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.4.3.tgz", + "integrity": "sha512-KfZ+UOBB+kU4kIDZqqP5jIzUezsnEDJOyuBjw60xtqmvB7xr8UpnMdTvU3aivMZH1LtyYWLeo2qqQkEUcR4CYg==", "dependencies": { - "moment-of-symmetry": "^0.5.3", - "xen-dev-utils": "^0.7.0" + "moment-of-symmetry": "^0.8.1", + "xen-dev-utils": "^0.9.0" }, "bin": { "sonic-weave": "bin/sonic-weave.js" @@ -5473,13 +5508,13 @@ "url": "https://github.com/sponsors/frostburn" }, "optionalDependencies": { - "commander": "^12.0.0" + "commander": "^12.1.0" } }, "node_modules/sonic-weave/node_modules/commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "optional": true, "engines": { "node": ">=18" @@ -6604,9 +6639,9 @@ } }, "node_modules/xen-dev-utils": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.7.0.tgz", - "integrity": "sha512-KECBCnnHD9sh4lY0Q6+KcgKVwMBWUOGjEJ/7S8sER2L3BKciy9kLhPFB+LR3z1oQiNS/OI7lv3L4rtPEX5SPUQ==", + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.9.0.tgz", + "integrity": "sha512-JsbXSg1zXaBoiKI19p2jC8Ka22YADQsTBD7fc2FkVxLWSdCO5BCS5KcRquDP5vP6J9v8t3B14G/7GZ5DC73rzg==", "engines": { "node": ">=10.6.0" }, diff --git a/package.json b/package.json index 6c97fb3f..5d330d30 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.31", + "version": "3.0.0-beta.41", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", @@ -15,20 +15,21 @@ "format": "prettier --write src/" }, "dependencies": { + "harmonic-entropy": "^0.2.0", "isomorphic-qwerty": "^0.0.2", - "ji-lattice": "^0.0.3", + "ji-lattice": "^0.2.0", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.5.2", + "moment-of-symmetry": "^0.8.1", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.2.0", + "sonic-weave": "0.4.3", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", "vue": "^3.3.4", "vue-router": "^4.3.0", "webmidi": "^3.1.8", - "xen-dev-utils": "^0.7.0", + "xen-dev-utils": "^0.9.0", "xen-midi": "^0.2.0" }, "devDependencies": { diff --git a/src/App.vue b/src/App.vue index 61a4a162..ffd785aa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,7 @@ import { useAudioStore } from '@/stores/audio' import { useStateStore } from './stores/state' import { useMidiStore } from './stores/midi' import { useScaleStore } from './stores/scale' +import { useHarmonicEntropyStore } from '@/stores/harmonic-entropy' import { clamp, mmod } from 'xen-dev-utils' import { parseScaleWorkshop2Line, setNumberOfComponents } from 'sonic-weave' @@ -21,6 +22,7 @@ const state = useStateStore() const scale = useScaleStore() const midi = useMidiStore() const audio = useAudioStore() +const entropy = useHarmonicEntropyStore() // == URL path handling == /** @@ -328,7 +330,7 @@ function typingKeydown(event: CoordinateKeyboardEvent) { } // === Lifecycle === -onMounted(() => { +onMounted(async () => { window.addEventListener('keyup', windowKeyup) window.addEventListener('keydown', windowKeydownOrUp) window.addEventListener('keyup', windowKeydownOrUp) @@ -346,7 +348,7 @@ onMounted(() => { // Special handling for the empty app state so that // the browser's back button can undo to the clean state. if (![...query.keys()].length) { - router.push({ path: getPath(url), query: { version } }) + await router.push({ path: getPath(url), query: { version } }) } else if (!query.has('version')) { // Scale Workshop 1 compatibility try { @@ -376,7 +378,7 @@ onMounted(() => { audio.releaseTime = scaleWorkshopOneData.releaseTime // Replace query with version 3. - router.push({ path: getPath(url), query: { version } }) + await router.push({ path: getPath(url), query: { version } }) } catch (error) { console.error('Error parsing version 1 URL', error) } @@ -431,11 +433,12 @@ onMounted(() => { scale.computeScale() // Replace query with version 3. - router.push({ path: getPath(url), query: { version } }) + await router.push({ path: getPath(url), query: { version } }) } catch (error) { console.error(`Error parsing version ${query.get('version')} URL`, error) } } + await entropy.fetchTable() }) onUnmounted(() => { @@ -474,6 +477,9 @@ function panic() { Sw
  • Build Scale
  • +
  • + MOS +
  • Analysis
  • Lattice
  • Virtual Keyboard
  • @@ -527,6 +533,7 @@ nav#app-navigation { display: flex; } +#app > #view, #app > main { flex: 1 1 auto; overflow-y: hidden; @@ -610,6 +617,7 @@ nav a:first-of-type { line-height: 1; padding-right: 1em; text-align: right; + color: var(--color-text-mute); } #app-footer a { color: var(--color-text-mute); diff --git a/src/__tests__/util.spec.ts b/src/__tests__/util.spec.ts index 7869a817..92fe751a 100644 --- a/src/__tests__/util.spec.ts +++ b/src/__tests__/util.spec.ts @@ -102,7 +102,7 @@ describe('Gap key color algorithm', () => { describe('URL safe number encoder', () => { it('encodes the whole range', () => { - const expected = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-~' + const expected = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_' for (let i = 0; i < 64; ++i) { expect(encodeUrlSafe64(i)).toBe(expected[i]) } @@ -111,7 +111,7 @@ describe('URL safe number encoder', () => { describe('Unique ID generator', () => { it('produces a short URL-friendly identifier', () => { - const urlSafe = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-~' + const urlSafe = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_' const id = randomId() expect(id).toHaveLength(9) for (const char of id) { diff --git a/src/assets/base.css b/src/assets/base.css index d154d17c..a24864ee 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -15,7 +15,7 @@ --color-accent: #803080; --color-accent-text: #fff; - --color-accent-text-btn: #803080; + --color-accent-text-btn: #703070; --color-accent-mute: #b7b; --color-accent-deeper: #603060; --color-accent-background: #fffeff; @@ -25,6 +25,9 @@ --color-warning: orangered; --color-indicator: #c35; + --color-bright-indicator: #63d023; + --color-dark-indicator: #427210; + /* Mimic Bootstrap alert with 'danger' variant */ --color-alert-danger: rgba(104, 35, 39, 1); --color-alert-danger-background: rgba(243, 216, 218, 1); @@ -53,7 +56,7 @@ --color-accent: #803080; --color-accent-text: #fff; - --color-accent-text-btn: #a3a; + --color-accent-text-btn: #a848a8; --color-accent-mute: #646; --color-accent-deeper: #602060; --color-accent-background: #011; diff --git a/src/assets/harmonic-entropy.ydata.raw b/src/assets/harmonic-entropy.ydata.raw new file mode 100644 index 00000000..d0a5a94f Binary files /dev/null and b/src/assets/harmonic-entropy.ydata.raw differ diff --git a/src/character-palette.json b/src/character-palette.json index c8bb01aa..d40f5a3f 100644 --- a/src/character-palette.json +++ b/src/character-palette.json @@ -13,6 +13,7 @@ "¾": "Sesqui-semi-prefix. E.g. D sesqui semisharp four D¾♯4.", "⅓": "One-third-prefix. E.g. Third-major second ⅓M2.", "⅔": "Two-thirds-prefix. E.g. E two-thirds flat four E⅔♭4.", + "£": "Popped scale. E.g. £ rdc £[-1] reduces all intervals in your scale by the equave.", "×": "Times symbol. E.g. 4/3 × 4/3 is 16/9.", "÷": "Division symbol. E.g. 9/8 ÷ 81/80 is 10/9.", "·": "Dot product. E.g. 12@ · 3/2 is 7 i.e. a fifth is seven steps of 12-tone equal temperament.", diff --git a/src/components/EdoCycles.vue b/src/components/EdoCycles.vue new file mode 100644 index 00000000..aae015a5 --- /dev/null +++ b/src/components/EdoCycles.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/components/Faux3DLattice.vue b/src/components/Faux3DLattice.vue new file mode 100644 index 00000000..22be0375 --- /dev/null +++ b/src/components/Faux3DLattice.vue @@ -0,0 +1,207 @@ + + + diff --git a/src/components/GridLattice.vue b/src/components/GridLattice.vue index b569264f..6fec5a92 100644 --- a/src/components/GridLattice.vue +++ b/src/components/GridLattice.vue @@ -1,6 +1,6 @@ {{ modal.previewName }} + @@ -327,6 +330,9 @@ function edoClick(info: MosScaleInfo) { .pyramid button { font-size: small; } +.right { + margin-left: auto; +} @media only screen and (max-width: 38rem) { .pyramid { text-align: left; diff --git a/src/components/modals/modification/EnumerateScale.vue b/src/components/modals/modification/EnumerateScale.vue index 49634e5c..bbbd0362 100644 --- a/src/components/modals/modification/EnumerateScale.vue +++ b/src/components/modals/modification/EnumerateScale.vue @@ -43,7 +43,7 @@ function retroversion() { if (scale.sourceText) { scale.sourceText += '\n' } - scale.sourceText += 'retroverted(' + tones.join(':') + ')' + scale.sourceText += 'retrovert(' + tones.join(':') + ')' scale.computeScale() emit('done') } @@ -57,7 +57,7 @@ function revposition() { if (scale.sourceText) { scale.sourceText += '\n' } - scale.sourceText += 'revposed(' + tones.join(':') + ')' + scale.sourceText += 'revpose(' + tones.join(':') + ')' scale.computeScale() emit('done') } diff --git a/src/harmonic-entropy-worker.ts b/src/harmonic-entropy-worker.ts new file mode 100644 index 00000000..84951b5a --- /dev/null +++ b/src/harmonic-entropy-worker.ts @@ -0,0 +1,13 @@ +import { EntropyCalculator, type HarmonicEntropyOptions } from 'harmonic-entropy' + +let entropy: EntropyCalculator | undefined + +onmessage = (e) => { + const options: HarmonicEntropyOptions = e.data.options + if (!entropy) { + entropy = new EntropyCalculator(options) + } else { + entropy.options = options + } + postMessage({ json: entropy.toJSON(), jobId: e.data.jobId }) +} diff --git a/src/importers/__tests__/brun_wilsonic.scl b/src/importers/__tests__/brun_wilsonic.scl new file mode 100644 index 00000000..6aa39240 --- /dev/null +++ b/src/importers/__tests__/brun_wilsonic.scl @@ -0,0 +1,23 @@ +!BRUN3_G1_0.2000_G0_0.5833_O_1_AB_NPO_7_M_0.scl +! +Brun3: G1:0.2000,G0:0.5833,O:1,AB,L:4,NPO=7,M=0 +7 +! +239.999968 +260.000020 +499.999952 +699.999905 +939.999819 +959.999871 +2/1 +! Created with the iOS app "Wilsonic" by Marcus Hobbs +! +! 0.000000 +! 239.999968 +! 260.000020 +! 499.999952 +! 699.999905 +! 939.999819 +! 959.999871 + +! Please see the Scala archives at: http://www.huygens-fokker.org/docs/scalesdir.txt diff --git a/src/importers/__tests__/scala.spec.ts b/src/importers/__tests__/scala.spec.ts index 48eb5cab..54605c0a 100644 --- a/src/importers/__tests__/scala.spec.ts +++ b/src/importers/__tests__/scala.spec.ts @@ -1,10 +1,13 @@ import { describe, it, expect } from 'vitest' import { ScalaImporter } from '../scala' +import { NEWLINE_TEST } from '../../constants' // @ts-ignore import SHANG_TEST_SCALE from './shang_pentatonic.scl?raw' // @ts-ignore import ZHI_TEST_SCALE from './zhi_pentatonic.scl?raw' +// @ts-ignore +import BRUN_WILSONIC from './brun_wilsonic.scl?raw' describe('Scala importer', () => { it('can handle all line types', () => { @@ -29,9 +32,11 @@ describe('Scala importer', () => { ].join('\n') const importer = new ScalaImporter() - const { sourceText } = importer.parseText(text) + const { name, sourceText } = importer.parseText(text) const lines = sourceText.split('\n') + expect(name).toBe('1/8-schisma temperament, Helmholtz') + expect(lines[0]).toBe('91.44607 "C#"') expect(lines[3]).toBe('5/4 "E"') @@ -41,19 +46,40 @@ describe('Scala importer', () => { it('can handle the shang user test scale', () => { const importer = new ScalaImporter() - const { sourceText } = importer.parseText(SHANG_TEST_SCALE) + const { name, sourceText } = importer.parseText(SHANG_TEST_SCALE) const lines = sourceText.split('\n') + expect(name).toBe( + 'The pentatonic scale in the shang mode, generated by the up-and-down generation procedure as described in the Guan Zi.' + ) expect(lines[0]).toBe('9/8 "gong"') expect(lines).toHaveLength(5) }) it('can handle the zhi user test scale', () => { const importer = new ScalaImporter() - const { sourceText } = importer.parseText(ZHI_TEST_SCALE) + const { name, sourceText } = importer.parseText(ZHI_TEST_SCALE) const lines = sourceText.split('\n') + expect(name).toBe( + 'The pentatonic scale in the zhi mode, generated by the up-and-down generation procedure as described in the Guan Zi.' + ) expect(lines[2]).toBe('3/2 "gong宮"') expect(lines).toHaveLength(5) }) + + it('can handle the wilsonic test scale', () => { + const importer = new ScalaImporter() + const { name, sourceText } = importer.parseText(BRUN_WILSONIC) + expect(name).toBe('Brun3: G1:0.2000,G0:0.5833,O:1,AB,L:4,NPO=7,M=0') + expect(sourceText.split(NEWLINE_TEST)).toEqual([ + '239.999968', + '260.000020', + '499.999952', + '699.999905', + '939.999819', + '959.999871', + '2/1' + ]) + }) }) diff --git a/src/importers/scala.ts b/src/importers/scala.ts index 3b5b944c..a296cafe 100644 --- a/src/importers/scala.ts +++ b/src/importers/scala.ts @@ -4,22 +4,38 @@ import { TextImporter, type ImportResult } from '@/importers/base' export class ScalaImporter extends TextImporter { parseText(input: string): ImportResult { const lines = input.split(NEWLINE_TEST) - const firstLine = lines.lastIndexOf('!') + 1 + let validLineCount = 0 + let name = '' const sourceLines: string[] = [] - lines.slice(firstLine).forEach((line) => { + for (let line of lines) { line = line.trim() + // Ignore comments + if (line.startsWith('!')) { + continue + } + validLineCount++ + // Empty lines are valid, but not processed if (!line.length) { - return + continue + } + // Store scale title + if (validLineCount === 1) { + name = line } - const parts = line.split(/\s/) - if (parts.length === 1) { - // Valid .scl is valid SonicWeave - sourceLines.push(parts[0]) - } else if (parts.length > 1) { - // Unofficially labeled .scl is valid SonicWeave if quoted - sourceLines.push(parts[0] + ' ' + JSON.stringify(parts.slice(1).join(''))) + // validLineCount === 2: Ignore number of notes + + // Process true lines + if (validLineCount > 2) { + const parts = line.split(/\s/) + if (parts.length === 1) { + // Valid .scl is valid SonicWeave + sourceLines.push(parts[0]) + } else if (parts.length > 1) { + // Unofficially labeled .scl is valid SonicWeave if quoted + sourceLines.push(parts[0] + ' ' + JSON.stringify(parts.slice(1).join(''))) + } } - }) - return { sourceText: sourceLines.join('\n') } + } + return { name, sourceText: sourceLines.join('\n') } } } diff --git a/src/presets.json b/src/presets.json index 43945320..db42118c 100644 --- a/src/presets.json +++ b/src/presets.json @@ -141,7 +141,7 @@ }, "werckmeisteriii": { "name": "Werckmeister III (1691)", - "source": "{\n const pythComma = aa1 - M2\n const flatFif = P5 - pythComma / 4\n P5 white 'C'\n P5 white 'G'\n flatFif white 'D'\n P5 white 'A'\n P5 white 'E'\n P5 white 'B'\n P5 black 'F♯'\n P5 black 'C♯'\n P5 black 'G♯'\n flatFif black 'D♯'\n flatFif black 'A♯'\n stack()\n P8 white 'F'\n reduce()\n sort()\n i => cents(i, 3)\n}\n", + "source": "{\n const pythComma = aa1 - M2\n const flatFif = P5 - pythComma / 4\n P5 white 'C'\n P5 white 'G'\n flatFif white 'D'\n P5 white 'A'\n P5 white 'E'\n P5 white 'B'\n P5 black 'F♯'\n P5 black 'C♯'\n P5 black 'G♯'\n flatFif black 'D♯'\n flatFif black 'A♯'\n stack()\n P8 white 'F'\n reduce()\n sort()\n cents(£, 3)\n}\n", "categories": ["traditional"], "baseMidiNote": 65, "baseFrequency": 349 @@ -181,7 +181,7 @@ "gradycentauras": { "title": "Kraig Grady Centaura Subharmonic (11-limit)", "name": "Kraig Grady Centaura Subharmonic", - "source": "4/3 white\n3/2 white\n2/1 white\nmergeOffset([15/14 cyan, 5/4 yellow, 20/11 black, 5/3 yellow], 'wrap')\nvoid(pop($, 5))\n", + "source": "4/3 white\n3/2 white\n2/1 white\nmergeOffset([15/14 cyan, 5/4 yellow, 20/11 black, 5/3 yellow], 'wrap')\ndel $[5]\n", "baseFrequency": 264, "baseMidiNote": 60, "categories": ["just intonation"] @@ -189,7 +189,7 @@ "gradycentaurah": { "title": "Kraig Grady Centaura Harmonic (11-limit)", "name": "Kraig Grady Centaura Harmonic", - "source": "4/3 white\n3/2 white\n2/1 white\nmergeOffset([7/6 blue, 5/4 yellow, 11/8 lavender, 3/2 white], 'wrap')\nvoid(pop($, 11))\nsimplify\n", + "source": "4/3 white\n3/2 white\n2/1 white\nmergeOffset([7/6 blue, 5/4 yellow, 11/8 lavender, 3/2 white], 'wrap')\ndel $[10]\nsimplify\n", "baseFrequency": 264, "baseMidiNote": 60, "categories": ["just intonation"] @@ -233,7 +233,7 @@ }, "22porcupine8": { "name": "22edo porcupine[8]", - "source": "rank2(27/25 white, 7, 0, 2/1 gray)\n22@\n", + "source": "rank2(10/9 white, 7, 0, 2/1 gray, 1, 3\\22)\n22@\n", "categories": ["MOS", "equal temperament"] }, "22orwell9": { @@ -310,7 +310,7 @@ }, "otonalstacks": { "name": "Sevish stacked otonal framents", - "source": "unstacked(24 white:27:30:32:36)\nunstacked(24 lightblue:27:28:32:36)\nunstacked(24 palegoldenrod:27:30:32:36)\nstack()\n", + "source": "unstack(24 white:27:30:32:36)\nunstack(24 lightblue:27:28:32:36)\nunstack(24 palegoldenrod:27:30:32:36)\nstack()\n", "categories": ["non-octave", "just intonation"] }, "blackdye": { diff --git a/src/router/index.ts b/src/router/index.ts index 677804c3..5b85c763 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -87,6 +87,11 @@ const router = createRouter({ name: 'qwerty', component: () => import('../views/VirtualQwerty.vue') }, + { + path: '/mos', + name: 'mos', + component: () => import('../views/MosView.vue') + }, // Root aliases mainly for compatibility with old SW1 URLs. { path: '/index.html', diff --git a/src/stores/edo-cycles.ts b/src/stores/edo-cycles.ts new file mode 100644 index 00000000..5b65b643 --- /dev/null +++ b/src/stores/edo-cycles.ts @@ -0,0 +1,39 @@ +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { mmod, modInv } from 'xen-dev-utils' +import { parseVal } from '@/utils' + +export const useCyclesStore = defineStore('edo-cycles', () => { + // View + const size = ref(0.15) + const labelOffset = ref(2) + const showLabels = ref(true) + + // Elements + const valString = ref('12p') + const generator = ref(7) + + const val = computed(() => parseVal(valString.value)) + + const modulus = computed(() => val.value.divisions.round().valueOf()) + const generatorPseudoInverse = computed(() => modInv(generator.value, modulus.value, false)) + const numCycles = computed( + () => mmod(generator.value * generatorPseudoInverse.value, modulus.value) || 1 + ) + const cycleLength = computed(() => modulus.value / numCycles.value) + + return { + // State + size, + labelOffset, + showLabels, + valString, + generator, + // Computed state + val, + modulus, + generatorPseudoInverse, + numCycles, + cycleLength + } +}) diff --git a/src/stores/grid.ts b/src/stores/grid.ts index 3624b7d0..9b60dbea 100644 --- a/src/stores/grid.ts +++ b/src/stores/grid.ts @@ -2,10 +2,9 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import { shortestEdge, type GridOptions } from 'ji-lattice' import { LOG_PRIMES, mmod } from 'xen-dev-utils' -import { Val, evaluateExpression, parseChord } from 'sonic-weave' -import { computedAndError } from '@/utils' - -const TWELVE = evaluateExpression('12@', false) as Val +import { parseChord } from 'sonic-weave' +import { computedAndError, parseVal } from '@/utils' +import { FIFTH, THIRD } from '@/constants' export const useGridStore = defineStore('grid', () => { // View @@ -35,25 +34,7 @@ export const useGridStore = defineStore('grid', () => { const diagonals1 = ref(false) const diagonals2 = ref(false) - const val = computed(() => { - try { - const val = evaluateExpression(valString.value) - if (val instanceof Val) { - return val - } - } catch { - /* empty */ - } - try { - const val = evaluateExpression(valString.value.trim() + '@') - if (val instanceof Val) { - return val - } - } catch { - /* empty */ - } - return TWELVE - }) + const val = computed(() => parseVal(valString.value)) const modulus = computed(() => val.value.divisions.round().valueOf()) @@ -142,6 +123,33 @@ export const useGridStore = defineStore('grid', () => { edgesString.value = '3/2 5/4' } + /** + * Create a square lattice configuration based on the val and edges. + */ + function autoSquare() { + resetBounds() + + size.value = 0.15 + viewScale.value = 3.1 + viewCenterX.value = 0 + viewCenterY.value = -0.1 + + const edge1 = edges.value[0] ?? FIFTH + delta1.value = val.value.dot(edge1).valueOf() + delta1X.value = 1 + delta1Y.value = 0 + + const edge2 = edges.value[1] ?? THIRD + delta2.value = val.value.dot(edge2).valueOf() + delta2X.value = 0 + delta2Y.value = -1 + + gridlines1.value = true + gridlines2.value = true + diagonals1.value = false + diagonals2.value = false + } + function squareBP(divisions: number) { resetBounds() @@ -186,6 +194,33 @@ export const useGridStore = defineStore('grid', () => { edgesString.value = '3/2 5/4 6/5' } + /** + * Create a triangular lattice configuration based on the val and edges. + */ + function autoTonnetz() { + resetBounds() + + size.value = 1 + viewScale.value = 30.1 + viewCenterX.value = 0 + viewCenterY.value = 0 + + const edge1 = edges.value[0] ?? FIFTH + delta1.value = val.value.dot(edge1).valueOf() + delta1X.value = 6 + delta1Y.value = 0 + + const edge2 = edges.value[1] ?? THIRD + delta2.value = val.value.dot(edge2).valueOf() + delta2X.value = 3 + delta2Y.value = -5 + + gridlines1.value = true + gridlines2.value = true + diagonals1.value = true + diagonals2.value = false + } + function preset311() { resetBounds() @@ -239,10 +274,13 @@ export const useGridStore = defineStore('grid', () => { val, modulus, gridOptions, - // Methods + // Methods (presets) square, squareBP, tonnetz, - preset311 + preset311, + // Methods (auto-params) + autoSquare, + autoTonnetz } }) diff --git a/src/stores/harmonic-entropy.ts b/src/stores/harmonic-entropy.ts new file mode 100644 index 00000000..7322ffd9 --- /dev/null +++ b/src/stores/harmonic-entropy.ts @@ -0,0 +1,115 @@ +import HE_DATA_URL from '@/assets/harmonic-entropy.ydata.raw?url' +import EntropyWorker from '@/harmonic-entropy-worker?worker' +import { computed, reactive, ref, watch } from 'vue' +import { debounce } from '@/utils' +import { defineStore } from 'pinia' +import { type HarmonicEntropyOptions } from 'harmonic-entropy' + +// The app freezes if we try to recalculate entropy in the main thread. +const worker = new EntropyWorker() +// Debounce doesn't block workers so we need extra guards to hide changes that would be overwritten. +let jobId = 0 + +// Constant options for harmonic-entropy package +const MIN_CENTS = 0 +const MAX_CENTS = 6000 +const RES = 0.5 +const SERIES = 'tenney' +const NORMALIZE = true + +export const useHarmonicEntropyStore = defineStore('harmonic-entropy', () => { + const table = reactive<[number, number][]>([]) + + // The fetched N is much larger, but we use a smaller value for the UI. + const N = ref(10000) + const a = ref(1) + const s = ref(0.01) + + const minY = computed(() => Math.min(...table.map((xy) => xy[1]))) + const maxY = computed(() => Math.max(...table.map((xy) => xy[1]))) + + async function fetchTable(force = false) { + if (table.length && !force) { + return + } + const response = await fetch(HE_DATA_URL) + const buffer = await response.arrayBuffer() + const tableY = Array.from(new Float32Array(buffer)) + + table.length = 0 + + let i = 0 + for (let x = 0; x <= MAX_CENTS; x += RES) { + table.push([x, tableY[i++]]) + } + } + + worker.onmessage = (e) => { + if (e.data.jobId === jobId) { + const tableY = e.data.json.tableY + + table.length = 0 + let i = 0 + for (let x = 0; x <= MAX_CENTS; x += RES) { + table.push([x, tableY[i++]]) + } + } + } + + // Pinia fails to serialize EntropyCalculator so we recreate its functionality here. + function entropyPercentage(cents: number) { + if (!table.length) { + return 0 + } + cents = Math.abs(cents) + if (cents >= MAX_CENTS) { + return (table[table.length - 1][1] - minY.value) / (maxY.value - minY.value) + } + + let mu = cents / RES + const index = Math.floor(mu) + mu -= index + + const y = table[index][1] * (1 - mu) + table[index + 1][1] * mu + return (y - minY.value) / (maxY.value - minY.value) + } + + watch( + N, + debounce((newValue) => { + const opts = { ...options.value } + opts.N = newValue + worker.postMessage({ options: opts, jobId: ++jobId }) + }) + ) + + watch( + a, + debounce((newValue) => { + const opts = { ...options.value } + opts.a = newValue + worker.postMessage({ options: opts, jobId: ++jobId }) + }) + ) + + watch( + s, + debounce((newValue) => { + const opts = { ...options.value } + opts.s = newValue + worker.postMessage({ options: opts, jobId: ++jobId }) + }) + ) + + const options = computed(() => ({ + N: N.value, + a: a.value, + s: s.value, + minCents: MIN_CENTS, + maxCents: MAX_CENTS, + res: RES, + series: SERIES, + normalize: NORMALIZE + })) + return { table, N, a, s, minY, maxY, options, fetchTable, entropyPercentage } +}) diff --git a/src/stores/ji-lattice.ts b/src/stores/ji-lattice.ts index 34e9fab0..73f95f1e 100644 --- a/src/stores/ji-lattice.ts +++ b/src/stores/ji-lattice.ts @@ -1,6 +1,15 @@ import { computed, reactive, ref, watch } from 'vue' import { defineStore } from 'pinia' -import { kraigGrady9, type LatticeOptions, scottDakota24, primeRing72, align } from 'ji-lattice' +import { + kraigGrady9, + type LatticeOptions, + scottDakota24, + primeRing72, + align, + type LatticeOptions3D, + WGP9, + primeSphere +} from 'ji-lattice' import { LOG_PRIMES, mmod } from 'xen-dev-utils' import { computedAndError } from '@/utils' import { TimeMonzo, parseChord } from 'sonic-weave' @@ -52,6 +61,48 @@ export const useJiLatticeStore = defineStore('ji-lattice', () => { ) } }) + const xs = computed({ + get() { + return xCoords.map((x) => x.toFixed(2)).join(' ') + }, + set(value: string) { + xCoords.length = 0 + xCoords.push( + ...value.split(' ').map((v) => { + const c = parseFloat(v) + return isNaN(c) ? 0 : c + }) + ) + } + }) + const ys = computed({ + get() { + return yCoords.map((y) => y.toFixed(2)).join(' ') + }, + set(value: string) { + yCoords.length = 0 + yCoords.push( + ...value.split(' ').map((v) => { + const c = parseFloat(v) + return isNaN(c) ? 0 : c + }) + ) + } + }) + const zs = computed({ + get() { + return zCoords.map((z) => z.toFixed(2)).join(' ') + }, + set(value: string) { + zCoords.length = 0 + zCoords.push( + ...value.split(' ').map((v) => { + const c = parseFloat(v) + return isNaN(c) ? 0 : c + }) + ) + } + }) const edgeMonzos = computed(() => { const numComponents = horizontalCoordinates.length @@ -79,12 +130,31 @@ export const useJiLatticeStore = defineStore('ji-lattice', () => { } }) + const opts3D = WGP9(0) + const xCoords = reactive(opts3D.horizontalCoordinates) + const yCoords = reactive(opts3D.verticalCoordinates) + const zCoords = reactive(opts3D.depthwiseCoordinates) + const depth = ref(100) + + const latticeOptions3D = computed(() => { + return { + horizontalCoordinates: xCoords, + verticalCoordinates: yCoords, + depthwiseCoordinates: zCoords, + maxDistance: maxDistance.value, + edgeMonzos: edgeMonzos.value, + mergeEdges: false + } + }) + watch(rotation, (newValue) => { if (newValue < 0 || newValue >= 360) { rotation.value = mmod(newValue, 360) } }) + // 2D presets + function kraigGrady(equaveIndex = 0) { size.value = 2 const kg = kraigGrady9(equaveIndex) @@ -97,45 +167,112 @@ export const useJiLatticeStore = defineStore('ji-lattice', () => { function scott24(equaveIndex = 0) { size.value = 2 - const logs = LOG_PRIMES.slice(0, 24) - if (equaveIndex !== 0) { - logs.unshift(logs.splice(equaveIndex, 1)[0]) - } - const sd = scottDakota24(logs) + const sd = scottDakota24(equaveIndex) horizontalCoordinates.length = 0 horizontalCoordinates.push(...sd.horizontalCoordinates) verticalCoordinates.length = 0 verticalCoordinates.push(...sd.verticalCoordinates) - edgesString.value = '6/5' + if (equaveIndex === 0) { + edgesString.value = '6/5' + } else { + edgesString.value = '' + } } function pr72(equaveIndex = 0) { size.value = 4 - const logs = LOG_PRIMES.slice(0, 72) - if (equaveIndex !== 0) { - logs.unshift(logs.splice(equaveIndex, 1)[0]) - } - const pr = primeRing72(logs) + const pr = primeRing72(equaveIndex) horizontalCoordinates.length = 0 horizontalCoordinates.push(...pr.horizontalCoordinates) verticalCoordinates.length = 0 verticalCoordinates.push(...pr.verticalCoordinates) - edgesString.value = '6/5' + if (equaveIndex === 0) { + edgesString.value = '6/5' + } else { + edgesString.value = '' + } } function pe72(equaveIndex = 0) { size.value = 4 - const logs = LOG_PRIMES.slice(0, 72) - if (equaveIndex !== 0) { - logs.unshift(logs.splice(equaveIndex, 1)[0]) - } - const pr = primeRing72(logs, false) - align(pr, 1, 2) + const pr = primeRing72(equaveIndex, undefined, false) + align(pr, equaveIndex + 1, equaveIndex + 2) horizontalCoordinates.length = 0 horizontalCoordinates.push(...pr.horizontalCoordinates.map(Math.round)) verticalCoordinates.length = 0 verticalCoordinates.push(...pr.verticalCoordinates.map(Math.round)) - edgesString.value = '6/5' + if (equaveIndex === 0) { + edgesString.value = '6/5' + } else { + edgesString.value = '' + } + } + + // 3D presets + function WGP(equaveIndex = 0) { + size.value = 2 + const w = WGP9(equaveIndex) + xCoords.length = 0 + xCoords.push(...w.horizontalCoordinates) + yCoords.length = 0 + yCoords.push(...w.verticalCoordinates) + zCoords.length = 0 + zCoords.push(...w.depthwiseCoordinates) + edgesString.value = '' + } + + function sphere(equaveIndex = 0, numberOfComponents = 24) { + size.value = 2 + depth.value = 300 + const ps = primeSphere(equaveIndex, LOG_PRIMES.slice(0, numberOfComponents)) + const scale = 3000 + xCoords.length = 0 + xCoords.push(...ps.horizontalCoordinates.map((x) => Math.round(x * scale) / 100)) + yCoords.length = 0 + yCoords.push(...ps.verticalCoordinates.map((y) => Math.round(y * scale) / 100)) + zCoords.length = 0 + zCoords.push(...ps.depthwiseCoordinates.map((z) => Math.round(z * scale) / 100)) + if (equaveIndex === 0) { + edgesString.value = '6/5' + } else { + edgesString.value = '' + } + } + + function pitch(degrees: number) { + const theta = (degrees / 180) * Math.PI + const c = Math.cos(theta) + const s = Math.sin(theta) + for (let i = 0; i < Math.max(yCoords.length, zCoords.length); ++i) { + const y = yCoords[i] ?? 0 + const z = zCoords[i] ?? 0 + yCoords[i] = c * y + s * z + zCoords[i] = c * z - s * y + } + } + + function yaw(degrees: number) { + const theta = (degrees / 180) * Math.PI + const c = Math.cos(theta) + const s = Math.sin(theta) + for (let i = 0; i < Math.max(xCoords.length, zCoords.length); ++i) { + const x = xCoords[i] ?? 0 + const z = zCoords[i] ?? 0 + xCoords[i] = c * x + s * z + zCoords[i] = c * z - s * x + } + } + + function roll(degrees: number) { + const theta = (degrees / 180) * Math.PI + const c = Math.cos(theta) + const s = Math.sin(theta) + for (let i = 0; i < Math.max(xCoords.length, yCoords.length); ++i) { + const x = xCoords[i] ?? 0 + const y = yCoords[i] ?? 0 + xCoords[i] = c * x + s * y + yCoords[i] = c * y - s * x + } } return { @@ -150,16 +287,29 @@ export const useJiLatticeStore = defineStore('ji-lattice', () => { rotation, drawArrows, grayExtras, + xCoords, + yCoords, + zCoords, + depth, // Computed state edgeMonzos, edgesError, horizontals, verticals, latticeOptions, + latticeOptions3D, + xs, + ys, + zs, // Methods kraigGrady, scott24, pr72, - pe72 + pe72, + WGP, + sphere, + pitch, + yaw, + roll } }) diff --git a/src/stores/scale.ts b/src/stores/scale.ts index 83f722c6..d17b1ec5 100644 --- a/src/stores/scale.ts +++ b/src/stores/scale.ts @@ -91,6 +91,7 @@ export const useScaleStore = defineStore('scale', () => { const sourceText = ref('') const relativeIntervals = ref(INTERVALS_12TET) const latticeIntervals = ref(INTERVALS_12TET) + const latticeEquave = ref(undefined) const colors = ref(defaultColors(baseMidiNote.value)) const labels = ref(defaultLabels(baseMidiNote.value, accidentalPreference.value)) const error = ref('') @@ -293,7 +294,7 @@ export const useScaleStore = defineStore('scale', () => { syncValues({ accidentalPreference, hasLeftOfZ, gas }) // Extra builtins - function latticeView(this: ExpressionVisitor) { + function latticeView(this: ExpressionVisitor, equave?: Interval) { const scale = this.currentScale for (let i = 0; i < scale.length; ++i) { scale[i] = scale[i].shallowClone() @@ -302,8 +303,11 @@ export const useScaleStore = defineStore('scale', () => { } const rel = relative.bind(this) latticeIntervals.value = scale.map((i) => rel(i)) + + latticeEquave.value = equave } - latticeView.__doc__ = 'Store the current scale to be displayed in the lattice tab.' + latticeView.__doc__ = + 'Store the current scale to be displayed in the lattice tab. Optionally with an explicit equave.' latticeView.__node__ = builtinNode(latticeView) function warn(this: ExpressionVisitor, ...args: any[]) { @@ -366,6 +370,7 @@ export const useScaleStore = defineStore('scale', () => { error.value = '' warning.value = '' latticeIntervals.value = [] + latticeEquave.value = undefined const globalVisitor = getGlobalScaleWorkshopVisitor() const visitor = new StatementVisitor(globalVisitor) visitor.isUserRoot = true @@ -457,6 +462,7 @@ export const useScaleStore = defineStore('scale', () => { scale, relativeIntervals, latticeIntervals, + latticeEquave, colors, labels, error, diff --git a/src/stores/state.ts b/src/stores/state.ts index 23463e62..30f0fc71 100644 --- a/src/stores/state.ts +++ b/src/stores/state.ts @@ -8,7 +8,7 @@ export const useStateStore = defineStore('state', () => { const heldNotes = reactive(new Map()) const typingActive = ref(true) - const latticeType = ref<'ji' | 'et'>('et') + const latticeType = ref<'ji' | 'et' | 'cycles' | '3d' | 'auto'>('auto') // These user preferences are fetched from local storage. const storage = window.localStorage @@ -22,6 +22,7 @@ export const useStateStore = defineStore('state', () => { const centsFractionDigits = ref(parseInt(storage.getItem('centsFractionDigits') ?? '3', 10)) const decimalFractionDigits = ref(parseInt(storage.getItem('decimalFractionDigits') ?? '5', 10)) const showVirtualQwerty = ref(storage.getItem('showVirtualQwerty') === 'true') + const showMosTab = ref(storage.getItem('showMosTab') === 'true') const showKeyboardLabel = ref(storage.getItem('showKeyboardLabel') !== 'false') const showKeyboardCents = ref(storage.getItem('showKeyboardCents') !== 'false') const showKeyboardRatio = ref(storage.getItem('showKeyboardRatio') !== 'false') @@ -55,6 +56,7 @@ export const useStateStore = defineStore('state', () => { centsFractionDigits, decimalFractionDigits, showVirtualQwerty, + showMosTab, showKeyboardLabel, showKeyboardCents, showKeyboardRatio, @@ -92,6 +94,7 @@ export const useStateStore = defineStore('state', () => { centsFractionDigits, decimalFractionDigits, showVirtualQwerty, + showMosTab, showKeyboardLabel, showKeyboardCents, showKeyboardRatio, diff --git a/src/utils.ts b/src/utils.ts index 7cfe51a5..b967ccf2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,13 @@ import { computed, watch, type ComputedRef, type Ref } from 'vue' import { gcd, mmod } from 'xen-dev-utils' -import { evaluateExpression, getSourceVisitor, Interval, parseAST, repr } from 'sonic-weave' +import { evaluateExpression, getSourceVisitor, Interval, parseAST, repr, Val } from 'sonic-weave' import { version } from '../package.json' import { Scale } from './scale' +const TAU = 2 * Math.PI + +const TWELVE = evaluateExpression('12@', false) as Val + /** * Calculate the smallest power of two greater or equal to the input value. * @param x Integer to compare to. @@ -24,6 +28,26 @@ export function parseInterval(input: string) { throw new Error('Must evaluate to an interval') } +export function parseVal(input: string) { + try { + const val = evaluateExpression(input) + if (val instanceof Val) { + return val + } + } catch { + /* empty */ + } + try { + const val = evaluateExpression(input.trim() + '@') + if (val instanceof Val) { + return val + } + } catch { + /* empty */ + } + return TWELVE +} + export function decimalString(amount: number) { const result = amount.toString() if (result.includes('e')) { @@ -475,7 +499,7 @@ export function padEndOrTruncate(array: T[], targetLength: number, padValue: array.length = targetLength } -const URL_SAFE_CHARS64 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-~' +const URL_SAFE_CHARS64 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_' export function encodeUrlSafe64(n: number) { if (n < 0 || n >= 64 || !Number.isInteger(n)) { @@ -550,3 +574,27 @@ export function unpackPayload(body: string, id: string) { data.scale.id = id return data } + +// Multi-label offsets +export function labelX(n: number, num: number) { + if (num < 3) { + return 0 + } + if (num & 1) { + // Odd counts exploit a different starting angle. + return Math.cos((TAU * n) / num) + } + // Text tends to extend horizontally so we draw an ellipse. + return 1.5 * Math.sin((TAU * n) / num) +} + +export function labelY(n: number, num: number) { + if (num === 1) { + return -1 + } + if (num & 1) { + // Odd counts exploit a different starting angle. + return Math.sin((TAU * n) / num) + } + return -Math.cos((TAU * n) / num) +} diff --git a/src/views/AnalysisView.vue b/src/views/AnalysisView.vue index 43e2b097..debe4ddf 100644 --- a/src/views/AnalysisView.vue +++ b/src/views/AnalysisView.vue @@ -9,6 +9,7 @@ import { varietySignature } from '@/analysis' import ChordWheel from '@/components/ChordWheel.vue' +import HarmonicEntropyPlot from '@/components/HarmonicEntropyPlot.vue' import ScaleLineInput from '@/components/ScaleLineInput.vue' import { computed, reactive, ref } from 'vue' import { useAudioStore } from '@/stores/audio' @@ -17,11 +18,16 @@ import { literalToString, type Interval } from 'sonic-weave' import { useScaleStore } from '@/stores/scale' import { Fraction, mmod } from 'xen-dev-utils' import { OCTAVE, UNISON } from '@/constants' +import { useHarmonicEntropyStore } from '@/stores/harmonic-entropy' + +const EPSILON = 1e-6 const audio = useAudioStore() const state = useStateStore() const scale = useScaleStore() +const entropy = useHarmonicEntropyStore() +const subtab = ref<'matrix' | 'wheels' | 'entropy'>('matrix') const cellFormat = ref<'best' | 'fraction' | 'cents' | 'decimal'>('best') const simplifyTolerance = ref(3.5) const showOptions = ref(false) @@ -203,241 +209,417 @@ function highlight(y?: number, x?: number) { } } } + +// === Harmonic entropy === +const heMode = ref(0) + +const centss = computed(() => { + const result: number[] = [] + let index = scale.baseMidiNote + heMode.value + const baseCents = scale.scale.getCents(index) + while (index < 10000) { + const cents = scale.scale.getCents(index++) - baseCents + if (cents > 6000 + EPSILON) { + break + } + result.push(cents) + } + return result +}) + +const labels = computed(() => + centss.value.map((_, i) => scale.labelForIndex(scale.baseMidiNote + heMode.value + i)) +) + +const colors = computed(() => + centss.value.map((_, i) => scale.colorForIndex(scale.baseMidiNote + heMode.value + i)) +) + +// These really should be direct v-models, but there's +// something wrong with how input ranges are handled. +const aSlider = computed({ + get: () => entropy.a, + set(newValue: number) { + if (typeof newValue !== 'number') { + newValue = parseFloat(newValue) + } + if (!isNaN(newValue)) { + entropy.a = newValue + } + } +}) + +const sSlider = computed({ + get: () => entropy.s, + set(newValue: number) { + if (typeof newValue !== 'number') { + newValue = parseFloat(newValue) + } + if (!isNaN(newValue)) { + entropy.s = newValue + } + } +}) diff --git a/src/views/LoadScaleView.vue b/src/views/LoadScaleView.vue index 9f75b274..18e17134 100644 --- a/src/views/LoadScaleView.vue +++ b/src/views/LoadScaleView.vue @@ -13,12 +13,14 @@ const router = useRouter() const text = ref('Loading scale...') -onMounted(() => { +onMounted(async () => { const route = useRoute() - const id = route.params.id as string + // Tildes are not wiki friendly. + // Versions < 3.0.0-beta.38 used them. This replacing can be removed at the end of the beta cycle. + const id = (route.params.id as string).replaceAll('~', '_') if (id === '000000000') { - router.push('/') + await router.push('/') return } @@ -30,27 +32,30 @@ onMounted(() => { if (!API_URL) { alert('API URL not configured') } else { - fetch(new URL(`scale/${id}.json`, API_URL)) - .then((res) => { - if (res.ok) { - text.value = 'Scale loaded. Redirecting...' - return res.text() - } else if (res.status === 404) { - text.value = 'Scale not found.' - } else { - text.value = 'Internal server error.' - } - }) - .then((body) => { + try { + // XXX: Dashes are not filesystem friendly, but that's a problem for sw-server to solve. + // XXX: The api should probably be extensionless now that compression negotation makes sw-server bypassing much harder. + const res = await fetch(new URL(`scale/${id}.json.gz`, API_URL)) + if (res.ok) { + text.value = 'Scale loaded. Redirecting...' + const body = await res.text() if (body) { const payload = unpackPayload(body, id) audio.initialize() audio.fromJSON(payload.audio) scale.fromJSON(payload.scale) - router.push('/') + await router.push('/') + } else { + text.value = 'Received empty response from the server.' } - }) - .catch(() => (text.value = 'Failed to connect to server.')) + } else if (res.status === 404) { + text.value = 'Scale not found.' + } else { + text.value = 'Internal server error.' + } + } catch { + text.value = 'Failed to connect to server.' + } } }) diff --git a/src/views/MosView.vue b/src/views/MosView.vue new file mode 100644 index 00000000..195932cc --- /dev/null +++ b/src/views/MosView.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/views/NotFoundView.vue b/src/views/NotFoundView.vue index 0f3fb7ae..42165aec 100644 --- a/src/views/NotFoundView.vue +++ b/src/views/NotFoundView.vue @@ -13,11 +13,11 @@ const ritualInProgress = ref(false) const router = useRouter() const scale = useScaleStore() -function openTheGates(source: string) { +async function openTheGates(source: string) { scale.sourceText = source scale.computeScale() ritualInProgress.value = false - router.push({ path: '/' }) + await router.push({ path: '/' }) } diff --git a/src/views/PreferencesView.vue b/src/views/PreferencesView.vue index 958ce4a3..a3abbeaf 100644 --- a/src/views/PreferencesView.vue +++ b/src/views/PreferencesView.vue @@ -38,6 +38,10 @@ const scale = useScaleStore()

    Advanced

    +
    + + +