diff --git a/src/assets/base.css b/src/assets/base.css index 5f58eb9a..be94b6bd 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -165,6 +165,10 @@ button:disabled:hover { color: var(--color-accent-mute); background-color: var(--color-accent-background); } +input:disabled { + color: var(--color-accent-mute); + background-color: var(--color-background-mute); +} ul.btn-group, .btn-dropdown-group ul { list-style: none; padding-left: unset; diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 211f3628..134335fb 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 { + circleDifference, + spineLabel as spineLabel_, + type AccidentalStyle, +} from "@/utils"; + +const props = defineProps<{ + centsFractionDigits: number; +}>(); + +const emit = defineEmits([ + "update:scaleName", + "update:scale", + "update:keyColors", + "update:baseFrequency", + "update:baseMidiNote", + "cancel", +]); + +// The presets default to middle C when there's an 'F' in the scale i.e. down >= 1 +const KEY_COLORS_C = [ + "white", + "black", + "white", + "black", + "white", + "white", + "black", + "white", + "black", + "white", + "black", + "white", +]; + +const MIDI_NOTE_C = 60; +const FREQUENCY_C = 261.6255653005986; + +const MAX_SIZE = 99; +const MAX_LENGTH = 10; + +const OCTAVE = parseLine("2/1", DEFAULT_NUMBER_OF_COMPONENTS); +const FIFTH = parseLine("3/2", DEFAULT_NUMBER_OF_COMPONENTS); + +const ACCIDENTAL_STYLE = + (localStorage.getItem("accidentalPreference") as AccidentalStyle) ?? "double"; + +function spineLabel(up: number) { + return spineLabel_(up, ACCIDENTAL_STYLE); +} + +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>( + new Map([ + [-6, "0"], + [-5, "0"], + [-4, "0"], + [-3, "0"], + [-2, "0"], + [-1, "-1/6"], + [0, "-1/6"], + [1, "-1/6"], + [2, "-1/6"], + [3, "-1/6"], + [4, "-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"); + // Not a generator in the strictest sense. It accumulates offsets along the way. + let generator = FIFTH.mul(0).asType("any"); + + // Unison + const result = [generator]; + + // Against the spiral of fifths + for (let i = 0; i < down.value; ++i) { + let frac = new Fraction(0); + try { + frac = new Fraction(wellCommaFractionStrings.get(-i - 1) ?? "0"); + } catch {} + generator = generator.sub(FIFTH).sub(comma.mul(frac)).mmod(OCTAVE); + result.unshift(generator); + } + + // Along the spiral of fifths + generator = FIFTH.mul(0).asType("any"); + // Note that this intentionally overshoots by one to reach the enharmonic + for (let i = 0; i < size.value - down.value; ++i) { + let frac = new Fraction(0); + try { + frac = new Fraction(wellCommaFractionStrings.get(i) ?? "0"); + } catch {} + generator = generator.add(FIFTH).add(comma.mul(frac)).mmod(OCTAVE); + result.push(generator); + } + + return result; +}); + +const enharmonicCents = computed(() => { + const ws = wellIntervals.value; + return circleDifference( + ws[ws.length - 1].totalCents(), + ws[0].totalCents(), + 1200.0 + ).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; +}; + +const presets: Record = { + pythagorean: { + name: "Pythagorean tuning", + generator: "3/2", + down: 5, + }, + helmholtz: { + name: "Helmholtz aka Schismatic", + down: 11, + size: 24, + generator: "3/2 - 1\\8<32805/32768>", + }, + 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>", + }, +}; + +type WellPreset = { + name: string; + down: number; + comma: string; + commaFractions: string; +}; + +const wellPresets: Record = { + grammateus: { + name: "Grammateus 1518", + down: 1, + comma: "531441/524288", + commaFractions: "0,0,0,0,0,0,-1/2,0,0,0,0", + }, + neidhardtgrosse: { + name: "Neidhardt Große Stadt 1724", + down: 5, + comma: "531441/524288", + commaFractions: "-1/12,0,-1/12,-1/12,0,-1/6,-1/6,-1/6,-1/12,0,-1/12", + }, + neidhardtkleine: { + name: "Neidhardt Kleine Stadt 1732", + down: 4, + comma: "531441/524288", + commaFractions: "-1/12,-1/12,0,0,-1/6,-1/6,-1/6,-1/6,-1/12,-1/12,0", + }, + vallotti: { + name: "Vallotti 1754", + down: 6, + comma: "531441/524288", + commaFractions: "0,0,0,0,0,-1/6,-1/6,-1/6,-1/6,-1/6,-1/6", + }, + lambert: { + name: "Lambert 1774", + down: 6, + comma: "531441/524288", + commaFractions: "0,0,0,0,0,-1/7,-1/7,-1/7,-1/7,-1/7,-1/7,-1/7", + }, + barnes: { + name: "Bach/Barnes 1979", + down: 3, + comma: "531441/524288", + commaFractions: "0,0,-1/6,-1/6,-1/6,-1/6,-1/6,0,-1/6,0,0", + }, + lehman: { + name: "Bach/Lehman 2005", + down: 1, + comma: "531441/524288", + commaFractions: "-1/6,-1/6,-1/6,-1/6,-1/6,0,0,0,-1/12,-1/12,-1/12", + }, + odonnell: { + name: "Bach/O'Donnell 2006", + down: 3, + comma: "531441/524288", + commaFractions: "-1/12,0,0,-1/6,-1/6,-1/6,0,-1/6,0,-1/12,-1/12,-1/12", + }, + hill: { + name: "Bach/Hill 2008", + down: 4, + comma: "531441/524288", + commaFractions: "0,0,0,-2/13,-2/13,-2/13,-2/13,-2/13,0,-1/13,-1/13,-1/13", + }, + swich: { + name: "Bach/Swich 2011", + down: 3, + comma: "531441/524288", + commaFractions: "1/36,0,-1/5,-1/5,-1/5,-1/5,-1/5,0,-1/12,0,1/36", + }, + louie: { + name: "Bach/Louie 2018", + down: 4, + comma: "531441/524288", + commaFractions: "0,0,0,0,-1/6,-1/6,-1/6,-1/6,-1/6,-1/18,-1/18", + }, + werckmeister3: { + name: "Werckmeister III 1691", + down: 3, + comma: "81/80", + commaFractions: "0,0,0,-1/4,-1/4,-1/4,0,0,-1/4,0,0", + }, + rameau: { + name: "Rameau 1726", + 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", + }, + rousseau: { + name: "d'Alembert/Rousseau 1752", + down: 3, + comma: "81/80", + commaFractions: + "1/12,1/12,1/12,-1/4,-1/4,-1/4,-1/4,-1/12,-1/12,-1/12,-1/12", + }, + corrette: { + name: "Corrette's Wolf 1753", + down: 3, + comma: "81/80", + commaFractions: "1/12,1/12,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/12", + }, + marpourg: { + name: "Marpourg 1756", + down: 3, + comma: "81/80", + commaFractions: + "3/20,3/20,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,3/20,3/20,3/20", + }, + kirnberger3: { + name: "Kirnberger III 1779", + down: 5, + comma: "81/80", + commaFractions: "0,0,0,0,0,-1/4,-1/4,-1/4,-1/4,0,0", + }, + kellner: { + name: "Kellner 1975", + down: 4, + comma: "81/80", + commaFractions: "0,0,0,0,-1/5,-1/5,-1/5,-1/5,0,-1/5,0", + }, + egarr: { + name: "Egarr's English Ord 2007", + down: 3, + comma: "81/80", + commaFractions: "1/4,1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/4,-1/8,-1/8,-1/8,0", + }, +}; + +// Record order is supposed to be quaranteed since ES2015, but that doesn't seem to be the case... +const presetKeys: string[] = [...Object.keys(presets)]; + +// Sort by wideness of the generator +presetKeys.sort( + (a, b) => + parseLine(presets[b].generator, DEFAULT_NUMBER_OF_COMPONENTS).totalCents() - + parseLine(presets[a].generator, DEFAULT_NUMBER_OF_COMPONENTS).totalCents() +); + +const wellPresetKeys: string[] = [...Object.keys(wellPresets)]; + +function extractYear(name: string) { + for (const word of name.split(" ")) { + const year = parseInt(word, 10); + if (isNaN(year)) { + continue; + } + return year; + } + return Infinity; +} + +// Sort by comma and then by date +wellPresetKeys.sort((a, b) => { + // XXX: String length used as a complexity proxy. + const c = wellPresets[b].comma.length - wellPresets[a].comma.length; + if (c) { + return c; + } + return extractYear(wellPresets[a].name) - extractYear(wellPresets[b].name); +}); + +watch(size, (newValue) => { + if (down.value >= newValue) { + down.value = newValue - 1; + } +}); + +function selectPreset(value: string) { + if (value === "none") { + return; + } + const preset = presets[value]; + size.value = preset.size ?? 12; + down.value = preset.down; + generator.value = parseLine(preset.generator, DEFAULT_NUMBER_OF_COMPONENTS); + generatorString.value = preset.generator; +} + +watch(selectedPreset, selectPreset, { immediate: true }); + +function selectWellPreset(value: string) { + if (value === "none") { + return; + } + const preset = wellPresets[value]; + 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(","); + wellCommaFractionStrings.clear(); + for (let i = 0; i < fracs.length; ++i) { + wellCommaFractionStrings.set(i - preset.down, fracs[i]); + } +} + +watch(selectedWellPreset, selectWellPreset, { immediate: true }); + +function generate() { + const lineOptions = { centsFractionDigits: props.centsFractionDigits }; + + // Check if the scale can be centered around C + if ( + size.value === 12 && + down.value >= 1 && + (method.value === "simple" || method.value === "well temperament") + ) { + emit("update:baseFrequency", FREQUENCY_C); + emit("update:baseMidiNote", MIDI_NOTE_C); + emit("update:keyColors", KEY_COLORS_C); + } + + if (method.value === "simple") { + const scale = Scale.fromRank2( + temperedGenerator.value, + OCTAVE.mergeOptions(lineOptions), + size.value, + down.value + ); + + if (selectedPreset.value in presets) { + emit("update:scaleName", presets[selectedPreset.value].name); + } else { + emit("update:scaleName", `Rank 2 temperament (${generatorString.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); + } + if (selectedWellPreset.value in wellPresets) { + emit("update:scaleName", wellPresets[selectedWellPreset.value].name); + } else { + emit("update:scaleName", "Custom Well Temperament"); + } + } +} + + + diff --git a/src/utils.ts b/src/utils.ts index 8d6e469c..dc76f617 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -259,3 +259,58 @@ export function monzoEuclideanDistance( return distance; } + +export type AccidentalStyle = "double" | "single" | "ASCII"; + +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. + * @param style Whether or not to use '𝄫' and '𝄪' or to use 'b' and '#' throughout. + * @returns The label of the pitch class. + */ +export function spineLabel( + fifthsUp: number, + style: AccidentalStyle = "double" +): string { + let label = NOMINALS[mmod(fifthsUp + 1, NOMINALS.length)]; + let accidentals = Math.floor((fifthsUp + 1) / NOMINALS.length); + const flat = style === "ASCII" ? "b" : "♭"; + const sharp = style === "ASCII" ? "#" : "♯"; + while (accidentals <= -2) { + if (style === "double") { + label += "𝄫"; + } else { + label += flat + flat; + } + accidentals += 2; + } + while (accidentals >= 2) { + if (style === "double") { + label += "𝄪"; + } else { + label += sharp + sharp; + } + accidentals -= 2; + } + if (accidentals > 0) { + label += sharp; + } + if (accidentals < 0) { + label += flat; + } + return label; +} + +/** + * Calculate the difference between two cents values such that equave equivalence is taken into account. + * @param a The first pitch measured in cents. + * @param b The second pitch measured in cents. + * @param equaveCents The interval of equivalence measured in cents. + * @returns The first pitch minus the second pitch but on a circle such that large differences wrap around. + */ +export function circleDifference(a: number, b: number, equaveCents: number) { + const half = 0.5 * equaveCents; + return mmod(a - b + half, equaveCents) - half; +} diff --git a/src/views/PreferencesView.vue b/src/views/PreferencesView.vue index 6c59dbac..6f61762c 100644 --- a/src/views/PreferencesView.vue +++ b/src/views/PreferencesView.vue @@ -1,6 +1,7 @@