"""
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)