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 segmentEuler-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})`
+ );
+ }
+}
+
+
+
+
+ Generate historical temperament
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+