diff --git a/package-lock.json b/package-lock.json index be077069..a2b74e01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "scale-workshop", "version": "3.0.0-beta.36", "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..f16d3b1d 100644 --- a/package.json +++ b/package.json @@ -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 00000000..d0a5a94f Binary files /dev/null and b/src/assets/harmonic-entropy.ydata.raw differ diff --git a/src/components/HarmonicEntropyPlot.vue b/src/components/HarmonicEntropyPlot.vue new file mode 100644 index 00000000..efbafe39 --- /dev/null +++ b/src/components/HarmonicEntropyPlot.vue @@ -0,0 +1,214 @@ + + + + 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..7ae6fbf2 --- /dev/null +++ b/src/stores/harmonic-entropy.ts @@ -0,0 +1,114 @@ +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 + } + 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..a098105a 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,405 @@ 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)) +) + +// 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 + } + } +})