mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.0–1.0.
|
||||
- ``chorus_rate``: LFO speed in Hz. 0.5–1 = slow shimmer, 2–4 = 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
@@ -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,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
@@ -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.0–1.0.
|
||||
rate: LFO speed in Hz (default 1.5). 0.5–1 = slow shimmer,
|
||||
2–4 = 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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user