Compare commits

..

4 Commits

Author SHA1 Message Date
kennethreitz b29b33524f v0.30.0: Drums as Parts, split drums, kick-only sidechain, MIDI import
- Drums are real Parts with full effects pipeline
- split=True creates kick/snare/hats/toms/cymbals/percussion Parts
- Sidechain triggers on kick only
- Score.from_midi() imports Standard MIDI Files
- Document split drums workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:27:10 -04:00
kennethreitz 25f25c1f23 Split drums into separate Parts: kick, snare, hats, toms, cymbals, percussion
score.drums("rock", split=True) creates independent Parts per group.
Each gets its own effects chain. set_drum_effects() applies to all.
Sidechain triggers on kick only. Render loop handles multiple drum Parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:26:08 -04:00
kennethreitz 3f1d632285 Sidechain triggers on kick only, not all drum hits
Hi-hats and snares no longer duck the pad — only the kick does.
This is how sidechain compression works in real mixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:22:20 -04:00
kennethreitz 1938037458 Update changelog: drums as Part
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:20:05 -04:00
7 changed files with 178 additions and 75 deletions
+15
View File
@@ -2,6 +2,21 @@
All notable changes to PyTheory are documented here.
## 0.30.0
- Drums are a real Part — same effects pipeline as any voice
- `score.drums("rock", split=True)` splits kit into kick/snare/hats/toms/cymbals/percussion Parts
- Each split Part gets independent effects (reverb on snare, LP on hats, etc.)
- `set_drum_effects()` applies to all drum Parts (split or not)
- Sidechain triggers on kick only — hats and snare don't duck the pad
- MIDI import via `Score.from_midi(path)`
## 0.29.3
- Drums are now a real Part — same effects pipeline as any other voice, zero code duplication
- `score.parts["drums"]` is a standard Part with reverb, delay, lowpass, etc.
- `set_drum_effects()` is sugar over the Part's attributes
## 0.29.2
- Add `score.set_drum_effects()` — reverb, delay, lowpass, distortion, chorus on the drum bus
+44
View File
@@ -29,6 +29,50 @@ Score:
The default is 0.15 — just enough to feel alive without sounding loose.
Drums Are Parts
~~~~~~~~~~~~~~~~
Drums are a real Part — the same as any melodic voice. You can set
effects on them the same way:
.. code-block:: python
score.drums("rock", repeats=4)
score.parts["drums"].reverb_mix = 0.2
score.parts["drums"].reverb_type = "plate"
Or use the shorthand:
.. code-block:: python
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
Split Drums
~~~~~~~~~~~
For maximum control, split the kit into separate Parts — kick, snare,
hats, toms, cymbals, and percussion — each with independent effects:
.. code-block:: python
score.drums("rock", repeats=4, split=True)
# Now each group is its own Part
score.parts["snare"].reverb_mix = 0.3
score.parts["snare"].reverb_type = "plate"
score.parts["hats"].lowpass = 7000
score.parts["kick"] # dry, no effects
# set_drum_effects still works — applies to all drum Parts
score.set_drum_effects(reverb=0.1)
This is how real studios work — the snare gets its own reverb send,
the hats get their own EQ, the kick stays dry and punchy. Now you
can do the same thing in Python.
Sidechain compression triggers on kick hits only — hi-hats and snares
don't duck the pad.
Every drum sound is stereo-panned like a real kit — kick and snare
center, hi-hat right, crash left, toms spread across the field,
percussion instruments placed naturally. Put on headphones and you'll
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.29.0"
version = "0.30.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.29.0"
__version__ = "0.30.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+45 -42
View File
@@ -1952,54 +1952,57 @@ def render_score(score):
DrumSound.MARACAS.value: 0.3,
}
# Drum hits — render to mono sidechain trigger + stereo output
# Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.)
import random as _drum_rnd
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain (kick only)
drum_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
drum_swing = score.swing
drum_humanize = getattr(score, '_drum_humanize', 0.3) # subtle by default
for hit in score._drum_hits:
pos = hit.position
if drum_swing > 0:
beat_frac = pos % 1.0
if 0.1 < beat_frac < 0.9:
pos += drum_swing * 0.15
if has_tempo_changes:
start = _beat_to_sample(pos, tempo_map)
else:
start = int(pos * samples_per_beat)
# Humanize: random timing jitter + velocity variation
if drum_humanize > 0:
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
start += _drum_rnd.randint(-max_offset, max_offset)
start = max(0, start)
if start >= total_samples or start < 0:
continue
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
vel = hit.velocity
if drum_humanize > 0:
vel_jitter = int(drum_humanize * 10)
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
mono_hit = wave * vel_scale * 0.7
# Mono sidechain trigger (always center)
drum_buf[start:start + hit_len] += mono_hit
# Stereo panned output
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
panned = _pan_to_stereo(mono_hit, pan)
drum_stereo[start:start + hit_len] += panned
drum_humanize = getattr(score, '_drum_humanize', 0.15)
# Apply drum Part effects through the same pipeline as any other Part
drums_part = score.parts.get("drums")
if drums_part:
has_drum_fx = (drums_part.lowpass > 0 or drums_part.delay_mix > 0
or drums_part.reverb_mix > 0 or drums_part.distortion_mix > 0
or drums_part.chorus_mix > 0)
drum_parts = [p for p in score.parts.values() if p.is_drums]
for drum_part in drum_parts:
part_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
for hit in drum_part._drum_hits:
pos = hit.position
if drum_swing > 0:
beat_frac = pos % 1.0
if 0.1 < beat_frac < 0.9:
pos += drum_swing * 0.15
if has_tempo_changes:
start = _beat_to_sample(pos, tempo_map)
else:
start = int(pos * samples_per_beat)
if drum_humanize > 0:
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
start += _drum_rnd.randint(-max_offset, max_offset)
start = max(0, start)
if start >= total_samples or start < 0:
continue
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
vel = hit.velocity
if drum_humanize > 0:
vel_jitter = int(drum_humanize * 10)
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
mono_hit = wave * vel_scale * 0.7
# Sidechain trigger — kick only
if hit.sound.value == DrumSound.KICK.value:
drum_buf[start:start + hit_len] += mono_hit
# Stereo panned output for this drum Part
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
panned = _pan_to_stereo(mono_hit, pan)
part_stereo[start:start + hit_len] += panned
# Apply this drum Part's effects
has_drum_fx = (drum_part.lowpass > 0 or drum_part.delay_mix > 0
or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0
or drum_part.chorus_mix > 0)
if has_drum_fx:
for ch in range(2):
drum_stereo[:, ch] = _apply_part_effects(drum_stereo[:, ch], drums_part)
part_stereo[:, ch] = _apply_part_effects(part_stereo[:, ch], drum_part)
drum_stereo += part_stereo
# Apply sidechain compression to parts that request it
for part, part_buf in _pending_sidechain:
+71 -30
View File
@@ -1837,21 +1837,24 @@ class Score:
}
def set_drum_effects(self, **kwargs) -> "Score":
"""Set effects on the drum bus.
"""Set effects on all drum parts.
The drums Part is a real Part — set effects the same way
you would on any other part.
When drums are split, applies to every drum Part (kick, snare,
hats, etc.). When not split, applies to the single drums Part.
Example::
score.set_drum_effects(reverb=0.2, reverb_type="plate")
"""
p = self._ensure_drums_part()
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix"}
for k, v in kwargs.items():
attr = param_map.get(k, k)
setattr(p, attr, v)
drum_parts = [p for p in self.parts.values() if p.is_drums]
if not drum_parts:
drum_parts = [self._ensure_drums_part()]
for p in drum_parts:
for k, v in kwargs.items():
attr = param_map.get(k, k)
setattr(p, attr, v)
return self
def part(self, name: str, *, synth: str = "sine",
@@ -1967,46 +1970,84 @@ class Score:
return self.add_pattern(fill_pattern, repeats=1)
def drums(self, preset: str, repeats: int = 4, fill: str = None,
fill_every: int = None) -> "Score":
"""Add a drum pattern by preset name, with optional auto-fills.
# Drum sound groups for split mode
_DRUM_GROUPS = {
"kick": {DrumSound.KICK.value},
"snare": {DrumSound.SNARE.value, DrumSound.RIMSHOT.value, DrumSound.CLAP.value},
"hats": {DrumSound.CLOSED_HAT.value, DrumSound.OPEN_HAT.value, DrumSound.PEDAL_HAT.value},
"toms": {DrumSound.LOW_TOM.value, DrumSound.MID_TOM.value, DrumSound.HIGH_TOM.value},
"cymbals": {DrumSound.CRASH.value, DrumSound.RIDE.value, DrumSound.RIDE_BELL.value},
"percussion": {DrumSound.COWBELL.value, DrumSound.CLAVE.value, DrumSound.SHAKER.value,
DrumSound.TAMBOURINE.value, DrumSound.CONGA_HIGH.value, DrumSound.CONGA_LOW.value,
DrumSound.BONGO_HIGH.value, DrumSound.BONGO_LOW.value, DrumSound.TIMBALE_HIGH.value,
DrumSound.TIMBALE_LOW.value, DrumSound.AGOGO_HIGH.value, DrumSound.AGOGO_LOW.value,
DrumSound.GUIRO.value, DrumSound.MARACAS.value},
}
Shorthand for ``score.add_pattern(Pattern.preset(name), repeats=n)``.
def drums(self, preset: str, repeats: int = 4, fill: str = None,
fill_every: int = None, split: bool = False) -> "Score":
"""Add a drum pattern by preset name, with optional auto-fills.
Args:
preset: Pattern preset name (e.g. ``"bossa nova"``, ``"rock"``).
repeats: Number of times to repeat (default 4).
fill: Optional fill name. When provided, groove bars are
periodically replaced with the named fill pattern.
fill_every: Replace every Nth bar with a fill. If *fill* is
provided but *fill_every* is not, defaults to filling only
the last bar.
fill: Optional fill name.
fill_every: Replace every Nth bar with a fill.
split: If True, create separate Parts for kick, snare, hats,
toms, cymbals, and percussion — each with independent
effects. Access via ``score.parts["kick"]``, etc.
Returns:
Self for chaining.
Example::
>>> score = Score("4/4", bpm=140)
>>> score.drums("bossa nova", repeats=4)
>>> score.drums("rock", repeats=4, split=True)
>>> score.parts["snare"].reverb_mix = 0.3
>>> score.parts["hats"].lowpass = 6000
"""
if fill is None:
return self.add_pattern(Pattern.preset(preset), repeats=repeats)
self.add_pattern(Pattern.preset(preset), repeats=repeats)
else:
groove = Pattern.preset(preset)
fill_pattern = Pattern.fill(fill)
if fill_every is None:
fill_every = repeats
for bar in range(1, repeats + 1):
if bar % fill_every == 0:
self.add_pattern(fill_pattern, repeats=1)
else:
self.add_pattern(groove, repeats=1)
groove = Pattern.preset(preset)
fill_pattern = Pattern.fill(fill)
if split:
self._split_drums()
if fill_every is None:
# Fill only the last bar
fill_every = repeats
for bar in range(1, repeats + 1):
if bar % fill_every == 0:
self.add_pattern(fill_pattern, repeats=1)
else:
self.add_pattern(groove, repeats=1)
return self
def _split_drums(self):
"""Move drum hits from the 'drums' Part into separate group Parts."""
drums_part = self.parts.get("drums")
if not drums_part:
return
all_hits = list(drums_part._drum_hits)
pattern_beats = drums_part._drum_pattern_beats
drums_part._drum_hits.clear()
drums_part._drum_pattern_beats = 0.0
for group_name, sound_values in self._DRUM_GROUPS.items():
group_hits = [h for h in all_hits if h.sound.value in sound_values]
if group_hits:
if group_name not in self.parts:
self.parts[group_name] = Part(group_name, synth="sine", volume=0.7)
p = self.parts[group_name]
p._drum_hits.extend(group_hits)
p._drum_pattern_beats = max(p._drum_pattern_beats, pattern_beats)
# Remove empty drums Part
if not drums_part._drum_hits and "drums" in self.parts:
del self.parts["drums"]
def add(self, tone_or_chord, duration=Duration.QUARTER) -> "Score":
"""Add a note to the default (unnamed) part.
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.29.0"
version = "0.30.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },