v0.21.0: Effect automation via Part.set(), chorus effect

- Part.set() inserts automation markers for mid-song parameter changes
- Chorus effect (LFO-modulated delay, Juno-style thickening)
- Renderer segments at automation points for per-section processing
- Chain: distortion → chorus → lowpass → delay → reverb
- Docs: automation, chorus, updated signal chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:49:12 -04:00
parent a7ad8a374b
commit 2de263c814
8 changed files with 358 additions and 25 deletions
+8
View File
@@ -2,6 +2,14 @@
All notable changes to PyTheory are documented here.
## 0.21.0
- Add `Part.set()` for mid-song effect automation (filter sweeps, reverb swells, distortion kicks)
- Add chorus effect (LFO-modulated delay, Juno-style)
- Renderer segments audio at automation points for per-section effect processing
- Updated effect chain: distortion → chorus → lowpass → delay → reverb
- Document automation, chorus, and updated signal chain
## 0.20.0
- Add `Part.arpeggio()` — arpeggiator with up/down/updown/downup/random patterns, octave spanning
+46
View File
@@ -358,6 +358,52 @@ An acid lead with resonant filter and delay:
... lowpass=1500, lowpass_q=3.0,
... delay=0.3, delay_time=0.242, delay_feedback=0.4)
Chorus
~~~~~~
`Chorus <https://en.wikipedia.org/wiki/Chorus_(audio_effect)>`_
a slightly detuned, LFO-modulated delayed copy mixed back in.
Thickens the sound like two musicians playing the same part:
.. code-block:: pycon
>>> pad = score.part("pad", synth="supersaw", envelope="pad",
... chorus=0.5, chorus_rate=1.5, chorus_depth=0.003)
- ``chorus``: Wet/dry mix 0.01.0.
- ``chorus_rate``: LFO speed in Hz. 0.51 = slow shimmer, 24 = vibrato.
- ``chorus_depth``: Modulation depth in seconds (default 0.003).
Effect Automation
~~~~~~~~~~~~~~~~~
``Part.set()`` changes effect parameters mid-song at the current beat
position. The renderer splits the audio at automation points and
processes each section independently:
.. code-block:: pycon
>>> lead = score.part("lead", synth="saw", lowpass=400, lowpass_q=3.0)
>>> # Verse: filtered and clean
>>> lead.arpeggio("Cm", bars=4, pattern="up", octaves=2)
>>> # Chorus: filter opens, chorus kicks in
>>> lead.set(lowpass=2000, chorus=0.3)
>>> lead.arpeggio("Fm", bars=4, pattern="updown", octaves=2)
>>> # Drop: full send
>>> lead.set(lowpass=4000, distortion=0.7, reverb=0.3)
>>> lead.arpeggio("Gm", bars=4, pattern="updown", octaves=2)
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``reverb``,
``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
``distortion``, ``distortion_drive``, ``chorus``, ``volume``.
The updated effect chain::
Signal → Distortion → Chorus → Lowpass → Delay → Reverb → Mix
MIDI Export
-----------
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.20.0"
version = "0.21.0"
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.20.0"
__version__ = "0.21.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+117 -19
View File
@@ -838,6 +838,51 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
sample_rate=SAMPLE_RATE):
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
Chorus works by duplicating the signal, modulating the copy's delay
time with an LFO, and mixing it back. The varying delay creates
pitch wobble that thickens the sound — like two musicians playing
the same part slightly out of sync.
This is the classic Roland Juno chorus, the Boss CE-1, and every
string ensemble synth ever made.
Args:
samples: Float32 numpy array.
mix: Wet/dry ratio 0.01.0.
rate: LFO speed in Hz (default 1.5). 0.51 = slow shimmer,
24 = fast vibrato, 5+ = Leslie speaker territory.
depth: Modulation depth in seconds (default 0.003 = 3ms).
Controls how far the pitch wobbles.
sample_rate: Sample rate in Hz.
Returns:
Float32 array with chorus applied.
"""
if mix <= 0:
return samples
n = len(samples)
t = numpy.arange(n, dtype=numpy.float32) / sample_rate
# LFO modulates the delay time
base_delay = 0.007 # 7ms base delay
lfo = depth * numpy.sin(2 * numpy.pi * rate * t)
delay_samples = ((base_delay + lfo) * sample_rate).astype(numpy.int32)
# Build the modulated delayed copy
wet = numpy.zeros(n, dtype=numpy.float32)
for i in range(n):
read_pos = i - delay_samples[i]
if 0 <= read_pos < n:
wet[i] = samples[read_pos]
return samples * (1 - mix * 0.5) + wet * mix * 0.5
def _apply_distortion(samples, drive=1.0, mix=1.0):
"""Apply soft-clip distortion (tanh waveshaping).
@@ -867,22 +912,48 @@ def _apply_distortion(samples, drive=1.0, mix=1.0):
return samples * (1 - mix) + driven * mix
def _apply_effects_with_params(samples, params):
"""Apply effects using a params dict. Used for both static and automated rendering."""
# Signal chain: distortion → chorus → lowpass → delay → reverb
if params.get("distortion_mix", 0) > 0:
samples = _apply_distortion(samples,
drive=params.get("distortion_drive", 3.0),
mix=params["distortion_mix"])
if params.get("chorus_mix", 0) > 0:
samples = _apply_chorus(samples,
mix=params["chorus_mix"],
rate=params.get("chorus_rate", 1.5),
depth=params.get("chorus_depth", 0.003))
if params.get("lowpass", 0) > 0:
samples = _apply_lowpass(samples, params["lowpass"],
params.get("lowpass_q", 0.707))
if params.get("delay_mix", 0) > 0:
samples = _apply_delay(samples, mix=params["delay_mix"],
time=params.get("delay_time", 0.375),
feedback=params.get("delay_feedback", 0.4))
if params.get("reverb_mix", 0) > 0:
samples = _apply_reverb(samples, mix=params["reverb_mix"],
decay=params.get("reverb_decay", 1.0))
return samples
def _apply_part_effects(samples, part):
"""Apply all effects configured on a Part to a float32 buffer."""
# Distortion first (before filter, like a real signal chain)
if part.distortion_mix > 0:
samples = _apply_distortion(samples, drive=part.distortion_drive,
mix=part.distortion_mix)
if part.lowpass > 0:
samples = _apply_lowpass(samples, part.lowpass, part.lowpass_q)
if part.delay_mix > 0:
samples = _apply_delay(samples, mix=part.delay_mix,
time=part.delay_time,
feedback=part.delay_feedback)
if part.reverb_mix > 0:
samples = _apply_reverb(samples, mix=part.reverb_mix,
decay=part.reverb_decay)
return samples
params = {
"distortion_mix": part.distortion_mix,
"distortion_drive": part.distortion_drive,
"chorus_mix": part.chorus_mix,
"chorus_rate": part.chorus_rate,
"chorus_depth": part.chorus_depth,
"lowpass": part.lowpass,
"lowpass_q": part.lowpass_q,
"delay_mix": part.delay_mix,
"delay_time": part.delay_time,
"delay_feedback": part.delay_feedback,
"reverb_mix": part.reverb_mix,
"reverb_decay": part.reverb_decay,
}
return _apply_effects_with_params(samples, params)
def _resolve_synth(name):
@@ -1053,11 +1124,38 @@ def render_score(score):
_render_notes_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm)
# Apply per-part effects
has_fx = (part.lowpass > 0 or part.delay_mix > 0
or part.reverb_mix > 0 or part.distortion_mix > 0)
if has_fx:
part_buf = _apply_part_effects(part_buf, part)
# Apply effects — segmented if automation exists
auto_points = part._get_automation_points()
if auto_points:
# Split buffer at automation boundaries, process each segment
boundaries = sorted(set([0.0] + auto_points + [total_beats]))
for i in range(len(boundaries) - 1):
seg_start_beat = boundaries[i]
seg_end_beat = boundaries[i + 1]
seg_start = int(seg_start_beat * samples_per_beat)
seg_end = min(int(seg_end_beat * samples_per_beat),
total_samples)
if seg_end <= seg_start:
continue
params = part._get_params_at(seg_start_beat)
segment = part_buf[seg_start:seg_end].copy()
has_fx = any(params.get(k, 0) > 0 for k in
["distortion_mix", "chorus_mix", "lowpass",
"delay_mix", "reverb_mix"])
if has_fx:
segment = _apply_effects_with_params(segment, params)
# Apply volume automation
seg_vol = params.get("volume", part.volume)
if seg_vol != part.volume:
segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment
part_buf[seg_start:seg_end] = segment
else:
has_fx = (part.lowpass > 0 or part.delay_mix > 0
or part.reverb_mix > 0 or part.distortion_mix > 0
or part.chorus_mix > 0)
if has_fx:
part_buf = _apply_part_effects(part_buf, part)
buf += part_buf
# Drum hits
+88 -3
View File
@@ -1360,7 +1360,9 @@ class Part:
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0,
legato: bool = False, glide: float = 0.0):
legato: bool = False, glide: float = 0.0,
chorus: float = 0.0, chorus_rate: float = 1.5,
chorus_depth: float = 0.003):
self.name = name
self.synth = synth
self.envelope = envelope
@@ -1376,7 +1378,11 @@ class Part:
self.distortion_drive = distortion_drive
self.legato = legato
self.glide = glide
self.chorus_mix = chorus
self.chorus_rate = chorus_rate
self.chorus_depth = chorus_depth
self.notes: list[Note] = []
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER) -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
@@ -1393,6 +1399,81 @@ class Part:
self.notes.append(Note(tone=tone_or_string, duration=duration))
return self
def set(self, **params) -> "Part":
"""Change effect parameters at the current beat position.
Inserts an automation marker — from this point forward, the
specified parameters take new values. Use this to open filters,
add reverb, kick in distortion, or change volume mid-song.
Args:
**params: Any Part parameter — ``lowpass``, ``lowpass_q``,
``reverb``, ``reverb_decay``, ``delay``, ``delay_time``,
``delay_feedback``, ``distortion``, ``distortion_drive``,
``volume``, ``chorus``, ``chorus_rate``, ``chorus_depth``.
Returns:
Self for chaining.
Example::
>>> lead = score.part("lead", synth="saw", lowpass=800)
>>> lead.add("C5", Duration.WHOLE) # filtered
>>> lead.set(lowpass=3000, reverb=0.4) # filter opens
>>> lead.add("E5", Duration.WHOLE) # bright + reverb
>>> lead.set(distortion=0.6, lowpass=1500) # grit
>>> lead.add("G5", Duration.WHOLE)
"""
beat_pos = sum(n.beats for n in self.notes)
# Map shorthand param names to internal attribute names
param_map = {
"reverb": "reverb_mix", "delay": "delay",
"distortion": "distortion", "chorus": "chorus_mix",
}
mapped = {}
for k, v in params.items():
attr = param_map.get(k, k)
# Handle the special naming conventions
if k == "reverb":
mapped["reverb_mix"] = v
elif k == "delay":
mapped["delay_mix"] = v
elif k == "distortion":
mapped["distortion_mix"] = v
elif k == "chorus":
mapped["chorus_mix"] = v
else:
mapped[k] = v
self._automation.append((beat_pos, mapped))
return self
def _get_params_at(self, beat: float) -> dict:
"""Get the effective parameters at a given beat position."""
# Start with initial values
params = {
"volume": self.volume,
"reverb_mix": self.reverb_mix, "reverb_decay": self.reverb_decay,
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
"delay_feedback": self.delay_feedback,
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
"distortion_mix": self.distortion_mix,
"distortion_drive": self.distortion_drive,
"chorus_mix": self.chorus_mix, "chorus_rate": self.chorus_rate,
"chorus_depth": self.chorus_depth,
}
# Apply automation up to the given beat
for auto_beat, changes in sorted(self._automation, key=lambda a: a[0]):
if auto_beat <= beat:
params.update(changes)
else:
break
return params
def _get_automation_points(self) -> list[float]:
"""Return sorted list of beat positions where parameters change."""
points = sorted(set(beat for beat, _ in self._automation))
return points
def rest(self, duration=Duration.QUARTER) -> "Part":
"""Add a rest. Returns self for chaining."""
if isinstance(duration, (int, float)):
@@ -1545,7 +1626,9 @@ class Score:
delay_feedback: float = 0.4,
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0,
legato: bool = False, glide: float = 0.0) -> Part:
legato: bool = False, glide: float = 0.0,
chorus: float = 0.0, chorus_rate: float = 1.5,
chorus_depth: float = 0.003) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
@@ -1591,7 +1674,9 @@ class Score:
delay_feedback=delay_feedback,
lowpass=lowpass, lowpass_q=lowpass_q,
distortion=distortion, distortion_drive=distortion_drive,
legato=legato, glide=glide)
legato=legato, glide=glide,
chorus=chorus, chorus_rate=chorus_rate,
chorus_depth=chorus_depth)
self.parts[name] = p
return p
+96
View File
@@ -5581,3 +5581,99 @@ def test_arpeggio_updown_length():
division=Duration.EIGHTH)
# 2 bars of 8ths = 16 notes
assert len(lead) == 16
# ── Part.set() automation ─────────────────────────────────────────────────
def test_part_set_stores_automation():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=2000)
lead.add("E5", Duration.WHOLE)
assert len(lead._automation) == 1
assert lead._automation[0][0] == 4.0
def test_part_set_chaining():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead")
result = lead.set(lowpass=1000, reverb=0.3)
assert result is lead
def test_part_get_params_at():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", lowpass=500)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=2000, reverb=0.4)
lead.add("E5", Duration.WHOLE)
p0 = lead._get_params_at(0)
assert p0["lowpass"] == 500
assert p0["reverb_mix"] == 0
p4 = lead._get_params_at(4.0)
assert p4["lowpass"] == 2000
assert p4["reverb_mix"] == 0.4
@needs_portaudio
def test_automation_changes_output():
from pytheory import Score, Duration
from pytheory.play import render_score
s1 = Score("4/4", bpm=120)
s1.part("lead", synth="saw", lowpass=500).add("C5", Duration.WHOLE).add("C5", Duration.WHOLE)
buf1 = render_score(s1)
s2 = Score("4/4", bpm=120)
p2 = s2.part("lead", synth="saw", lowpass=500)
p2.add("C5", Duration.WHOLE)
p2.set(lowpass=5000)
p2.add("C5", Duration.WHOLE)
buf2 = render_score(s2)
assert not numpy.allclose(buf1, buf2, atol=0.01)
def test_part_set_multiple():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
lead = score.part("lead", lowpass=400)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=1000)
lead.add("C5", Duration.WHOLE)
lead.set(lowpass=3000, distortion=0.5)
lead.add("C5", Duration.WHOLE)
assert len(lead._automation) == 2
p8 = lead._get_params_at(8.0)
assert p8["lowpass"] == 3000
assert p8["distortion_mix"] == 0.5
# ── Chorus effect ──────────────────────────────────────────────────────────
@needs_portaudio
def test_chorus_effect():
from pytheory.play import _apply_chorus
t = numpy.arange(44100, dtype=numpy.float32) / 44100
signal = numpy.sin(2 * numpy.pi * 440 * t).astype(numpy.float32)
wet = _apply_chorus(signal, mix=0.5)
assert not numpy.allclose(signal, wet, atol=0.01)
@needs_portaudio
def test_chorus_zero_mix():
from pytheory.play import _apply_chorus
dry = numpy.random.uniform(-1, 1, 1000).astype(numpy.float32)
result = _apply_chorus(dry, mix=0.0)
assert numpy.allclose(result, dry)
@needs_portaudio
def test_part_with_chorus():
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
score.part("lead", synth="saw", chorus=0.5).add("C5", Duration.WHOLE)
buf = render_score(score)
assert len(buf) > 0
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.20.0"
version = "0.21.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },