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 } from "vue";
+import { DEFAULT_NUMBER_OF_COMPONENTS } from "@/constants";
+import ScaleLineInput from "@/components/ScaleLineInput.vue";
+import { mmod } from "xen-dev-utils";
+
+const props = defineProps<{
+ centsFractionDigits: number;
+}>();
+
+const emit = defineEmits([
+ "update:scaleName",
+ "update:scale",
+ "update:keyColors",
+ "cancel",
+]);
+
+const name = ref("");
+
+const OCTAVE = parseLine("2/1", DEFAULT_NUMBER_OF_COMPONENTS);
+
+// TODO: Implement tempering to target interval as a new radio tab.
+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(4);
+const format = ref<"cents" | "default">("cents");
+
+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);
+
+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;
+ }
+ },
+});
+
+function equalizeBeating() {
+ // This is a naïve, simplified and linearized model of beating
+ const generatorMaxBeats = Math.abs(
+ generator.value.monzo.valueOf() * (1 - Math.pow(2, tempering.value / 1200))
+ );
+ const targetMaxBeats = Math.abs(
+ target.value.monzo.valueOf() *
+ (1 - Math.pow(2, (tempering.value * pureExponent.value) / 1200))
+ );
+
+ // Solve the linearized model:
+ // generatorBeats = generatorMaxBeats * strength = targetMaxBeats * (1 - strength) = targetBeats
+ temperingStrength.value =
+ targetMaxBeats / (generatorMaxBeats + targetMaxBeats);
+}
+
+function preset(line: string) {
+ generator.value = parseLine(line, DEFAULT_NUMBER_OF_COMPONENTS);
+ generatorString.value = line;
+}
+
+function presetPythagorean() {
+ name.value = "Pythagorean tuning";
+ preset("3/2");
+}
+
+function preset12TET() {
+ name.value = "12-tone equal temperament";
+ preset("7\\12");
+}
+
+function presetEleventhCommaMeantone() {
+ name.value = "1/11-comma Meantone";
+ preset("3/2 - 1\\11<81/80>");
+}
+
+function presetSixthCommaMeantone() {
+ name.value = "⅙-comma Meantone";
+ preset("3/2 - 1\\6<81/80>");
+}
+
+function presetFifthCommaMeantone() {
+ name.value = "⅕-comma Meantone";
+ preset("3/2 - 1\\5<81/80>");
+}
+
+function presetQuarterCommaMeantone() {
+ name.value = "¼-comma Meantone";
+ preset("3/2 - 1\\4<81/80>");
+}
+
+function presetTwoSeventhsCommaMeantone() {
+ name.value = "2/7-comma Meantone";
+ preset("3/2 - 2\\7<81/80>");
+}
+
+function presetThirdCommaMeantone() {
+ name.value = "⅓-comma Meantone";
+ preset("3/2 - 1\\3<81/80>");
+}
+
+function presetHalfCommaMeantone() {
+ name.value = "½-comma Meantone";
+ preset("3/2 - 1\\2<81/80>");
+}
+
+function generate() {
+ const lineOptions = { centsFractionDigits: props.centsFractionDigits };
+
+ if (method.value === "simple") {
+ const scale = Scale.fromRank2(
+ generator.value.mergeOptions(lineOptions),
+ 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 g = pureGenerator.value
+ .mergeOptions(lineOptions)
+ .add(
+ new Interval(
+ ExtendedMonzo.fromCents(
+ tempering.value * temperingStrength.value,
+ DEFAULT_NUMBER_OF_COMPONENTS
+ ),
+ "cents",
+ undefined,
+ lineOptions
+ )
+ );
+ const scale = Scale.fromRank2(
+ g,
+ period.value.mergeOptions(lineOptions),
+ size.value,
+ down.value
+ );
+
+ emit(
+ "update:scaleName",
+ `Rank 2 temperament (${g.toString()}, ${periodString.value})`
+ );
+ if (format.value === "cents") {
+ emit("update:scale", scale.asType("cents"));
+ } else {
+ emit("update:scale", scale);
+ }
+ }
+}
+
+
+
+
+ Generate historical temperament
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+