diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e3dc32..a5e5310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to PyTheory are documented here. +## 0.38.2 + +- **`Part.ramp()`** — smooth parameter automation from current value to + target over a duration. Works for lowpass, reverb, distortion, chorus, + delay, volume, and any `.set()` parameter. Four interpolation curves: + linear, ease_in, ease_out, ease_in_out. + ## 0.38.1 - **Dynamic curves** — `Part.crescendo()`, `Part.decrescendo()`, diff --git a/docs/guide/sequencing.rst b/docs/guide/sequencing.rst index 26708f4..66b0cbe 100644 --- a/docs/guide/sequencing.rst +++ b/docs/guide/sequencing.rst @@ -586,6 +586,50 @@ integrate naturally with the rest of the automation system: pad.rest(Duration.WHOLE) pad.rest(Duration.WHOLE) +Parameter Ramps +--------------- + +Fades only control volume. ``Part.ramp()`` smoothly sweeps *any* +parameter from its current value to a target — filters, reverb, +distortion, chorus, delay, anything ``.set()`` accepts. This is how +you build filter sweeps, gradual effect sends, and EDM buildups. + +.. code-block:: python + + lead = score.part("lead", synth="saw", lowpass=200, lowpass_q=3.0) + + # Open the filter over 8 bars + lead.ramp(over=Duration.WHOLE * 8, lowpass=8000) + + # Ramp multiple params at once + pad.ramp(over=Duration.WHOLE * 4, reverb=0.5, chorus=0.3) + + # Close the filter with distortion fading in + lead.ramp(over=Duration.WHOLE * 4, lowpass=400, distortion=0.5) + +Four interpolation curves: + +- **linear** — constant rate of change (default). +- **ease_in** — starts slow, accelerates. Good for buildups. +- **ease_out** — starts fast, decelerates. Good for releases. +- **ease_in_out** — slow at both ends. Smooth and natural. + +.. code-block:: python + + # EDM buildup: slow start, accelerating filter sweep + lead.ramp(over=Duration.WHOLE * 8, curve="ease_in", lowpass=8000) + + # Smooth reverb wash fading in and settling + pad.ramp(over=Duration.WHOLE * 4, curve="ease_in_out", reverb=0.6) + +``ramp()`` generates automation points every quarter-beat by default. +Set ``resolution=0.125`` for smoother curves (every 32nd note), or +``resolution=1.0`` for lighter automation (every beat). + +Combine with ``lfo()`` for cyclic modulation and ``ramp()`` for +one-shot sweeps — together they cover the full range of parameter +automation. + Humanize -------- diff --git a/pyproject.toml b/pyproject.toml index 98a3928..c885dd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.38.1" +version = "0.38.2" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 0396d36..4926650 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.38.1" +__version__ = "0.38.2" from .tones import Tone, Interval from .systems import System, SYSTEMS, TET diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 59863c6..4a67bbf 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -2935,6 +2935,79 @@ class Part: points = sorted(set(beat for beat, _ in self._automation)) return points + def ramp(self, over: float = 4.0, resolution: float = 0.25, + curve: str = "linear", **params) -> "Part": + """Smoothly ramp parameters from their current values to new targets. + + Generates interpolated automation points — like turning a knob + gradually instead of jumping to a new position. Works for any + parameter that ``.set()`` accepts. + + Args: + over: Duration of the ramp in beats (default 4.0 = 1 bar). + Use ``Duration.WHOLE * 4`` for a 4-bar ramp, etc. + resolution: How often to insert points, in beats (default 0.25). + Lower = smoother but more points. + curve: Interpolation shape — ``"linear"`` (default), + ``"ease_in"`` (slow start, fast end), + ``"ease_out"`` (fast start, slow end), + ``"ease_in_out"`` (slow start and end). + **params: Target values for any parameter. The ramp starts + from the parameter's current value at this beat position. + + Returns: + Self for chaining. + + Example:: + + >>> lead = score.part("lead", synth="saw", lowpass=200) + >>> # Open the filter over 4 bars + >>> lead.ramp(over=Duration.WHOLE * 4, lowpass=8000) + >>> # Fade reverb in over 2 bars + >>> pad.ramp(over=Duration.WHOLE * 2, reverb=0.5) + >>> # Multiple params at once with easing + >>> lead.ramp(over=8.0, curve="ease_in", lowpass=6000, distortion=0.4) + """ + current_beat = sum(n.beats for n in self.notes) + + # Map param names to internal names + param_map = { + "reverb": "reverb_mix", "delay": "delay_mix", + "distortion": "distortion_mix", "chorus": "chorus_mix", + "phaser": "phaser_mix", + } + + # Get current values for each param + current_params = self._get_params_at(current_beat) + ramps = {} + for param, target in params.items(): + internal = param_map.get(param, param) + start = current_params.get(internal, getattr(self, internal, 0.0)) + ramps[internal] = (float(start), float(target)) + + # Generate interpolated points + beat = 0.0 + while beat <= over: + t = beat / over if over > 0 else 1.0 + t = max(0.0, min(1.0, t)) + + # Apply curve + if curve == "ease_in": + t = t * t + elif curve == "ease_out": + t = 1.0 - (1.0 - t) ** 2 + elif curve == "ease_in_out": + t = 3 * t * t - 2 * t * t * t + + point = {} + for internal, (start, end) in ramps.items(): + point[internal] = start + (end - start) * t + + self._automation.append((current_beat + beat, point)) + beat += resolution + + return self + def lfo(self, param: str, *, rate: float = 0.5, min: float = 0.0, max: float = 1.0, bars: float = 4, shape: str = "sine", resolution: float = 0.25) -> "Part": diff --git a/uv.lock b/uv.lock index 604ab93..72bc081 100644 --- a/uv.lock +++ b/uv.lock @@ -698,7 +698,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.38.1" +version = "0.38.2" source = { editable = "." } dependencies = [ { name = "numeral" },