"""General-purpose composition tools."""
from collections.abc import Iterable, Iterator
from fractions import Fraction
from itertools import cycle, islice
from .bar import Bar
from .note import Note
from .voice import Voice
def _split_note(
note: Note,
first_duration: Fraction,
) -> tuple[Note, Note]:
"""Split an event, adding internal ties only for sounding notes."""
second_duration = note.duration - first_duration
first = note.with_duration(first_duration)
second = note.with_duration(second_duration)
if note.is_rest():
return (
first.with_ties(tie_in=False, tie_out=False),
second.with_ties(tie_in=False, tie_out=False),
)
return (
first.with_ties(tie_in=note.tie_in, tie_out=True),
second.with_ties(tie_in=True, tie_out=note.tie_out),
)
[docs]
def note_repeat(
bar: Bar,
repeats: Iterable[int],
*,
frames: int | None = None,
) -> Iterator[Note]:
"""Yield notes indefinitely using a cycling repeat-count pattern.
The notes in ``bar`` and the values in ``repeats`` advance together and
cycle independently. For example, a bar containing ``a, b, c`` and repeat
counts ``1, 2`` produces ``a, b, b, c, a, a, b, c, c, a, ...``. Emitted
values are the original :class:`Note` objects, so their pitches, duration,
velocity, and tie metadata are retained.
Parameters
----------
bar : Bar
Non-empty bar whose notes are cycled indefinitely.
repeats : Iterable[int]
Finite, non-empty pattern of positive repeat counts. The iterable is
materialized once so that it can be validated and cycled.
frames : int | None, default=None
Number of note/count pairs to consume. The default yields
indefinitely; a finite value makes the stream stop after that many
pairs.
Yields
------
Note
Each note from ``bar``, repeated by its corresponding count.
Raises
------
ValueError
If the bar or repeat pattern is empty, or a repeat count is not
positive, or ``frames`` is negative.
TypeError
If a repeat count is not an integer or ``frames`` has the wrong type.
"""
if not bar:
raise ValueError("bar must contain at least one note")
repeat_pattern = tuple(repeats)
if not repeat_pattern:
raise ValueError("repeats must contain at least one count")
if not all(
isinstance(count, int) and not isinstance(count, bool)
for count in repeat_pattern
):
raise TypeError("repeat counts must be integers")
if not all(count > 0 for count in repeat_pattern):
raise ValueError("repeat counts must be positive")
if (
frames is not None
and (isinstance(frames, bool) or not isinstance(frames, int))
):
raise TypeError("frames must be an integer or None")
if frames is not None and frames < 0:
raise ValueError("frames must not be negative")
events = zip(cycle(bar.notes), cycle(repeat_pattern))
if frames is not None:
events = islice(events, frames)
for note, count in events:
for _ in range(count):
yield note
[docs]
def gate_notes(
bar: Bar,
pattern: Iterable[bool | int] | str,
*,
extend_previous: bool = False,
frames: int | None = None,
) -> Iterator[Note]:
"""Yield a note stream controlled by a cycling gate pattern.
The notes in ``bar`` and switches in ``pattern`` advance together and
cycle independently. A true switch opens the gate and yields the original
event unchanged; a false switch closes it and normally yields an untied
rest with the same duration and velocity. With ``extend_previous=True``,
closed frames following an active event are instead absorbed into that
event's duration. Chords are treated as single events. Existing rests
remain rests.
Parameters
----------
bar : Bar
Non-empty bar whose events are cycled indefinitely.
pattern : Iterable[bool | int] | str
Finite, non-empty pattern containing Boolean values or the equivalent
integers ``0`` and ``1``. A string may use ``"x"`` for an open gate
and ``"."`` for a closed gate. The pattern is materialized once so
generator patterns can be validated and cycled.
extend_previous : bool, default=False
Merge the durations of muted events into the preceding active event.
Muted events before the first active event remain rests.
frames : int | None, default=None
Number of source frames to consume. The default yields indefinitely;
a finite value stops after that many frames and flushes a final
extended event.
Yields
------
Note
An active event, an extended active event, or a rest that occurs
before any active event.
Raises
------
ValueError
If the bar or switch pattern is empty, a string pattern contains
characters other than ``"x"`` and ``"."``, or ``frames`` is
negative.
TypeError
If a switch is not Boolean, ``0``, or ``1``, or an option has the
wrong type.
Notes
-----
Muted events have both tie flags cleared because rests cannot form valid
ties. Active events are yielded unchanged, so callers masking tied
material are responsible for ensuring adjacent tie chains remain valid.
"""
if not bar:
raise ValueError("bar must contain at least one note")
if isinstance(pattern, str):
invalid = set(pattern) - {"x", "."}
if invalid:
raise ValueError(
'string patterns may contain only "x" and "."'
)
supplied_pattern = tuple(character == "x" for character in pattern)
else:
supplied_pattern = tuple(pattern)
if not supplied_pattern:
raise ValueError("pattern must contain at least one switch")
if not all(
isinstance(switch, (bool, int))
and switch in (0, 1)
for switch in supplied_pattern
):
raise TypeError("pattern switches must be booleans or 0/1 integers")
if not isinstance(extend_previous, bool):
raise TypeError("extend_previous must be a boolean")
if (
frames is not None
and (isinstance(frames, bool) or not isinstance(frames, int))
):
raise TypeError("frames must be an integer or None")
if frames is not None and frames < 0:
raise ValueError("frames must not be negative")
switch_pattern = tuple(bool(switch) for switch in supplied_pattern)
events = zip(cycle(bar.notes), cycle(switch_pattern))
if frames is not None:
events = islice(events, frames)
if not extend_previous:
for note, switch in events:
if switch:
yield note
else:
yield note.with_pitches(()).with_ties(
tie_in=False,
tie_out=False,
)
return
previous = None
for note, switch in events:
if switch:
if previous is not None:
yield previous
previous = note
elif previous is None:
yield note.with_pitches(()).with_ties(
tie_in=False,
tie_out=False,
)
else:
previous = previous.with_duration(
previous.duration + note.duration
)
if previous is not None:
yield previous
[docs]
def pulses_to_durations(
bar: Bar,
pulses: str,
legato: bool = True,
unit: Fraction = Fraction(1, 16),
offset: int = 0,
emit_ties: bool = False,
) -> Bar:
"""Apply an onset pattern to a bar's events.
Each ``"x"`` starts the next source event, cycling through ``bar`` when
necessary. Each ``"."`` is either a unit rest or, in legato mode, a
continuation of the preceding onset. Dots before the first onset remain
rests.
In the default legato representation, an onset and its continuation frames
become one longer event. With ``emit_ties=True``, sounding continuations
are instead emitted as separate unit events joined by ties. Rest spans are
never tied. Pitch spelling, velocity, outer tie flags, and bar tonality are
retained, and the source bar is not modified.
Parameters
----------
bar : Bar
Non-empty source of events when ``pulses`` contains an onset.
pulses : str
Pattern whose onsets are ``"x"`` and whose rests or continuations are
``"."``.
legato : bool, default=True
Merge onsets with their following dot frames. When false, every frame
produces one unit-length event.
unit : Fraction, default=Fraction(1, 16)
Positive duration of one pattern frame.
offset : int, default=0
Number of frames by which to rotate the pattern to the left.
emit_ties : bool, default=False
In legato mode, emit separate tied sounding frames instead of one
merged event. This has no effect when ``legato`` is false.
Returns
-------
Bar
A new rhythmically transformed bar with the source tonality.
Raises
------
TypeError
If ``bar`` is not a bar, ``pulses`` is not a string, or ``offset`` is
not an integer.
ValueError
If the pattern contains unsupported characters, ``unit`` is not
positive, or an onset is requested from an empty bar.
"""
if not isinstance(bar, Bar):
raise TypeError("bar must be a Bar")
if not isinstance(pulses, str):
raise TypeError("pulses must be a string")
if not isinstance(offset, int):
raise TypeError("offset must be an integer")
unit = Fraction(unit)
if unit <= 0:
raise ValueError("unit must be positive")
invalid_characters = set(pulses) - {"x", "."}
if invalid_characters:
invalid = "".join(sorted(invalid_characters))
raise ValueError(f"Invalid pulse character(s): {invalid!r}")
if not pulses:
return Bar(tonality=bar.tonality)
if "x" in pulses and not bar:
raise ValueError("Cannot emit pulse onsets from an empty bar")
offset %= len(pulses)
pulses = pulses[offset:] + pulses[:offset]
source_notes = cycle(bar.notes)
generated_notes: list[Note] = []
def resize(source: Note, duration: Fraction) -> Note:
return source.with_pitches(source.pitches).with_duration(duration)
if not legato:
for pulse in pulses:
if pulse == "x":
generated_notes.append(resize(next(source_notes), unit))
else:
generated_notes.append(Note.rest(unit))
return Bar(generated_notes, tonality=bar.tonality)
index = 0
while index < len(pulses):
if pulses[index] == ".":
end = index + 1
while end < len(pulses) and pulses[end] == ".":
end += 1
generated_notes.append(Note.rest((end - index) * unit))
index = end
continue
source = next(source_notes)
end = index + 1
while end < len(pulses) and pulses[end] == ".":
end += 1
frame_count = end - index
if not emit_ties or source.is_rest():
generated_notes.append(resize(source, frame_count * unit))
else:
for frame in range(frame_count):
first = frame == 0
last = frame == frame_count - 1
generated_notes.append(
resize(source, unit).with_ties(
tie_in=source.tie_in if first else True,
tie_out=source.tie_out if last else True,
)
)
index = end
return Bar(generated_notes, tonality=bar.tonality)
[docs]
def euclidean_rhythm(
bar: Bar,
n: int,
k: int,
legato: bool = True,
unit: Fraction = Fraction(1, 16),
offset: int = 0,
emit_ties: bool = False,
) -> Bar:
"""Distribute ``k`` source onsets evenly over ``n`` frames.
Source events are selected cyclically at generated onsets. Rhythm
realization is delegated to :func:`pulses_to_durations`, so legato
durations, explicit ties, metadata, rotation, and tonality behave
identically in both functions.
Parameters
----------
bar : Bar
Source events cycled at generated onsets.
n : int
Positive number of frames in the generated rhythm.
k : int
Number of onsets between zero and ``n``, inclusive.
legato : bool, default=True
Merge each onset with its following continuation frames.
unit : Fraction, default=Fraction(1, 16)
Duration of one frame.
offset : int, default=0
Number of frames by which to rotate the pattern to the left.
emit_ties : bool, default=False
In legato mode, emit tied unit events instead of merged durations.
Returns
-------
Bar
A new bar containing the generated rhythm and source tonality.
Raises
------
TypeError
If ``bar`` is not a bar or ``n`` and ``k`` are not integers.
ValueError
If ``n`` is not positive or ``k`` lies outside ``0 <= k <= n``.
"""
if not isinstance(bar, Bar):
raise TypeError("bar must be a Bar")
if not isinstance(n, int) or not isinstance(k, int):
raise TypeError("n and k must be integers")
if n <= 0:
raise ValueError("n must be positive")
if not 0 <= k <= n:
raise ValueError("k must satisfy 0 <= k <= n")
if k == n:
pulses = "x" * n
else:
remainders = [(frame * k) % n for frame in range(-1, n)]
pulses = "".join(
"x" if left > right else "."
for left, right in zip(remainders[:-1], remainders[1:])
)
return pulses_to_durations(
bar,
pulses,
legato=legato,
unit=unit,
offset=offset,
emit_ties=emit_ties,
)
[docs]
def fill_bars(
bars: Iterable[Bar],
notes: Iterable[Note],
) -> Voice:
"""Fill template bars with events from a note source.
The function consumes events in order and returns one new bar for every
template bar. Each result has exactly the template's span and tonality.
Events that cross a bar boundary are split; sounding notes and chords are
tied across the boundary, while rests are split without ties. Consecutive
rests within one result bar are combined into a single rest, retaining the
first rest's velocity. Existing tie flags are retained at the outer ends
of a split sounding event.
The finite bar input is consumed eagerly and the result is a concrete
:class:`Voice`. If ``bars`` is a voice, its default tonality, tonality plan,
and name are inherited by the result. The note source is consumed only as
far as needed. Source events that fit without splitting are reused
unchanged except when rest normalization requires a new event; split
segments are created with :meth:`Note.with_duration` and
:meth:`Note.with_ties`.
Parameters
----------
bars : Iterable[Bar]
Finite collection of bars whose spans and tonalities define the output
structure. A :class:`Voice` can be passed directly.
notes : Iterable[Note]
Finite or infinite source of notes, chords, and rests.
Returns
-------
Voice
A new voice containing the filled bars. Voice-level tonal context and
name are inherited when the templates are supplied as a voice.
Raises
------
TypeError
If either input produces an object of the wrong type.
ValueError
If the note source ends before a template bar can be filled.
"""
def append_note(filled: list[Note], note: Note) -> None:
if not note.is_rest():
filled.append(note)
return
rest = note.with_ties(tie_in=False, tie_out=False)
if filled and filled[-1].is_rest():
previous = filled[-1]
filled[-1] = previous.with_duration(
previous.duration + rest.duration
)
else:
filled.append(rest)
note_iterator = iter(notes)
carried: Note | None = None
source_index = 0
result: list[Bar] = []
for bar_index, template in enumerate(bars):
if not isinstance(template, Bar):
raise TypeError(
f"Bar template {bar_index} is not a Bar"
)
remaining = Fraction(template.span())
filled: list[Note] = []
while remaining > 0:
if carried is None:
try:
note = next(note_iterator)
except StopIteration:
raise ValueError(
"Note source ended while filling "
f"bar {bar_index}"
) from None
if not isinstance(note, Note):
raise TypeError(
f"Generated event {source_index} is not a Note"
)
source_index += 1
else:
note = carried
carried = None
if note.duration <= remaining:
append_note(filled, note)
remaining -= note.duration
continue
first, carried = _split_note(note, remaining)
append_note(filled, first)
remaining = Fraction(0)
result.append(Bar(filled, tonality=template.tonality))
if isinstance(bars, Voice):
return Voice(
result,
default_tonality=bars.default_tonality,
tonality_plan=bars.tonality_plan,
name=bars.name,
)
return Voice(result)