UNPKG

moment-of-symmetry

Version:

Moment of Symmetry (MOS) musical scale generation and analysis for Javascript

664 lines 27 kB
import { getHardness } from './hardness.js'; import { tamnamsInfo, modeName } from './names.js'; import { bjorklund, bjorklundStr, mosGeneratorMonzo } from './helpers.js'; import { fareyInterior } from 'xen-dev-utils/core'; import { Fraction, gcd, mmod } from 'xen-dev-utils/fraction'; import { dot } from 'xen-dev-utils/number-array'; export * from './hardness.js'; export * from './names.js'; export * from './generator-ratio.js'; export * from './info.js'; export * from './notation.js'; /** * Produce an array of booleans that is mixed as evenly as possible. * @param numberOfTrue Number of true elements * @param numberOfFalse Number of false elements * @returns The array of evenly mixed booleans */ export function euclid(numberOfTrue, numberOfFalse) { return bjorklund(numberOfTrue, numberOfFalse, true, false); } /** * Obtain the bright generator of the MOS scale expressed as multipliers of the size of the large and small steps. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @returns An array of [number of large steps in the bright generator, number of small steps in the bright generator]. */ export function brightGeneratorMonzo(numberOfLargeSteps, numberOfSmallSteps) { const numPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); return [ ...mosGeneratorMonzo(numberOfLargeSteps / numPeriods, numberOfSmallSteps / numPeriods), ]; } function getDown(options, period, numPeriods) { let down = 0; if (options.up !== undefined) { down = period * numPeriods - numPeriods - options.up; if (options.down !== undefined && down !== options.down) { throw new Error('Incompatible up and down with the scale size'); } } else if (options.down !== undefined) { down = options.down; } if (down < 0) { throw new Error('Down must not be negative'); } if (down >= period * numPeriods) { throw new Error('Up must not be negative'); } if (down % numPeriods !== 0) { throw new Error('Up/down must be divisible by the number of periods'); } return down; } function mergeParentMembership(existing, incoming) { return (existing ?? incoming) ? true : incoming; } function mergeDaughterLabel(existing, incoming) { if (existing === undefined || existing === incoming) { return incoming; } return 'both'; } /** * Obtain an abstract string like 'LLsLLLs' corresponding to the mode specified. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param options Options for brightness of the scale. * @returns String with the given number of 'L' and 's' characters in the specified mode. */ export function stepString(numberOfLargeSteps, numberOfSmallSteps, options) { if (!numberOfLargeSteps) { return 's'.repeat(numberOfSmallSteps); } if (!numberOfSmallSteps) { return 'L'.repeat(numberOfLargeSteps); } options ?? (options = {}); const brightest = bjorklundStr(numberOfLargeSteps, numberOfSmallSteps); let mode = brightest; const modes = []; while (true) { modes.push(mode); mode = mode.slice(1) + mode[0]; if (mode === brightest) { break; } } // Lexicographic order corresponds to brightness. modes.sort(); const numPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = (numberOfLargeSteps + numberOfSmallSteps) / numPeriods; const brightGeneratorsDown = getDown(options, period, numPeriods); return modes[brightGeneratorsDown / numPeriods]; } /** * Generate MOS pattern as a subset of an EDO. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param options Options for sizes of the steps and brightness of the scale. * @returns An array of integers representing the EDO subset. The 0 degree is not included, but the final degree representing the size of the EDO is. */ export function mos(numberOfLargeSteps, numberOfSmallSteps, options) { const abstract = stepString(numberOfLargeSteps, numberOfSmallSteps, options); const sizeOfLargeStep = options?.sizeOfLargeStep ?? 2; const sizeOfSmallStep = options?.sizeOfSmallStep ?? 1; let step = 0; const result = []; for (const character of abstract) { if (character === 'L') { step += sizeOfLargeStep; } else { step += sizeOfSmallStep; } result.push(step); } return result; } /** * Generate MOS pattern as a subset of an EDO with parent MOS relationship indicated. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param options Options for sizes of the steps, brightness of the scale and flat/sharp relationship. * @returns A map of integers representing the EDO subset to booleans indicating if the scale degree belongs to the parent MOS or not. * The 0 degree is not included, but the final degree representing the size of the EDO is. */ export function mosWithParent(numberOfLargeSteps, numberOfSmallSteps, options) { options ?? (options = {}); const numPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = (numberOfLargeSteps + numberOfSmallSteps) / numPeriods; const sizeOfLargeStep = options.sizeOfLargeStep ?? 2; const sizeOfSmallStep = options.sizeOfSmallStep ?? 1; const brightGeneratorsDown = getDown(options, period, numPeriods); const l = numberOfLargeSteps / numPeriods; const s = numberOfSmallSteps / numPeriods; const d = brightGeneratorsDown / numPeriods; const p = l * sizeOfLargeStep + s * sizeOfSmallStep; const gMonzo = mosGeneratorMonzo(l, s); const g = gMonzo[0] * sizeOfLargeStep + gMonzo[1] * sizeOfSmallStep; const parentPeriod = Math.max(l, s); const base = new Map(); for (let i = 0; i < period; ++i) { let isParent; if (options.accidentals === 'flat') { isParent = period - i <= parentPeriod; } else { isParent = i < parentPeriod; } const degree = mmod((i - d) * g, p); base.set(degree, mergeParentMembership(base.get(degree), isParent)); } const edoDegrees = [...base.keys()].sort((a, b) => a - b); let result = new Map(); for (let i = 0; i < numPeriods; ++i) { edoDegrees.forEach(degree => { const key = degree + i * p; result = result.set(key, mergeParentMembership(result.get(key), base.get(degree))); }); } const rootIsParent = result.get(0); result.delete(0); result.set(numPeriods * p, rootIsParent); return result; } /** * Generate a daughter MOS as a subset of an EDO while labeling each degree by its relationship to the parent MOS. * @param numberOfLargeSteps Number of large steps in the parent MOS. * @param numberOfSmallSteps Number of small steps in the parent MOS. * @param options Options for sizes of the steps, brightness of the scale, and daughter accidental labeling. * @returns A map of EDO degrees to labels indicating whether a degree is in the parent MOS (`'parent'`) or belongs to the daughter as `'flat'`, `'sharp'`, or `'both'`. * The 0 degree is not included, but the final degree representing the size of the EDO is. */ export function mosWithDaughter(numberOfLargeSteps, numberOfSmallSteps, options) { options ?? (options = {}); const numPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = (numberOfLargeSteps + numberOfSmallSteps) / numPeriods; const sizeOfLargeStep = options.sizeOfLargeStep ?? 2; const sizeOfSmallStep = options.sizeOfSmallStep ?? 1; const brightGeneratorsDown = getDown(options, period, numPeriods); const l = numberOfLargeSteps / numPeriods; const s = numberOfSmallSteps / numPeriods; const d = brightGeneratorsDown / numPeriods; const p = l * sizeOfLargeStep + s * sizeOfSmallStep; const gMonzo = mosGeneratorMonzo(l, s); const g = gMonzo[0] * sizeOfLargeStep + gMonzo[1] * sizeOfSmallStep; const daughterPeriod = 2 * l + s; const base = new Map(); for (let i = 0; i < period; ++i) { const degree = mmod((i - d) * g, p); base.set(degree, mergeDaughterLabel(base.get(degree), 'parent')); } const accs = options.accidentals ?? 'sharp'; if (accs === 'flat' || (accs === 'both' && sizeOfLargeStep > 2)) { for (let i = period - daughterPeriod; i < 0; ++i) { const degree = mmod((i - d) * g, p); base.set(degree, mergeDaughterLabel(base.get(degree), 'flat')); } } if (accs === 'sharp' || accs === 'both') { const acc = sizeOfLargeStep === 2 ? 'both' : 'sharp'; for (let i = period; i < daughterPeriod; ++i) { const degree = mmod((i - d) * g, p); base.set(degree, mergeDaughterLabel(base.get(degree), acc)); } } const edoDegrees = [...base.keys()].sort((a, b) => a - b); let result = new Map(); for (let i = 0; i < numPeriods; ++i) { edoDegrees.forEach(degree => { const key = degree + i * p; result = result.set(key, mergeDaughterLabel(result.get(key), base.get(degree))); }); } const rootIsParent = result.get(0); result.delete(0); result.set(numPeriods * p, rootIsParent); return result; } /** * Information about the modes of a MOS scale. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param extraNames If true adds extra mode names in parenthesis such as Ionian (Major). * @returns An array of mode information, ordered from darkest to brightest. */ export function mosModes(numberOfLargeSteps, numberOfSmallSteps, extraNames = false) { const numberOfPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = (numberOfLargeSteps + numberOfSmallSteps) / numberOfPeriods; const l = numberOfLargeSteps / numberOfPeriods; const s = numberOfSmallSteps / numberOfPeriods; const p = l * 2 + s; const gMonzo = mosGeneratorMonzo(l, s); const g = gMonzo[0] * 2 + gMonzo[1]; const result = []; for (let u = 0; u < period; ++u) { const base = []; for (let i = 0; i < period; ++i) { base.push(mmod((u - i) * g, p)); } base.sort((a, b) => a - b); let scale = base; for (let i = 1; i < numberOfPeriods; ++i) { scale = scale.concat(base.map(s => s + i * p)); } scale.push(numberOfPeriods * p); let pattern = ''; for (let i = 1; i < scale.length; ++i) { if (scale[i] - scale[i - 1] === 2) { pattern += 'L'; } else { pattern += 's'; } } const modeName_ = modeName(pattern, extraNames); let udp = `${u * numberOfPeriods}|${(period - 1 - u) * numberOfPeriods}`; if (numberOfPeriods > 1) { udp += `(${numberOfPeriods})`; } result.push({ period, numberOfPeriods, udp, mode: pattern, modeName: modeName_, }); } return result; } /** * Information about a mode of a MOS scale. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param options Options for brightness of the scale and for adding extra names like Ionian (Major). * @returns Information about the selected mode. */ export function modeInfo(numberOfLargeSteps, numberOfSmallSteps, options) { options ?? (options = {}); const numberOfPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = (numberOfLargeSteps + numberOfSmallSteps) / numberOfPeriods; const brightGeneratorsDown = getDown(options, period, numberOfPeriods); const scale = mos(numberOfLargeSteps, numberOfSmallSteps, options); scale.unshift(0); let pattern = ''; for (let i = 1; i < scale.length; ++i) { if (scale[i] - scale[i - 1] === 2) { pattern += 'L'; } else { pattern += 's'; } } const modeName_ = modeName(pattern, options.extraNames); const brightGeneratorsUp = (period - 1) * numberOfPeriods - brightGeneratorsDown; let udp = `${brightGeneratorsUp}|${brightGeneratorsDown}`; if (numberOfPeriods > 1) { udp += `(${numberOfPeriods})`; } return { period, numberOfPeriods, udp, mode: pattern, modeName: modeName_, }; } /** * Split a string like "5L 2s" into [5, 2]. * @param mosPattern MOS pattern such as "5L 2s". * @returns A pair of integers representing the number of large and small steps. */ export function splitMosPattern(mosPattern) { const [l, s] = mosPattern.split('L'); const numberOfLargeSteps = parseInt(l.trim(), 10); const numberOfSmallSteps = parseInt(s.split('s')[0].trim(), 10); return [numberOfLargeSteps, numberOfSmallSteps]; } export function parentMos(patternOrLarge, numberOfSmallSteps) { let numberOfLargeSteps; if (typeof patternOrLarge === 'string') { [numberOfLargeSteps, numberOfSmallSteps] = splitMosPattern(patternOrLarge); } else { numberOfLargeSteps = patternOrLarge; if (typeof numberOfSmallSteps !== 'number') { throw new Error('Number of small steps must be given'); } } // Calculate the parent's size. const size = Math.max(numberOfLargeSteps, numberOfSmallSteps); numberOfLargeSteps = Math.min(numberOfLargeSteps, numberOfSmallSteps); numberOfSmallSteps = size - numberOfLargeSteps; const mosPattern = `${numberOfLargeSteps}L ${numberOfSmallSteps}s`; const info = { size, numberOfLargeSteps, numberOfSmallSteps, mosPattern, }; Object.assign(info, tamnamsInfo(mosPattern)); return info; } /** * Obtain detailed information about a MOS scale embedded in an EDO. * @param numberOfLargeSteps Number of large steps in the MOS pattern. * @param numberOfSmallSteps Number of small steps in the MOS pattern. * @param sizeOfLargeStep Size of the large step in EDO steps. * @param sizeOfSmallStep Size of the small step in EDO steps. * @returns Information about the MOS scale, including generators, period data, and hardness. */ export function mosScaleInfo(numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep = 2, sizeOfSmallStep = 1) { const mosPattern = `${numberOfLargeSteps}L ${numberOfSmallSteps}s`; const numberOfPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const edo = numberOfLargeSteps * sizeOfLargeStep + numberOfSmallSteps * sizeOfSmallStep; const period = edo / numberOfPeriods; const periodMonzo = [ numberOfLargeSteps / numberOfPeriods, numberOfSmallSteps / numberOfPeriods, ]; const brightGeneratorMonzo = mosGeneratorMonzo(...periodMonzo); const brightGenerator = dot(brightGeneratorMonzo, [ sizeOfLargeStep, sizeOfSmallStep, ]); const info = { numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep, edo, numberOfPeriods, period, brightGenerator, darkGenerator: period - brightGenerator, periodMonzo, brightGeneratorMonzo, mosPattern, hardness: getHardness(sizeOfLargeStep, sizeOfSmallStep), }; Object.assign(info, tamnamsInfo(mosPattern)); return info; } /** * Calculate the daughter MOS implied by a parent MOS and its step sizes. * @param numberOfLargeSteps Number of large steps in the parent MOS. * @param numberOfSmallSteps Number of small steps in the parent MOS. * @param sizeOfLargeStep Size of the parent MOS large step in EDO steps. * @param sizeOfSmallStep Size of the parent MOS small step in EDO steps. * @returns Information about the daughter MOS scale. */ export function daughterMos(numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep) { const size = numberOfLargeSteps + numberOfSmallSteps; if (sizeOfLargeStep >= 2 * sizeOfSmallStep) { numberOfSmallSteps = size; sizeOfLargeStep -= sizeOfSmallStep; } else { numberOfSmallSteps = numberOfLargeSteps; numberOfLargeSteps = size; const temp = sizeOfSmallStep; sizeOfSmallStep = sizeOfLargeStep - sizeOfSmallStep; sizeOfLargeStep = temp; } return mosScaleInfo(numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep); } // One entry in the EDO map for each hardness class const STEP_SIZES = [ [2, 1], // basic [3, 2], // soft [3, 1], // hard [4, 3], // supersoft [4, 1], // superhard [5, 3], // semisoft [5, 2], // semihard [5, 4], // ultrasoft [5, 1], // ultrahard [7, 5], // parasoft [7, 4], // minisoft [7, 3], // minihard [7, 2], // parahard [8, 5], // quasisoft [8, 3], // quasihard ]; /** * Construct a mapping from EDO size to supported MOS scales. * @param maxSize Maximum size of the MOS patterns to include. * @returns A mapping from EDO size to an array of information about the supported MOS scales. */ export function makeEdoMap(maxSize = 12) { const result = new Map(); STEP_SIZES.forEach(([sizeOfLargeStep, sizeOfSmallStep]) => { const hardness = getHardness(sizeOfLargeStep, sizeOfSmallStep); for (let size = 2; size <= maxSize; ++size) { for (let numberOfLargeSteps = 1; numberOfLargeSteps < size; ++numberOfLargeSteps) { const numberOfSmallSteps = size - numberOfLargeSteps; const mosPattern = `${numberOfLargeSteps}L ${numberOfSmallSteps}s`; const edo = numberOfLargeSteps * sizeOfLargeStep + numberOfSmallSteps * sizeOfSmallStep; const info = { mosPattern, numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep, hardness, }; Object.assign(info, tamnamsInfo(mosPattern)); const infos = result.get(edo) || []; infos.push(info); result.set(edo, infos); } } }); return result; } const STEP_COUNTS = [ [5, 2], // diatonic [4, 3], // smitonic [3, 4], // mosh [2, 5], // antidiatonic [3, 5], // sensoid [5, 3], // oneirotonic [6, 2], // echinoid [2, 6], // antiechinoid [4, 2], // lemon [2, 4], // antilemon [5, 1], // machinoid [2, 3], // pentic [3, 2], // antipentic [1, 4], // machinoid (subset) [1, 3], // manic [1, 2], // happy [2, 1], // grumpy [1, 1], // trivial ]; /** * Find a MOS scale supported by the given EDO. * @param edo Size of the EDO. * @returns Information about the supported MOS scale. */ export function anyForEdo(edo) { if (edo <= 1) { throw new Error('Minimum size is 2'); } if (edo === 2) { return { mosPattern: '1L 1s', numberOfLargeSteps: 1, numberOfSmallSteps: 1, sizeOfLargeStep: 1, sizeOfSmallStep: 1, edo, numberOfPeriods: 1, period: edo, brightGenerator: 1, darkGenerator: 1, periodMonzo: [1, 1], brightGeneratorMonzo: [1, 0], hardness: 'equalized', name: 'trivial', subset: false, }; } for (let i = 0; i < STEP_COUNTS.length; ++i) { const [numberOfLargeSteps, numberOfSmallSteps] = STEP_COUNTS[i]; let sizeOfLargeStep = 2; while (true) { const largePart = sizeOfLargeStep * numberOfLargeSteps; const smallPart = edo - largePart; if (smallPart <= 0) { break; } if (smallPart % numberOfSmallSteps === 0) { const sizeOfSmallStep = smallPart / numberOfSmallSteps; if (sizeOfLargeStep <= 3 * sizeOfSmallStep && 3 * sizeOfSmallStep <= 2 * sizeOfLargeStep) { const mosPattern = `${numberOfLargeSteps}L ${numberOfSmallSteps}s`; const hardness = getHardness(sizeOfLargeStep, sizeOfSmallStep); const numberOfPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = edo / numberOfPeriods; const periodMonzo = [ numberOfLargeSteps / numberOfPeriods, numberOfSmallSteps / numberOfPeriods, ]; const brightGeneratorMonzo = mosGeneratorMonzo(...periodMonzo); const brightGenerator = dot(brightGeneratorMonzo, [ sizeOfLargeStep, sizeOfSmallStep, ]); const info = { mosPattern, numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep, edo, numberOfPeriods, period, brightGenerator, darkGenerator: period - brightGenerator, periodMonzo, brightGeneratorMonzo, hardness, }; Object.assign(info, tamnamsInfo(mosPattern)); return info; } } sizeOfLargeStep++; } } throw new Error(`Failed to find MOS pattern for ${edo}`); } /** * Find all MOS scales supported by the given EDO within the given constraints. * @param edo Size of the EDO. * @param minSize Minimum size of a MOS scale in the result. * @param maxSize Maximum size of a MOS scale in the result. * @param maxHardness Maximum hardness of the step ratio L/s. * @returns Array of information about the supported MOS scales. */ export function allForEdo(edo, minSize = 2, maxSize, maxHardness) { if (maxSize === undefined) { maxSize = edo; } if (minSize < 2) { throw new Error('Minimum size must be at least 2'); } if (maxSize > edo) { throw new Error(`Maximum size must be smaller or equal to edo (${edo})`); } const result = []; for (let numberOfLargeSteps = 1; numberOfLargeSteps < maxSize; ++numberOfLargeSteps) { for (let numberOfSmallSteps = Math.max(1, minSize - numberOfLargeSteps); numberOfSmallSteps <= maxSize - numberOfLargeSteps; numberOfSmallSteps++) { for (const hardness of fareyInterior(edo - numberOfSmallSteps)) { const { n: sizeOfSmallStep, d: sizeOfLargeStep } = hardness; if (maxHardness && sizeOfLargeStep > sizeOfSmallStep * maxHardness) { continue; } if (numberOfLargeSteps * sizeOfLargeStep + numberOfSmallSteps * sizeOfSmallStep === edo) { const mosPattern = `${numberOfLargeSteps}L ${numberOfSmallSteps}s`; const hardness = getHardness(sizeOfLargeStep, sizeOfSmallStep); const numberOfPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); const period = edo / numberOfPeriods; const periodMonzo = [ numberOfLargeSteps / numberOfPeriods, numberOfSmallSteps / numberOfPeriods, ]; const brightGeneratorMonzo = mosGeneratorMonzo(...periodMonzo); const brightGenerator = dot(brightGeneratorMonzo, [ sizeOfLargeStep, sizeOfSmallStep, ]); const info = { mosPattern, numberOfLargeSteps, numberOfSmallSteps, sizeOfLargeStep, sizeOfSmallStep, hardness, edo, numberOfPeriods, period, brightGenerator, darkGenerator: period - brightGenerator, periodMonzo, brightGeneratorMonzo, }; Object.assign(info, tamnamsInfo(mosPattern)); result.push(info); } } } } return result; } /** * Find the ranges of all (equally tempered) fractions of the equave that span MOS scales. * @param size Size of the scales to consider. * @param includeMultiPeriods Include scales that split the equave into multiple periods. * @returns Information about the ranges of generator that span MOS. Ranges are grouped by period and otherwise sorted in ascending order. */ export function generatorRanges(size, includeMultiPeriods = false) { const result = []; for (let numberOfLargeSteps = 1; numberOfLargeSteps < size; numberOfLargeSteps++) { const numberOfSmallSteps = size - numberOfLargeSteps; const numPeriods = gcd(numberOfLargeSteps, numberOfSmallSteps); if (!includeMultiPeriods && numPeriods !== 1) { continue; } const period = new Fraction(1, numPeriods); const monzo = mosGeneratorMonzo(numberOfLargeSteps / numPeriods, numberOfSmallSteps / numPeriods); // Collapsed endpoint let lowerBound = new Fraction(monzo[0], numberOfLargeSteps); // Equalized endpoint let upperBound = new Fraction(monzo[0] + monzo[1], size); if (lowerBound.compare(upperBound) > 0) { [lowerBound, upperBound] = [upperBound, lowerBound]; } result.push({ period, lowerBound, upperBound, numberOfLargeSteps, numberOfSmallSteps, bright: true, }); result.push({ period, lowerBound: period.sub(upperBound), upperBound: period.sub(lowerBound), numberOfLargeSteps, numberOfSmallSteps, bright: false, }); } result.sort((a, b) => a.period.compare(b.period) || a.lowerBound.compare(b.lowerBound)); return result; } //# sourceMappingURL=core.js.map