From 2f02df15b862c2ac8bc066b7b8f401332d5793e2 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sat, 28 Mar 2026 15:23:16 -0400 Subject: [PATCH] v0.38.1: Dynamic curves (crescendo, decrescendo, swell, dynamics) Part.crescendo(), Part.decrescendo(), Part.swell(), and Part.dynamics() for velocity ramps and custom curves across note sequences. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 ++ pyproject.toml | 2 +- pytheory/__init__.py | 2 +- pytheory/rhythm.py | 132 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 141 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 832d118..1e3dc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to PyTheory are documented here. +## 0.38.1 + +- **Dynamic curves** — `Part.crescendo()`, `Part.decrescendo()`, + `Part.swell()`, and `Part.dynamics()` for velocity ramps and custom + curves across a sequence of notes + ## 0.38.0 - **Articulations** — `staccato`, `legato`, `marcato`, `tenuto`, `accent`, diff --git a/pyproject.toml b/pyproject.toml index 393f7fc..98a3928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.38.0" +version = "0.38.1" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index fee6943..0396d36 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.38.0" +__version__ = "0.38.1" from .tones import Tone, Interval from .systems import System, SYSTEMS, TET diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index c600bb3..59863c6 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -2720,6 +2720,138 @@ class Part: velocity=velocity, articulation=articulation)) return self + def crescendo(self, notes, duration=Duration.QUARTER, *, + start_vel: int = 40, end_vel: int = 110, + articulation: str = "") -> "Part": + """Add notes with velocity ramping up (getting louder). + + Args: + notes: List of note strings (e.g. ``["C4", "D4", "E4"]``). + duration: Duration for each note. + start_vel: Starting velocity (quiet). + end_vel: Ending velocity (loud). + articulation: Optional articulation for all notes. + + Example:: + + >>> piano.crescendo(["C4","D4","E4","F4","G4"], Duration.QUARTER, + ... start_vel=40, end_vel=110) + """ + return self.dynamics(notes, duration, velocities=(start_vel, end_vel), + articulation=articulation) + + def decrescendo(self, notes, duration=Duration.QUARTER, *, + start_vel: int = 110, end_vel: int = 40, + articulation: str = "") -> "Part": + """Add notes with velocity ramping down (getting quieter). + + Args: + notes: List of note strings. + duration: Duration for each note. + start_vel: Starting velocity (loud). + end_vel: Ending velocity (quiet). + articulation: Optional articulation for all notes. + + Example:: + + >>> piano.decrescendo(["G4","F4","E4","D4","C4"], Duration.QUARTER, + ... start_vel=110, end_vel=40) + """ + return self.dynamics(notes, duration, velocities=(start_vel, end_vel), + articulation=articulation) + + def dynamics(self, notes, duration=Duration.QUARTER, *, + velocities=None, articulation: str = "") -> "Part": + """Add notes with a velocity curve. + + Args: + notes: List of note strings or Tone/Chord objects. + duration: Duration for each note (or list of durations). + velocities: Velocity curve — either a ``(start, end)`` tuple + for a linear ramp, or a list of ints (one per note). + articulation: Optional articulation for all notes (or list). + + Example:: + + >>> # Linear ramp + >>> piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER, + ... velocities=(50, 120)) + >>> # Custom curve (swell and fade) + >>> piano.dynamics(["C4","D4","E4","F4","G4","F4","E4","D4"], + ... Duration.EIGHTH, + ... velocities=[50, 70, 90, 110, 110, 90, 70, 50]) + """ + n = len(notes) + if n == 0: + return self + + # Resolve velocities + if velocities is None: + vels = [100] * n + elif isinstance(velocities, (tuple, list)) and len(velocities) == 2 and isinstance(velocities[0], (int, float)): + # (start, end) tuple — linear ramp + start_v, end_v = velocities + if n == 1: + vels = [int(start_v)] + else: + vels = [int(start_v + (end_v - start_v) * i / (n - 1)) + for i in range(n)] + else: + vels = list(velocities) + + # Resolve durations + if isinstance(duration, (list, tuple)): + durs = list(duration) + else: + durs = [duration] * n + + # Resolve articulations + if isinstance(articulation, (list, tuple)): + arts = list(articulation) + else: + arts = [articulation] * n + + for note, vel, dur, art in zip(notes, vels, durs, arts): + vel = max(1, min(127, vel)) + self.add(note, dur, velocity=vel, articulation=art) + + return self + + def swell(self, notes, duration=Duration.QUARTER, *, + low_vel: int = 40, peak_vel: int = 110, + articulation: str = "") -> "Part": + """Add notes that swell up then fade back down (< > shape). + + The velocity ramps up to the midpoint then back down, + creating the classic orchestral swell. + + Args: + notes: List of note strings. + duration: Duration for each note. + low_vel: Velocity at start and end. + peak_vel: Velocity at the peak (midpoint). + articulation: Optional articulation. + + Example:: + + >>> strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"], + ... Duration.QUARTER, low_vel=40, peak_vel=110) + """ + n = len(notes) + if n <= 2: + return self.dynamics(notes, duration, velocities=[peak_vel] * n, + articulation=articulation) + mid = n // 2 + vels = [] + for i in range(n): + if i <= mid: + v = low_vel + (peak_vel - low_vel) * i / mid + else: + v = peak_vel - (peak_vel - low_vel) * (i - mid) / (n - 1 - mid) + vels.append(int(v)) + return self.dynamics(notes, duration, velocities=vels, + articulation=articulation) + def set(self, **params) -> "Part": """Change effect parameters at the current beat position. diff --git a/uv.lock b/uv.lock index 2c7fb9d..604ab93 100644 --- a/uv.lock +++ b/uv.lock @@ -698,7 +698,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.38.0" +version = "0.38.1" source = { editable = "." } dependencies = [ { name = "numeral" },