mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b29b33524f | |||
| 25f25c1f23 | |||
| 3f1d632285 | |||
| 1938037458 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user