Source code for music21.meter.tools

# -----------------------------------------------------------------------------
# Name:         meter.tools.py
# Purpose:      Tools for working with meter
#
# Authors:      Christopher Ariza
#               Michael Scott Asato Cuthbert
#
# Copyright:    Copyright © 2009-2024 Michael Scott Asato Cuthbert
# License:      BSD, see license.txt
# -----------------------------------------------------------------------------
from __future__ import annotations

import fractions
from functools import lru_cache
import math
import re
import typing as t

from music21 import common
from music21.common.enums import MeterDivision
from music21 import environment
from music21.exceptions21 import MeterException, Music21Exception, TimeSignatureException

environLocal = environment.Environment('meter.tools')

[docs] class MeterTerminalTuple(t.NamedTuple): numerator: int denominator: int division: MeterDivision
type NumDenom = tuple[int, int] type NumDenomTuple = tuple[NumDenom, ...] type MeterOptions = tuple[tuple[str, ...], ...] validDenominators = (1, 2, 4, 8, 16, 32, 64, 128) # in order validDenominatorsSet = frozenset(validDenominators) @lru_cache(512) def slashToTuple(value: str) -> MeterTerminalTuple: ''' Returns a three-element MeterTerminalTuple of numerator, denominator, and optional division of the meter. >>> meter.tools.slashToTuple('3/8') MeterTerminalTuple(numerator=3, denominator=8, division=<MeterDivision.NONE>) >>> meter.tools.slashToTuple('7/32') MeterTerminalTuple(numerator=7, denominator=32, division=<MeterDivision.NONE>) >>> meter.tools.slashToTuple('slow 6/8') MeterTerminalTuple(numerator=6, denominator=8, division=<MeterDivision.SLOW>) ''' # split by numbers, include slash valueNumbers, valueChars = common.getNumFromStr(value, numbers='0123456789/.') valueNumbers = valueNumbers.strip() # remove whitespace if not valueChars: division = MeterDivision.NONE # speed up most common case else: valueChars = valueChars.strip() # remove whitespace if 'slow' in valueChars.lower(): division = MeterDivision.SLOW elif 'fast' in valueChars.lower(): division = MeterDivision.FAST else: division = MeterDivision.NONE matches = re.match(r'(\d+)/(\d+)', valueNumbers) if matches is not None: n = int(matches.group(1)) d = int(matches.group(2)) return MeterTerminalTuple(n, d, division) raise MeterException(f'slashToTuple() cannot find two part fraction for {value}') @lru_cache(512) def slashCompoundToFraction(value: str) -> NumDenomTuple: ''' Change a compount meter into a list of simple numberator, demoninator values >>> meter.tools.slashCompoundToFraction('3/8+2/8') ((3, 8), (2, 8)) >>> meter.tools.slashCompoundToFraction('5/8') ((5, 8),) >>> meter.tools.slashCompoundToFraction('5/8+2/4+6/8') ((5, 8), (2, 4), (6, 8)) * Changed in v7: new location and returns a tuple. ''' post: list[NumDenom] = [] value = value.strip() # rem whitespace valueList = value.split('+') for part in valueList: try: m = slashToTuple(part) post.append((m.numerator, m.denominator)) except MeterException: pass return tuple(post) @lru_cache(512) def slashMixedToFraction(valueSrc: str) -> tuple[NumDenomTuple, bool]: ''' Given a mixture if possible meter fraction representations, return a tuple of two elements: The first element is a tuple of pairs of numerator, denominators that are implied by the time signature. The second element is False if the value was a simple time signature (like 4/4) or a composite meter where all numerators had their own denominators. >>> meter.tools.slashMixedToFraction('4/4') (((4, 4),), False) >>> meter.tools.slashMixedToFraction('3/8+2/8') (((3, 8), (2, 8)), False) >>> meter.tools.slashMixedToFraction('3+2/8') (((3, 8), (2, 8)), True) >>> meter.tools.slashMixedToFraction('3+2+5/8') (((3, 8), (2, 8), (5, 8)), True) >>> meter.tools.slashMixedToFraction('3+2+5/8+3/4') (((3, 8), (2, 8), (5, 8), (3, 4)), True) >>> meter.tools.slashMixedToFraction('3+2+5/8+3/4+2+1+4/16') (((3, 8), (2, 8), (5, 8), (3, 4), (2, 16), (1, 16), (4, 16)), True) >>> meter.tools.slashMixedToFraction('3+2+5/8+3/4+2+1+4') Traceback (most recent call last): music21.exceptions21.MeterException: cannot match denominator to numerator in: 3+2+5/8+3/4+2+1+4 >>> meter.tools.slashMixedToFraction('3.0/4.0') Traceback (most recent call last): music21.exceptions21.TimeSignatureException: Cannot create time signature from "3.0/4.0" * Changed in v7: new location and returns a tuple as first value. ''' pre: list[NumDenom|tuple[int, None]] = [] summedNumerator = False value = valueSrc.strip().split('+') for part in value: if '/' in part: try: tup = slashToTuple(part) except MeterException as me: raise TimeSignatureException( f'Cannot create time signature from "{valueSrc}"') from me pre.append((tup.numerator, tup.denominator)) else: # it is just a numerator try: pre.append((int(part), None)) summedNumerator = True except ValueError: raise Music21Exception( 'Cannot parse this file -- this error often comes ' 'up if the musicxml pickled file is out of date after a change ' 'in musicxml/__init__.py . ' 'Clear your temp directory of .p and .p.gz files and try again. ' f'Time Signature: {valueSrc} ') post: list[NumDenom] = [] # when encountering a missing denominator, find the first defined # and apply to all previous for i, (intNum, intDenom) in enumerate(pre): if intDenom is None: # search for next denominator # this O(n^2) operation is easily simplified to O(n) for (_, nextDenom) in pre[i + 1:]: if nextDenom is not None: intDenom = nextDenom break else: raise MeterException(f'cannot match denominator to numerator in: {valueSrc}') post.append((intNum, intDenom)) return tuple(post), summedNumerator @lru_cache(512) def fractionToSlashMixed(fList: NumDenomTuple) -> tuple[tuple[str, int], ...]: ''' Given a tuple of fraction values, compact numerators by sum if denominators are the same >>> from music21.meter.tools import fractionToSlashMixed >>> fractionToSlashMixed(((3, 8), (2, 8), (5, 8), (3, 4), (2, 16), (1, 16), (4, 16))) (('3+2+5', 8), ('3', 4), ('2+1+4', 16)) * Changed in v7: new location and returns a tuple. ''' pre: list[tuple[list[int], int]] = [] for n, d in fList: # look at previous fraction and determine if denominator is the same if pre and pre[-1][1] == d: pre[-1][0].append(n) else: # if not found in one less pre.append(([n], d)) # create string representation post: list[tuple[str, int]] = [] for part in pre: nStrList = [str(x) for x in part[0]] nStr = '+'.join(nStrList) dInt = part[1] post.append((nStr, dInt)) return tuple(post) @lru_cache(512) def fractionSum(numDenomTuple: NumDenomTuple) -> NumDenom: ''' Given a tuple of tuples of numerator and denominator, find the sum; does NOT reduce to its lowest terms. >>> from music21.meter.tools import fractionSum >>> fractionSum(((3, 8), (5, 8), (1, 8))) (9, 8) >>> fractionSum(((1, 6), (2, 3))) (5, 6) >>> fractionSum(((3, 4), (1, 2))) (5, 4) >>> fractionSum(((1, 13), (2, 17))) (43, 221) >>> fractionSum(()) (0, 1) This method might seem like an easy place to optimize and simplify by just doing a fractions.Fraction() sum (I tried!), but not reducing to its lowest terms is a feature of this method. 3/8 + 3/8 = 6/8, not 3/4: >>> fractionSum(((3, 8), (3, 8))) (6, 8) ''' nList = [] dList = [] dListUnique = set() for n, d in numDenomTuple: nList.append(n) dList.append(d) dListUnique.add(d) if len(dListUnique) == 1: n = sum(nList) d = dList[0] # Does not reduce to lowest terms return (n, d) else: # there might be a better way to do this dRed = math.lcm(*dListUnique) # after finding d, multiply each numerator nRed = 0 for nSrc, dSrc in zip(nList, dList): nRed += nSrc * (dRed // dSrc) return (nRed, dRed) @lru_cache(512) def proportionToFraction(value: float) -> NumDenom: ''' Given a floating point proportional value between 0 and 1, return the best-fit slash-base fraction up to 16. >>> from music21.meter.tools import proportionToFraction >>> proportionToFraction(0.5) (1, 2) >>> proportionToFraction(0.25) (1, 4) >>> proportionToFraction(0.75) (3, 4) >>> proportionToFraction(0.125) (1, 8) >>> proportionToFraction(0.375) (3, 8) >>> proportionToFraction(0.625) (5, 8) >>> proportionToFraction(0.333) (1, 3) >>> proportionToFraction(0.83333) (5, 6) ''' f = fractions.Fraction(value).limit_denominator(16) return (f.numerator, f.denominator) # ------------------------------------------------------------------------- # load common meter templates into this sequence # no need to cache these -- getPartitionOptions is cached
[docs] def divisionOptionsFractionsUpward(n: int, d: int) -> tuple[str, ...]: ''' This simply gets restatements of the same fraction in smaller units, up to the largest valid denominator. >>> meter.tools.divisionOptionsFractionsUpward(2, 4) ('4/8', '8/16', '16/32', '32/64', '64/128') >>> meter.tools.divisionOptionsFractionsUpward(3, 4) ('6/8', '12/16', '24/32', '48/64', '96/128') Note that this returns a tuple of strings not MeterOptions ''' opts = [] # equivalent fractions upward if d < validDenominators[-1]: nMod = n * 2 dMod = d * 2 while dMod <= validDenominators[-1]: opts.append(f'{nMod}/{dMod}') dMod *= 2 nMod *= 2 return tuple(opts)
[docs] def divisionOptionsFractionsDownward(n: int, d: int) -> tuple[str, ...]: ''' Get restatements of the same fraction in larger units >>> meter.tools.divisionOptionsFractionsDownward(2, 4) ('1/2',) >>> meter.tools.divisionOptionsFractionsDownward(12, 16) ('6/8', '3/4') Note that this returns a tuple of strings not MeterOptions ''' opts = [] if d > validDenominators[0] and n % 2 == 0: nMod = n // 2 dMod = d // 2 while dMod >= validDenominators[0]: opts.append(f'{nMod}/{dMod}') if nMod % 2 != 0: # no longer even break dMod = dMod // 2 nMod = nMod // 2 return tuple(opts)
[docs] def divisionOptionsAdditiveMultiplesDownward(n: int, d: int) -> MeterOptions: ''' >>> meter.tools.divisionOptionsAdditiveMultiplesDownward(1, 16) (('1/32', '1/32'), ('1/64', '1/64', '1/64', '1/64'), ('1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128')) ''' opts = [] # this only takes n == 1 if d < validDenominators[-1] and n == 1: i = 2 dMod = d * 2 while dMod <= validDenominators[-1]: opts.append(tuple([f'{n}/{dMod}'] * i)) dMod = dMod * 2 i *= 2 return tuple(opts)
[docs] def divisionOptionsAdditiveMultiples(n: int, d: int) -> MeterOptions: ''' Additive multiples with the same denominators. >>> meter.tools.divisionOptionsAdditiveMultiples(4, 16) (('2/16', '2/16'),) >>> meter.tools.divisionOptionsAdditiveMultiples(6, 4) (('3/4', '3/4'),) ''' opts = [] if n > 3 and n % 2 == 0: div = 2 i = div nMod = n // div while nMod > 1: seq = tuple([f'{nMod}/{d}'] * i) if seq not in opts: # may be cases defined elsewhere opts.append(seq) nMod = nMod // div i *= div return tuple(opts)
[docs] def divisionOptionsAdditiveMultiplesEvenDivision(n: int, d: int) -> tuple[tuple[str, ...], ...]: ''' >>> meter.tools.divisionOptionsAdditiveMultiplesEvenDivision(4, 16) (('1/8', '1/8'),) >>> meter.tools.divisionOptionsAdditiveMultiplesEvenDivision(4, 4) (('1/2', '1/2'),) >>> meter.tools.divisionOptionsAdditiveMultiplesEvenDivision(3, 4) () ''' opts = [] # divided additive multiples # if given 4/4, get 2/4+2/4 if n % 2 == 0 and d // 2 >= 1: nMod = n // 2 dMod = d // 2 while dMod >= 1 and nMod > 1: opts.append(tuple([f'{1}/{dMod}'] * int(nMod))) if nMod % 2 != 0: # if no longer even must stop break dMod = dMod // 2 nMod = nMod // 2 return tuple(opts)
[docs] def divisionOptionsAdditiveMultiplesUpward(n: int, d: int) -> MeterOptions: ''' >>> meter.tools.divisionOptionsAdditiveMultiplesUpward(4, 16) (('1/16', '1/16', '1/16', '1/16'), ('1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32'), ('1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64')) >>> meter.tools.divisionOptionsAdditiveMultiplesUpward(3, 4) (('1/4', '1/4', '1/4'), ('1/8', '1/8', '1/8', '1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16')) ''' opts = [] if n > 1 and d >= 1: dCurrent = d nCount = n if n > 16: # go up to n if greater than 16 nCountLimit = n else: nCountLimit = 16 # place practical limits on number of units to get while dCurrent <= validDenominators[-1] and nCount <= nCountLimit: seq = [f'1/{dCurrent}'] * nCount opts.append(tuple(seq)) # double count, double denominator dCurrent *= 2 nCount *= 2 return tuple(opts)
@lru_cache(512) def divisionOptionsAlgo(n, d) -> MeterOptions: ''' This is a primitive approach to algorithmic division production. This can be extended. It is assumed that these values are provided in order of priority >>> meter.tools.divisionOptionsAlgo(4, 4) (('1/4', '1/4', '1/4', '1/4'), ('1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16'), ('1/2', '1/2'), ('4/4',), ('2/4', '2/4'), ('2/2',), ('1/1',), ('8/8',), ('16/16',), ('32/32',), ('64/64',), ('128/128',)) >>> meter.tools.divisionOptionsAlgo(1, 4) (('1/4',), ('1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16'), ('1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32'), ('1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64'), ('1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128', '1/128'), ('2/8',), ('4/16',), ('8/32',), ('16/64',), ('32/128',)) >>> meter.tools.divisionOptionsAlgo(2, 2) (('1/2', '1/2'), ('1/4', '1/4', '1/4', '1/4'), ('1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16'), ('2/2',), ('1/1',), ('4/4',), ('8/8',), ('16/16',), ('32/32',), ('64/64',), ('128/128',)) >>> meter.tools.divisionOptionsAlgo(3, 8) (('1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16'), ('1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32', '1/32'), ('3/8',), ('6/16',), ('12/32',), ('24/64',), ('48/128',)) >>> meter.tools.divisionOptionsAlgo(6, 8) (('3/8', '3/8'), ('1/8', '1/8', '1/8', '1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16'), ('1/4', '1/4', '1/4'), ('6/8',), ('3/4',), ('12/16',), ('24/32',), ('48/64',), ('96/128',)) >>> meter.tools.divisionOptionsAlgo(12, 8) (('3/8', '3/8', '3/8', '3/8'), ('1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8', '1/8'), ('1/4', '1/4', '1/4', '1/4', '1/4', '1/4'), ('1/2', '1/2', '1/2'), ('12/8',), ('6/8', '6/8'), ('6/4',), ('3/2',), ('24/16',), ('48/32',), ('96/64',), ('192/128',)) >>> meter.tools.divisionOptionsAlgo(5, 8) (('2/8', '3/8'), ('3/8', '2/8'), ('1/8', '1/8', '1/8', '1/8', '1/8'), ('1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16', '1/16'), ('5/8',), ('10/16',), ('20/32',), ('40/64',), ('80/128',)) >>> meter.tools.divisionOptionsAlgo(18, 4) (('3/4', '3/4', '3/4', '3/4', '3/4', '3/4'), ('1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4', '1/4'), ('1/2', '1/2', '1/2', '1/2', '1/2', '1/2', '1/2', '1/2', '1/2'), ('18/4',), ('9/4', '9/4'), ('4/4', '4/4', '4/4', '4/4'), ('2/4', '2/4', '2/4', '2/4', '2/4', '2/4', '2/4', '2/4'), ('9/2',), ('36/8',), ('72/16',), ('144/32',), ('288/64',), ('576/128',)) >>> meter.tools.divisionOptionsAlgo(3, 128) (('1/128', '1/128', '1/128'), ('3/128',)) ''' opts = [] group: tuple[int, ...] # TODO: look at music21j code for more readable version. # compound meters; 6, 9, 12, 15, 18 # 9/4, 9/2, 6/2 are all considered compound without d>4 # if n % 3 == 0 and n > 3 and d > 4: if n % 3 == 0 and n > 3: seq = [] for j in range(int(n / 3)): seq.append(f'3/{d}') opts.append(tuple(seq)) # odd meters with common groupings if n == 5: for group in ((2, 3), (3, 2)): seq = [] for nMod in group: seq.append(f'{nMod}/{d}') opts.append(tuple(seq)) if n == 7: for group in ((2, 2, 3), (3, 2, 2), (2, 3, 2)): seq = [] for nMod in group: seq.append(f'{nMod}/{d}') opts.append(tuple(seq)) # not really necessary but an example of a possibility if n == 10: for group in ((2, 2, 3, 3),): seq = [] for nMod in group: seq.append(f'{nMod}/{d}') opts.append(tuple(seq)) # simple additive options uses the minimum numerator of 1 # given 3/4, get 1/4 three times opts.extend(divisionOptionsAdditiveMultiplesUpward(n, d)) # divided additive multiples # if given 4/4, get 2/4+2/4 opts.extend(divisionOptionsAdditiveMultiplesEvenDivision(n, d)) # add src representation opts.append((f'{n}/{d}',)) # additive multiples with the same denominators # add to "opts" in-place opts.extend(divisionOptionsAdditiveMultiples(n, d)) # additive multiples with smaller denominators # only doing this for numerators of 1 for now opts.extend(divisionOptionsAdditiveMultiplesDownward(n, d)) # equivalent fractions downward opts.extend([(o,) for o in divisionOptionsFractionsDownward(n, d)]) # equivalent fractions upward opts.extend([(o,) for o in divisionOptionsFractionsUpward(n, d)]) return tuple(common.misc.unique(o for o in opts if o != ())) @lru_cache(512) def divisionOptionsPreset(n, d) -> MeterOptions: ''' Provide fixed set of meter divisions that will not be easily obtained algorithmically. Currently, does nothing except to allow partitioning 5/8 as 2/8, 2/8, 1/8 as a possibility (sim for 5/16, etc.) >>> meter.tools.divisionOptionsPreset(5, 8) (('2/8', '2/8', '1/8'), ('2/8', '1/8', '2/8')) >>> meter.tools.divisionOptionsPreset(3, 4) () >>> ms2 = meter.MeterSequence('5/32') >>> ms2.getPartitionOptions() (('2/32', '3/32'), ('3/32', '2/32'), ('1/32', '1/32', '1/32', '1/32', '1/32'), ('1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64', '1/64'), ('5/32',), ('10/64',), ('20/128',), ('2/32', '2/32', '1/32'), ('2/32', '1/32', '2/32')) ''' opts = [] if n == 5: opts.append((f'2/{d}', f'2/{d}', f'1/{d}')) opts.append((f'2/{d}', f'1/{d}', f'2/{d}')) return tuple(opts) if __name__ == '__main__': import music21 music21.mainTest()