"""
Motif and Phrase — immutable musical values.
A :class:`Motif` is a short musical figure stored as a value: a frozen tuple
of timed note events (with *specification* pitches — scale degrees, chord
tones, drum names, or absolute MIDI) plus an optional stream of control
gestures (CC sweeps, pitch bends, NRPN/RPN moves), and an explicit length in
beats. A :class:`Phrase` is a frozen sequence of Motifs whose segmentation
is preserved.
Values are frozen dataclasses: immutable, deterministic to construct,
hashable, printable, and safe to define at module level in a live-coded
file. They carry no playback position — the engine owns position; values
are placed onto patterns with ``p.motif()`` / ``p.phrase()``.
Pitch is resolved late: a stored :class:`Degree` or :class:`ChordTone`
becomes a MIDI note only at placement, against the key/scale (and, where
applicable, the chord) in effect at that event's own beat. The same motif
therefore sounds different under different harmony — by design.
The combination algebra:
- ``a + b`` — sequential: a Phrase of the two (segmentation preserved).
- ``a.then(b)`` / ``Motif.join([...])`` — closed sequential concat (one longer Motif).
- ``a & b`` / ``a.stack(b)`` — parallel merge (event union; length = max).
- ``m * n`` — repetition: a Phrase of n segments (``m * 1`` is ``m``).
- ``m.slice(start, end)`` — a window; durations and ramps truncate at the cut.
Transforms are pure and return new values. Time transforms (``reverse``,
``rotate``, ``stretch``, ``slice``) carry control gestures with them — a
reversed rising sweep becomes a falling one; pitch- and note-scoped
transforms (``transpose``, ``invert``, ``pitched``, ``accent``,
``with_velocity``, ``quantize``) leave control gestures untouched.
"""
import dataclasses
import math
import random
import typing
import warnings
import subsequence.cadences
import subsequence.constants.velocity
import subsequence.easing
import subsequence.intervals
import subsequence.sequence_utils
_DEFAULT_VELOCITY = subsequence.constants.velocity.DEFAULT_VELOCITY
# Degree ints beyond this are almost certainly pasted MIDI note numbers
# (e.g. 60 for middle C), not scale degrees; fail loud rather than emit a
# squeal eight octaves up.
_MAX_PLAUSIBLE_DEGREE = 24
# World-rhythm timelines: name → (onset step indices, grid pulses, default
# voice). Onset positions are the exact pulse indices catalogued in
# Toussaint, "The Geometry of Musical Rhythm" — the clave family and tresillo/
# cinquillo on a 16- or 8-pulse bar, the West-African bell patterns on 12.
# Read by Motif.preset(). Default voices are General MIDI percussion names
# (so a preset with no pitch= sounds against the standard GM drum map);
# override with pitch= for any other kit.
_WORLD_RHYTHMS: typing.Dict[str, typing.Tuple[typing.Tuple[int, ...], int, str]] = {
# Cuban clave family (16-pulse bar).
"son_clave_3_2": ((0, 3, 6, 10, 12), 16, "claves"),
"son_clave_2_3": ((2, 4, 8, 11, 14), 16, "claves"),
"rumba_clave_3_2": ((0, 3, 7, 10, 12), 16, "claves"),
"rumba_clave_2_3": ((2, 4, 8, 11, 15), 16, "claves"),
"bossa_nova_clave": ((0, 3, 6, 10, 13), 16, "side_stick"),
# Tresillo / cinquillo (the 3-3-2 family).
"tresillo": ((0, 3, 6), 8, "low_conga"),
"tresillo_16": ((0, 6, 12), 16, "low_conga"),
"cinquillo": ((0, 2, 3, 5, 6), 8, "low_conga"),
# West-African / Cuban 4-4 bell timelines (16-pulse).
"shiko": ((0, 4, 6, 10, 12), 16, "cowbell"),
"soukous": ((0, 3, 6, 10, 11), 16, "cowbell"),
"gahu": ((0, 3, 6, 10, 14), 16, "cowbell"),
"samba_necklace": ((0, 3, 5, 7, 10, 12, 14), 16, "side_stick"),
# The "standard pattern" / bembé bell on a 12-pulse cycle.
"bembe": ((0, 2, 4, 5, 7, 9, 11), 12, "cowbell"),
# bembe_euclidean is the specific Toussaint-catalogued Euclidean rotation
# of the bembé necklace (intervals 2-1-2-2-1-2-2); it differs from this
# library's own generate_euclidean_sequence(12, 7) default rotation.
"bembe_euclidean": ((0, 2, 3, 5, 7, 8, 10), 12, "cowbell"),
"fume_fume": ((0, 2, 4, 6, 7, 9, 11), 12, "cowbell"),
}
_CHORD_TONE_NAMES = {"root": 1, "third": 2, "fifth": 3, "seventh": 4}
# ── Pitch specifications ────────────────────────────────────────────────────
@dataclasses.dataclass(frozen=True)
[docs]
class Degree:
"""
A scale degree — 1-based, resolved against key + scale at placement.
Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the
scale length and resolve into higher octaves). ``octave`` shifts whole
octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened).
"""
step: int
octave: int = 0
chroma: int = 0
def __post_init__ (self) -> None:
"""Validate that the degree is 1-based and plausibly a degree."""
if self.step < 1:
raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")
@dataclasses.dataclass(frozen=True)
[docs]
class ChordTone:
"""
An index into the current chord's tones — 1-based, resolved at placement.
Accepts an int (1 = root, 2 = third, ...) or one of the names
``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave``
shifts whole octaves.
"""
index: int
octave: int = 0
def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None:
"""Normalize a tone name to its 1-based index."""
if isinstance(index_or_name, str):
if index_or_name not in _CHORD_TONE_NAMES:
raise ValueError(
f"Unknown chord tone name '{index_or_name}' — "
f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index"
)
index = _CHORD_TONE_NAMES[index_or_name]
else:
index = index_or_name
if index < 1:
raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}")
object.__setattr__(self, "index", index)
object.__setattr__(self, "octave", octave)
@dataclasses.dataclass(frozen=True)
[docs]
class Approach:
"""
A half-step approach into a target pitch at the next chord boundary.
Resolves at placement, one semitone below its target (the leading-tone
approach); a ``ChordTone`` target reads the NEXT chord through the
harmony window, so the approach lands as the harmony arrives.
"""
target: typing.Union[int, Degree, ChordTone]
# ── Control signals ─────────────────────────────────────────────────────────
@dataclasses.dataclass(frozen=True)
[docs]
class CC:
"""A MIDI CC signal — number, or a name resolved at placement via the pattern's ``cc_name_map``."""
control: typing.Union[int, str]
@dataclasses.dataclass(frozen=True)
[docs]
class PitchBend:
"""The channel pitch-bend wheel; values are normalised -1.0 to 1.0."""
@dataclasses.dataclass(frozen=True)
[docs]
class NRPN:
"""An NRPN parameter — number, or a name resolved at placement via the pattern's ``nrpn_name_map``."""
parameter: typing.Union[int, str]
fine: bool = False
null_reset: bool = True
@dataclasses.dataclass(frozen=True)
[docs]
class RPN:
"""An RPN parameter — number, or one of the standard RPN names (resolved at placement)."""
parameter: typing.Union[int, str]
fine: bool = False
null_reset: bool = True
@dataclasses.dataclass(frozen=True)
[docs]
class OSC:
"""An OSC address; values are sent as the single float argument."""
address: str
ControlSignal = typing.Union[CC, PitchBend, NRPN, RPN, OSC]
PitchSpec = typing.Union[int, str, Degree, ChordTone, Approach, None]
_SPEC_RANK = {int: 0, str: 1, Degree: 2, ChordTone: 3, Approach: 4, type(None): 5}
_SIGNAL_RANK = {CC: 0, PitchBend: 1, NRPN: 2, RPN: 3, OSC: 4}
def _pitch_sort_key (pitch: PitchSpec) -> tuple:
"""A total order over heterogeneous pitch specs (for canonical event order)."""
rank = _SPEC_RANK[type(pitch)]
if isinstance(pitch, (int, str)):
return (rank, pitch)
if isinstance(pitch, Degree):
return (rank, pitch.step, pitch.octave, pitch.chroma)
if isinstance(pitch, ChordTone):
return (rank, pitch.index, pitch.octave)
if isinstance(pitch, Approach):
return (rank,) + _pitch_sort_key(pitch.target)
return (rank,)
def _signal_sort_key (signal: ControlSignal) -> tuple:
"""A total order over control signals (for canonical event order)."""
rank = _SIGNAL_RANK[type(signal)]
if isinstance(signal, CC):
return (rank, str(signal.control))
if isinstance(signal, (NRPN, RPN)):
return (rank, str(signal.parameter), signal.fine, signal.null_reset)
if isinstance(signal, OSC):
return (rank, signal.address)
return (rank,)
def _velocity_key (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Tuple[int, int]:
"""Normalise scalar-or-range velocity to a sortable pair."""
if isinstance(velocity, tuple):
return (velocity[0], velocity[1])
return (velocity, velocity)
# ── Events ──────────────────────────────────────────────────────────────────
@dataclasses.dataclass(frozen=True)
[docs]
class MotifEvent:
"""
One timed note event inside a Motif.
``pitch`` is a specification: an absolute MIDI int, a drum name string,
a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None
for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which
must be re-pitched via :meth:`Motif.pitched` before placement.
``velocity`` is an int or a ``(low, high)`` random-range tuple.
"""
beat: float
pitch: PitchSpec
velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY
duration: float = 0.25
probability: float = 1.0
def __post_init__ (self) -> None:
"""Validate ranges that are wrong at any placement."""
if self.duration <= 0:
raise ValueError(f"Event duration must be positive — got {self.duration}")
if not 0.0 <= self.probability <= 1.0:
raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
def _sort_key (self) -> tuple:
"""Canonical ordering key — makes parallel merge order-independent."""
return (self.beat, _pitch_sort_key(self.pitch), _velocity_key(self.velocity), self.duration, self.probability)
@dataclasses.dataclass(frozen=True)
[docs]
class ControlEvent:
"""
One timed control gesture inside a Motif: a discrete write or a shaped ramp.
A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates
``start`` → ``end`` over ``span`` beats through the easing ``shape``.
Pulse density (``resolution=``) is deliberately not stored here — beats
and shapes are music; MIDI traffic density is set at the placement call.
"""
beat: float
signal: ControlSignal
start: float
end: typing.Optional[float] = None
span: float = 0.0
shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear"
probability: float = 1.0
def __post_init__ (self) -> None:
"""Validate the discrete/ramp invariants."""
if (self.end is None) != (self.span == 0.0):
raise ValueError("A ramp needs both end= and span= (a discrete write has neither)")
if self.span < 0:
raise ValueError(f"Ramp span must be non-negative — got {self.span}")
if not 0.0 <= self.probability <= 1.0:
raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
def _sort_key (self) -> tuple:
"""Canonical ordering key — makes parallel merge order-independent."""
end = self.start if self.end is None else self.end
return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability)
def _value_at (self, fraction: float) -> float:
"""The interpolated value at a 0–1 fraction through the ramp."""
if self.end is None:
return self.start
easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape)
return self.start + (self.end - self.start) * easing_fn(max(0.0, min(1.0, fraction)))
def _expand (name: str, value: typing.Any, n: int) -> list:
"""Expand a scalar parameter to n values, or validate a per-event list."""
if isinstance(value, (int, float, str)) or value is None or isinstance(value, tuple):
return [value] * n
result = list(value)
if len(result) != n:
raise ValueError(f"{name} has {len(result)} values for {n} events — parallel lists must match")
return result
def _computed_length (events: typing.Iterable[MotifEvent], controls: typing.Iterable[ControlEvent]) -> float:
"""Default length: the next whole beat at or after the last sounding moment."""
ends = [e.beat + e.duration for e in events] + [c.beat + c.span for c in controls]
return float(math.ceil(max(ends))) if ends else 0.0
# ── Motif ───────────────────────────────────────────────────────────────────
@dataclasses.dataclass(frozen=True)
[docs]
class Motif:
"""
An immutable musical figure: timed note events + control gestures + a length in beats.
Construct via the classmethods (:meth:`degrees`, :meth:`notes`,
:meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture
constructors, or :meth:`from_events`) rather than positionally.
``length`` is explicit — a trailing rest is meaningful.
"""
events: typing.Tuple[MotifEvent, ...]
length: float
controls: typing.Tuple[ControlEvent, ...] = ()
fit: typing.Optional[float] = None # placement default for the fit dial; set by generate()
def __post_init__ (self) -> None:
"""Validate, and normalise both streams to canonical order."""
if self.length < 0:
raise ValueError(f"Motif length must be non-negative — got {self.length}")
object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key)))
object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key)))
# ── constructors ────────────────────────────────────────────────────
@classmethod
[docs]
def empty (cls) -> "Motif":
"""The empty motif (zero events, zero length) — the identity for ``then``."""
return cls(events=(), length=0.0)
@classmethod
[docs]
def from_events (
cls,
events: typing.Iterable[MotifEvent],
length: typing.Optional[float] = None,
controls: typing.Iterable[ControlEvent] = (),
) -> "Motif":
"""Build a motif from explicit events (power use; length defaults to the next whole beat)."""
events = tuple(events)
controls = tuple(controls)
return cls(
events = events,
length = _computed_length(events, controls) if length is None else length,
controls = controls,
)
@classmethod
def _from_sequence (
cls,
pitches: typing.List[PitchSpec],
beats: typing.Optional[typing.List[float]],
velocities: typing.Any,
durations: typing.Any,
probabilities: typing.Any,
length: typing.Optional[float],
) -> "Motif":
"""Shared core: one event per element, None = rest (slot still advances)."""
n = len(pitches)
onsets = list(beats) if beats is not None else [float(i) for i in range(n)]
if len(onsets) != n:
raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match")
velocity_list = _expand("velocities", velocities, n)
duration_list = _expand("durations", durations, n)
probability_list = _expand("probabilities", probabilities, n)
events = tuple(
MotifEvent(
beat = float(onsets[i]),
pitch = pitches[i],
velocity = velocity_list[i],
duration = float(duration_list[i]),
probability = float(probability_list[i]),
)
for i in range(n)
if pitches[i] is not None
)
return cls(
events = events,
length = _computed_length(events, ()) if length is None else float(length),
)
@classmethod
[docs]
def degrees (
cls,
degrees: typing.List[typing.Union[int, Degree, None]],
beats: typing.Optional[typing.List[float]] = None,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 1.0,
probabilities: typing.Any = 1.0,
length: typing.Optional[float] = None,
) -> "Motif":
"""
A melody written as 1-based scale degrees, one per beat by default.
Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a
rest (the beat slot still advances), or :class:`Degree` for octave/
chromatic detail. Resolved against key + scale at placement.
Durations default to a full beat (each note holds its slot).
"""
converted: typing.List[PitchSpec] = []
for element in degrees:
if isinstance(element, int):
if element > _MAX_PLAUSIBLE_DEGREE:
raise ValueError(
f"Degree {element} is implausibly large — scale degrees are 1-based "
f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()."
)
converted.append(Degree(element))
elif isinstance(element, Degree) or element is None:
converted.append(element)
else:
raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}")
return cls._from_sequence(converted, beats, velocities, durations, probabilities, length)
@classmethod
[docs]
def notes (
cls,
notes: typing.List[typing.Union[int, None]],
beats: typing.Optional[typing.List[float]] = None,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 1.0,
probabilities: typing.Any = 1.0,
length: typing.Optional[float] = None,
) -> "Motif":
"""A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest."""
for element in notes:
if not (isinstance(element, int) or element is None):
raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}")
return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
@classmethod
[docs]
def hits (
cls,
pitch: typing.Union[int, str],
beats: typing.List[float],
length: typing.Optional[float] = None,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 0.1,
probabilities: typing.Any = 1.0,
) -> "Motif":
"""One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention."""
return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length)
@classmethod
[docs]
def steps (
cls,
steps: typing.List[int],
pitches: typing.Any,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 0.1,
probabilities: typing.Any = 1.0,
step_duration: float = 0.25,
length: typing.Optional[float] = None,
) -> "Motif":
"""
Grid placement — the ``sequence()`` convention: ``steps`` are 0-based
grid indices (sixteenths by default), ``pitches`` a scalar or
parallel list of MIDI ints or drum names.
"""
n = len(steps)
pitch_list = _expand("pitches", pitches, n)
onsets = [s * step_duration for s in steps]
if length is None and n:
length = float(math.ceil((max(steps) + 1) * step_duration))
return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length)
@classmethod
[docs]
def euclidean (
cls,
pulses: int,
steps: int,
pitch: typing.Union[int, str],
length: float = 4.0,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 0.1,
probabilities: typing.Any = 1.0,
) -> "Motif":
"""A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats."""
# The kernel returns one 0/1 flag per grid step; onsets are the 1s.
flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses)
step_duration = length / steps
onsets = [i * step_duration for i, flag in enumerate(flags) if flag]
return cls._from_sequence(
[pitch] * len(onsets),
onsets,
velocities, durations, probabilities, length,
)
@classmethod
[docs]
def preset (
cls,
name: str,
pitch: typing.Optional[typing.Union[int, str]] = None,
length: float = 4.0,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 0.1,
probabilities: typing.Any = 1.0,
) -> "Motif":
"""A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``.
Looks a curated timeline up in the world-rhythm table (clave family,
West-African bell patterns, tresillo/cinquillo, samba) and lays its
onsets across *length* beats. Onset positions are exact pulse indices
from Toussaint's "The Geometry of Musical Rhythm"; each preset declares
its own grid (16 for the clave/4-4 timelines, 12 for the bell
patterns) and a default drum voice.
Parameters:
name: A preset name (``KeyError``-style ValueError lists them all).
pitch: The voice — a drum name or MIDI int; defaults to the
preset's General-MIDI voice (``"claves"``, ``"cowbell"``,
``"side_stick"``, ``"low_conga"``), so it sounds against the
standard GM drum map without a ``pitch=``.
length: Total beats the cycle spans (4 = one common-time bar).
velocities / durations / probabilities: The parallel-list params.
Returns:
A drum/pitched :class:`Motif` of the timeline's onsets.
Raises:
ValueError: If *name* is not a known preset.
Example:
```python
clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves"
bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse
```
"""
if name not in _WORLD_RHYTHMS:
known = ", ".join(sorted(_WORLD_RHYTHMS))
raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.")
steps, grid, voice = _WORLD_RHYTHMS[name]
return cls.steps(
steps = list(steps),
pitches = pitch if pitch is not None else voice,
velocities = velocities,
durations = durations,
probabilities = probabilities,
step_duration = length / grid,
length = length,
)
# ── control-gesture constructors (mirror the pattern_midi verbs) ────
@classmethod
def _control_writes (
cls,
signal: ControlSignal,
values: typing.List[float],
beats: typing.List[float],
length: typing.Optional[float],
probabilities: typing.Any = 1.0,
) -> "Motif":
"""Shared core for discrete control writes."""
if len(values) != len(beats):
raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match")
probability_list = _expand("probabilities", probabilities, len(values))
controls = tuple(
ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i]))
for i in range(len(values))
)
return cls(
events = (),
length = _computed_length((), controls) if length is None else float(length),
controls = controls,
)
@classmethod
def _control_ramp (
cls,
signal: ControlSignal,
start: float,
end: float,
beat_start: float,
beat_end: typing.Optional[float],
shape: typing.Union[str, "subsequence.easing.EasingFn"],
length: typing.Optional[float],
probability: float = 1.0,
) -> "Motif":
"""Shared core for shaped control ramps."""
if beat_end is None:
if length is None:
raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)")
beat_end = float(length)
if beat_end <= beat_start:
raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})")
controls = (
ControlEvent(
beat = float(beat_start),
signal = signal,
start = float(start),
end = float(end),
span = float(beat_end) - float(beat_start),
shape = shape,
probability = probability,
),
)
return cls(
events = (),
length = float(math.ceil(beat_end)) if length is None else float(length),
controls = controls,
)
@classmethod
[docs]
def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
"""Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement."""
return cls._control_writes(CC(control), list(values), list(beats), length, probabilities)
@classmethod
[docs]
def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
"""A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``."""
return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)
@classmethod
[docs]
def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
"""Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``."""
return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities)
@classmethod
[docs]
def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
"""Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``."""
return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)
@classmethod
[docs]
def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
"""Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``."""
return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
@classmethod
[docs]
def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
"""An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``."""
return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
@classmethod
[docs]
def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
"""Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``."""
return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
@classmethod
[docs]
def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
"""An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``."""
return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
@classmethod
[docs]
def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
"""Discrete OSC float sends at beat positions — mirrors ``p.osc()``."""
return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
@classmethod
[docs]
def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
"""An OSC float swept over a beat range — mirrors ``p.osc_ramp()``."""
return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability)
# ── the algebra ─────────────────────────────────────────────────────
[docs]
def then (self, other: "Motif") -> "Motif":
"""Closed sequential concat: glue *other* after this motif into ONE longer motif."""
if not isinstance(other, Motif):
raise TypeError(f"then() takes a Motif — got {type(other).__name__}")
return Motif(
events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events),
length = self.length + other.length,
controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls),
fit = self.fit,
)
@classmethod
[docs]
def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif":
"""Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``)."""
result = cls.empty()
for m in motifs:
result = result.then(m)
return result
@classmethod
[docs]
def generate (
cls,
rhythm: typing.Any,
length: typing.Optional[float] = None,
scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None,
contour: typing.Optional[str] = None,
end_on: typing.Optional[typing.Union[int, Degree]] = None,
cadence: typing.Optional[str] = None,
pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None,
max_pitches: typing.Optional[int] = None,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 0.25,
seed: typing.Optional[int] = None,
rng: typing.Optional[random.Random] = None,
state: typing.Optional[typing.Any] = None,
nir_strength: float = 0.5,
pitch_diversity: float = 0.6,
tessitura_strength: float = 0.6,
) -> "Motif":
"""Generate a melodic motif — rhythm first, pitches walked, a value out.
The melody engine emitting a value: you give the **rhythm** (an onset
list in beats, or another motif whose rhythm to borrow — cross-pattern
rhythm reuse is shared values); the engine walks pitches over it
through the soft scoring factors (NIR expectation, contour envelope,
tessitura regression, diversity), honouring any pins.
The result emits **scale degrees** (resolved at placement against the
composition key/scale), so a generated hook transposes, varies, and
develops like a hand-written one. ``scale=`` constrains *candidate
choice only*: a name or interval list masks which pitches the walk
may use, spelled relative to its best-fit reference (major or minor)
— bind it in a composition whose scale matches that family and
resolution is exact. An explicit MIDI pitch pool (a list of note
numbers) switches to absolute output (the sieve/atonal path).
Parameters:
rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif
(its onsets are borrowed).
length: Motif length in beats; defaults to the onsets rounded
up to a whole 4-beat bar.
scale: A scale name, an interval list, or an explicit MIDI
pitch pool. ``None`` = the plain seven degrees.
contour: Envelope shaping the line's height over its span —
``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``.
end_on: Degree the line must end on — sugar for ``pins={-1: ...}``.
cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/
``"fakeout"``) — the line closes on that cadence's melodic
degree (1 for the full closes and the fakeout, 5 for the
open half). Sugar for ``end_on=``; conflicts with it.
pins: ``{position: degree}`` — 1-based note positions (``-1`` =
the last, the Python idiom); the engine fills between.
max_pitches: Cap on distinct pitches (a tight pool is a hook);
keeps the most central candidates.
velocities / durations: Scalar or per-note list (the parallel-
list convention).
seed: Seed for the walk (required or warned — module-level
nondeterminism breaks live reload).
rng: Explicit stream (overrides ``seed``).
state: A ``MelodicState`` whose settings and history seed the
walk. It is **copied** — building a value never mutates a
module-level live object.
nir_strength / pitch_diversity / tessitura_strength: The walk's
dials when no ``state`` is given.
Example:
```python
hook = subsequence.Motif.generate(
rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic",
contour="arch", end_on=1, seed=7,
)
```
"""
import subsequence.melodic_state
onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm]
if cadence is not None:
if end_on is not None:
raise ValueError("cadence= already names the close degree — it conflicts with end_on=")
end_on = subsequence.cadences.cadence_formula(cadence).close_degree
if not onsets:
raise ValueError("generate() needs at least one onset — the rhythm comes first")
if sorted(onsets) != onsets:
raise ValueError("rhythm onsets must ascend")
if length is None:
length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0)
if onsets[-1] >= length:
raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}")
if rng is None:
if seed is None:
warnings.warn(
"generate() without seed= is nondeterministic — pass seed= so the "
"value survives live reload",
stacklevel = 2,
)
rng = random.Random()
else:
rng = random.Random(seed)
# --- The candidate pool ------------------------------------------------
absolute_pool: typing.Optional[typing.List[int]] = None
intervals: typing.List[int]
if scale is None:
intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian"))
elif isinstance(scale, str):
intervals = list(subsequence.intervals.scale_pitch_classes(0, scale))
else:
values = [int(v) for v in scale]
if values and (min(values) != 0 or max(values) > 11):
absolute_pool = sorted(values) # an explicit MIDI pool: absolute output
intervals = []
else:
intervals = sorted(set(values))
# Best-fit reference scale for degree spelling: whichever of major/
# minor contains more of the pool (ties to major). Bound under a
# matching composition scale, resolution is exact.
if absolute_pool is None:
ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian"))
aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor"))
reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian"
reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name))
# --- The walking state (copied, never mutated in place) ----------------
if state is not None:
walker = state.clone()
walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a
# note, so the walker never rests (and never falls
# back to a stuck repeat) — rests come from the rhythm
else:
walker = subsequence.melodic_state.MelodicState(
nir_strength = nir_strength,
pitch_diversity = pitch_diversity,
tessitura_strength = tessitura_strength,
chord_weight = 0.0, # values have no chord context; fit applies at placement
)
if absolute_pool is not None:
walker.set_pool(absolute_pool)
else:
# Offsets over ~1.5 octaves anchored at 60 — register is decided
# at placement (root=), so the anchor is arbitrary and erased.
walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19])
if max_pitches is not None:
if max_pitches < 1:
raise ValueError("max_pitches must be at least 1")
pool = sorted(walker._pitch_pool)
centre = pool[len(pool) // 2]
walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches]))
# --- Pins ---------------------------------------------------------------
resolved_pins: typing.Dict[int, int] = {}
combined = dict(pins or {})
if end_on is not None:
if -1 in combined or len(onsets) in combined:
raise ValueError("end_on conflicts with a pin on the last note — they name the same position")
combined[-1] = end_on
for pin_position, pin_spec in combined.items():
if not isinstance(pin_position, int) or isinstance(pin_position, bool):
raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}")
index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position
if not 0 <= index < len(onsets):
raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm")
if absolute_pool is not None:
resolved_pins[index] = int(pin_spec if isinstance(pin_spec, int) else pin_spec.step)
else:
degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec))
step_index = (degree.step - 1) % len(reference)
carry = (degree.step - 1) // len(reference)
resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma
# --- The walk -----------------------------------------------------------
envelopes: typing.Dict[str, typing.Callable[[float], float]] = {
"arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos),
"valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos),
"ascending": lambda pos: 0.1 + 0.85 * pos,
"descending": lambda pos: 0.95 - 0.85 * pos,
}
if contour is not None and contour not in envelopes:
known = ", ".join(sorted(envelopes))
raise ValueError(f"unknown contour {contour!r} — expected one of: {known}")
chosen_pitches: typing.List[int] = []
for index, onset in enumerate(onsets):
if index in resolved_pins:
pitch = resolved_pins[index]
walker.record(pitch) # pins enter the NIR context like chosen notes
else:
span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0
target = envelopes[contour](span_position) if contour is not None else None
picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target)
pitch = picked if picked is not None else walker._pitch_pool[0]
chosen_pitches.append(pitch)
# --- Emission ------------------------------------------------------------
velocity_values = _expand("velocities", velocities, len(onsets))
duration_values = _expand("durations", durations, len(onsets))
events = []
for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)):
spec: PitchSpec
if absolute_pool is not None:
spec = pitch
else:
offset = pitch - 60
octave, pc = divmod(offset, 12)
if pc in reference:
spec = Degree(reference.index(pc) + 1, octave = octave)
elif (pc + 1) % 12 in reference and pc + 1 <= 11:
spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1)
else:
spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1)
events.append(MotifEvent(
beat = onset,
pitch = spec,
velocity = velocity_values[index],
duration = float(duration_values[index]),
))
return cls(events = tuple(events), length = float(length), fit = 0.7)
[docs]
def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif":
"""
Parallel merge (the spelled form of ``&``): event union, length = max.
No implicit tiling — a short gesture stacked under a long figure
plays once. Phrase operands flatten first.
"""
if isinstance(other, Phrase):
merged = other.flatten()
elif isinstance(other, Motif):
merged = other
else:
raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}")
return Motif(
events = self.events + merged.events,
length = max(self.length, merged.length),
controls = self.controls + merged.controls,
fit = self.fit,
)
[docs]
def slice (self, start: float, end: float) -> "Motif":
"""
A window onto the motif, on its own authority: events starting outside
are dropped; durations and ramp spans truncate at the cut (a truncated
ramp ends at its interpolated cut value). Beats shift so the window
starts at 0.
"""
if end <= start:
raise ValueError(f"slice end ({end}) must be after start ({start})")
events = tuple(
dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat))
for e in self.events
if start <= e.beat < end
)
controls = []
for c in self.controls:
if not (start <= c.beat < end):
continue
if c.end is not None and c.beat + c.span > end:
kept = end - c.beat
controls.append(dataclasses.replace(
c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span),
))
else:
controls.append(dataclasses.replace(c, beat=c.beat - start))
return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit)
def __add__ (self, other: typing.Any) -> "Phrase":
"""``a + b`` — sequential: a two-segment Phrase (segmentation preserved)."""
if isinstance(other, Motif):
return Phrase((self, other))
return NotImplemented
def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]:
"""``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty."""
if not isinstance(count, int):
return NotImplemented
if count < 0:
raise ValueError(f"Repetition count must be non-negative — got {count}")
if count == 0:
return Motif.empty()
if count == 1:
return self
return Phrase((self,) * count)
__rmul__ = __mul__
def __and__ (self, other: typing.Any) -> "Motif":
"""``a & b`` — parallel merge; the spelled form is :meth:`stack`."""
if isinstance(other, (Motif, Phrase)):
return self.stack(other)
return NotImplemented
# ── transforms (pure; control gestures ride time, ignore pitch) ─────
[docs]
def reverse (self) -> "Motif":
"""Mirror the figure in time; ramps swap direction (a rising sweep falls)."""
events = tuple(
dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration))
for e in self.events
)
controls = tuple(
dataclasses.replace(
c,
beat = max(0.0, self.length - c.beat - c.span),
start = c.start if c.end is None else c.end,
end = c.end if c.end is None else c.start,
)
for c in self.controls
)
return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
[docs]
def rotate (self, beats: float) -> "Motif":
"""Shift every onset by *beats*, wrapping modulo the length (spans ride along)."""
if self.length == 0:
return self
events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events)
controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls)
return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
[docs]
def stretch (self, factor: float) -> "Motif":
"""Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length."""
if factor <= 0:
raise ValueError(f"Stretch factor must be positive — got {factor}")
events = tuple(
dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor)
for e in self.events
)
controls = tuple(
dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor)
for c in self.controls
)
return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit)
[docs]
def quantize (self, grid: float) -> "Motif":
"""Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched)."""
if grid <= 0:
raise ValueError(f"Quantize grid must be positive — got {grid}")
events = tuple(
dataclasses.replace(e, beat=round(e.beat / grid) * grid)
for e in self.events
)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
[docs]
def accent (self, beat: float, amount: int = 20) -> "Motif":
"""Add *amount* velocity to every note at the given beat position (0-based beats)."""
def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]:
if isinstance(velocity, tuple):
return (min(127, velocity[0] + amount), min(127, velocity[1] + amount))
return min(127, velocity + amount)
events = tuple(
dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e
for e in self.events
)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
[docs]
def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif":
"""Replace every note's velocity (an int, or a ``(low, high)`` random range)."""
events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec:
"""One varied pitch: a small melodic nudge that always changes the note.
Degrees move by scale steps, MIDI ints by semitones, chord tones by
index; an Approach's target is nudged. Drum names raise — a varied
drum is a different instrument, not a variation.
"""
if isinstance(pitch, Degree):
steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1]
return dataclasses.replace(pitch, step = rng.choice(steps))
if isinstance(pitch, ChordTone):
indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1]
return ChordTone(rng.choice(indices), octave = pitch.octave)
if isinstance(pitch, Approach):
nudged = self._nudged_pitch(pitch.target, rng)
if not isinstance(nudged, (int, Degree, ChordTone)):
raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content")
return Approach(nudged)
if isinstance(pitch, int):
return pitch + rng.choice((-2, -1, 1, 2))
raise TypeError(
f"vary() moves pitches — {type(pitch).__name__} content cannot vary "
"(a varied drum is a different instrument)"
)
[docs]
def vary (
self,
notes: int = 1,
position: str = "end",
seed: typing.Optional[int] = None,
rng: typing.Optional[random.Random] = None,
keep_contour: bool = False,
) -> "Motif":
"""Replace a few pitches, preserving the rhythm — the smallest variation.
Rhythm, velocities, durations, rests, and control gestures are
untouched; only the chosen notes' pitches move (by a small melodic
nudge: scale steps for degrees, semitones for MIDI ints).
Parameters:
notes: How many pitched notes to vary (clamped to what exists).
position: Which notes — ``"end"`` (the tail, the default),
``"start"``, or ``"anywhere"`` (drawn from the stream).
seed: Seed for the variation. A standalone vary without a seed
warns — module-level nondeterminism breaks live reload.
rng: An explicit random stream (overrides ``seed``; used by
recipe machinery).
keep_contour: When True, the variation preserves the line's
CSEG — every varied note keeps its rank relations with
every other note, so the melodic shape is identical (the
motif-identity guard). Where no nudge can preserve the
contour, that note stays unchanged — shape wins over
motion.
Example:
```python
answer = call.vary(notes=1, seed=4) # same figure, new tail note
```
"""
if notes < 0:
raise ValueError(f"notes must be at least 0, got {notes}")
if position not in ("end", "start", "anywhere"):
raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}')
if rng is None:
if seed is None:
warnings.warn(
"vary() without seed= is nondeterministic — pass seed= so the "
"value survives live reload",
stacklevel = 2,
)
rng = random.Random()
else:
rng = random.Random(seed)
pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
count = min(notes, len(pitched_indices))
if count == 0:
return self
if position == "end":
chosen = pitched_indices[-count:]
elif position == "start":
chosen = pitched_indices[:count]
else:
chosen = sorted(rng.sample(pitched_indices, count))
events = list(self.events)
for index in chosen:
if keep_contour:
replacement = self._contour_safe_nudge(events, index, pitched_indices, rng)
if replacement is not None:
events[index] = dataclasses.replace(events[index], pitch = replacement)
else:
events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng))
return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
@staticmethod
def _rank_value (pitch: PitchSpec) -> float:
"""A comparable height for contour ranking (uniform content only)."""
if isinstance(pitch, Degree):
return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma
if isinstance(pitch, ChordTone):
return pitch.octave * 4 + pitch.index
if isinstance(pitch, int):
return float(pitch)
raise TypeError(f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height")
def _contour_safe_nudge (
self,
events: typing.List[MotifEvent],
index: int,
pitched_indices: typing.List[int],
rng: random.Random,
) -> typing.Optional[PitchSpec]:
"""A nudge for events[index] that preserves its CSEG rank relations.
Candidates are the usual small nudges, filtered to those keeping the
note's above/below/equal relation to every other pitched note. One
rng draw happens regardless (stream stability); ``None`` means no
candidate preserves the shape — leave the note alone.
"""
pitch = events[index].pitch
if isinstance(pitch, Degree):
candidates: typing.List[PitchSpec] = [
dataclasses.replace(pitch, step = pitch.step + delta)
for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1
]
elif isinstance(pitch, int):
candidates = [pitch + delta for delta in (-2, -1, 1, 2)]
else:
raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content")
original = self._rank_value(pitch)
others = [
(self._rank_value(events[other].pitch), other)
for other in pitched_indices if other != index
]
def preserves (candidate: PitchSpec) -> bool:
height = self._rank_value(candidate)
for other_height, _ in others:
before = (original > other_height) - (original < other_height)
after = (height > other_height) - (height < other_height)
if before != after:
return False
return True
surviving = [candidate for candidate in candidates if preserves(candidate)]
# One draw either way, so adding keep_contour never shifts the stream
# consumed by the notes around this one.
draw = rng.random()
if not surviving:
return None
return surviving[int(draw * len(surviving)) % len(surviving)]
[docs]
def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif":
"""Call → response: re-aim the tail to a stable degree.
The classic consequent move — the figure repeats but its last pitched
note lands home (degree 1 by default; pass ``to=5`` for a half-close,
or a full ``Degree`` for register control). Everything else —
rhythm, the other pitches, velocities, controls — is untouched.
Degree content only: absolute MIDI has no degrees to re-aim (build
the call with ``motif([...])``), and drums raise.
"""
target = to if isinstance(to, Degree) else Degree(int(to))
pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
if not pitched_indices:
return self
last = self.events[pitched_indices[-1]]
if not isinstance(last.pitch, Degree):
raise TypeError(
f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} "
"content (build the call with motif([...]) for degree content)"
)
if isinstance(to, int):
# Keep the call's register: only the step is re-aimed.
target = dataclasses.replace(last.pitch, step = int(to), chroma = 0)
events = list(self.events)
events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target)
return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
[docs]
def pitched (self, spec: PitchSpec) -> "Motif":
"""
Replace every pitch with one spec — a kick rhythm becomes a bass line.
``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord
tones; any other string is a drum name; ints are MIDI; Degree /
ChordTone / Approach pass through.
"""
if isinstance(spec, str) and spec in _CHORD_TONE_NAMES:
spec = ChordTone(spec)
events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
[docs]
def rhythm (self) -> "Motif":
"""
Strip pitches (and control gestures): a reusable rhythmic skeleton.
Timing, velocities, durations, and probabilities survive; re-pitch
with :meth:`pitched` before placement (placing a skeleton raises).
"""
events = tuple(dataclasses.replace(e, pitch=None) for e in self.events)
return Motif(events=events, length=self.length)
[docs]
def onsets (self) -> typing.List[float]:
"""The note onset beats, in order — ready for rhythm-first generation."""
return [e.beat for e in self.events]
[docs]
def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif":
"""
Transpose pitched content; the keyword names the unit.
``steps=`` moves scale degrees diatonically (the sequencing move) and
raises on absolute-MIDI or drum content; ``semitones=`` is the
literal chromatic form for MIDI ints and degrees. Drum motifs raise
on both — a transposed drum name is a different instrument, not a
transposition.
"""
if (steps is None) == (semitones is None):
raise ValueError("transpose() takes exactly one of steps= or semitones=")
def move (pitch: PitchSpec) -> PitchSpec:
if pitch is None:
return None
if isinstance(pitch, Approach):
moved = move(pitch.target)
if not isinstance(moved, (int, Degree, ChordTone)):
raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content")
return Approach(moved)
if steps is not None:
if isinstance(pitch, Degree):
return dataclasses.replace(pitch, step=pitch.step + steps)
raise TypeError(
f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content "
f"has no degrees (use semitones= for MIDI ints)"
)
assert semitones is not None # exactly one of steps/semitones is set (validated above)
if isinstance(pitch, int):
return pitch + semitones
if isinstance(pitch, Degree):
return dataclasses.replace(pitch, chroma=pitch.chroma + semitones)
raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content")
events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
[docs]
def invert (self, pivot: typing.Optional[int] = None) -> "Motif":
"""
Mirror pitches around a pivot: MIDI content around a MIDI pivot,
degree content around a degree pivot (default: the first note's pitch).
Drum motifs raise.
"""
pitched_events = [e for e in self.events if e.pitch is not None]
if not pitched_events:
return self
first = pitched_events[0].pitch
if pivot is None:
if isinstance(first, int):
pivot = first
elif isinstance(first, Degree):
pivot = first.step
else:
raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content")
def mirror (pitch: PitchSpec) -> PitchSpec:
if pitch is None:
return None
if isinstance(pitch, int):
return 2 * pivot - pitch
if isinstance(pitch, Degree):
mirrored = 2 * pivot - pitch.step
if mirrored < 1:
raise ValueError(
f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — "
f"raise the pivot or use Degree octaves"
)
# Reflection around the pivot (read at octave 0) is an isometry, so a
# note's register flips too: a degree an octave above the pivot lands an
# octave below it. Negating octave needs no scale length and leaves
# octave-0 content unchanged.
return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma)
raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content")
events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events)
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
# ── description ─────────────────────────────────────────────────────
[docs]
def describe (self) -> str:
"""A readable one-line summary: length, notes (pitch@beat), and control gestures."""
notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events)
parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"]
if self.controls:
gestures = ", ".join(_control_label(c) for c in self.controls)
parts.append(f"controls [{gestures}]")
return " ".join(parts)
def __str__ (self) -> str:
"""Printable form (same as :meth:`describe`)."""
return self.describe()
def _pitch_label (pitch: PitchSpec) -> str:
"""Compact label for a pitch spec in describe() output."""
if pitch is None:
return "·"
if isinstance(pitch, Degree):
marks = ("+" * pitch.octave if pitch.octave > 0 else "-" * -pitch.octave)
chroma = (f"#{pitch.chroma}" if pitch.chroma > 0 else f"b{-pitch.chroma}" if pitch.chroma < 0 else "")
return f"^{pitch.step}{marks}{chroma}"
if isinstance(pitch, ChordTone):
return f"tone{pitch.index}"
if isinstance(pitch, Approach):
return f">{_pitch_label(pitch.target)}"
return str(pitch)
def _control_label (c: ControlEvent) -> str:
"""Compact label for a control event in describe() output."""
if isinstance(c.signal, CC):
name = f"CC{c.signal.control}" if isinstance(c.signal.control, int) else f"CC:{c.signal.control}"
elif isinstance(c.signal, PitchBend):
name = "bend"
elif isinstance(c.signal, NRPN):
name = f"NRPN{c.signal.parameter}"
elif isinstance(c.signal, RPN):
name = f"RPN{c.signal.parameter}"
else:
name = c.signal.address
if c.end is None:
return f"{name}={c.start:g}@{c.beat:g}"
return f"{name} {c.start:g}→{c.end:g} over {c.beat:g}–{c.beat + c.span:g}"
# ── Phrase ──────────────────────────────────────────────────────────────────
@dataclasses.dataclass(frozen=True)
class _PhraseRecipe:
"""Provenance of a generated phrase — what reroll() regenerates from.
Generated values carry their recipe (the generator spec and seed) so
per-region regeneration is possible; a hand-written or transformed
phrase has none, and rerolling it raises loudly.
Attributes:
source: The motif the phrase was developed from.
plan: The unit-label tuple, or the recipe name.
bars: The phrase length in bars.
seed: The development seed (None = the unseeded warning path).
beats_per_bar: The bar size the plan was spread against.
cadence: The cadence name the phrase closes on (``sentence()``/
``period()`` record it; ``develop()`` leaves it ``None``).
"""
source: Motif
plan: typing.Union[typing.Tuple[str, ...], str]
bars: int
seed: typing.Optional[int]
beats_per_bar: float = 4.0
cadence: typing.Optional[str] = None
def _contrast_unit (source: Motif, rng: random.Random) -> Motif:
"""A generated contrast unit: the source's rhythm, freshly re-pitched.
Roughly half the pitched notes move (small melodic nudges), so the unit
is recognisably related but melodically new. The richer rhythm-first
generator arrives with the melody engine stage.
"""
pitched = sum(1 for event in source.events if event.pitch is not None)
return source.vary(notes = max(1, pitched // 2), position = "anywhere", rng = rng)
def _call_response_units (call: Motif, seed: typing.Optional[int]) -> typing.List[Motif]:
"""The call_response recipe: call, answer, call, varied answer."""
response = call.answer()
varied = response.vary(notes = 1, position = "end", rng = random.Random(f"{seed}:cr:vary"))
return [call, response, call, varied]
def _tile_source (motif: Motif, bars: int, unit_count: int, beats_per_bar: float) -> Motif:
"""Validate the bars/unit arithmetic and tile the motif up to one unit.
A 1-bar hook in 2-bar units repeats — the unit is the tile, and
answer()/vary() act on the whole tile (its tail is the unit's tail).
"""
if bars % unit_count != 0:
raise ValueError(
f"bars={bars} does not divide evenly across {unit_count} plan units — "
"each unit must fill a whole number of bars"
)
unit_beats = bars * beats_per_bar / unit_count
if motif.length <= 0:
raise ValueError("cannot develop an empty motif")
tiling = unit_beats / motif.length
if abs(tiling - round(tiling)) > 1e-9 or round(tiling) < 1:
raise ValueError(
f"the motif is {motif.length:g} beats but each of the {unit_count} plan units "
f"spans {unit_beats:g} beats ({bars} bars / {unit_count} units) — units must be "
"a whole tiling of the motif (adjust bars, the plan, or the motif's length)"
)
return motif if round(tiling) == 1 else Motif.join([motif] * int(round(tiling)))
# The curated recipe table — names reserved for plans whose semantics exceed
# a label skeleton. Each entry is (unit_count, builder); the builder takes
# (source_motif, seed) and returns exactly unit_count units.
_PHRASE_RECIPES: typing.Dict[str, typing.Tuple[int, typing.Callable[[Motif, typing.Optional[int]], typing.List[Motif]]]] = {
"call_response": (4, _call_response_units),
}
@dataclasses.dataclass(frozen=True)
[docs]
class Phrase:
"""
A sequence of Motifs with segmentation preserved.
Segmentation is the unit of editing — it is what development and
per-region regeneration operate on. ``flatten()`` erases it into one
long Motif. Length is the sum of segment lengths.
A phrase made by :meth:`develop` carries its recipe, so
:meth:`reroll` can regenerate a region; transforms and hand edits
return recipe-less phrases (their notes no longer come from the
recipe, so there is nothing honest to regenerate from).
"""
segments: typing.Tuple[Motif, ...]
recipe: typing.Optional[_PhraseRecipe]
def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None:
"""Coerce any iterable of Motifs."""
segments = tuple(segments)
for segment in segments:
if not isinstance(segment, Motif):
raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}")
object.__setattr__(self, "segments", segments)
object.__setattr__(self, "recipe", recipe)
@property
[docs]
def length (self) -> float:
"""Total length in beats (sum of segment lengths)."""
return sum(segment.length for segment in self.segments)
@classmethod
[docs]
def develop (
cls,
motif: Motif,
bars: int = 8,
plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None,
seed: typing.Optional[int] = None,
beats_per_bar: float = 4.0,
) -> "Phrase":
"""Grow a motif into a phrase by a plan — the phrase generator.
``plan`` follows the standard form. The literal form is a **list of
unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently
``["a"] * 3 + ["b"]``: the first label is the given motif, each new
label is a generated contrast unit (the source's rhythm, freshly
re-pitched), a repeated label is a restatement, and *bars* spreads
evenly across the units. A bare string is a **recipe name** from
the curated table — ``plan="call_response"`` (call, answer, call,
varied answer) — reserved for plans whose semantics exceed a label
skeleton. A letter string is not a plan: a sequence of labels is a
sequence, so it is a list.
The result carries its recipe, so :meth:`reroll` can regenerate a
region later.
Parameters:
motif: The source unit (its length must be ``bars / len(units)``
bars — the plan's units tile the phrase exactly).
bars: Phrase length in bars (must divide evenly by the unit
count).
plan: A list of unit labels, or a recipe name.
seed: Seed for the generated units. Without one, develop()
warns — module-level nondeterminism breaks live reload.
beats_per_bar: Bar size in beats (the value is context-free;
4 is the common-time default).
Example:
```python
call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11)
```
"""
if plan is None:
raise ValueError(
'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) '
'or a recipe name (plan="call_response")'
)
if seed is None:
warnings.warn(
"develop() without seed= is nondeterministic — pass seed= so the "
"value survives live reload",
stacklevel = 2,
)
# How many units the plan asks for — known before any unit is built,
# so a short motif can tile up to the unit size first.
if isinstance(plan, str):
if plan not in _PHRASE_RECIPES:
known = ", ".join(sorted(_PHRASE_RECIPES))
hint = ""
if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan):
spelled = ", ".join(repr(c) for c in plan)
hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]."
raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}")
unit_count = _PHRASE_RECIPES[plan][0]
else:
labels = list(plan)
if not labels or not all(isinstance(label, str) and label for label in labels):
raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']")
unit_count = len(labels)
source = _tile_source(motif, bars, unit_count, beats_per_bar)
if isinstance(plan, str):
units = _PHRASE_RECIPES[plan][1](source, seed)
stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan
else:
generated: typing.Dict[str, Motif] = {labels[0]: source}
for label in labels:
if label not in generated:
generated[label] = _contrast_unit(source, random.Random(f"{seed}:unit:{label}"))
units = [generated[label] for label in labels]
stored_plan = tuple(labels)
return cls(units, recipe = _PhraseRecipe(
source = motif,
plan = stored_plan,
bars = bars,
seed = seed,
beats_per_bar = beats_per_bar,
))
[docs]
def reroll (
self,
bar: typing.Optional[int] = None,
bars: typing.Optional[typing.Sequence[int]] = None,
seed: typing.Optional[int] = None,
) -> "Phrase":
"""Regenerate only the named bars — rhythm and boundary pitches kept.
Within each named bar, the first and last pitched notes stay (the
boundary pins) and the interior pitches re-roll from the recipe's
stream; onsets, durations, velocities, rests, drums, and control
gestures are untouched. Segmentation and the recipe survive, so
rerolls compose.
Only a phrase that carries a recipe can reroll — a hand-written or
transformed phrase raises loudly (its notes no longer come from a
generator, so regenerating them would invent music).
Parameters:
bar: A single 1-based bar to reroll.
bars: A list of 1-based bars (the paired plural spelling).
seed: Seed for the new pitches (salted per bar). Without one,
reroll() warns.
Example:
```python
lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept
```
"""
if self.recipe is None:
raise ValueError(
"this phrase carries no recipe (it was written by hand, or transformed "
"since generation) — reroll() regenerates from a recipe; edit segments "
"with replace(), or rebuild with Phrase.develop()"
)
if (bar is None) == (bars is None):
raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)")
region = [bar] if bar is not None else list(bars or [])
beats_per_bar = self.recipe.beats_per_bar
total_bars = int(round(self.length / beats_per_bar))
for number in region:
if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars:
raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})")
if seed is None:
warnings.warn(
"reroll() without seed= is nondeterministic — pass seed= so the "
"value survives live reload",
stacklevel = 2,
)
windows = [
((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{seed}:reroll:{number}"))
for number in sorted(set(region))
]
new_segments: typing.List[Motif] = []
offset = 0.0
for segment in self.segments:
events = list(segment.events)
for window_start, window_end, rng in windows:
inside = [
index for index, event in enumerate(events)
if window_start <= offset + event.beat < window_end
and event.pitch is not None and not isinstance(event.pitch, str)
]
# Boundary pins: the first and last pitched notes of the bar
# stay; only the interior re-rolls.
for index in inside[1:-1]:
events[index] = dataclasses.replace(
events[index],
pitch = segment._nudged_pitch(events[index].pitch, rng),
)
new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls))
offset += segment.length
return Phrase(new_segments, recipe = self.recipe)
[docs]
def flatten (self) -> Motif:
"""Erase segmentation: one long Motif (the monoid homomorphism onto ``then``)."""
return Motif.join(self.segments)
# ── algebra ─────────────────────────────────────────────────────────
def __add__ (self, other: typing.Any) -> "Phrase":
"""Append a Motif segment, or concatenate another Phrase's segments."""
if isinstance(other, Motif):
return Phrase(self.segments + (other,))
if isinstance(other, Phrase):
return Phrase(self.segments + other.segments)
return NotImplemented
def __radd__ (self, other: typing.Any) -> "Phrase":
"""A Motif on the left prepends as a segment."""
if isinstance(other, Motif):
return Phrase((other,) + self.segments)
return NotImplemented
def __mul__ (self, count: int) -> "Phrase":
"""Tile the segments *count* times."""
if not isinstance(count, int):
return NotImplemented
if count < 0:
raise ValueError(f"Repetition count must be non-negative — got {count}")
return Phrase(self.segments * count)
__rmul__ = __mul__
def __and__ (self, other: typing.Any) -> Motif:
"""Parallel merge is vertical: Phrase operands flatten to Motif first."""
if isinstance(other, (Motif, Phrase)):
return self.flatten().stack(other)
return NotImplemented
[docs]
def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif:
"""The spelled form of ``&`` — flattens, then merges."""
return self.flatten().stack(other)
[docs]
def slice (self, start: float, end: float) -> "Phrase":
"""A window; re-segments at the cut points (partial segments are sliced)."""
segments = []
offset = 0.0
for segment in self.segments:
seg_start, seg_end = offset, offset + segment.length
lo, hi = max(start, seg_start), min(end, seg_end)
if lo < hi:
segments.append(segment.slice(lo - seg_start, hi - seg_start))
offset = seg_end
return Phrase(segments)
[docs]
def replace (self, position: int, motif: Motif) -> "Phrase":
"""Replace the segment at a 1-based position (musicians count from one)."""
if not 1 <= position <= len(self.segments):
raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)")
segments = list(self.segments)
segments[position - 1] = motif
return Phrase(segments)
# ── transforms: lifted segment-wise, except time-reordering ─────────
[docs]
def reverse (self) -> "Phrase":
"""Reverse the whole timeline: segments reverse order AND each reverses internally."""
return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
[docs]
def rotate (self, beats: float) -> "Phrase":
"""Rotate the whole timeline modulo the total length, then re-segment at the original boundaries."""
flat = self.flatten().rotate(beats)
segments = []
offset = 0.0
# Re-segment by onset (events keep their full durations — a note may
# ring past its new segment, exactly as it does on the flat timeline).
for segment in self.segments:
lo, hi = offset, offset + segment.length
segments.append(Motif(
events = tuple(
dataclasses.replace(e, beat=e.beat - lo)
for e in flat.events if lo <= e.beat < hi
),
length = segment.length,
controls = tuple(
dataclasses.replace(c, beat=c.beat - lo)
for c in flat.controls if lo <= c.beat < hi
),
))
offset = hi
return Phrase(segments)
def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase":
"""Apply a Motif transform to every segment."""
return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments))
[docs]
def stretch (self, factor: float) -> "Phrase":
"""Scale time in every segment (lengths scale with them)."""
return self._lift("stretch", factor)
[docs]
def quantize (self, grid: float) -> "Phrase":
"""Snap note onsets segment-wise."""
return self._lift("quantize", grid)
[docs]
def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase":
"""Replace every note's velocity, segment-wise."""
return self._lift("with_velocity", velocity)
[docs]
def pitched (self, spec: PitchSpec) -> "Phrase":
"""Replace every pitch, segment-wise."""
return self._lift("pitched", spec)
[docs]
def rhythm (self) -> "Phrase":
"""Strip pitches segment-wise: a phrase-shaped skeleton."""
return self._lift("rhythm")
[docs]
def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase":
"""Transpose every segment (see :meth:`Motif.transpose`)."""
return self._lift("transpose", steps=steps, semitones=semitones)
[docs]
def invert (self, pivot: typing.Optional[int] = None) -> "Phrase":
"""Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`)."""
if pivot is None:
for segment in self.segments:
for event in segment.events:
if event.pitch is not None:
if isinstance(event.pitch, int):
pivot = event.pitch
elif isinstance(event.pitch, Degree):
pivot = event.pitch.step
break
if pivot is not None:
break
return self._lift("invert", pivot=pivot)
[docs]
def describe (self) -> str:
"""A readable summary: total length and each segment on its own line."""
header = f"Phrase {self.length:g} beats, {len(self.segments)} segments"
lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)]
return "\n".join([header] + lines)
def __str__ (self) -> str:
"""Printable form (same as :meth:`describe`)."""
return self.describe()
[docs]
def motif (
degrees: typing.List[typing.Union[int, Degree, None]],
beats: typing.Optional[typing.List[float]] = None,
velocities: typing.Any = _DEFAULT_VELOCITY,
durations: typing.Any = 1.0,
probabilities: typing.Any = 1.0,
length: typing.Optional[float] = None,
) -> Motif:
"""
The lowercase shortcut: a melody as 1-based scale degrees.
``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` —
relative pitch is the primary form. For absolute MIDI note numbers use
``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so
a pasted MIDI list fails loud instead of squealing octaves up.
"""
return Motif.degrees(
degrees,
beats = beats,
velocities = velocities,
durations = durations,
probabilities = probabilities,
length = length,
)
[docs]
def sentence (
motif: Motif,
bars: int = 8,
cadence: str = "strong",
seed: typing.Optional[int] = None,
beats_per_bar: float = 4.0,
) -> Phrase:
"""The classical sentence, as a thin combinator — idea, idea, drive, close.
Four units: the basic idea stated twice (the presentation), a generated
contrast unit (the continuation — the source's rhythm, freshly
re-pitched), and a second contrast unit whose tail lands on the
cadence's close degree (the cadential close). An 8-bar sentence from a
2-bar idea is the textbook proportion; a shorter idea tiles up to the
unit size first.
The melodic side of a cadence only — pair it with the harmonic side
(``prog.cadence()``, ``Progression.generate(cadence=)``, or
``request_cadence()``) and the two arrive together.
Parameters:
motif: The basic idea (degree content — the close re-aims a degree).
bars: Sentence length (must divide evenly across the 4 units).
cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5,
``"soft"``/``"fakeout"`` on 1 (theory aliases accepted).
seed: Seed for the generated continuation units (seed-or-warn).
beats_per_bar: Bar size in beats (context-free; 4 is the default).
Example:
```python
idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11)
```
"""
spec = subsequence.cadences.cadence_formula(cadence)
if seed is None:
warnings.warn(
"sentence() without seed= is nondeterministic — pass seed= so the "
"value survives live reload",
stacklevel = 2,
)
source = _tile_source(motif, bars, 4, beats_per_bar)
continuation = _contrast_unit(source, random.Random(f"{seed}:sentence:continuation"))
cadential = _contrast_unit(source, random.Random(f"{seed}:sentence:cadential")).answer(to = spec.close_degree)
return Phrase([source, source, continuation, cadential], recipe = _PhraseRecipe(
source = motif,
plan = "sentence",
bars = bars,
seed = seed,
beats_per_bar = beats_per_bar,
cadence = spec.name,
))
[docs]
def period (
antecedent: typing.Union[Motif, Phrase],
cadence: str = "strong",
beats_per_bar: float = 4.0,
) -> Phrase:
"""The classical period, as a thin combinator — question, then answer.
Two halves: the antecedent with its tail re-aimed to the open half-close
(degree 5 — the question), then the same material restated with its tail
on the cadence's close degree (the answer). The two halves differ
exactly at their closes — the open/closed contrast *is* the period.
Deterministic: no notes are generated, only the two tail notes re-aim
(so there is no seed). Vary the consequent yourself for a looser
restatement: ``period(a).reroll(bar=7, seed=4)``.
Parameters:
antecedent: The first half — a Motif, or a Phrase whose segmentation
is kept (only its last segment's tail re-aims).
cadence: The consequent's close — ``"strong"`` lands on 1 (theory
aliases accepted).
beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows.
Example:
```python
idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8)
lead = subsequence.period(idea) # 16 beats: half-close, then home
```
"""
spec = subsequence.cadences.cadence_formula(cadence)
open_degree = subsequence.cadences.cadence_formula("open").close_degree
units = list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent]
if not units or sum(unit.length for unit in units) <= 0:
raise ValueError("cannot build a period from an empty antecedent")
tail = units[-1]
antecedent_units = units[:-1] + [tail.answer(to = open_degree)]
consequent_units = units[:-1] + [tail.answer(to = spec.close_degree)]
source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent
total_beats = 2 * sum(unit.length for unit in units)
return Phrase(antecedent_units + consequent_units, recipe = _PhraseRecipe(
source = source,
plan = "period",
bars = int(round(total_beats / beats_per_bar)),
seed = None,
beats_per_bar = beats_per_bar,
cadence = spec.name,
))