"""
Envelopes for shaping the dynamics of musical sequences.
This module provides the Envelope class for creating and manipulating
time-varying amplitude or parameter envelopes with support for various
curve shapes and normalization options.
"""
import numpy as np
__all__ = [
'Envelope',
]
[docs]
class Envelope:
"""
A flexible envelope generator for time-varying parameter control.
The Envelope class creates smooth transitions between a series of values
over specified time durations, with support for curve shaping, normalization,
and scaling. The envelope is immutable after construction.
Parameters
----------
values : list
List of breakpoint values to interpolate between.
times : float or list, optional
Duration(s) for each segment. If a single value, all segments use
the same duration. If a list, must have one fewer element than values.
Default is 1.0.
curve : float or list, optional
Curve shape for each segment. 0 = linear, negative = exponential,
positive = logarithmic. If a single value, all segments use the same
curve. Default is 0.0.
normalize_values : bool, optional
Whether to normalize values to 0-1 range at construction. Default is False.
normalize_times : bool, optional
Whether to normalize times to sum to 1 at construction. Default is False.
value_scale : float, optional
Scale factor applied to all values at construction. Default is 1.0.
time_scale : float, optional
Scale factor applied to all times when computing durations. Default is 1.0.
Examples
--------
>>> env = Envelope([0, 1, 0.5, 0], times=[0.1, 0.8, 0.1])
>>> env.at_time(0.5)
0.875
>>> decay = Envelope([1, 0], times=2.0, curve=-3)
>>> decay.at_time(0)
1.0
>>> decay.at_time(2.0)
0.0
"""
[docs]
def __init__(self, values, times=1.0, curve=0.0,
normalize_values=False, normalize_times=False,
value_scale=1.0, time_scale=1.0):
values = list(values)
times = times if isinstance(times, (list, tuple)) else [times] * (len(values) - 1)
times = list(times)
curve = curve if isinstance(curve, (list, tuple)) else [curve] * (len(values) - 1)
curve = list(curve)
if normalize_values and len(values) > 1:
min_val = min(values)
max_val = max(values)
if max_val != min_val:
values = [(v - min_val) / (max_val - min_val) for v in values]
if value_scale != 1.0:
values = [v * value_scale for v in values]
if normalize_times and len(times) > 0:
time_sum = sum(times)
if time_sum != 0:
times = [t / time_sum for t in times]
self._values = values
self._times = times
self._curve = curve
self._time_scale = time_scale
self._at_time_cache: dict = {}
[docs]
@classmethod
def perc(cls, attackTime=0.01, releaseTime=1.0, curve=-4.0, time_scale=1.0):
"""
Create a percussive envelope: 0 -> 1 -> 0
Parameters
----------
attackTime : float, optional
Duration of attack phase. Default is 0.01.
releaseTime : float, optional
Duration of release phase. Default is 1.0.
curve : float, optional
Curve shape for both segments. Default is -4.0.
time_scale : float, optional
Time scale factor. Default is 1.0.
Returns
-------
Envelope
A percussive envelope instance.
"""
return cls(values=[0, 1, 0], times=[attackTime, releaseTime], curve=curve, time_scale=time_scale)
[docs]
@classmethod
def adr(cls, attackTime=0.01, decayTime=0.3, decayLevel=0.5, releaseTime=1.0, curve=-4.0, time_scale=1.0):
"""
Create an ADR envelope (3 segments): 0 -> 1 -> decayLevel -> 0
Parameters
----------
attackTime : float, optional
Duration of attack phase. Default is 0.01.
decayTime : float, optional
Duration of decay phase. Default is 0.3.
decayLevel : float, optional
Level after decay. Default is 0.5.
releaseTime : float, optional
Duration of release phase. Default is 1.0.
curve : float, optional
Curve shape for all segments. Default is -4.0.
time_scale : float, optional
Time scale factor. Default is 1.0.
Returns
-------
Envelope
An ADR envelope instance.
"""
return cls(values=[0, 1, decayLevel, 0], times=[attackTime, decayTime, releaseTime], curve=curve, time_scale=time_scale)
[docs]
@classmethod
def adsr(cls, attackTime=0.01, decayTime=0.3, sustainTime=0.5, sustainLevel=0.5, releaseTime=1.0, curve=-4.0, time_scale=1.0):
"""
Create an ADSR envelope (4 segments): 0 -> 1 -> sustainLevel (hold) -> 0
Parameters
----------
attackTime : float, optional
Duration of attack phase. Default is 0.01.
decayTime : float, optional
Duration of decay phase. Default is 0.3.
sustainTime : float, optional
Duration of sustain phase. Default is 0.5.
sustainLevel : float, optional
Level during sustain. Default is 0.5.
releaseTime : float, optional
Duration of release phase. Default is 1.0.
curve : float, optional
Curve shape for all segments. Default is -4.0.
time_scale : float, optional
Time scale factor. Default is 1.0.
Returns
-------
Envelope
An ADSR envelope instance.
"""
return cls(values=[0, 1, sustainLevel, sustainLevel, 0], times=[attackTime, decayTime, sustainTime, releaseTime], curve=curve, time_scale=time_scale)
[docs]
@classmethod
def pairs(cls, pairs, curve=0.0, time_scale=1.0):
"""
Create an envelope from (time, value) pairs.
Parameters
----------
pairs : list of tuples
List of (time, value) pairs defining the envelope shape.
Times should be absolute positions, not durations.
curve : float, optional
Curve shape for all segments. Default is 0.0 (linear).
time_scale : float, optional
Time scale factor. Default is 1.0.
Returns
-------
Envelope
An envelope instance defined by the given pairs.
Examples
--------
>>> env = Envelope.pairs([(0, 0), (0.1, 1), (0.5, 0.5), (1.0, 0)])
"""
sorted_pairs = sorted(pairs, key=lambda p: p[0])
times_abs = [p[0] for p in sorted_pairs]
values = [p[1] for p in sorted_pairs]
durations = [times_abs[i+1] - times_abs[i] for i in range(len(times_abs)-1)]
return cls(values=values, times=durations, curve=curve, time_scale=time_scale)
@property
def values(self):
"""List of breakpoint values."""
return self._values
@property
def times(self):
"""List of segment durations."""
return self._times
@property
def time_scale(self):
"""Time scale factor applied to segment durations."""
return self._time_scale
@property
def total_time(self):
"""Total duration of the envelope."""
return sum(t * self._time_scale for t in self._times)
@property
def breakpoint_times(self):
"""Cumulative time points for each breakpoint value."""
result = [0.0]
current_time = 0.0
for t in self._times:
current_time += t * self._time_scale
result.append(current_time)
return result
@property
def normalized_times(self):
"""Cumulative breakpoint times normalized to the [0, 1] range."""
total = self.total_time
if total <= 0:
return [0.0] * len(self._values)
return [t / total for t in self.breakpoint_times]
[docs]
def at_time(self, time):
"""
Get the envelope value at a specific time.
Results are memoized on a per-instance cache keyed by the requested
time, so repeated queries against the same envelope are O(1) after
the first. The cache lifetime is bound to the envelope instance
itself (unlike a module-level ``lru_cache``, which would keep every
envelope alive for the life of the interpreter).
Parameters
----------
time : float
Time point to query (must be within [0, total_time]).
Returns
-------
float
Interpolated envelope value at the given time.
Raises
------
ValueError
If time is outside the envelope duration.
"""
cached = self._at_time_cache.get(time)
if cached is not None:
return cached
if time < 0 or time > self.total_time:
raise ValueError(f"Time {time} is outside envelope duration [0, {self.total_time}]")
if time == 0:
result = self._values[0]
self._at_time_cache[time] = result
return result
if time == self.total_time:
result = self._values[-1]
self._at_time_cache[time] = result
return result
scaled_times = [t * self._time_scale for t in self._times]
current_time = 0
for i in range(len(self._values) - 1):
segment_duration = scaled_times[i]
segment_end_time = current_time + segment_duration
if time <= segment_end_time:
segment_progress = (time - current_time) / segment_duration
start_val = self._values[i]
end_val = self._values[i + 1]
curve_val = self._curve[i]
if curve_val == 0:
result = start_val + (end_val - start_val) * segment_progress
else:
curved_progress = (np.exp(curve_val * segment_progress) - 1) / (np.exp(curve_val) - 1)
result = start_val + (end_val - start_val) * curved_progress
self._at_time_cache[time] = result
return result
current_time = segment_end_time
result = self._values[-1]
self._at_time_cache[time] = result
return result
def __str__(self):
def format_list(lst):
if len(set(lst)) == 1:
return lst[0]
return lst
effective_times = [t * self._time_scale for t in self._times]
return f"Envelope(values={format_list(self._values)}, times={format_list(effective_times)}, curve={format_list(self._curve)})"
def __repr__(self):
return self.__str__()