From 73bb9f87ad57d5dd74a11fc3bc2367bd74d5c593 Mon Sep 17 00:00:00 2001 From: Lumi Pakkanen Date: Mon, 6 Nov 2023 16:05:46 +0200 Subject: [PATCH] Generate historical temperaments, target-tempered generator-stacks and well temperaments Move the other Rank-2 temperament modal further down the list. ref #461 --- src/components/ScaleBuilder.vue | 20 +- .../modals/generation/HistoricalScale.vue | 729 ++++++++++++++++++ src/utils.ts | 27 + 3 files changed, 774 insertions(+), 2 deletions(-) create mode 100644 src/components/modals/generation/HistoricalScale.vue 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, reactive, ref, watch } from "vue"; +import { DEFAULT_NUMBER_OF_COMPONENTS } from "@/constants"; +import ScaleLineInput from "@/components/ScaleLineInput.vue"; +import { mmod, centsToValue, lcm, Fraction } from "xen-dev-utils"; +import { mosSizes } from "moment-of-symmetry"; +import { spineLabel } from "@/utils"; + +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 FIFTH = parseLine("3/2", DEFAULT_NUMBER_OF_COMPONENTS); + +const method = ref<"simple" | "target" | "well temperament">("simple"); + +const generator = ref(FIFTH); +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(FIFTH); +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 wellComma = ref(parseLine("531441/524288", DEFAULT_NUMBER_OF_COMPONENTS)); +const wellCommaString = ref("531441/524288"); +const wellCommaFractionStrings = reactive([ + "0", + "0", + "0", + "0", + "0", + "-1/6", + "-1/6", + "-1/6", + "-1/6", + "-1/6", + "-1/6", +]); + +const selectedWellPreset = ref("vallotti"); + +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"); +}); + +const wellIntervals = computed(() => { + const comma = wellComma.value.asType("any"); + let spine = FIFTH.mul(0).asType("any"); + let offset = comma.mul(0); + // Unison + const result = [spine.add(offset)]; + // Against the spiral of fifths + for (let i = 0; i < down.value; ++i) { + spine = spine.sub(FIFTH).mmod(OCTAVE); + let frac = new Fraction(0); + try { + frac = new Fraction(wellCommaFractionStrings[down.value - 1 - i]); + } catch {} + offset = offset.sub(comma.mul(frac)); + result.unshift(spine.add(offset)); + } + + spine = FIFTH.mul(0).asType("any"); + offset = wellComma.value.mul(0).asType("any"); + // Along the spiral of fifths + for (let i = 0; i < size.value - down.value - 1; ++i) { + spine = spine.add(FIFTH).mmod(OCTAVE); + let frac = new Fraction(0); + try { + frac = new Fraction(wellCommaFractionStrings[down.value + i]); + } catch {} + offset = offset.add(comma.mul(frac)); + result.push(spine.add(offset)); + } + + // Generate enough to reach enharmonics + while (result.length <= 12) { + spine = spine.add(FIFTH).mmod(OCTAVE); + result.push(spine.add(offset)); + } + return result; +}); + +const enharmonicCentsLow = computed(() => + wellIntervals.value[0] + .sub(wellIntervals.value[12]) + .totalCents() + .toFixed(props.centsFractionDigits) +); + +const enharmonicCentsHigh = computed(() => { + const ws = wellIntervals.value; + return ws[ws.length - 1] + .sub(ws[ws.length - 13]) + .totalCents() + .toFixed(props.centsFractionDigits); +}); + +// 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; + size?: 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: "1/8-comma Meantone", + generator: "3/2 - 1\\8<531441/524288>", + down: 5, + }, + sixth: { + name: "1/6-comma Meantone", + down: 5, + generator: "3/2 - 1\\6<531441/524288>", + }, + fifth: { + name: "1/5-comma Meantone", + down: 3, + generator: "3/2 - 1\\5<81/80>", + }, + quarter: { + name: "1/4-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: "1/3-comma Meantone", + down: 3, + generator: "3/2 - 1\\3<81/80>", + }, + helmholtz: { + name: "Helmholtz aka Schismatic", + down: 11, + size: 24, + generator: "3/2 - 1\\8<32805/32768>", + }, +}; + +type WellPreset = { + name: string; + down: number; + comma: string; + commaFractions: string; +}; + +const wellPresets: Record = { + kellner: { + name: "Kellner", + down: 4, + comma: "81/80", + commaFractions: "0,0,0,0,-1/5,-1/5,-1/5,-1/5,0,-1/5,0", + }, + werckmeister3: { + name: "Werckmeister III", + down: 3, + comma: "81/80", + commaFractions: "0,0,0,-1/4,-1/4,-1/4,0,0,-1/4,0,0", + }, + vallotti: { + name: "Vallotti", + down: 6, + comma: "531441/524288", + commaFractions: "0,0,0,0,0,-1/6,-1/6,-1/6,-1/6,-1/6,-1/6", + }, + grammateus: { + name: "Grammateus", + down: 1, + comma: "531441/524288", + commaFractions: "0,0,0,0,0,0,-1/2,0,0,0,0", + }, + rameau: { + name: "Rameau", + down: 3, + comma: "81/80", + commaFractions: "11/24,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,0,0,-1/6", + }, +}; + +watch(size, (newValue) => { + if (down.value >= newValue) { + down.value = newValue - 1; + } + while (wellCommaFractionStrings.length < size.value - 1) { + wellCommaFractionStrings.push("0"); + } +}); + +watch(selectedPreset, (newValue) => { + const preset = presets[newValue]; + name.value = preset.name; + size.value = preset.size ?? 12; + down.value = preset.down; + generator.value = parseLine(preset.generator, DEFAULT_NUMBER_OF_COMPONENTS); + generatorString.value = preset.generator; +}); + +watch(selectedWellPreset, (newValue) => { + const preset = wellPresets[newValue]; + name.value = preset.name; + size.value = 12; + down.value = preset.down; + wellCommaString.value = preset.comma; + wellComma.value = parseLine(preset.comma, DEFAULT_NUMBER_OF_COMPONENTS); + const fracs = preset.commaFractions.split(","); + for (let i = 0; i < fracs.length; ++i) { + wellCommaFractionStrings[i] = fracs[i]; + } +}); + +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 if (method.value === "target") { + 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})` + ); + } else { + const scale = new Scale( + wellIntervals.value.slice(0, size.value), + OCTAVE, + 440 + ).mergeOptions(lineOptions); + scale.sortInPlace(); + if (format.value === "cents") { + emit("update:scale", scale.asType("cents")); + } else { + emit("update:scale", scale); + } + emit("update:scaleName", name.value); + } +} + + diff --git a/src/utils.ts b/src/utils.ts index 8d6e469c..0faff140 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -259,3 +259,30 @@ export function monzoEuclideanDistance( return distance; } + +const NOMINALS = ["F", "C", "G", "D", "A", "E", "B"]; + +/** + * Obtain a nominal with sharps and flats along the chain of fifths starting from C. + * @param fifthsUp How far clockwise to travel along the spiral of fifths. + * @returns The label of the pitch class. + */ +export function spineLabel(fifthsUp: number): string { + let label = NOMINALS[mmod(fifthsUp + 1, NOMINALS.length)]; + let accidentals = Math.floor((fifthsUp + 1) / NOMINALS.length); + while (accidentals <= -2) { + label += "𝄫"; + accidentals += 2; + } + while (accidentals >= 2) { + label += "𝄪"; + accidentals -= 2; + } + if (accidentals > 0) { + label += "♯"; + } + if (accidentals < 0) { + label += "♭"; + } + return label; +}