diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb0670..6e4f046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/playback.rst b/docs/guide/playback.rst index 483a9aa..f719c73 100644 --- a/docs/guide/playback.rst +++ b/docs/guide/playback.rst @@ -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 `_ — +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 ----------- diff --git a/pyproject.toml b/pyproject.toml index 7e58eb7..c64ce78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 9b8aed6..3b30c98 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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 diff --git a/pytheory/play.py b/pytheory/play.py index 14b4738..e35b527 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -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 diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 687296c..c808cd5 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -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 diff --git a/test_pytheory.py b/test_pytheory.py index 0e16675..fb4d3e3 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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 diff --git a/uv.lock b/uv.lock index 184b95e..a11bccc 100644 --- a/uv.lock +++ b/uv.lock @@ -612,7 +612,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.20.0" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "numeral" },