diff --git a/src/assets/base.css b/src/assets/base.css index e8cab797..76bccf7b 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -175,6 +175,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; diff --git a/src/components/ScaleBuilder.vue b/src/components/ScaleBuilder.vue index 7f3c31f1..cfa80514 100644 --- a/src/components/ScaleBuilder.vue +++ b/src/components/ScaleBuilder.vue @@ -18,6 +18,7 @@ import ApproximateByHarmonicsModal from '@/components/modals/modification/Approx import SubharmonicSeriesModal from '@/components/modals/generation/SubharmonicSeries.vue' import EnumerateChordModal from '@/components/modals/generation/EnumerateChord.vue' import CpsModal from '@/components/modals/generation/CombinationProductSet.vue' +import HistoricalModal from '@/components/modals/generation/HistoricalScale.vue' import CrossPolytopeModal from '@/components/modals/generation/CrossPolytope.vue' import LatticeModal from '@/components/modals/generation/SpanLattice.vue' import EulerGenusModal from '@/components/modals/generation/EulerGenus.vue' @@ -162,6 +163,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) @@ -250,6 +252,7 @@ function updateScaleAndHideModals(scale: Scale) { showDwarfModal.value = false showCrossPolytopeModal.value = false showLatticeModal.value = false + showHistoricalModal.value = false showRotateModal.value = false showSubsetModal.value = false showStretchModal.value = false @@ -291,6 +294,7 @@ function confirmPreset() {
  • Enumerate chord
  • Combination product set
  • Moment of symmetry scale
  • +
  • Historical temperament
  • Euler-Fokker genus
  • Dwarf scale
  • Cross-polytope
  • @@ -540,6 +544,17 @@ function confirmPreset() { @cancel="showRankTwoModal = false" /> + + +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, FIFTH, OCTAVE } 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 HARMONIC_SEVENTH = new Interval( + ExtendedMonzo.fromFraction('7/4', DEFAULT_NUMBER_OF_COMPONENTS), + 'ratio' +) + +const SYNTONIC = new Interval( + ExtendedMonzo.fromFraction('81/80', DEFAULT_NUMBER_OF_COMPONENTS), + 'ratio' +) + +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 { + /* empty */ + } + 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 { + /* empty */ + } + 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 + ) +}) + +const ZERO = new Fraction(0) + +const allWellCommasAreZero = computed(() => { + const d = down.value + for (let i = 0; i < size.value; ++i) { + let frac: Fraction + try { + frac = new Fraction(wellCommaFractionStrings.get(i) ?? '0') + } catch { + return false + } + if (wellCommaFractionStrings.has(i - d) && !ZERO.equals(frac)) { + return false + } + } + return true +}) + +// 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 resetWellCommas() { + wellCommaFractionStrings.clear() + selectedWellPreset.value = 'none' +} + +function onWellCommaInput(event: Event, i: number) { + wellCommaFractionStrings.set(i - 1 - down.value, (event.target as HTMLInputElement).value) + selectedWellPreset.value = 'none' +} + +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 5c829373..811823ea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -262,3 +262,55 @@ 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 74e45012..f9988b76 100644 --- a/src/views/PreferencesView.vue +++ b/src/views/PreferencesView.vue @@ -1,6 +1,7 @@