Skip to content

Commit 696545a

Browse files
authored
Merge pull request #1540 from jacobtylerwalls/quantization-improvements
Minimize gaps produced by quantization algorithm
2 parents 1573e37 + df97c67 commit 696545a

5 files changed

Lines changed: 73 additions & 38 deletions

File tree

music21/_version.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,7 @@
4747
'''
4848
from __future__ import annotations
4949

50-
__version__ = '9.0.0a11'
51-
50+
__version__ = '9.0.0a12'
5251

5352
def get_version_tuple(vv):
5453
v = vv.split('.')

music21/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<class 'music21.base.Music21Object'>
2828
2929
>>> music21.VERSION_STR
30-
'9.0.0a11'
30+
'9.0.0a12'
3131
3232
Alternatively, after doing a complete import, these classes are available
3333
under the module "base":

music21/midi/translate.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2065,7 +2065,7 @@ def midiTrackToStream(
20652065
singleN.editorial.midiTickStart = notes[0][0][0]
20662066
s.coreInsert(o, singleN)
20672067

2068-
s.coreElementsChanged()
2068+
s.sort(force=True) # will also run coreElementsChanged()
20692069
# quantize to nearest 16th
20702070
if quantizePost:
20712071
s.quantize(quarterLengthDivisors=quarterLengthDivisors,

music21/stream/base.py

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686

8787
BestQuantizationMatch = namedtuple(
8888
'BestQuantizationMatch',
89-
['error', 'tick', 'match', 'signedError', 'divisor']
89+
['remainingGap', 'error', 'tick', 'match', 'signedError', 'divisor']
9090
)
9191

9292
class StreamDeprecationWarning(UserWarning):
@@ -9395,18 +9395,43 @@ def quantize(
93959395
# this presently is not trying to avoid overlaps that
93969396
# result from quantization; this may be necessary
93979397

9398-
def bestMatch(target, divisors):
9399-
found = []
9398+
def bestMatch(
9399+
target,
9400+
divisors,
9401+
zeroAllowed=True,
9402+
gapToFill=0.0
9403+
) -> BestQuantizationMatch:
9404+
found: list[BestQuantizationMatch] = []
94009405
for div in divisors:
9401-
match, error, signedErrorInner = common.nearestMultiple(target, (1 / div))
9402-
# Sort by unsigned error, then "tick" (divisor expressed as QL, e.g. 0.25)
9403-
found.append(BestQuantizationMatch(error, 1 / div, match, signedErrorInner, div))
9404-
# get first, and leave out the error
9405-
bestMatchTuple = sorted(found)[0]
9406+
tick = 1 / div # divisor expressed as QL, e.g. 0.25
9407+
match, error, signedErrorInner = common.nearestMultiple(target, tick)
9408+
if not zeroAllowed and match == 0.0:
9409+
match = tick
9410+
signedErrorInner = round(target - match, 7)
9411+
error = abs(signedErrorInner)
9412+
if gapToFill % tick == 0:
9413+
remainingGap = 0.0
9414+
else:
9415+
remainingGap = max(gapToFill - match, 0.0)
9416+
# Sort by remainingGap, then unsigned error, then tick
9417+
found.append(
9418+
BestQuantizationMatch(
9419+
remainingGap, error, tick, match, signedErrorInner, div))
9420+
# get smallest remainingGap, error, tick
9421+
bestMatchTuple = min(found)
94069422
return bestMatchTuple
94079423

9408-
# if we have a min of 0.25 (sixteenth)
9409-
# quarterLengthMin = quarterLengthDivisors[0]
9424+
def findNextElementNotCoincident(
9425+
useStream: Stream,
9426+
startIndex: int,
9427+
startOffset: OffsetQL,
9428+
) -> tuple[base.Music21Object | None, BestQuantizationMatch | None]:
9429+
for next_el in useStream._elements[startIndex:]:
9430+
next_offset = useStream.elementOffset(next_el)
9431+
look_ahead_result = bestMatch(float(next_offset), quarterLengthDivisors)
9432+
if look_ahead_result.match > startOffset:
9433+
return next_el, look_ahead_result
9434+
return None, None
94109435

94119436
if inPlace is False:
94129437
returnStream = self.coreCopyAsDerivation('quantize')
@@ -9419,6 +9444,11 @@ def bestMatch(target, divisors):
94199444

94209445
rests_lacking_durations: list[note.Rest] = []
94219446
for useStream in useStreams:
9447+
# coreSetElementOffset() will immediately set isSorted = False,
9448+
# but we need to know if the stream was originally sorted to know
9449+
# if it's worth "looking ahead" to the next offset. If a stream
9450+
# is unsorted originally, this "looking ahead" could become O(n^2).
9451+
originallySorted = useStream.isSorted
94229452
for i, e in enumerate(useStream._elements):
94239453
if processOffsets:
94249454
o = useStream.elementOffset(e)
@@ -9427,35 +9457,27 @@ def bestMatch(target, divisors):
94279457
sign = -1
94289458
o = -1 * o
94299459
o_matchTuple = bestMatch(float(o), quarterLengthDivisors)
9430-
useStream.coreSetElementOffset(e, o_matchTuple.match * sign)
9460+
o = o_matchTuple.match * sign
9461+
useStream.coreSetElementOffset(e, o)
94319462
if hasattr(e, 'editorial') and o_matchTuple.signedError != 0:
94329463
e.editorial.offsetQuantizationError = o_matchTuple.signedError * sign
94339464
if processDurations:
94349465
ql = e.duration.quarterLength
94359466
ql = max(ql, 0) # negative ql possible in buggy MIDI files?
9436-
d_matchTuple = bestMatch(float(ql), quarterLengthDivisors)
9437-
# Check that any gaps from this quantized duration to the next onset
9438-
# are at least as large as the smallest quantization unit (the largest divisor)
9439-
# If not, then re-quantize this duration with the divisor
9440-
# that will be used to quantize the next element's offset
9441-
if processOffsets and i + 1 < len(useStream._elements):
9442-
next_element = useStream._elements[i + 1]
9443-
next_offset = useStream.elementOffset(next_element)
9444-
look_ahead_result = bestMatch(float(next_offset), quarterLengthDivisors)
9445-
next_offset = look_ahead_result.match
9446-
next_divisor = look_ahead_result.divisor
9447-
if (0 < next_offset - (e.offset + d_matchTuple.match)
9448-
< 1 / max(quarterLengthDivisors)):
9449-
# Overwrite the earlier matchTuple with a better result
9450-
d_matchTuple = bestMatch(float(ql), (next_divisor,))
9451-
# Enforce nonzero duration for non-grace notes
9452-
if (d_matchTuple.match == 0
9453-
and isinstance(e, note.NotRest)
9454-
and not e.duration.isGrace):
9455-
e.quarterLength = 1 / max(quarterLengthDivisors)
9456-
if hasattr(e, 'editorial'):
9457-
e.editorial.quarterLengthQuantizationError = ql - e.quarterLength
9458-
elif d_matchTuple.match == 0 and isinstance(e, note.Rest):
9467+
zeroAllowed = not isinstance(e, note.NotRest) or e.duration.isGrace
9468+
if processOffsets and originallySorted:
9469+
next_element, look_ahead_result = (
9470+
findNextElementNotCoincident(
9471+
useStream=useStream, startIndex=i + 1, startOffset=o))
9472+
if next_element is not None and look_ahead_result is not None:
9473+
gapToFill = opFrac(look_ahead_result.match - e.offset)
9474+
d_matchTuple = bestMatch(
9475+
float(ql), quarterLengthDivisors, zeroAllowed, gapToFill)
9476+
else:
9477+
d_matchTuple = bestMatch(float(ql), quarterLengthDivisors, zeroAllowed)
9478+
else:
9479+
d_matchTuple = bestMatch(float(ql), quarterLengthDivisors, zeroAllowed)
9480+
if d_matchTuple.match == 0 and isinstance(e, note.Rest):
94599481
rests_lacking_durations.append(e)
94609482
else:
94619483
e.duration.quarterLength = d_matchTuple.match

music21/stream/tests.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4027,6 +4027,8 @@ def procCompare(srcOffset, srcDur, dstOffset, dstDur, divList):
40274027
n = note.Note()
40284028
n.quarterLength = srcDur[i]
40294029
s.insert(srcOffset[i], n)
4030+
# Must be sorted for quantizing to work optimally.
4031+
s.sort()
40304032

40314033
s.quantize(divList, processOffsets=True, processDurations=True, inPlace=True)
40324034

@@ -4071,6 +4073,18 @@ def procCompare(srcOffset, srcDur, dstOffset, dstDur, divList):
40714073

40724074
[8, 6]) # snap to 0.125 and 0.1666666
40734075

4076+
# User-reported example: contains overlap and tiny gaps
4077+
# Parsing with fewer gaps in v.9, as long as stream is sorted
4078+
# https://github.com/cuthbertLab/music21/issues/1536
4079+
procCompare([2.016, 2.026, 2.333, 2.646, 3.0, 3.323, 3.651],
4080+
[0.123, 0.656, 0.104, 0.094, 0.146, 0.099, 0.141],
4081+
4082+
[2, 2, F('7/3'), F('8/3'), 3.0, F('10/3'), F('11/3')],
4083+
[F('1/3'), F('2/3'), F('1/3'), F('1/3'),
4084+
F('1/3'), F('1/3'), 0.25],
4085+
4086+
[4, 3])
4087+
40744088
def testQuantizeMinimumDuration(self):
40754089
'''
40764090
Notes (not rests!) of nonzero duration should retain a nonzero

0 commit comments

Comments
 (0)