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 @@
@@ -150,11 +138,10 @@ watch(() => store.modulus, computeExtent)
v-for="(idx, j) of v.indices"
:key="idx"
class="node-label"
- :x="v.x + lx(j, v.indices.length)"
- :y="v.y + ly(j, v.indices.length)"
+ :x="v.x + store.size * store.labelOffset * labelX(j, v.indices.length)"
+ :y="v.y + store.size * store.labelOffset * labelY(j, v.indices.length)"
:font-size="`${2.5 * store.size}px`"
:stroke-width="store.size * 0.05"
- dominant-baseline="middle"
@touchstart="onTouchStart($event, props.baseMidiNote + idx + 1)"
@touchend="onTouchEnd($event, props.baseMidiNote + idx + 1)"
@touchcancel="onTouchEnd($event, props.baseMidiNote + idx + 1)"
diff --git a/src/components/HarmonicEntropyPlot.vue b/src/components/HarmonicEntropyPlot.vue
new file mode 100644
index 00000000..2288cafb
--- /dev/null
+++ b/src/components/HarmonicEntropyPlot.vue
@@ -0,0 +1,213 @@
+
+
+
+
+
+
diff --git a/src/components/JustIntonationLattice.vue b/src/components/JustIntonationLattice.vue
index 1aacf947..faa3534e 100644
--- a/src/components/JustIntonationLattice.vue
+++ b/src/components/JustIntonationLattice.vue
@@ -209,7 +209,6 @@ watch(
v-for="(v, i) of lattice.vertices"
:key="i"
class="node-label"
- dominant-baseline="middle"
:x="v.x"
:y="v.y - store.labelOffset * store.size"
:font-size="`${3 * store.size}px`"
diff --git a/src/components/MosPyramid.vue b/src/components/MosPyramid.vue
new file mode 100644
index 00000000..625f7795
--- /dev/null
+++ b/src/components/MosPyramid.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
diff --git a/src/components/PeriodCircle.vue b/src/components/PeriodCircle.vue
index 0c25656b..9835b428 100644
--- a/src/components/PeriodCircle.vue
+++ b/src/components/PeriodCircle.vue
@@ -378,9 +378,9 @@ svg text.generator {
}
path.bright {
- stroke: #23ff23;
+ stroke: var(--color-bright-indicator);
}
path.dark {
- stroke: #007200;
+ stroke: var(--color-dark-indicator);
}
diff --git a/src/components/ScaleControls.vue b/src/components/ScaleControls.vue
index a47194b3..585d6314 100644
--- a/src/components/ScaleControls.vue
+++ b/src/components/ScaleControls.vue
@@ -126,7 +126,7 @@ defineExpose({ focus, clearPaletteInfo })
@focus="clearPaletteInfo"
>
-
+
{{ scale.error }}
{{ scale.warning }}
Character palette
diff --git a/src/components/ScaleRule.vue b/src/components/ScaleRule.vue
index bccfca2a..bf935eca 100644
--- a/src/components/ScaleRule.vue
+++ b/src/components/ScaleRule.vue
@@ -5,6 +5,7 @@ import { mmod, valueToCents } from 'xen-dev-utils'
const props = defineProps<{
scale: Scale
+ orientation: 'horizontal' | 'vertical'
}>()
const ticksAndColors = computed(() => {
@@ -32,7 +33,7 @@ const ticksAndColors = computed(() => {
-
diff --git a/src/components/modals/generation/EnumerateChord.vue b/src/components/modals/generation/EnumerateChord.vue
index 9a62f26b..a54497cd 100644
--- a/src/components/modals/generation/EnumerateChord.vue
+++ b/src/components/modals/generation/EnumerateChord.vue
@@ -12,7 +12,7 @@ function generate(expand = true) {
let source = modal.chordIntervals.map((i) => i.toString()).join(':')
emit('update:scaleName', `Chord ${modal.chord}`)
if (modal.retrovertChord) {
- source = `retroverted(${source})`
+ source = `retrovert(${source})`
}
if (expand) {
emit('update:source', expandCode(source))
diff --git a/src/components/modals/generation/GeneratorSequence.vue b/src/components/modals/generation/GeneratorSequence.vue
index 8b800799..814a461b 100644
--- a/src/components/modals/generation/GeneratorSequence.vue
+++ b/src/components/modals/generation/GeneratorSequence.vue
@@ -88,7 +88,7 @@ function generate(kind: 'expanded' | 'raw' | 'lattice' = 'expanded') {
source += `repeat(${modal.numPeriods})\n`
}
source += 'unshift(pop())\n'
- source += 'latticeView()\n'
+ source += `latticeView(${modal.period})\n`
source += 'sort()\n'
} else {
source = `gs(${arrayToString(modal.generators)}, ${modal.size}`
diff --git a/src/components/modals/generation/MosScale.vue b/src/components/modals/generation/MosScale.vue
index d6a4d190..ff515a62 100644
--- a/src/components/modals/generation/MosScale.vue
+++ b/src/components/modals/generation/MosScale.vue
@@ -97,7 +97,7 @@ function generate() {
emit('update:scaleName', name)
const projector = modal.equave.compare(OCTAVE) ? `<${modal.equave.toString()}>` : ''
- const divisions = steps[steps.length - 1]
+ const divisions = Math.abs(steps[steps.length - 1])
let source = ''
for (let i = 0; i < steps.length; ++i) {
const color = colors[i] ? ' ' + colors[i] : ''
@@ -311,6 +311,9 @@ function edoClick(info: MosScaleInfo) {
>{{ modal.hardness }}
{{ modal.previewName }}
+
+ Fullscreen view
+
@@ -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
+ }
+ }
+})
-
- Interval matrix (modes)
-
-
-
- |
-
- {{ i - 1 + state.intervalMatrixIndexing }}
- |
- ({{ scale.scale.size + state.intervalMatrixIndexing }}) |
- Bright % |
-
-
-
-
- {{ scale.labels[mmod(i - 1, scale.labels.length)] }}
-
-
- {{ formatMatrixCell(i ? scale.relativeIntervals[i - 1] : UNISON) }}
-
- |
-
+ |
- {{ brightness[i] }} |
-
-
- Var |
- {{ v }} |
- |
-
-
-
-
-
- More options
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ Chord wheels
+
+
+ Harmonic entropy
+
+
+
+
+ Interval matrix (modes)
+
+
+
+ |
+
+ {{ i - 1 + state.intervalMatrixIndexing }}
+ |
+ ({{ scale.scale.size + state.intervalMatrixIndexing }}) |
+ Bright % |
+
+
+
+
+ {{ scale.labels[mmod(i - 1, scale.labels.length)] }}
+
+
+ {{ formatMatrixCell(i ? scale.relativeIntervals[i - 1] : UNISON) }}
+
+ |
+
+ {{ name }}
+ |
+ {{ brightness[i] }} |
+
+
+ Var |
+ {{ v }} |
+ |
+
+
-
-
-
+
-
-
-
-
Otonal chord
-
-
+ More options
+
+
-
-
-
Chord analysis
-
-
-
-
+
+
+
+
Otonal chord
+
+
-
-
-
+
+
+
Utonal chord
+
+
-
-
-
+
+
-
-
-
Equally tempered chord
-
-
- Chord: [{{ equallyTemperedChordData.degrees.join(',') }}] \
- {{ equallyTemperedChordData.divisions }}{{ nedjiProjector }}
-
-
Error: {{ equallyTemperedChordData.error.toFixed(5) }} c
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
Equally tempered chord
+
+
+ Chord: [{{ equallyTemperedChordData.degrees.join(',') }}] \
+ {{ equallyTemperedChordData.divisions }}{{ nedjiProjector }}
+
+
Error: {{ equallyTemperedChordData.error.toFixed(5) }} c
+
+
-
-
+
+
+
+
+
+
+
+
+
+ Label |
+ Cents |
+ Entropy % |
+
+
+ {{ labels[i] }} |
+ {{ cents.toFixed(state.centsFractionDigits) }} |
+ {{ (100 * entropy.entropyPercentage(cents)).toFixed(3) }} |
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ message }}
+
+
+
+
+
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