Source code for subsequence.conductor

"""
Global automation signals — LFOs and ramps that modulate a composition over time.

The Conductor holds named, time-varying signals (swells, fades, filter sweeps)
that any pattern can read via ``p.signal(name)`` to shape velocity, CCs, or
note choices across long stretches of music.
"""

import math
import typing
import warnings

import subsequence.easing


_VALID_LFO_SHAPES = frozenset({"sine", "triangle", "saw", "square"})


[docs] class Signal: """ Abstract base class for a time-varying signal. """
[docs] def value_at (self, beat: float) -> float: """ Return the signal value at the given beat time. Subclasses must override. """ raise NotImplementedError
[docs] class LFO(Signal): """ A Low-Frequency Oscillator for generating periodic modulation signals. LFOs are used to create cyclical changes (like a 'swell' or 'vibrato') over many bars. """ def __init__ (self, shape: str = "sine", cycle_beats: float = 16.0, min_val: float = 0.0, max_val: float = 1.0, phase: float = 0.0) -> None: """ Initialize an LFO. Parameters: shape: The waveform shape ("sine", "triangle", "saw", "square"). cycle_beats: How many beats for one full cycle (e.g., 16.0 = 4 bars). min_val: The bottom of the LFO range (default 0.0). max_val: The top of the LFO range (default 1.0). phase: Phase offset (0.0 to 1.0). """ if cycle_beats <= 0: raise ValueError("cycle_beats must be positive") if shape not in _VALID_LFO_SHAPES: raise ValueError( f"Unknown LFO shape {shape!r}. " f"Valid shapes: {sorted(_VALID_LFO_SHAPES)}" ) self.shape = shape self.cycle_beats = cycle_beats self.min_val = min_val self.max_val = max_val self.phase = phase
[docs] def value_at (self, beat: float) -> float: """ Compute the signal value at a given beat time. """ progress = (beat / self.cycle_beats + self.phase) % 1.0 val = 0.0 if self.shape == "sine": # Map -1..1 to 0..1 raw = math.sin(progress * 2 * math.pi) val = (raw + 1) / 2 elif self.shape == "triangle": # 0 -> 0.5 -> 1 -> 0.5 -> 0 if progress < 0.5: val = progress * 2 else: val = 2 - (progress * 2) elif self.shape == "saw": val = progress else: # "square" val = 1.0 if progress < 0.5 else 0.0 return self.min_val + (val * (self.max_val - self.min_val))
[docs] class Line(Signal): """ A ramp signal that moves from one value to another over time. """ def __init__ (self, start_val: float, end_val: float, duration_beats: float, start_beat: float = 0.0, loop: bool = False, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None: """ Initialize a ramp signal. Parameters: start_val: Initial value. end_val: Target value. duration_beats: How long it takes to reach the target (in beats). start_beat: Global beat time when the ramp should start (default 0). loop: Whether to jump back to start_val and repeat (default False). shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``. See :mod:`subsequence.easing` for available shapes. """ if duration_beats <= 0: raise ValueError("duration_beats must be positive") self.start_val = start_val self.end_val = end_val self.duration_beats = duration_beats self.start_beat = start_beat self.loop = loop self._easing_fn = subsequence.easing.get_easing(shape)
[docs] def value_at (self, beat: float) -> float: """ Compute the ramp value at a given beat time. """ elapsed = beat - self.start_beat if elapsed < 0: return self.start_val if self.loop: elapsed %= self.duration_beats elif elapsed >= self.duration_beats: return self.end_val progress = elapsed / self.duration_beats eased = self._easing_fn(progress) return self.start_val + (eased * (self.end_val - self.start_val))
[docs] class Conductor: """ A registry for global automation signals. The ``Conductor`` allows you to define time-varying signals (like LFOs or ramps) that are available to all pattern builders. This is ideal for modulating parameters (like velocity or filter cutoff) over long compositional timeframes. """ def __init__ (self) -> None: """ Create an empty conductor; register signals with lfo() or line(). """ self._signals: typing.Dict[str, Signal] = {} self._warned_missing: typing.Set[str] = set() @property
[docs] def signal_names (self) -> typing.Tuple[str, ...]: """Names of all registered signals, in sorted order.""" return tuple(sorted(self._signals))
[docs] def lfo (self, name: str, shape: str = "sine", cycle_beats: float = 16.0, min_val: float = 0.0, max_val: float = 1.0, phase: float = 0.0) -> None: """ Register a named LFO signal. Example: ```python comp.conductor.lfo("swell", shape="sine", cycle_beats=32) ``` """ self._signals[name] = LFO(shape, cycle_beats, min_val, max_val, phase)
[docs] def line (self, name: str, start_val: float, end_val: float, duration_beats: float, start_beat: float = 0.0, loop: bool = False, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None: """ Register a named ramp signal. Parameters: name: Signal name, used to retrieve the value via ``p.signal(name)``. start_val: Value at the start of the ramp. end_val: Value at the end of the ramp. duration_beats: Duration of the ramp in beats. start_beat: Beat time at which the ramp begins (default 0). loop: If True, the ramp restarts from start_val after each cycle. shape: Easing curve name or callable. Defaults to ``"linear"``. Use ``"ease_in_out"`` for smooth crossfades, ``"exponential"`` for filter sweeps. See :mod:`subsequence.easing`. Example: ```python comp.conductor.line("fadein", start_val=0, end_val=1, duration_beats=64) comp.conductor.line("filter", start_val=0, end_val=1, duration_beats=32, shape="ease_in_out") ``` """ self._signals[name] = Line(start_val, end_val, duration_beats, start_beat, loop, shape)
[docs] def get (self, name: str, beat: float) -> float: """ Retrieve the value of a signal at a specific beat time. Most patterns should use ``p.signal("name")`` instead, which calls this method with the current bar time automatically. Use ``get()`` directly when you need a beat time other than the current bar. """ if name not in self._signals: if name not in self._warned_missing: warnings.warn( f"Conductor signal {name!r} not found; returning 0.0. " "Register it with conductor.lfo() or conductor.line() first.", stacklevel=2, ) self._warned_missing.add(name) return 0.0 return self._signals[name].value_at(beat)