Source code for subsequence.forms
"""Forms — the governing values for compositional structure.
:class:`Section` is the payload home: a frozen ``(name, bars, energy, key)``
leaf. :class:`Form` is a frozen tuple of Sections — constructible from
Sections or plain ``(name, bars)`` tuples (lists coerce), editable by slot
(:meth:`Form.replace` / :meth:`Form.insert` / :meth:`Form.with_energy`),
and concatenable with ``+``. Repetition is Python:
``[verse, verse, chorus] == [verse] * 2 + [chorus]`` — no letters DSL.
Bind a Form with ``composition.form(form, at_end="stop"|"hold"|"loop")``;
generate one from a graph form with ``composition.form_freeze()``.
Sections compose as plain Python lists that Form coerces — ``Section`` is a
leaf, not an operator algebra.
"""
import dataclasses
import typing
@dataclasses.dataclass(frozen=True)
[docs]
class Section:
"""One section of a form — the payload home.
Attributes:
name: The section name (``"verse"``).
bars: Length in bars (≥ 1).
energy: The section's energy level (0.0–1.0; the arranging dial).
Read by ``p.energy`` and ``min_energy=`` gating; a
``composition.energy()`` dict overrides it (the dict is the
later, performance-level dial).
key: Optional key override — re-anchors *key-relative* content
(degrees, romans, generated material, and key-relative section
progressions bound with ``section_chords``) to this section's
tonic. *Absolute* content (note names, MIDI pitches, frozen
chords) is never moved, and *chord-relative* content
(``ChordTone``, ``Approach``) tracks the sounding chord rather
than the key — see the three-intent model in the docs. The
live graph engine (``harmony(style=...)``) stays in the
composition key by design (a stateful walk does not transpose
mid-stream).
scale: Optional scale/mode override (e.g. ``"minor"``) — moves the
mode as well as the tonic, so a section can genuinely change
to the relative or parallel minor. Falls back to the form's
scale, then the composition's.
"""
def __post_init__ (self) -> None:
"""Validate the payload loudly."""
if not isinstance(self.name, str) or not self.name:
raise ValueError(f"a section needs a non-empty string name, got {self.name!r}")
if not isinstance(self.bars, int) or isinstance(self.bars, bool) or self.bars < 1:
raise ValueError(f"Section {self.name!r} must last at least 1 bar, got {self.bars!r}")
if not 0.0 <= float(self.energy) <= 1.0:
raise ValueError(f"Section {self.name!r} energy must be 0.0–1.0, got {self.energy!r}")
def _coerce_section (element: typing.Any) -> Section:
"""Coerce a Form element — a Section passes through, a (name, bars) tuple converts."""
if isinstance(element, Section):
return element
if isinstance(element, tuple) and len(element) == 2:
name, bars = element
return Section(name = name, bars = bars)
raise TypeError(
f"Form elements are Sections or (name, bars) tuples — got {element!r}"
)
@dataclasses.dataclass(frozen=True)
[docs]
class Form:
"""A frozen sequence of Sections — the editable, bindable form value.
List-friendly: the constructor coerces ``("name", bars)`` tuples, so
``Form([("verse", 8), ("chorus", 8)])`` and
``Form([Section("verse", 8), Section("chorus", 8)])`` are the same value.
Repetition is Python list arithmetic before construction.
A form may carry its own ``key``/``scale`` — the **form tier** of the
key-source chain (``Section.key`` overrides it; it overrides the
composition key). A whole AABA in one key with one section borrowing
another is ``Form([...], key="A")`` plus a ``Section(..., key="F")``.
"""
sections: typing.Tuple[Section, ...]
key: typing.Optional[str] = None
scale: typing.Optional[str] = None
def __init__ (
self,
sections: typing.Iterable[typing.Any],
key: typing.Optional[str] = None,
scale: typing.Optional[str] = None,
) -> None:
"""Coerce any iterable of Sections / (name, bars) tuples."""
coerced = tuple(_coerce_section(element) for element in sections)
if not coerced:
raise ValueError("a Form needs at least one section")
object.__setattr__(self, "sections", coerced)
object.__setattr__(self, "key", key)
object.__setattr__(self, "scale", scale)
@property
[docs]
def bars (self) -> int:
"""Total length in bars."""
return sum(section.bars for section in self.sections)
def __len__ (self) -> int:
"""Number of sections."""
return len(self.sections)
def __iter__ (self) -> typing.Iterator[Section]:
"""Iterate the sections in order."""
return iter(self.sections)
def __add__ (self, other: "Form") -> "Form":
"""Sequential concatenation: ``intro_form + body_form``.
The **left** operand's form-tier ``key``/``scale`` survives (a single
value cannot hold two form keys); the right form's form-tier key is
dropped. Per-section ``Section.key``/``scale`` on either side is
preserved — the sections concatenate intact.
"""
if not isinstance(other, Form):
return NotImplemented
return Form(self.sections + other.sections, key = self.key, scale = self.scale)
[docs]
def replace (
self,
slot: int,
section: typing.Optional[Section] = None,
**changes: typing.Any,
) -> "Form":
"""Replace the section at a 1-based slot — whole, or by field.
``form.replace(3, bars=16)`` stretches slot 3;
``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out.
"""
index = _check_slot(slot, len(self.sections))
if section is not None and changes:
raise ValueError("pass either a Section or field changes, not both")
if section is None:
if not changes:
raise ValueError("replace() needs a Section or field changes (bars=, energy=, key=, name=)")
section = dataclasses.replace(self.sections[index], **changes)
return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index + 1:], key = self.key, scale = self.scale)
[docs]
def insert (self, slot: int, section: typing.Any) -> "Form":
"""Insert a section *at* a 1-based slot (existing sections shift right).
``slot`` may be ``len(form) + 1`` to append.
"""
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= len(self.sections) + 1:
raise ValueError(f"slot {slot!r} is out of range (1–{len(self.sections) + 1})")
index = slot - 1
return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index:], key = self.key, scale = self.scale)
[docs]
def with_energy (self, energies: typing.Dict[str, float]) -> "Form":
"""Set the energy payload on named sections — ``{"chorus": 0.9}``.
Every section whose name appears in the mapping takes the new value;
naming a section the form does not contain raises. Energy *ramps*
(``(start, end)`` tuples) live in ``composition.energy()``, not in
the payload — a Section carries one number.
"""
names = {section.name for section in self.sections}
for name in energies:
if name not in names:
known = ", ".join(sorted(names))
raise ValueError(f"with_energy: no section named {name!r} in this form (sections: {known})")
return Form(tuple(
dataclasses.replace(section, energy = energies[section.name])
if section.name in energies else section
for section in self.sections
), key = self.key, scale = self.scale)
[docs]
def describe (self) -> str:
"""A readable one-section-per-line summary."""
lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"]
if self.key is not None or self.scale is not None:
lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})")
bar = 1
for slot, section in enumerate(self.sections, start = 1):
extras = f" energy={section.energy:g}"
if section.key is not None:
extras += f" key={section.key}"
if section.scale is not None:
extras += f" scale={section.scale}"
lines.append(f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}")
bar += section.bars
return "\n".join(lines)
def __str__ (self) -> str:
"""Same as :meth:`describe`."""
return self.describe()
def _check_slot (slot: int, count: int) -> int:
"""Validate a 1-based section slot and return its 0-based index."""
if not isinstance(slot, int) or isinstance(slot, bool):
raise TypeError(f"section slots are 1-based ints, got {slot!r}")
if slot < 1 or slot > count:
raise ValueError(f"section slot {slot} is out of range (1–{count})")
return slot - 1