88 lines
3.2 KiB
Python
88 lines
3.2 KiB
Python
"""Exact rhythmic groups for generated material; imported music is not quantized.
|
|
|
|
Binary boundaries may be displaced together. Tuplet runs must return to their
|
|
entry subdivision phase before binary material resumes. A search boundary is
|
|
not a rhythmic boundary and must not be used to clip a group.
|
|
"""
|
|
from __future__ import annotations
|
|
import dataclasses
|
|
import functools
|
|
import fractions
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RhythmGroup:
|
|
start: fractions.Fraction
|
|
end: fractions.Fraction
|
|
family: str
|
|
events: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
|
|
displacement: fractions.Fraction = fractions.Fraction()
|
|
source: str = ''
|
|
|
|
def describe(pattern, *, origin=fractions.Fraction(), source='', imported=False):
|
|
if not pattern:
|
|
return ()
|
|
if imported:
|
|
return (RhythmGroup(origin, origin + pattern[-1][1], 'source', pattern, source=source),)
|
|
result = []
|
|
i = 0
|
|
while i < len(pattern):
|
|
a, b = pattern[i]
|
|
if b <= a or i and a < pattern[i - 1][1]:
|
|
raise ValueError('overlapping rhythm group')
|
|
# A complete ternary figure can be intentionally displaced on an eighth.
|
|
if (b - a).denominator % 3 == 0:
|
|
if (a * 2).denominator != 1:
|
|
raise ValueError('unexplained tuplet entry phase')
|
|
start = i
|
|
cursor = a
|
|
while i < len(pattern):
|
|
x, y = pattern[i]
|
|
if x != cursor or (y - x).denominator not in (3, 6):
|
|
break
|
|
cursor = y
|
|
i += 1
|
|
if (cursor - a).denominator == 1:
|
|
break
|
|
if i == start or (cursor - a).denominator != 1:
|
|
raise ValueError('incomplete tuplet group')
|
|
group = pattern[start:i]
|
|
result.append(RhythmGroup(origin + a, origin + cursor, 'triplet', group, a % 1, source))
|
|
else:
|
|
if (a * 4).denominator != 1 or (b * 4).denominator != 1:
|
|
raise ValueError('unexplained binary subdivision phase')
|
|
family = 'sixteenth' if (a * 2).denominator != 1 or (b * 2).denominator != 1 else 'binary'
|
|
result.append(RhythmGroup(origin + a, origin + b, family, ((a, b),), a % 1, source))
|
|
i += 1
|
|
return tuple(result)
|
|
|
|
@functools.lru_cache(maxsize=8192)
|
|
def valid(pattern):
|
|
try:
|
|
describe(pattern)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
def signature(notes):
|
|
"""Translation-invariant duration, inter-onset and rest vocabulary."""
|
|
return tuple((n.end - n.start, notes[i + 1].start - n.end if i + 1 < len(notes) else fractions.Fraction())
|
|
for i, n in enumerate(notes))
|
|
|
|
def descriptor(pattern, duration):
|
|
"""Retain ordered attacks, durations and space, including a trailing breath.
|
|
|
|
Absolute placement belongs to the caller. Equal counts or Boolean labels
|
|
do not make two different rhythmic sentences interchangeable.
|
|
"""
|
|
return (duration, tuple(pattern))
|
|
|
|
def broaden(pattern, arrival, release):
|
|
"""Reopen an editable binary tail; never slice a tuplet or inherit its phase."""
|
|
if (arrival * 2).denominator != 1 or (release * 2).denominator != 1:
|
|
return ()
|
|
for group in describe(pattern):
|
|
if group.family == 'triplet' and group.start < arrival < group.end:
|
|
return ()
|
|
result = tuple((a, min(b, arrival)) for a, b in pattern if a < arrival) + ((arrival, release),)
|
|
return result if valid(result) else ()
|