From 09e9fbb24259d383bb6e4344d8183e529d1b94d4 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Sun, 19 May 2024 21:04:45 +0300 Subject: [PATCH 01/25] Implement a full-width MOS view ref #700 --- CHANGELOG.md | 1 + package-lock.json | 18 +- package.json | 4 +- src/App.vue | 4 + src/components/MosPyramid.vue | 160 ++++++++++++++ src/components/ScaleRule.vue | 24 ++- src/components/modals/generation/MosScale.vue | 6 + src/router/index.ts | 5 + src/stores/state.ts | 3 + src/views/MosView.vue | 204 ++++++++++++++++++ src/views/PreferencesView.vue | 4 + 11 files changed, 418 insertions(+), 15 deletions(-) create mode 100644 src/components/MosPyramid.vue create mode 100644 src/views/MosView.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index 56598f5f..1cd4d22b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ * 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) * 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..9a9f6d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.5.2", + "moment-of-symmetry": "^0.6.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.2.0", + "sonic-weave": "^0.3.1", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", @@ -4381,9 +4381,9 @@ } }, "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.6.0", + "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.6.0.tgz", + "integrity": "sha512-0dpJfCm4bcsEAhQazehWvQxm75UM1B2aZsKPNmeUgsLW/DvL9EOL2/qSVE472mGG+opFe9G7Ou02PN59sJPBPA==", "dependencies": { "xen-dev-utils": "^0.7.0" }, @@ -5455,11 +5455,11 @@ } }, "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.3.1", + "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.1.tgz", + "integrity": "sha512-Eovb+KrRXsIJiMHEzM+Ft96NEf6CoyUWnN3XDfysgSCVyr8Rlzcfqv9Vquz9zINC3vXcs1ACC7rT4ecPhOeI/Q==", "dependencies": { - "moment-of-symmetry": "^0.5.3", + "moment-of-symmetry": "^0.6.0", "xen-dev-utils": "^0.7.0" }, "bin": { diff --git a/package.json b/package.json index 6c97fb3f..c5750449 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.5.2", + "moment-of-symmetry": "^0.6.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.2.0", + "sonic-weave": "^0.3.1", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", diff --git a/src/App.vue b/src/App.vue index 61a4a162..a0ac7174 100644 --- a/src/App.vue +++ b/src/App.vue @@ -474,6 +474,9 @@ function panic() { Sw
  • Build Scale
  • +
  • + MOS +
  • Analysis
  • Lattice
  • Virtual Keyboard
  • @@ -610,6 +613,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/components/MosPyramid.vue b/src/components/MosPyramid.vue new file mode 100644 index 00000000..0e65d984 --- /dev/null +++ b/src/components/MosPyramid.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/src/components/ScaleRule.vue b/src/components/ScaleRule.vue index bccfca2a..c9ebcca1 100644 --- a/src/components/ScaleRule.vue +++ b/src/components/ScaleRule.vue @@ -3,9 +3,13 @@ import type { Scale } from '@/scale' import { computed } from 'vue' import { mmod, valueToCents } from 'xen-dev-utils' -const props = defineProps<{ - scale: Scale -}>() +const props = withDefaults( + defineProps<{ + scale: Scale + orientation: 'horizontal' | 'vertical' + }>(), + { orientation: 'horizontal' } +) const ticksAndColors = computed(() => { const equaveCents = valueToCents(props.scale.equaveRatio) @@ -32,7 +36,7 @@ const ticksAndColors = computed(() => { diff --git a/src/components/modals/generation/MosScale.vue b/src/components/modals/generation/MosScale.vue index d6a4d190..22799120 100644 --- a/src/components/modals/generation/MosScale.vue +++ b/src/components/modals/generation/MosScale.vue @@ -311,6 +311,9 @@ function edoClick(info: MosScaleInfo) { >{{ modal.hardness }} {{ 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/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/state.ts b/src/stores/state.ts index 23463e62..93b1dad7 100644 --- a/src/stores/state.ts +++ b/src/stores/state.ts @@ -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/views/MosView.vue b/src/views/MosView.vue new file mode 100644 index 00000000..029e9aba --- /dev/null +++ b/src/views/MosView.vue @@ -0,0 +1,204 @@ + + + + + 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

    +
    + + +
    From d8cec21359266a63b64eccee77b0ab8df788564c Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 09:33:03 +0300 Subject: [PATCH 02/25] Tweak button accent color to be less saturated --- src/assets/base.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/assets/base.css b/src/assets/base.css index d154d17c..d692f735 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; @@ -53,7 +53,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; From dfdd7512df7e0ba0950bd581f74a6ab3f91e852c Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 09:39:15 +0300 Subject: [PATCH 03/25] Move light and dark indicator colors to base.css Tweak them too. --- src/assets/base.css | 3 +++ src/components/PeriodCircle.vue | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/assets/base.css b/src/assets/base.css index d692f735..a24864ee 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -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); 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); } From 3e3aad543d740551172084403b433ac83e9f0e03 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 09:40:45 +0300 Subject: [PATCH 04/25] 3.0.0-beta.32 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9a9f6d55..1944e5ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.31", + "version": "3.0.0-beta.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.31", + "version": "3.0.0-beta.32", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", diff --git a/package.json b/package.json index c5750449..872b537c 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.32", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", From 3240d47b54a6421c8dc775f0c0c5c02f5fb52ce8 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 10:00:37 +0300 Subject: [PATCH 05/25] Tweak MOS view rational hardness approximation --- package-lock.json | 4 ++-- package.json | 2 +- src/views/MosView.vue | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1944e5ea..97dff15b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", diff --git a/package.json b/package.json index 872b537c..6ee9a4fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.32", + "version": "3.0.0-beta.33", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", diff --git a/src/views/MosView.vue b/src/views/MosView.vue index 029e9aba..a725baee 100644 --- a/src/views/MosView.vue +++ b/src/views/MosView.vue @@ -16,7 +16,9 @@ const message = ref('Your scale will be replaced with a MOS scale upon interacti const hardness = ref(2) const rationalHardness = computed(() => - isFinite(hardness.value) ? new Fraction(hardness.value).simplify(0.03) : { n: 1, d: 0 } + isFinite(hardness.value) + ? new Fraction(hardness.value).sub(1).simplifyRelative(25).add(1) + : { n: 1, d: 0 } ) const hardnessRange = computed(() => From fcf13b277ba00fdcc0727f0dfacc0f95c8bec0eb Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 16:43:40 +0300 Subject: [PATCH 06/25] Only compute potentially visible elements in MOS view Update moment-of-symmtery and sonic-weave dependencies. Fixes large MOS scale generation. ref #705 --- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- src/components/MosPyramid.vue | 7 +++++-- src/components/ScaleControls.vue | 2 +- src/components/ScaleRule.vue | 11 ++++------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 97dff15b..f99ed1a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.6.0", + "moment-of-symmetry": "^0.7.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.1", + "sonic-weave": "^0.3.2", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", @@ -4381,9 +4381,9 @@ } }, "node_modules/moment-of-symmetry": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.6.0.tgz", - "integrity": "sha512-0dpJfCm4bcsEAhQazehWvQxm75UM1B2aZsKPNmeUgsLW/DvL9EOL2/qSVE472mGG+opFe9G7Ou02PN59sJPBPA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.7.0.tgz", + "integrity": "sha512-aiED8cEwD7TrjiHnJ7rus3AK5OGjBwFlNi9WqY0M+pws/usCIdId4AGOa54r0jTi0wYdoQMpAb0TZlISVTfSgA==", "dependencies": { "xen-dev-utils": "^0.7.0" }, @@ -5455,11 +5455,11 @@ } }, "node_modules/sonic-weave": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.1.tgz", - "integrity": "sha512-Eovb+KrRXsIJiMHEzM+Ft96NEf6CoyUWnN3XDfysgSCVyr8Rlzcfqv9Vquz9zINC3vXcs1ACC7rT4ecPhOeI/Q==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.2.tgz", + "integrity": "sha512-6S3Gm2RkiFN0veNthrWWy+ngweqcce6BKX+cbjHeJtjzWS3/rgAH53Z3nEzz6kySVxMHtxEjhMMpdX8cJKR/Dw==", "dependencies": { - "moment-of-symmetry": "^0.6.0", + "moment-of-symmetry": "^0.7.0", "xen-dev-utils": "^0.7.0" }, "bin": { diff --git a/package.json b/package.json index 6ee9a4fd..ba30a2c2 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,10 @@ "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.6.0", + "moment-of-symmetry": "^0.7.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.1", + "sonic-weave": "^0.3.2", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", diff --git a/src/components/MosPyramid.vue b/src/components/MosPyramid.vue index 0e65d984..2cbb09c5 100644 --- a/src/components/MosPyramid.vue +++ b/src/components/MosPyramid.vue @@ -31,10 +31,13 @@ type BasicInfo = { const basics = computed(() => { const v = props.vertical const rows: BasicInfo[][] = [] - // Do two extra rows to compensate for potentially non-square container. + // Do two extra rows at both ends to compensate for potentially non-square container. for (let size = Math.max(2, v); size < props.rows + v + 4; ++size) { const row: BasicInfo[] = [] - for (let numL = 1; numL < size; ++numL) { + // Do six extra columns at both ends. + const low = Math.max(1, Math.floor(props.horizontal - props.rows / 2 + size / 2 - 6)) + const high = Math.min(size, Math.ceil(props.horizontal + props.rows / 2 + size / 2 + 6)) + for (let numL = low; numL < high; ++numL) { const numS = size - numL const info = mosScaleInfo(numL, numS) const p = info.numberOfPeriods 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 c9ebcca1..bf935eca 100644 --- a/src/components/ScaleRule.vue +++ b/src/components/ScaleRule.vue @@ -3,13 +3,10 @@ import type { Scale } from '@/scale' import { computed } from 'vue' import { mmod, valueToCents } from 'xen-dev-utils' -const props = withDefaults( - defineProps<{ - scale: Scale - orientation: 'horizontal' | 'vertical' - }>(), - { orientation: 'horizontal' } -) +const props = defineProps<{ + scale: Scale + orientation: 'horizontal' | 'vertical' +}>() const ticksAndColors = computed(() => { const equaveCents = valueToCents(props.scale.equaveRatio) From d0dfb268f01a2b532bbeb1f2bb39e79c4c33d161 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 19:46:21 +0300 Subject: [PATCH 07/25] Fix Scala .scl import line inference Also import .scl scale title. ref #706 --- src/importers/__tests__/brun_wilsonic.scl | 23 +++++++++++++ src/importers/__tests__/scala.spec.ts | 32 ++++++++++++++++-- src/importers/scala.ts | 40 ++++++++++++++++------- 3 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 src/importers/__tests__/brun_wilsonic.scl 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') } } } From 459e320f8f59aa61b0a3dc02d0af19b5f42b9535 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 19:49:08 +0300 Subject: [PATCH 08/25] 3.0.0-beta.34 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f99ed1a3..754d6621 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.33", + "version": "3.0.0-beta.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.33", + "version": "3.0.0-beta.34", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", diff --git a/package.json b/package.json index ba30a2c2..cfbe9e8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.33", + "version": "3.0.0-beta.34", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", From 137d3f1cb07874b9535416161b9571408b654320 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Tue, 21 May 2024 20:10:30 +0300 Subject: [PATCH 09/25] Add missing changelog entries --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cd4d22b..9b8258d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ * 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) From 4f9a2ba04c2d9de37670c5d9695efefdf59ee83f Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Wed, 22 May 2024 19:44:34 +0300 Subject: [PATCH 10/25] Update dependencies Fixes various MOS issues. --- package-lock.json | 34 +++++++++---------- package.json | 8 ++--- src/components/modals/generation/MosScale.vue | 2 +- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 754d6621..1baa1a4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.34", + "version": "3.0.0-beta.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.34", + "version": "3.0.0-beta.35", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.7.0", + "moment-of-symmetry": "^0.8.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.2", + "sonic-weave": "^0.3.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.8.0", "xen-midi": "^0.2.0" }, "devDependencies": { @@ -4381,11 +4381,11 @@ } }, "node_modules/moment-of-symmetry": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.7.0.tgz", - "integrity": "sha512-aiED8cEwD7TrjiHnJ7rus3AK5OGjBwFlNi9WqY0M+pws/usCIdId4AGOa54r0jTi0wYdoQMpAb0TZlISVTfSgA==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/moment-of-symmetry/-/moment-of-symmetry-0.8.0.tgz", + "integrity": "sha512-K6yekyZzk6Yn9vS7mEI46yLHLctWXOvtXJC9GJk72SmD+JZZ8heeSQrKgxX3pacza3OjDRIkwTMZEevn4uhGKQ==", "dependencies": { - "xen-dev-utils": "^0.7.0" + "xen-dev-utils": "^0.8.0" }, "funding": { "type": "github", @@ -5455,12 +5455,12 @@ } }, "node_modules/sonic-weave": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.2.tgz", - "integrity": "sha512-6S3Gm2RkiFN0veNthrWWy+ngweqcce6BKX+cbjHeJtjzWS3/rgAH53Z3nEzz6kySVxMHtxEjhMMpdX8cJKR/Dw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.3.tgz", + "integrity": "sha512-4eff0Mkfy719MZRtve0WIttFMOhK1vqvfE57X7fw7z5QOPGvXQm7/YdOlut7TrXWf0HQlEnoca5R6LLW364flA==", "dependencies": { - "moment-of-symmetry": "^0.7.0", - "xen-dev-utils": "^0.7.0" + "moment-of-symmetry": "^0.8.0", + "xen-dev-utils": "^0.8.0" }, "bin": { "sonic-weave": "bin/sonic-weave.js" @@ -6604,9 +6604,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.8.0", + "resolved": "https://registry.npmjs.org/xen-dev-utils/-/xen-dev-utils-0.8.0.tgz", + "integrity": "sha512-na9WWF1JlFhGjzvQDk/VHYWmTTvR1TD6nKHHy7GXC4f41u9CsLKxB7jphwioz1pR/5dBsbiSKBKTkWR4Uyactg==", "engines": { "node": ">=10.6.0" }, diff --git a/package.json b/package.json index cfbe9e8b..d9ba299a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.34", + "version": "3.0.0-beta.35", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", @@ -18,17 +18,17 @@ "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", - "moment-of-symmetry": "^0.7.0", + "moment-of-symmetry": "^0.8.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.2", + "sonic-weave": "^0.3.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.8.0", "xen-midi": "^0.2.0" }, "devDependencies": { diff --git a/src/components/modals/generation/MosScale.vue b/src/components/modals/generation/MosScale.vue index 22799120..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] : '' From f7ec89d8b50a0f0c02b46e89b4ca5379419751ff Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Wed, 22 May 2024 20:07:29 +0300 Subject: [PATCH 11/25] Work around SVG baseline bug in Firefox ref #710 --- src/components/MosPyramid.vue | 33 +++++---------------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/src/components/MosPyramid.vue b/src/components/MosPyramid.vue index 2cbb09c5..625f7795 100644 --- a/src/components/MosPyramid.vue +++ b/src/components/MosPyramid.vue @@ -93,40 +93,16 @@ const generators = computed(() => { :class="{ selected: selected === info.pattern }" > - + {{ info.pattern }} - + {{ generators[i][j] }} - + {{ info.abbreviation }} - + {{ info.name }} @@ -155,6 +131,7 @@ g:hover > rect { stroke-width: 0.02; } text { + dominant-baseline: central; fill: var(--color-accent-text-btn); } g:hover > text { From ec197d25d0c2bb146592726ec11e1b1d3adeab79 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Thu, 23 May 2024 16:19:09 +0300 Subject: [PATCH 12/25] Update sonic-weave dependency Fixes gas limits in MOS declaration and automos Adds size hints to rank2 and parallelotope --- package-lock.json | 12 ++++++------ package.json | 4 ++-- src/presets.json | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1baa1a4b..be077069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.35", + "version": "3.0.0-beta.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.35", + "version": "3.0.0-beta.36", "dependencies": { "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", @@ -14,7 +14,7 @@ "moment-of-symmetry": "^0.8.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.3", + "sonic-weave": "*", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", @@ -5455,9 +5455,9 @@ } }, "node_modules/sonic-weave": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.3.tgz", - "integrity": "sha512-4eff0Mkfy719MZRtve0WIttFMOhK1vqvfE57X7fw7z5QOPGvXQm7/YdOlut7TrXWf0HQlEnoca5R6LLW364flA==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/sonic-weave/-/sonic-weave-0.3.4.tgz", + "integrity": "sha512-6AriXviwWUf48J/4KbJNHE0Riabh8hoTRSzoLCVo07LeRLEWJnvbF55+Xk/uH0w28iMzn17T7gaVanPsNppFfA==", "dependencies": { "moment-of-symmetry": "^0.8.0", "xen-dev-utils": "^0.8.0" diff --git a/package.json b/package.json index d9ba299a..c464cd94 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.35", + "version": "3.0.0-beta.36", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", @@ -21,7 +21,7 @@ "moment-of-symmetry": "^0.8.0", "pinia": "^2.1.7", "qs": "^6.12.0", - "sonic-weave": "^0.3.3", + "sonic-weave": "*", "sw-synth": "^0.1.0", "temperaments": "^0.5.3", "values.js": "^2.1.1", diff --git a/src/presets.json b/src/presets.json index 43945320..6f3bcdae 100644 --- a/src/presets.json +++ b/src/presets.json @@ -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": { From 0d7bebc7d20d755c4db4db1c5849580552267657 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Fri, 31 May 2024 13:15:18 +0300 Subject: [PATCH 13/25] Add harmonic entropy to analysis tab --- package-lock.json | 39 +- package.json | 3 +- src/App.vue | 6 +- src/assets/harmonic-entropy.ydata.raw | Bin 0 -> 48004 bytes src/components/HarmonicEntropyPlot.vue | 213 +++++++++ src/harmonic-entropy-worker.ts | 13 + src/stores/harmonic-entropy.ts | 115 +++++ src/views/AnalysisView.vue | 613 ++++++++++++++++--------- 8 files changed, 789 insertions(+), 213 deletions(-) create mode 100644 src/assets/harmonic-entropy.ydata.raw create mode 100644 src/components/HarmonicEntropyPlot.vue create mode 100644 src/harmonic-entropy-worker.ts create mode 100644 src/stores/harmonic-entropy.ts diff --git a/package-lock.json b/package-lock.json index be077069..74bb268b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,14 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.36", + "version": "3.0.0-beta.37", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scale-workshop", - "version": "3.0.0-beta.36", + "version": "3.0.0-beta.37", "dependencies": { + "harmonic-entropy": "^0.2.0", "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", @@ -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", diff --git a/package.json b/package.json index c464cd94..a3d4735a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scale-workshop", - "version": "3.0.0-beta.36", + "version": "3.0.0-beta.37", "scripts": { "dev": "vite", "build": "run-p type-check \"build-only {@}\" --", @@ -15,6 +15,7 @@ "format": "prettier --write src/" }, "dependencies": { + "harmonic-entropy": "^0.2.0", "isomorphic-qwerty": "^0.0.2", "ji-lattice": "^0.0.3", "jszip": "^3.10.1", diff --git a/src/App.vue b/src/App.vue index a0ac7174..34791b50 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) @@ -436,6 +438,7 @@ onMounted(() => { console.error(`Error parsing version ${query.get('version')} URL`, error) } } + await entropy.fetchTable() }) onUnmounted(() => { @@ -530,6 +533,7 @@ nav#app-navigation { display: flex; } +#app > #view, #app > main { flex: 1 1 auto; overflow-y: hidden; diff --git a/src/assets/harmonic-entropy.ydata.raw b/src/assets/harmonic-entropy.ydata.raw new file mode 100644 index 0000000000000000000000000000000000000000..d0a5a94fac0af0fd179781444c9299c8d009cece GIT binary patch literal 48004 zcmYh^dDuXxoco-$*Is+A*LtnBw^e^MEbg+lVX;3g{%lxmwXR`t z)cS_Srhhjq*4@~!c*drN#i^Sc7H=(6C|dLUFH3h2kew3dPr}7m5pN6p9UN z7m5?>7K)wsEfh~ZuuwerkV3KFVTI!SqYA|nn-q$lo>(Y$X;~=lcY2|?>YPIHqc(-& z*ozCr$F3|C@4lu`yrXNOceS`~F!dcK@qT9J8@deCyvranu&yxlKuNhw>%Gd$%ho{5GxOMczmnpm3}HGy@Gslf^Se}K8~*lp-|-<&b31J~m`y(C z10JGHK9d!`=UJ}cKz{K3&v7|>@{KY*!THo=p0eIYD=PDe@(Lt z?OIa&lJVTjWi+7<|2QXKGM)#xo>m;f_WZm{N%3RGFp#ThMFT3c)_GmP8#%w+&c&R> z{#0SD^Zzw(^9+Nyj`L|sJu32hb?;+3FEWHX=tOIdr#{u$qFw#Oe5UdWBN<2++H)3- zsmIQ2*-;(wJ+qn2iwxsFZsTe$;eRyYVCqnXP1^GszUK?3GlAzA#vpE|D_7EnQ)oY7)UQ}FdpPy z`qG`QT*bv)z?qyzk;XJARnP0LRHFjhvayoy=4Y0(i22N7I+J;oml(we9^-EM(SsYf zmJYP%0?wc%$8$7?a}fKn2fI<33T)Bm{l!{-@ed z(%1a_NU6uX#@4^v`ub4mzvp-JIVnrNzdK#&%$2m~98Tp}O4y%0*q*YiHJ<&zJU-+N zUgR+bauZi@9?faY0o0;0|5i}PEMp;a`Iu?E#Vd^AX`U;6ebVbF^7qg460b9b5136p z>nGMz!T0RK{xsq=F5_kf@Hi>=ES9mJ%8u;-8k0J>f?G-b=iKIerwz&h*(za?)h01J4-t#*vS-=NOAZ;b*_!?S~`mV}v%JVVL(35ki&lblvhtx$EPND|O z%e$VrgLBxE)#cO=gSnU^sKkjyAUFAQUyLgYkILvW&&kukgKZF^EfOz~9RFKI!*PCv7XoGl&z( zvF4h)gsQw*S~r!wPE=QI*@iLd@-$Yd=X+?tXU<0_Dl@jMcE{Q>u3wI3x!83ON3#6C zLh(LMVEyJo@g+L4KdZ#KuW%dxqc$5h6^e6tm0|SdMmp1p%jifKdhr-}?&Y9j${+99sg@oRwq~THan@O zE166c_1A{9v9%noZgc%jVeEGDtVW@?nS)4f$@hYmQ53Kt;Y=RVaSI6kg}8(m#Lgbt8Lw&zaoI9CqZ7uHnMeoqR7HJ_=REbdj{DSQ{nRU`tLJ50t_E@!;GfqCrj zeCC??kgCq}`3&JR%4rLYNGyLJ6Ij3>?4m6l#EG2A#au%-Zl@o8O5Iudclo!jS}9HaqtNV`oN{eiUYX*|b+bR+HbR1T*m|0?ItWSn`ON4bUe9Lpa3>)4htjVDPv zZp)FR-^my^i_!F^4M&ieBr!|>|405V|8^rixtm8BOZx5Ee9vE0 z^IZpUJeSae$9bEDY@n9%9?M1aBr)YwzGEB5xgRa)L}J8e`H1h?M*Y+!{r}nANaE;c zn8JL1W)r)p(|6AyeRlfl18L5s^yX=%u#msVoc|ckqZ>~!iLd#G$B>9c|NxtI^rjtC#a(>}&HnN$2S@NE8>hh4U z{!%EO@v}JXM`O;)Lh-mC^#9)%iVu8etXQu9{#LxU)aNdd+x(_bthTsN{CSbjTd2SP zs!)7qftc#cLUGCbLa_{o%_|hIn_DPO`a)dBfX@rX@;p9Ax%g>zp*W5{bmB5PGk{5K z;&ktOg?fH(EX{q+uZ;A$m-+moeNSEAS;==-P=+eXQb(B@e=WzVd=D$s7vY#dI zI?{2hcRVfSU!QV(TO4OQdDwRxcb#A5d)31?>f_io%K3Yt`1>E;Ypi-gy)9RN1$EiQ z*z|&W{Z{=}R@aTx`Pu6J8e>r}=b^vz(c5{s#ra7e)Y3WI$GQ8Hcb&(~*$;DWlgmi_ zwU2Y2e!7V^koY5g{}^`GMs6Z`u|2h&Yk7&)9HecvIiBu{q`+wqg* znZ;P{r85~L52q#@9rrTcV=Rg3ZlNuS!49Sdo7Ge1Ml(tN>sgZbx}B>@40$32(m!oa z`l}Trf0dXm@$oY}NMh8kBz8@ldjciYr7G!1lY9S$nY_suG8gQ{b!2Qjff8y_jy2Bl zm%PJsByW;DN#ereIhf4TGY?zLRG#B*x{&#L@*cG*$B)`hauk`T4W}Phld-fJ2eK>M z@~if?jM-%FoB7;Wo?#e|kT`S@0~tX7(my}sHSc+jmzhGw=Zt|Ft2dMHIe-&r%Pl-e zaw;j~YN{#oVVuuxj9>;U*u`-tHF)CPr90)i~*T5|D>Pl%pdx!Zj|RS zeOP@y)Sq3-HoRzzIG@T)*VpyqG-|O*|MvmUGKkx`zSK=#@8n@#=5sc%zu#-e5WZo5 zpL-Lt*w^>n#ZRDWeapktlD;g0(~TBwKFoTFYA z(oQ`sq^)}Un*XWCk2prXzDOPQoU!yb&Q|}gQpx$~NUql%ou_uZKw_yQoX6y{b6$U7 z59c}Y&aFI6`l7V69kq)R65C`vPuseiC&}8tTYSK$%qFq?0y6%8%{P3>eDa>WcQT2M z^81@IFhS4l;l3%=1y9&19R2=V_e9-Bu_VGF69!g;3}@;HU=`334F*B z{$mfHo4jOlkpp>+-FIvk_;Kyn6EZLrK0jdEcxjq`%C3;Sc^H`Qd*_zA``Ob%WQ{{KPlR zVFu}gM)NSq9pAtuoIw-zr#f4dC3)nzOy?Dbk{o?Et|0yUNgPRmj8{9d9sfDbbtG3G z>F45`Qu%o?$;JM_N-{Uk@BGWQJ|nr4QvMVfO8S#vs%_FPU!ZsZnjBRQo(B;I(8XBo>lCX)GI;*!k& z7P55?m@zZ!XO&8?<-{{tXG?5z49Vl2$wgeowQQaLC1*E`s&DNm`k{x^pnYfKg>v8B;#}Dh8d$5ll$}_yi4AO6|WG%_*?MyuqOP@>|l4I%4{XEG_%-~B_vWY5U)x9~4lWE7b^x+Z4 zF@<^j!WMQF{~pBgWWJd=y+2t4PrN*f6>O$jOQ-9-YJ^&BQ1b#4Y2+ zHYb}Seqz4ZbhS9hJaU0KWd(D~{mh?B%sUTWA~*WAx$6RRTl3ai&1Luh%q3_nQ0Ln^bOqxobah(;afEy?jPbf9_%a+f96UvpMcfh2nWP6pE9(nAct}?z^r~ zT+z8u{Lx;gy4-oS?_zc*RY{6R{x8ghbMk56pwda z=KgM->CZy(UFWOnhC=bAf2=M2EC1kp-r>An;ru4%UhRBmEqY()|0(SteNx)RQ0?Pm z?c{sy*w(Jy&Rd3prMMO#A_qodL9^0sr~#Ps!e}Tnops9rKlKG&gYqm07IZ z&!4XtsvE!YmhLTwD@lyG73@85{Pn8Qk??`HYk&6haT$Kqmu0Wb6q0jDtb8rWDJP!Zoekpc#NktTioSHjW0K?T%6@#IUT!DrXkV$vtbZNAd~?>G>_ztJT*t0V zG577jj=XIw?7(h(Air`8hp@spIf6FSVX<7y({$oks*wE_lNmw}5+}9hB#z+-8qtIk zIEPE=#Rw*`jO~5a;atf`zG4^OnLPXBEM-4sx{-IOs;rkXfeMbHE#s-^*fN)&#Ey>n z3Z}7>y2#q#dsJ6Pmy&#E@~5Ye_4>?H_ffBxlj~+Vb=CX%ByagHYssF#79m@;SY&}O#xyOr66Jt?6nr_qjU=}pR; z_MEo=89(wL$%iHn--657nwy>I=ecA}FtPUjw4fcm7{)|0MrJ-&$+(%h+zDhJm-X^N zjO0zS&vzMXDQk@0kzLuBdK^sl3Ljqjy1&=jWIW&EcYfh>UguG+r8%|u+4oQ2R*ocN z?lYXtM#uO#=?`YBuggh3raQ^&-#~I2$*ZPN(MDW*_gZ;W>s1@YxyRCD zjr$qIy*21yO>5e3_G>U$JiHCJ{$h>oXL(+ByFr`1Dub#k)0sjZH(H|BNqm3i6m>adKue4n1`^-y*EE!nHsNZn^_ zxygCR-h&Yw=p1Dq+(FLSXlgr$_maGCuCZ51jw5Re$y=-^?Vt-|Nba_>c5(!nBlP46 zCXo0rapDTnHnVQMk>nY&S3N(k=4Y0WHaLx~G38)C-$vreRvg7%WIy^JzV|DpGMYgo zKXNX|un)PmepBB0Wbabi?E_@JBDtAXG-iLQP?kR&?^5QFnC}ge8-9$feNy_ZTe+U} zWm!8*zt)EH$bPW&O=pxk)ob!ii3#(XKI^2?*Aq*BPONx5xt1D{{_GIackaQ?q(4f( zl>RGw#K)cKW~z?z@mCej(M z$%%iHGfj-1HSlxkL|=xIxPJ=CY0f3%=(nsO^MqK!SA5KSOd|VX9-}{9xtNwDmdl)= z48Qo^xg?HGe4o5}C(hYSTqoK9llE4Y@PWG;3eiFHPj z>+e-2lk+i)`DD%f2NGATC3C*a{i;%f#43qd4kg!puK)BK=|3(ZeM?u;-y}{-|1+HD z$y(goB&MFvH>@IKa{96C+spcY`nIEK!s%oW+%;rQoVoF1q+fiQ512#t;UqT696M`z zyHlXZnOx5G^yML*Bk{^-Eai8~>!Wuk`qLhh0Jm)+$qj^qrIYtDUEFYpn`f0U66*`E`+f&pZ2(k!xPDf>eX<2<_a zBp>i2mE>cRSH6_HNX}+HTc|IWa}K#5Z8Tr7k-g-8PT?k!3!A}ec9SbQmgHRf^8z#Y zg=+Fk`_h~?+(>^O;Ymg_mgJ&Clc(xCU)A4cp3^#O z)8dfHVv+@7lpn+^--=%rB$g4|+%48QNBpysxM;e)MqTVVswyw}+!FJe#bU9A;w3rD zcjt-qzOX-Qw*69{T0fsAYpNbW^F>sg=sv{-6Xp}2CS{j*P6uODIm*KqG2CTBa;-Yt4PA#w9nwZV;9pxU`NsjlJkL^Wp{HM+lFRG6-<|~K# zX|+iC)K{aW_V25|P3rMX`?f}?*CpzAH}Pb0?rp`79mI|u6AS<4dp9^w{}=-|i8(im zUyX(DIDgHIZ&RGly^UShJHKx_&siVMJSS^dSwlZSo5&pEH>PMOJ++sH+Rm5c9?So@ zL%aHiE48y(r0r$xs~zJEo~9eQ zM>KoLs_=t)$iB#@=}qnjJ(imMt)3S021Dsadz!Kr+p`l zznqUq44qiIFPW1ir`n8$>_!=Wb-osoYdG_?%pJ3LKl8Qh!8n@}$ljg3smd1Tc@_3C?nmyOyn@zbzeMhtEMXu1Z?5!D?PdiF$=o;jor%1}NFL=L?%)Cdw#&jl<_20xw*_SbZ9%O7z%#(QPWKJZp&wCpSir|5H<=u0a+X>1PHglT50UloKBY3Ry@4)VOJb+&nMn>Uuh*CUe4W=D zxrH9|W&n5dC?iPzGP%-MnMUG~#_=OMasCW*gyWlL#1zQF5razES)Jjrk#A#;w0 z$o+Ep`!T%9CoEzW+xV=#Iga-9;u#X(=N^gYj^BDvigsY3D;4auCV$cN6&$;@=_PGYii+>lXXl+5i08Rym-@7j@hVv!e(fi<{G zuH<)P;)Q%+d~C|ZjdH0xWz5`@VPcxS7;79oitGiXe>>(vZq0!ygocU$oQUvZ$%?#;Ix?>om(Lm38;J&L#UH$9cJtTNxj z?vCMEj&@8Rae-s}l{<6HId+ay7oUCYB&h;w#3pRtQ`nLNl-e9JD*b?y`E!AmTnj5d+HOmnht?{*&Mc_#BI z$$4fU^be$6t|jw{H6#}qX@A)(m^Sz^Y0nw^p5j50L%o&@$-dRJ$J%6HVDdqk>DRGq!f zU0g&7+546`@w4>ebn26Q!aT;1HJaa6q`N#SE zo8S43+`pQfP5y2h|6Yl#0qo0RG@~uok{s=DCXw%4&o0VvB6h1$>mmDP51=ty=k~Yw`5v++{|eJdt~J+T#)8c2 zb8lYO2ri%t1IT*B+sq+r3T4EzH7Ox;`m5>7Gfd)Z{$w|?@e!OuX9hEl>|^+o+^cj1 z=g^((7kiiN6)Pt$-=EXDo=13_?)soTMyKB4s|W$TH4ojm6+;F*K%@}7wgM+)~9Dr_$m7Q%=uay z52hO<4l`B^Hij%Wwj6A{>13Wb%6##2^Tu`7C3ZK5ESO6+GpB50&(I~y-N*B-HI^mT z9u^yS7Ww_JtT}$^o}YQ<&gQ0b%u&~TWCoOu{_|<<`=V6ue(P8udi$xA99eZauZyTJytcjiEd}crIxz6t? z&ht^ucVfO@NW9ijd)Qsun8x|q$x5y&ZT}Z&NAIwwwsj-f1G$YhcPzK^EOW@SvZ`sL z2a&ehg3~#h(@6fRIceVqk~UwCwCP2>!_)Mo6U|85-j?hGn@Fy&TxZ$av>#iPYY}fS zn9IoXmUbuiU423B-OBToE+h90W`Ah*G38lGV@YnW6RkLqiX<RkV z@5_CppY1^6^URYQQkN=hb}rYDnCJ_p^9C<4gu(P6{eAMi7to5#jnfViBWIk*+&DR! zt!qG8r~Z-e$rv+_GYF`dLVJ301a>A+yJ2A6oI zlDf>iJh_UyNL;ay4dgs$jqoZSU;=3~e~`Ut2az~6Is6+Kz%a5dyLCVLXMWCl=feME zfu9rerrl;fmi=*$(wnr~lSp1C>js%~=6e%+oJGo%HkP)OIO_mbI*#GwT>s}-U#2x# z!+DNV_(>fOU{9urA6v6Ve|HC&+mF@%HRTIq*X8`JU(CF%4zHP~oXjHQSy!quPXBot zd1lIk9L;JHJnM2hSX=@QVqN|<#akSi193D2cL5+ zH}D15SxrgQ>USk-! zMsMY6(r;vL)r=$l7Bqa_{*t@?6P(m2U(`F~_l7#4^Wx9<$U>Vvz^cX<~^2X$NUDA8A)< zhyUp-%27t&u|c1*QXli7{$_-}r<1;@pkG?1&l;f*J67MeK#Y35ywaw1;#YBNIq_?6 zaqMdG>?PLlJ{8{{W{&WP{Y)F(OVCE_J4p;&(;8GSYf;}WwO+MEzqr`F9AAr>7rIwg z>^xKqU2neirn&NF;_7mr7m8EG*>{;Uv=(z8_KABb#NYePvL8|`exCKOyTs*Rh|l-q zF>8Oj@q(EBe0E`_wX)Coj3rd^cP)9C&D`ewRsHUGF85i7`^@cq_IlR&o-)33ci(-Q zGW1oJPsIGYD_aj`{8rq5zA}Hg!2U|duxyd%HaMnoj%}D@Jj}5!5dU}k-W=x#Yk}(G ze0B1hIlv%ww2!)aS)HAv?q>Xa?-C9ixsbs_U!N`J3u~Tj!vqbJ5*7dBM5) z)H%xjtmJ6^blwtsWbedO=QL{&$!{FsT<4rWE zLY{BXgsgFoVKJE#@6FMiNe8-c3%yG9_S%j7{iU=fZ9C80-G{X2wLUA)Rh>lE%5JA4 zr&1u-(vQmUA-QMaE^^H^BiCTo=yOd@=5cNzbDOmNnrw7T^Ldjz2Q|+?ZA%lfr*RvS zWB8P;VP{W457KXBzd;F!@&8p<$rmQKnEQ{D1J6ClHRiP>_qymBHEV*48Bgl+LQ42o*;8Nllh`)*2rO`2X1yyhMKxx)R&OC|T-ooRwWCwm z=vp4lIpn#G<6Pglcdj~9UGLYkA9>DRV%|LO>~H-+_QG{0G2<`#h~({WChHf;5tz`11Kd7dS>E89_yvTPQQ=FdB^7l}X3 zM=D<*zF>FdzLxjc$?;rH*4whT{8I9~hODRMd6^T)GZZi28P<_?!oei}otP;(@uyf! z9rb=b_wfN)15KQmnCCWfeSApfBV}Dbdyw2|_E%ocbtI-q9GYwI5i-Yqywszmf6l+% z&23yyem6P(%(?S<$;sBDBAI9B`{t4M^fHOL(iU$e{qwnG?tCQm$$E1o%8+BozPrS8 zX=jPk)81#2^@1E{)?l~(oIgj_r9UNWv{`Fe!A~S6Ea!7~p$-R=`TEJUr87OamrQ*9b@5^cGbzGS`QH8MZQJTS3e;=cVkj@DdF`oioLc%6^QezC-QyXij< zp&4yS9(gc{^QMtp%imPiFV`pchh0qKyvKNr&q%+XXXflf?mxSj+Zf737La?_b}>$r zkmvB^c|7-%XKyEW^apwVR0HxHpR4Ih;=SZu7qOPg#;L@7N0A)z`nI{_Oqu!?9*hT-I6psGGIw zu`TbY`x;#4oF&ilv+H9ITDopJx{i9fwg$V_hPn10buHfO+U(|9z0mc0RIY2+f1Z>0 zq}Zc_JmenYkooeG{lz7Ri%%AcQ+kS5>aLN$6u-2U$NXEY_kft@aIwvNG0qKQomwjj z#rMB+pNaUVZsMS&@}f(ut$rh)EJi9L?)yp1G*{gBnfU1oanwq&-?lUmS9KI$y(P}7 z#_i(34dSjo?8_(Ou!p&X<{ZcIrGFmm^+RfUUr+wvI=^4h=ZvMj&)vu8uVuFH`oQHDWB$H&U^ix_S@<;!!rx+?Dk^{7B|D$%l?+ z9@}bHY2#;*Hr|KaADWme_ta;dB&PBnZ!(@-2Sa&)Zsd2j-cOqSfoZRQ`TWI9=NaxH z>oeJRc^K75oR;e`>p8hTd()mGdr_X;FFKQ#xsT*xvzO^O_9SgTYrWZ%kUZ=oBoBKz zr*Jrl&$g(a@0rbOjA9U-$^L@$QQ2cqjephYFU%p=((^pTAkybw#3`h|-=7`W;#{PU zT*h1yTO^N?^})ms_tBs3Bo~%kM&gs4$1`a~=IAGsN{n$VjXAFL=e*|M(_d%INQ`q< zsl+8$kTK<2GQK32lkXhFqdd;jj3MifiG$u@3h$N57`gT5^!@LW_l@H@hVUSL>B`0Y zkBnb6DNpuAPv;r3cRIPYGLGD>QgiWra%#pIUBhRkMvzGT^TUP4xGQZ8UPaoz^x^M{> zaRFzQzFz1xdmpancJ3$huXo8k0h_7oGmCWQ5#Hf9>M6&03?%mqZl*+8JCiu?TlR1~ z?Rb*2`>B@XzV z>dsBZ{IsF8yH2E?3?OYI=Qrm$ac|<4NL!diV&hNvqV&&OW9P)td0*PlTclkjZg`Pr z8AaOv6Qm73z`dkR-c3LHkTMJ;f0x(1?;##0Z7%J0EcxtwSK4XHmDuD*vOZqMby%I` zos;88jFQ+aIq~d+PhUBfNqogmByXKH@a#`)$>sDRx$#LXVilG3A=wMklB>wRsO%ZZ zJw(}0vW4_V>6h|t`#m6Pdg3&d%iT(zmbmo~6v>bsph*PGBdN`_8O`b!1=WDrax@WrpKv zL)NXjlY0YhAo1+!yqfkzV(=HW$KSQl%w^hYzXP=C%|#A8sWEbLdRWB9W+uG9o5NwVz0N<&ocG2 z-QQxE4Pu{-@=5<#qfn=L7JP4Y+)rI6M|7>a&%M0ax4n;ZlDTE#iTj+Vs^nTWJioRb%}EMMI{z99GgWgaw|%pd#FosOl_ADuz^rsFu214$f_IcesOxnHj;8G*do@K z`rYd)R!((LbnYbV^!X;#W+=PRuMH#Y(cs_6PIDJU& zr70O-%8yZ@%#sE?sc-RnmK*?in7et9`ihd?Cm|1 zgUKGW#oFpb9^gjK;0S7wF*N_L#XP1mo~Ov3-uubASpM&f+#`G!_maK7+2fmjDfhF_ zW*Hkv{^1~UuS)U|gBZsbtf#K>wB#1Df9_*`C+m}^leOWj1J5VxZCMM-+D~#7llhLS z>gHIkAbFu&ciE?!HPHRlXG^Xjd%cs}O8m5(P3-KP9Lhyp#=Xis}!Jo2-wDs)W&stvQ_DZ>xSld--H+wq3-=h*&qynWf3;p(d{iGiw+{qi~gnbX~)ePr*z zfn*=~Q)zqJWm)R!zfaSTU!gzG|GRO&{yoq09I3y5TECzBxknlY9yJ~eHiqW8qqi9& zt~FM)GiID+>}X*OImS4e|Ciuk<4S$w%K^rj65~x%W9jL}o=c2DeT+rV8EhJ7|#x3rm?L9yYr2)?j;6r3s-Xk{TRbn?Bsoy@dkVP{Xy*D z^B$nS&;OWPecyq;H{PRhHTa&O^Z$FRk*ly_WJ9N%t^Gygxs zVUGJ4$A7ANI8S|CtX?{*pBvRvSM`;BRr!BWI;h8Xn;rju#@H=#m+E;Vi`4mx>ORk) z&ffm?Q*V)V;a!}scWLeXt)_=_xsCIh99UwcuFiGZdNb#LBpb<|yvO;L%xll(0Vb0e zY)9>?0jF{~-ANmKh+$;D{sK?)3{R4EkF?da;hVUUb4fcdP=nm>nD&}yV86u(29iBb zXVZv1*rHq+Ge2f5573Rou~`o}fT|?_mRL8}=~yz??oLOtKHG>~+dJ@=I{2Q9p_6%m zC&=}10~tq8C-d&?AIRQ-%KWVke_#&pGLG~^8Aq?@5;FI0OvcL!{OuepCgWY^2$?(F zOyD-CH*dv4`H@*Lz($yk*+kvben_990R-?OxbDzw&qk}t_Vs&X8q@4Hk#I7ok(|7&WN{xQ$Nu0UOV zX7)avtPgFiFTF~i+C$$uKp#6&U;Bzace=j!YklxP`r=(_q+f1lytzjoJyl=5UY~s^ z-Spwp^yM|_u3ulOe?N;)^!4YkSij$kefZKi@G$37LKU{*U*kkxYj7y-d5pz0@OuMU zO?#iWh)aC_Z`|$s4)(nZ>8~8eE6=vd_6d(GXJ_SYsoeW1{|=7j7v?ywDUNTP*pCWZYA@*wwy@z(bOf^#%kZSi1&Dr zJO?Q2GMAFE?=TY2{i~cmk$Z36WCVTbMCM=FpHPqFTGl$oubD>XUk{My4qQTW3hc-~ z>Lq*0(r1h&IiBPx64xF>=Cg@|)9y~%n^8&09fQKX+vESP(#>QbAG zDfv0Cc`n&uG@&KuaXDE}&K}j|O*1Ba$YR#BvoaLO8c^bxz9g5J{irLc=s5Nxd*r(E zFv-(?$v>nXj;9k@>wcN6NB+u=>hEw`lW{k>#5b7F2DbL~XF3lV!|x?|xLGXYKWaIb z*+Y0HStsnrQ)F*yVu+t9t6kLM2-3H=C+ma{@H~_Gl3&?O_Jk#NX+kU7)0sYG%`pA^ zINm00_fxW0ENyu)iPw_rh_A_Ym9>cU$N9Z{Mn0_)aI`e_d-LeLp_ICpt)b(xr?VNNXdGM2XQD3nGd1g-TS*%DK{Yjon^SwT1Z?bQ6 zsD5X*{-+!z`l3tqOAqUxrs}6w>aTX?M19w_`mm?;WwZ5ZOW6nslN115}z;8zYZt!iKD1V8UEJyuBWV@cO}oj zx{|ziG4=g!KfdEMpE-qm$4BHlSCao<<^W~+oIc9dSQ*#xhBEh3_B{Wpx?}l{R~_3x z$Jo}fmN@2ej{iHdr}u7k(oWqJ)X^qpsk2Aa-9_p!bATm0u5M3P$JvAN66dM=%{<{; zWPYFP=1k`)*GF6DE%}mG&SP@Z2RN^3@ADLCQi^yEy4j$%t5@XF{HR(_H zq%r4{b(-v#n!sFsV<*SekgV4vAMgmtxh)}ipFPz{Gdj|ram*oW9a-Pc`r`#;FUm+# z@8441dDx3)TuN`!mnN5)x#@apIG<^c=|>Yw_G36{n`r~d!(?A=W$j`Q3Y@?>v?c8) z@kS5Q2c}JJl|QFV=ijd)_xYuNJc2ruA^Ymy-~le?NXqe{vJIv=85dsRVk(m3{~y^8 zlYP(IlKf+GYZ-U9C--Ok;X1vD%$xS3x9fPZ>-li{ySBf`b_@ULTNWYu=9lkCl`Mq7Q%IQ>m|E=+u=589J^^hf{dn{MJy{ZtPs z@sz%+89(dChH*B#vRwc6HjmJs+sLy8`|&WZuz>Bow>jA}`W1)ztovBcl|Fwd7yHhY zbW?`JuA?|X*?!^yhp! z`H#2MTkb`up)P0ALETnS$1iiLI$uI(=OFuu?{q%0#?YI;NbInHtZhHVf1K;QW_>;L z{_G9UxS#%IGuh8@B@dB#+hQ`l*3^CuXKQ=x=;tonKu^;4(gxEu(*`rIyNs>v@K`^m zJ?}=^;%c9{fcF{AK(6O((k^#rqw;)LTDDwEeYlw9FOuI&KJ-go+Sp!`w*nOSu;+xrr_6F8eTNk^Uq5Ci~Kf^Ei?8f!X7eYa#bT ztylMn>(U=CYAPmvfw1W61TJwTJZoi4ij%WNl;L()vn#nEHE*g`^M29qsvadFK#}X56@7(Oq$z-nIhsPMtOw!N%LC*6|?8Tv^4P*~l#^2M)SeTzrBXgD$ zNgJz2elK&EJXhd765I9STn^$-ucB9F${6tWlv=bhPW1gb6qy!e%EOB<{wP%Vfw^1ya(5~ zrl-2L6Z3U;o#&aS^%?B?U#%a=b5CdJ7mg$Atw-^;KI0UY>O*d!7BloIx0CsNS!U>K zhI0oUxqx#@W$%CbyvND^dz(J6zTfG}6n6A^SMw1^`<|z%>wAZ@k8-@k(aQA&9hGwn zd5%SMOxo!@o8p)^4C+m}s(t`^*o~#dT&q~LawWQa{dSP!a=M0W!FVc?xRws$q za=%gbpeAlfUiDFOKUUTZv$yiwiuDy)! ztxe|a&1uIiJWA$KU$C00j^iLsX6t-2dBu5ToJp=Zd+^VvC&?pb%t`i?>CS%VOlK(iI>{}T^;_4jl{T#w4>ZlE?wA&t}9rtG&axd#9ZT&j(|GJ$*{$H>? z$^ZMc!u4?y&$(X8aH;F(9oJE9ZgpMFE$v_Kb*-(*b;ktP;Lh}RP5$EA%(LS@b*-LE z{=d7E$#vY0-(A;vzD?$tU%J-&)0DEjuMc>P8#s-_$^U=7H~VlH&1g>_USSQTB)lp)fJRiNex?4mCb(y^tcdFaU>Ul7kPuxe&LGne*IL*0vjT+9?jX7V` zbPliKP5z~+^V@^uwKKj|bN-85%RRinC#0`PTg<#|7xpB%j)Q1O;+qC!yvzJ9xsbBt zchbhvzoebz^YR>>^eY)ZTXG1wZ+)Hb|B83n+NWIQ=ZtabQ?mCVdFMH$s=RHUnrjeY& zS=3^I@~7WBh4hQb$DK!Vm6;zmA@@mirYyOqy(Uj-qX+V=_L@CGqqN^z+^;=nU*wJ2 z_fOh+TRztAkK#GkK%Rfq-!-w`^>HP0T`Q+C)wOd3FS(8m;T6}_k-X z!4%ixo|kfjy?Obd#j7;>SZ*C zsiSF}rp~^ky*kVu&P&zlceGWi^N=}e`sA~npTrX7ov-uAb+Cp*ozL!M zT+X@P*Ev6z{^Y*cPs!eijPr?gPv8vN)0wOTbf-6oesb@m+ad z-B0|__m5{kWqOqDl(7&0&_lV`)6H>|aZGuRLhf06klh^fNcK?&d6s*goAV0E|GdeO z>MPHKtgrq?v5Pv*`uqkiQO`3;zVBW#A8zh^WWKwGM$S=R5^H5na59OJ6ECN2*KmHb z?v(w0{dk5CSi~yQ7OS!+`%s^QNL+Uq4N9f0<~=)8iOqiZcXIt@{a^-f^8$}?C+S13 zAlG58jU%Z?ZFV5}mbLsyuCZKuX^ZcXu`zksR~g4krLSYXzF7L_#MsGi=e~gKG0g8T zB-iPBGLKK}U5i|!jY%AE0awwDjA4(Ha!(@HN3PSABsQ<$Sku?kCjCz8p}-O3+C7#= zrGL)f=RGya^Kz0a$me{<1oAz%(2CT{_EYi#Q|C+k*=p4^Jfa7+xXi=@z21SaL5~Hd{jS{m)Dbk|GTdQim2vTaM zW^9!jr9{jsQKUv|dyU#7cF~AYn_BPpd(M+{{P8(YM3U#euj{(6@w>)zopKY2Oz<4U zH!zQ~6#~mzNq81Xd90@_zK`{k&6?OjS@pAfPEvN;;U?vHDE#bZpT#gFQQpU(G%oNA zOh#Q~#4etV3HSnKksUstiP(wF*aSbT&wu+|-GpaBmO&dt!@uJ*9)>YE2ix#bxQJ?8 z$8u!nx`yK#Z2LXCq#@Vu`hcq3kK=@j++PCT<9=78H21#@B}k9OC`9`F0Am{8!7~QC zl8z@(opfCcWA6XO0hA*Bqi`I>$(R0cpQCN3eLU;WO|WgSpJ|^n3h_9Me_a49r6;e!+Tdz-CzYZwHFYuwOk9jgSTA zmGhaw_>(*w1p7k99NQs1e5T4^J7vIly=P4NOuY=x=zSZ@C_5FggR*2C=M-hjw$1$n ze)eQm%%lAIc~_Rl)_6quoQe04NSPgh(zr}njzLT0!D-6)5=5dYLg9G!3D3b5IA3}i zXOV=*$j<-!j5L7j2|f4qAZ(jzVGIr-AMe*2J5iA9h{P%QSze3qD%U#@?#ph@J@`2# z6}d0>aTnq~XCM>zJqXv)j8#uR;ioFhAl5b~fe zjMJngZ)#vDe17iX9rCXQM#6gY6l`w_kk=Jq+3bl?@VQw6pNl;>fU~%WE4YSRxDCtG zU05Fd+Op}-U4ZjY_N)Gcf5&HT2mHIgVihdomUa2QvP{m#EW}`DfY0=_!1wIS`F(%J zpY`jMpY`YbzCSw^(U^cJ48=fnM;Ck!$9q*!0=bb6NuhKGf^35$XEC2+BaEEe%HZy^1nZ_Vm@WUnD8#j z$>+F1x$(1b@?r&LsWvVI%GevcCIp_1w7lB?3r7}gqxPTSB#$!9q;t}%l^BRDd4qe0x`Xt+MNE%&`2?~@LmVVRY*i7 zj>(K!IxpqAMb`uk!>>q!eHr^OonU{$_T?yU;uXq5VN`)z4YR*GsVojvIYeEcafAZO256 z!Sm(ZYi@-GsET)CyO{;IxX06QUU>oRyLEweem*=VefD53dct|V4DjrMiTDKesrHf9 z)!UF>r0{Y4Xu_ATL&vzs+%r{Pr`Uiot!G?$dlixtIz2 zSf?l}wgWUw_bH28;CyE{)I$llmM=XXPi3QZ9O2b=#^3kZ*bbiqp8?wj%lUM84UzZ?_NBs54*8HC zmS_8hE8&=_H$Firq=B)ASVX{jz*u7<=`;(@TNH+McMM!p>)LSF0YxAf$4GzIAGm(+ zSRj9bdEG^R^+6uEU&*%oMR<;1LpV2OdtV*S?@hNH;2vdTE}9`5T&FYwjv-#ce#(@e z`)T>AhMc%hnLB|kSc322IftG#Fdorx??@ch;t1~Hb$+)3{M}UgI8nVjte&-rZN_x^?$bx%Soh2-lB3 zwL}~qp&n^#8+jVGna+vF;||J_FUHRun;Iv|N!~fvV}8zu@o2}A)?<~?9)n>yu}mJs zbv%Y~Zewbm6X2YW^J>P`oMY<_=kA;X8i{d;hVk-gm;&eZQocWyuZP3*si^#0o48d~JWlb<`(t5B8Uh z%Q`1B1g=j%3(q#Hk8$w5vj?ulbL*1f=LNdv=O~JD?StX|o)GS77%re3_c{)jP?0q7 zET-!SBW>I#@eit!X0Fk`j54I9Yg+ao7wP;N=E40X_V?Pu{pu%>jeN3i7y-vj$6-Bc zJKGEq_#PW@0uSNX%sH5vFm7uM*VyhDI2Y-h&2p^82JFC2cm{@?AKHa&_#Nx80?t3j z!16l;Jz;FPKHh_6(y`#*T!ZB?4x?co!TLToZgc&X#VIi6`!0;DY{OVI@Sd@kv>1)L zNRI^4%6Tx`q?4qfZB%(&A#FWN*EOz=WoDr&{LF#raJ-XD9?gLBZ8ylfAJ71g$x~x3 zt&tn9>l%mJ$c(*|hbeF_vlyOHUJhXyqTqQ}jZgtakr&Q$WJNaQLLrobb?)crj~TEH zckE^v{}6oT|EgBOM1@N4nSa z3M!H|?%6&Ew}7r&fTy8#rQke{pCwcakH`;W#IA2E2-o0DLlgL!qzB2%nP`h5xJy3! zdEfR&d&0Ic1lEbSC=*Ap9mWDZYkv_IVgZ(79d^ThtUqr(Rs#N=KA3|oc!1)(pXZU- zXYd?2=c7|Tlhpp=n}O?g52NP@RpNf!NA0|SVeYd#)*%!3-xNP08Ln#{0An<+Uu^{2 zU}JE3Nmthv#K5tVF`n8O1mng@aK5K191ojkzrnHcOXOt{)PQaAw=lo0UlL#$vkklf z+ry_wMH$He%bYQ)7Xlvee~*xily=jaUiLAl0?+4n-MIV7bHTG| z#=<(x`5Mpi^K*1OGseAF`^d+sXpeW18i&a1Sd4@*uP}rnH!N2VDJR#E1V3N?Y~X9l z&tqieXRM1V;ZyX&Ec}inyvBQkArgM}dlCw8EuXeq+2lM?KjDmHM<44;(%gS8Lg5_i)M!A_Vr$@9_5zVKuB@zCsurNB_e$Ikz?muAwWAf4P3ww0(zTJpER=l_pAx`@!DoNUs)S3!u`VbUmL^oG3?XYr!ZDn9iAO}g**h}wU0 zKJ;}sZ*2KF1^f1!u?}mn3YM#Fu#P%|+epvv<%j3dyANpqe#9=^MSkAXzKwnCwYY}D zT$}Y_4BYE%Y_l;Y!@4sk_tpeH7mlTK1=3(DuSa3IaL>(nY=iyE3UF+=45#6|#yK9qGLr`O)onxkx8J`4%amhG=XIB2GVI^G zPrvQk1eG=Od_afKF zSUA6$mHV?^oB+>MdA=N&E~ZaYjDw#IVjQ;`oa3+@oQG|><-j#y#685^K&77 zg=gTjgy-RTo{0U}wU~jvung2dN#w!HNQI~5tM$Cky74K;S{eDid2Jt|ENpv>#oLdW zgUz^zEWA%BTEp_^entBZ1-Y)~7!B*J)LeUIcwWITxP=njpXVqT7rumo+`s1%Ovhmu zbE}C+#3KoBkcJI01n!}5z3@{MA-$a|?g7U@%diXQ;JlalWFNz|0VPld6;UDZTAo+S zN+G<7%<%V&IXT|-@A+&S`*zLYJ4lb?ytjGm^)!L~$Yic_EsTRZ?~@bmx0nO_Ng=qw z{d&&G05~4b0Ov+0p*ixyak1wf4njSc*Ve7Z6-{gV2-OgTr{slwxUDeO=$^)5Fpkp^ z*4xJF96LC!X@+ogf&AHcfa2>-B=!j4_-?0zYI?ppgdcfF6Zd@hp{ESlD%7F;S z2XIa#4U$Rk!?2EDj)jJi5cF)se1d+BM z!MNGCn1gjN-)y^6o>gOMK8L}08^vIs&*#_vmgCPCU_8ovHec6c9)`ktI1Gi62G$d+ z;B(p%#!nt_o$k%CKh_Dx`Yex@OWWf%Fn)TE``!r0JN8*j7h{H-VOj40V+!`qOi$-< zN1{DGf@^Qn!@j-q`U#j1pC`{O8-(uY65zAu*FE6>MZvuGcl_P`u+C2%_}h;8!{JzM zAv`z6cEY|wUHljJ#rME{&7LXH9HU?z^fz3OQw3im2HS88j)QB!_=;`tO4x?oK~~bR z7;M|Z;Tn+Pn2I@w!%D1!F`I%woa>tQV(F4|} zo?Y;OYjb?={G#iK>cP6@8uzjR(f9(Mo#2@3DEA$U2$&|uMjW^AMI21KcBqQ{NQbL| Xw6<+c$*-Y&ZJgY^F^`NHb;kbz(Rrv7 literal 0 HcmV?d00001 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/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/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/views/AnalysisView.vue b/src/views/AnalysisView.vue index 43e2b097..b112659d 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,409 @@ 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/components/GridLattice.vue b/src/components/GridLattice.vue index cc04cbd7..7f050b44 100644 --- a/src/components/GridLattice.vue +++ b/src/components/GridLattice.vue @@ -1,6 +1,6 @@ + + diff --git a/src/components/GridLattice.vue b/src/components/GridLattice.vue index 7f050b44..b4062946 100644 --- a/src/components/GridLattice.vue +++ b/src/components/GridLattice.vue @@ -126,7 +126,6 @@ watch( :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" > {{ labels[idx] }} diff --git a/src/components/JustIntonationLattice.vue b/src/components/JustIntonationLattice.vue index 8b4767bc..05750a7c 100644 --- a/src/components/JustIntonationLattice.vue +++ b/src/components/JustIntonationLattice.vue @@ -192,7 +192,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/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/state.ts b/src/stores/state.ts index 45d2cb4b..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' | 'cycles' | 'auto'>('auto') + const latticeType = ref<'ji' | 'et' | 'cycles' | '3d' | 'auto'>('auto') // These user preferences are fetched from local storage. const storage = window.localStorage diff --git a/src/views/LatticeView.vue b/src/views/LatticeView.vue index f022b4ef..98bc075a 100644 --- a/src/views/LatticeView.vue +++ b/src/views/LatticeView.vue @@ -11,6 +11,7 @@ import { useJiLatticeStore } from '@/stores/ji-lattice' import { useGridStore } from '@/stores/grid' import { useCyclesStore } from '@/stores/edo-cycles' import { setAndReportValidity } from '@/utils' +import Faux3DLattice from '@/components/Faux3DLattice.vue' const state = useStateStore() const scale = useScaleStore() @@ -21,6 +22,7 @@ const cycles = useCyclesStore() const showConfig = ref(false) const jiPreset = ref<'nothing' | 'grady' | 'grady3' | 'dakota' | 'pr72' | 'pe72'>('nothing') const etPreset = ref<'nothing' | '12' | '53' | '311' | 'b13'>('nothing') +const preset3D = ref<'nothing' | 'WGP' | 'sphere' | 'sphere3'>('nothing') const extraEdgesElement = ref(null) watch(extraEdgesElement, (newElement) => setAndReportValidity(newElement, jiLattice.edgesError), { @@ -91,6 +93,20 @@ watch(etPreset, (newValue) => { } }) +watch(preset3D, (newValue) => { + switch (newValue) { + case 'WGP': + jiLattice.WGP() + return + case 'sphere': + jiLattice.sphere() + return + case 'sphere3': + jiLattice.sphere(1) + return + } +}) + function inferConfig() { // Default to 12-TET even if it looks bad state.latticeType = 'et' @@ -133,11 +149,15 @@ function inferConfig() { if (d === 1 && isPrime(n)) { equaveIndex = primeLimit(n, true) - 1 } + // Set the config for 3D too, but use isometric by default if (limit <= 9) { + jiLattice.WGP(equaveIndex) jiLattice.kraigGrady(equaveIndex) } else if (limit <= 24) { + jiLattice.sphere(equaveIndex, 24) jiLattice.scott24(equaveIndex) } else if (equaveIndex < 72) { + jiLattice.sphere(equaveIndex, 72) jiLattice.pe72(equaveIndex) } else { return @@ -223,6 +243,13 @@ onMounted(() => { :relativeIntervals="scale.latticeIntervals" :heldNotes="heldNotes" /> + @@ -252,6 +279,10 @@ onMounted(() => { + + + +
    -