diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 211f3628..ffdc2565 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -21,6 +21,7 @@ import LatticeModal from "@/components/modals/generation/SpanLattice.vue"; import EulerGenusModal from "@/components/modals/generation/EulerGenus.vue"; import DwarfModal from "@/components/modals/generation/DwarfScale.vue"; import RankTwoModal from "@/components/modals/generation/RankTwo.vue"; +import HistoricalModal from "@/components/modals/generation/HistoricalScale.vue"; import RotateModal from "@/components/modals/modification/RotateScale.vue"; import SubsetModal from "@/components/modals/modification/TakeSubset.vue"; import StretchModal from "@/components/modals/modification/StretchScale.vue"; @@ -163,6 +164,7 @@ const showEulerGenusModal = ref(false); const showDwarfModal = ref(false); const showRankTwoModal = ref(false); const showCrossPolytopeModal = ref(false); +const showHistoricalModal = ref(false); const showRotateModal = ref(false); const showSubsetModal = ref(false); @@ -221,8 +223,8 @@ function copyToClipboard() {
  • Equal temperament
  • -
  • Rank-2 temperament
  • Historical temperament
  • Harmonic series segment
  • Euler-Fokker genus
  • +
  • Rank-2 temperament
  • Dwarf scale
  • Cross-polytope
  • + + +import { ExtendedMonzo, Interval, parseLine, Scale } from "scale-workshop-core"; +import Modal from "@/components/ModalDialog.vue"; +import { computed, ref, watch } from "vue"; +import { DEFAULT_NUMBER_OF_COMPONENTS } from "@/constants"; +import ScaleLineInput from "@/components/ScaleLineInput.vue"; +import { mmod, centsToValue, lcm } from "xen-dev-utils"; +import { mosSizes } from "moment-of-symmetry"; + +const props = defineProps<{ + centsFractionDigits: number; +}>(); + +const emit = defineEmits([ + "update:scaleName", + "update:scale", + "update:keyColors", + "cancel", +]); + +const MAX_SIZE = 99; +const MAX_LENGTH = 10; + +const name = ref(""); + +const OCTAVE = parseLine("2/1", DEFAULT_NUMBER_OF_COMPONENTS); + +const method = ref<"simple" | "target">("simple"); + +const generator = ref(parseLine("3/2", DEFAULT_NUMBER_OF_COMPONENTS)); +const generatorString = ref("3/2"); + +const size = ref(12); +const down = ref(3); +const format = ref<"cents" | "default">("cents"); + +const selectedPreset = ref("pythagorean"); + +const pureGenerator = ref(parseLine("3/2", DEFAULT_NUMBER_OF_COMPONENTS)); +const pureGeneratorString = ref("3/2"); +const target = ref(parseLine("7/4", DEFAULT_NUMBER_OF_COMPONENTS)); +const targetString = ref("7/4"); +const searchRange = ref(11); +const period = ref(OCTAVE); +const periodString = ref("2/1"); +const pureExponent = ref(10); +const temperingStrength = ref(1); + +const mosSizeList = computed(() => { + const p = method.value === "simple" ? 1200 : period.value.totalCents(); + const g = temperedGenerator.value.totalCents(); + const sizes = mosSizes(g / p, MAX_SIZE, MAX_LENGTH + 2); + if (p > 600) { + while (sizes.length && sizes[0] < 4) { + sizes.shift(); + } + } + while (sizes.length > MAX_LENGTH) { + sizes.pop(); + } + return sizes; +}); + +type Candidate = { + exponent: number; + tempering: number; +}; + +const candidates = computed(() => { + const pureCents = pureGenerator.value.totalCents(); + const targetCents = target.value.totalCents(); + const periodCents = period.value.totalCents(); + const halfPeriod = 0.5 * periodCents; + const result: Candidate[] = []; + for (let i = -searchRange.value; i <= searchRange.value; ++i) { + if (i === 0 || i === 1 || i === -1) { + continue; + } + const offset = + mmod(targetCents - pureCents * i + halfPeriod, periodCents) - halfPeriod; + result.push({ + exponent: i, + tempering: offset / i, + }); + } + result.sort((a, b) => Math.abs(a.tempering) - Math.abs(b.tempering)); + return result; +}); + +// This way makes the selection behave slightly nicer when other values change +const tempering = computed(() => { + for (const candidate of candidates.value) { + if (candidate.exponent === pureExponent.value) { + return candidate.tempering; + } + } + return 0; +}); + +const strengthSlider = computed({ + get: () => temperingStrength.value, + set(newValue: number) { + // There's something wrong with how input ranges are handled. + if (typeof newValue !== "number") { + newValue = parseFloat(newValue); + } + if (!isNaN(newValue)) { + temperingStrength.value = newValue; + } + }, +}); + +const temperedGenerator = computed(() => { + const lineOptions = { centsFractionDigits: props.centsFractionDigits }; + if (method.value === "simple") { + return generator.value.mergeOptions(lineOptions); + } + return pureGenerator.value + .mergeOptions(lineOptions) + .add( + new Interval( + ExtendedMonzo.fromCents( + tempering.value * temperingStrength.value, + DEFAULT_NUMBER_OF_COMPONENTS + ), + "cents", + undefined, + lineOptions + ) + ) + .asType("any"); +}); + +// This is a simplified and linearized model of beating +function equalizeBeating() { + const monzo = generator.value.monzo; + const g = monzo.valueOf(); + let multiGenExponent = 1; + if (!monzo.cents) { + multiGenExponent = monzo.vector.reduce( + (denom, component) => lcm(component.d, denom), + 1 + ); + } + const t = target.value.monzo.valueOf(); + + const generatorMaxBeats = Math.abs( + g * (1 - centsToValue(tempering.value * multiGenExponent)) + ); + const targetMaxBeats = Math.abs( + t * (1 - centsToValue(tempering.value * pureExponent.value)) + ); + + // Solve the linearized model: + // generatorBeats = generatorMaxBeats * strength = targetMaxBeats * (1 - strength) = targetBeats + temperingStrength.value = + targetMaxBeats / (generatorMaxBeats + targetMaxBeats); + + // Do one more iteration of linearization: + const generatorMidBeats = Math.abs( + g * + (1 - + centsToValue( + tempering.value * multiGenExponent * temperingStrength.value + )) + ); + const targetMidBeats = Math.abs( + g * + (1 - + centsToValue( + tempering.value * pureExponent.value * (1 - temperingStrength.value) + )) + ); + + // generatorBeats = generatorMidBeats + mu * (generatorMaxBeats - generatorMidBeats) = targetMidBeats - mu * targetMidBeats = targetBeats + const mu = + (targetMidBeats - generatorMidBeats) / + (generatorMaxBeats + targetMidBeats - generatorMidBeats); + + temperingStrength.value += mu * (1 - temperingStrength.value); +} + +type Preset = { + name: string; + generator: string; + down: number; +}; + +// Note that since ES2015 this order is quaranteed. +const presets: Record = { + pythagorean: { + name: "Pythagorean tuning", + generator: "3/2", + down: 5, + }, + twelve: { + name: "12-tone equal temperament", + generator: "7\\12", + down: 3, + }, + eight: { + name: "⅛-comma Meantone", + generator: "3/2 - 1\\8<531441/524288>", + down: 5, + }, + sixth: { + name: "⅙-comma Meantone", + down: 5, + generator: "3/2 - 1\\6<531441/524288>", + }, + fifth: { + name: "⅕-comma Meantone", + down: 3, + generator: "3/2 - 1\\5<81/80>", + }, + quarter: { + name: "¼-comma Meantone", + down: 3, + generator: "3/2 - 1\\4<81/80>", + }, + twosevenths: { + name: "2/7-comma Meantone", + down: 3, + generator: "3/2 - 2\\7<81/80>", + }, + third: { + name: "⅓-comma Meantone", + down: 3, + generator: "3/2 - 1\\3<81/80>", + }, + half: { + name: "½-comma Meantone", + down: 3, + generator: "3/2 - 1\\2<81/80>", + }, +}; + +watch(size, (newValue) => { + if (down.value >= newValue) { + down.value = newValue - 1; + } +}); + +watch(selectedPreset, (newValue) => { + const preset = presets[newValue]; + name.value = preset.name; + down.value = preset.down; + generator.value = parseLine(preset.generator, DEFAULT_NUMBER_OF_COMPONENTS); + generatorString.value = preset.generator; +}); + +function generate() { + const lineOptions = { centsFractionDigits: props.centsFractionDigits }; + + if (method.value === "simple") { + const scale = Scale.fromRank2( + temperedGenerator.value, + OCTAVE.mergeOptions(lineOptions), + size.value, + down.value + ); + + if (name.value === "") { + name.value = `Rank 2 temperament (${generatorString.value})`; + } + + emit("update:scaleName", name.value); + if (format.value === "cents") { + emit("update:scale", scale.asType("cents")); + } else { + emit("update:scale", scale); + } + } else { + const scale = Scale.fromRank2( + temperedGenerator.value, + period.value.mergeOptions(lineOptions), + size.value, + down.value + ); + + let genString = temperedGenerator.value.toString(); + if (format.value === "cents") { + emit("update:scale", scale.asType("cents")); + genString = temperedGenerator.value + .totalCents() + .toFixed(props.centsFractionDigits); + } else { + emit("update:scale", scale); + } + emit( + "update:scaleName", + `Rank 2 temperament (${genString}, ${periodString.value})` + ); + } +} + +