Compare commits

...

4 Commits

Author SHA1 Message Date
kennethreitz 0e10359236 v0.38.2: Part.ramp() for smooth parameter automation
Smoothly sweep any parameter (lowpass, reverb, distortion, etc.)
from current value to target with linear, ease_in, ease_out, or
ease_in_out interpolation curves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:12:32 -04:00
kennethreitz df00c3436d Docs: articulations, dynamic curves, Part.hit(), Duration arithmetic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:24:21 -04:00
kennethreitz 2f02df15b8 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) <noreply@anthropic.com>
2026-03-28 15:23:16 -04:00
kennethreitz a2740b8d57 v0.38.0: Articulations, Part.hit(), djembe expansion, cross-choke
Articulations (staccato, legato, marcato, tenuto, accent, fermata)
on Part.add() and Part.hold(). Part.hit() for placing individual
drum sounds with articulation support. 5 new djembe patterns,
3 djembe fills, cross-choke damping, improved djembe slap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:14:21 -04:00
7 changed files with 484 additions and 11 deletions
+25
View File
@@ -2,6 +2,31 @@
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()`,
`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`,
`fermata` via `articulation=` on `Part.add()` and `Part.hold()`
- **`Part.hit()`** — place individual drum sounds in a Part's note stream
with articulation, velocity, and effects support
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
- **3 new djembe fills** — djembe call, djembe roll, djembe break (30 fills total)
- **Cross-choke drum damping** — striking one sound fades out related sounds
(djembe, hi-hats, cajón, doumbek)
- **Improved djembe slap** — dry goatskin pop instead of snare-like noise
## 0.37.0
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
+152
View File
@@ -47,6 +47,18 @@ A ``Duration`` represents a note length in beats (quarter note = 1 beat):
>>> Duration.TRIPLET_QUARTER.value
0.6666666666666666
Duration supports arithmetic — multiply, divide, and add to create
compound durations:
.. code-block:: pycon
>>> Duration.WHOLE * 2
8.0
>>> Duration.HALF + Duration.QUARTER
3.0
>>> Duration.WHOLE / 2
2.0
Time Signatures
---------------
@@ -399,6 +411,102 @@ The arpeggiator also accepts velocity:
lead.arpeggio("Am", bars=2, pattern="up", velocity=80)
Articulations
-------------
Articulations change *how* a note is played — its attack, duration, and
weight. A staccato note is short and bouncy. A marcato note hits hard.
A legato note melts into the next one. This is the difference between
a melody that sounds like a MIDI file and one that sounds like a
musician played it.
Pass ``articulation=`` to ``Part.add()``:
.. code-block:: python
piano.add("C4", Duration.QUARTER, articulation="staccato") # short, bouncy
piano.add("D4", Duration.QUARTER, articulation="legato") # smooth, overlaps
piano.add("E4", Duration.QUARTER, articulation="marcato") # heavy accent
piano.add("F4", Duration.QUARTER, articulation="tenuto") # held, soft attack
piano.add("G4", Duration.QUARTER, articulation="accent") # louder
piano.add("C5", Duration.HALF, articulation="fermata") # held longer
What each articulation does:
- **staccato** — plays ~40% of the note duration with a quick fade-out. Short and detached.
- **legato** — extends ~15% into the next note. Smooth and connected.
- **marcato** — 25% velocity boost + sharper attack. Heavy and accented.
- **tenuto** — full duration with a softer attack ramp. Held and deliberate.
- **accent** — 20% velocity boost, no duration change.
- **fermata** — stretches the note 50% longer.
Articulations work on ``Part.hold()`` and ``Part.hit()`` too.
Dynamic Curves
--------------
Real music breathes — phrases get louder, get quieter, swell and
recede. Dynamic curves let you shape the velocity across a sequence
of notes instead of setting each one manually.
.. code-block:: python
# Crescendo: quiet to loud
piano.crescendo(["C4","D4","E4","F4","G4","A4","B4","C5"],
Duration.QUARTER, start_vel=30, end_vel=110)
# Decrescendo: loud to quiet
piano.decrescendo(["C5","B4","A4","G4","F4","E4","D4","C4"],
Duration.QUARTER, start_vel=110, end_vel=30)
# Swell: up then back down (orchestral < > shape)
strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"],
Duration.QUARTER, low_vel=35, peak_vel=110)
# Custom curve: explicit velocity per note
piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER,
velocities=[50, 80, 110, 90])
Four methods:
- **crescendo()** — linear velocity ramp from ``start_vel`` to ``end_vel``.
- **decrescendo()** — same thing, but typically loud to quiet.
- **swell()** — ramps up to the midpoint, then back down. The classic
orchestral crescendo-decrescendo.
- **dynamics()** — the general form. Pass a ``(start, end)`` tuple for
a linear ramp, or a list of velocities for a custom curve.
All four accept ``articulation=`` to combine dynamics with articulations:
.. code-block:: python
# Staccato crescendo — bouncy notes getting louder
piano.crescendo(["C4","E4","G4","C5","E5","G5","C6","E6"],
Duration.EIGHTH, start_vel=40, end_vel=110,
articulation="staccato")
Part.hit() — Manual Drum Placement
-----------------------------------
The pattern system is great for grooves, but sometimes you want to
place individual drum hits with full control — articulations, effects,
and all. ``Part.hit()`` puts a drum sound into a Part's note stream:
.. code-block:: python
from pytheory import DrumSound
kit = score.part("kit", synth="sine", volume=0.7)
kit.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
kit.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH, velocity=60)
kit.hit(DrumSound.SNARE, Duration.EIGHTH, articulation="marcato")
Because hits go through the normal Part renderer, they get humanize,
effects, and articulations for free. Use this for custom beats that
don't fit a preset pattern, or for one-shot accent hits layered on
top of a pattern.
Swing and Groove
----------------
@@ -478,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
--------
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.37.0"
version = "0.38.2"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.37.0"
__version__ = "0.38.2"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+49 -4
View File
@@ -4145,10 +4145,48 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
start += _rnd.randint(-max_offset, max_offset)
start = max(0, start)
dur_ms = note.beats * 60_000 / bpm
# Articulation: adjust duration and velocity
art = getattr(note, 'articulation', '')
art_vel_mult = 1.0
art_attack_mult = 1.0 # multiplier for envelope attack
if art == 'staccato':
dur_ms *= 0.4 # short and bouncy
elif art == 'legato':
dur_ms *= 1.15 # slight overlap into next note
elif art == 'marcato':
art_vel_mult = 1.25 # heavier
art_attack_mult = 0.3 # sharper attack
elif art == 'tenuto':
art_attack_mult = 1.8 # softer attack, full duration
elif art == 'accent':
art_vel_mult = 1.2
elif art == 'fermata':
dur_ms *= 1.5 # held longer
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
if start + n_samples > total_samples:
n_samples = total_samples - start
if n_samples > 0 and start >= 0:
# Drum hit via Part.hit() — use drum synth directly
from .rhythm import _DrumTone
if isinstance(note.tone, _DrumTone):
drum_wave = _render_drum_hit(note.tone.sound.value, n_samples)
mixed = drum_wave.astype(numpy.float32)
# Staccato fade-out for drums
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume * vel_scale
if not getattr(note, '_hold', False):
beat_pos += note.beats
continue
# Get pitches
if hasattr(note.tone, 'tones'):
pitches = [t.pitch(temperament=temperament, reference_pitch=reference_pitch) for t in note.tone.tones]
@@ -4249,11 +4287,18 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
if noise_mix > 0:
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
# Amplitude envelope
if a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, a, d, s, r)
# Per-note velocity
# Amplitude envelope (articulation may adjust attack)
art_a = a * art_attack_mult
if art_a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, art_a, d, s, r)
# Staccato: apply a quick fade-out at the end
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
# Per-note velocity (articulation may boost)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
+255 -4
View File
@@ -449,6 +449,7 @@ class Note:
bend: float = 0.0
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
lyric: str = "" # syllable for vocal synth
articulation: str = "" # "", "staccato", "legato", "marcato", "tenuto", "accent", "fermata"
_hold: bool = False # if True, don't advance beat position
@property
@@ -565,6 +566,17 @@ class DrumSound(Enum):
METAL_HAT = 107 # tight, short, precise
class _DrumTone:
"""Wrapper so a DrumSound can be placed in a Part's note list."""
__slots__ = ('sound',)
def __init__(self, sound: DrumSound):
self.sound = sound
def pitch(self, **kwargs):
return -self.sound.value
class _Hit:
"""A single drum hit at a specific position in a pattern."""
__slots__ = ("sound", "position", "velocity")
@@ -2624,7 +2636,8 @@ class Part:
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
articulation: str = "") -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Duration can be a ``Duration`` enum or a raw float (beats).
@@ -2632,6 +2645,10 @@ class Part:
Bend specifies a pitch bend in semitones over the note duration
(e.g. ``bend=2`` bends up a whole step, ``bend=-1`` bends down
a half step). Used for guitar bends, sitar meends, slides.
Articulation changes how the note is played: ``"staccato"`` (short,
~40% duration), ``"legato"`` (overlaps next note), ``"marcato"``
(heavy accent), ``"tenuto"`` (full duration, soft attack),
``"accent"`` (velocity bump), ``"fermata"`` (held ~50% longer).
Returns self for chaining.
"""
@@ -2642,11 +2659,13 @@ class Part:
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type, lyric=lyric))
bend_type=bend_type, lyric=lyric,
articulation=articulation))
return self
def hold(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
articulation: str = "") -> "Part":
"""Add a note without advancing the beat position.
The note plays at the current position but the next note
@@ -2671,9 +2690,168 @@ class Part:
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type, lyric=lyric, _hold=True))
bend_type=bend_type, lyric=lyric,
articulation=articulation, _hold=True))
return self
def hit(self, sound, duration=Duration.EIGHTH, *, velocity: int = 100,
articulation: str = "") -> "Part":
"""Add a drum hit to this part.
Places a drum sound into the note stream so it goes through the
normal renderer meaning articulations, humanize, and effects
all work on individual hits.
Args:
sound: A :class:`DrumSound` enum member (e.g. ``DrumSound.KICK``).
duration: How long the hit occupies in the timeline (default 8th note).
velocity: Hit loudness 1-127.
articulation: ``"accent"``, ``"staccato"``, ``"marcato"``, etc.
Example::
>>> drums = score.part("kit", synth="sine")
>>> drums.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
>>> drums.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH)
"""
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=_DrumTone(sound), duration=duration,
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.
@@ -2757,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":
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.37.0"
version = "0.38.2"
source = { editable = "." }
dependencies = [
{ name = "numeral" },