Compare commits

...

3 Commits

Author SHA1 Message Date
kennethreitz 840bfcc36c v0.37.0: Djembe expansion and cross-choke drum damping
5 new djembe patterns (dununba, tiriba, yankadi, djansa, mendiani),
3 djembe fills, cross-choke damping across drum families, and
improved djembe slap synthesis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:32:14 -04:00
kennethreitz 938c1cc132 v0.36.6: Cajón and metal drum fills
Add 6 new drum fills: cajon flam, cajon rumble, cajon breakdown,
metal triplet, metal blast, metal cascade. 27 fills total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:25:52 -04:00
kennethreitz 9dc22db4b2 v0.36.5: Duration arithmetic support
Duration enum now supports multiplication, division, and addition
so expressions like `Duration.WHOLE * 2` work instead of raising TypeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:19:28 -04:00
8 changed files with 353 additions and 34 deletions
+21
View File
@@ -2,6 +2,27 @@
All notable changes to PyTheory are documented here.
## 0.37.0
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
- **3 new djembe fills** — djembe call, djembe roll, djembe break (30 fills total)
- **Cross-choke drum damping** — striking one sound on a hand drum fades
out the ring of related sounds (djembe slap kills bass resonance, closed
hat chokes open hat, cajón slap dampens bass, doumbek tek dampens dum)
- **Improved djembe slap** — dry, high-pitched goatskin pop instead of
snare-like noise rattle
## 0.36.6
- **6 new drum fills** — 3 cajón (flam, rumble, breakdown) and 3 metal
(triplet, blast, cascade). 27 fills total.
- Updated drums documentation with fill lists and examples
## 0.36.5
- **Duration arithmetic** — `Duration.WHOLE * 2`, `Duration.HALF + Duration.QUARTER`,
division, and reverse multiply all work now (previously raised TypeError)
## 0.36.3
- **`Part.hold()`** — polyphonic overlap on a single part. Add notes
+29 -11
View File
@@ -10,7 +10,7 @@ the genre -- they tell the listener's body how to move before a single
melodic note is played.
PyTheory includes a complete drum system -- 51 synthesized percussion
sounds, 80+ pattern presets across dozens of genres, and 21 fill presets.
sounds, 85+ pattern presets across dozens of genres, and 30 fill presets.
Every sound is generated from waveforms; no samples needed.
Drum Sounds
@@ -252,14 +252,17 @@ ending and a new one is about to begin. Without fills, a drum pattern
just loops. With them, it breathes and has structure.
``Pattern.fill()`` loads a 1-bar drum fill -- a short break that
transitions between sections. 21 fill presets are available:
transitions between sections. 30 fill presets are available:
.. code-block:: pycon
>>> Pattern.list_fills()
['afrobeat', 'blast', 'bossa nova', 'breakdown', 'buildup',
'cumbia', 'disco', 'funk', 'highlife', 'hip hop', 'house',
'jazz', 'jazz brush', 'metal', 'reggae', 'rock', 'rock crash',
'cajon breakdown', 'cajon flam', 'cajon rumble',
'cumbia', 'disco', 'djembe break', 'djembe call', 'djembe roll',
'funk', 'highlife', 'hip hop', 'house',
'jazz', 'jazz brush', 'metal', 'metal blast', 'metal cascade',
'metal triplet', 'reggae', 'rock', 'rock crash',
'salsa', 'samba', 'second line', 'trap']
>>> fill = Pattern.fill("rock")
@@ -433,14 +436,19 @@ central to the drum ensemble traditions of Mali, Guinea, and Senegal.
**3 sounds** -- bass (open center strike), tone (edge strike), and
slap (sharp edge strike).
**3 patterns:** djembe (a basic accompanying rhythm), kuku (a
traditional rhythm from Guinea associated with fishing), and soli (a
solo/celebration rhythm).
**8 patterns:** djembe (basic accompanying rhythm), kuku (Guinean harvest
dance), soli (powerful Mandinka rhythm), dununba (heavy bass-driven),
tiriba (joyful Susu rhythm), yankadi (gentle greeting/welcome), djansa
(fast Malinke dance), mendiani (women's celebratory dance).
**3 fills:** djembe call (bass-tone-slap conversation building to climax),
djembe roll (rapid slaps accelerating into bass), djembe break (syncopated
West African-style break).
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("djembe", repeats=4)
score.drums("djembe", repeats=8, fill="djembe call", fill_every=4)
Metal Kit
~~~~~~~~~
@@ -456,10 +464,15 @@ metal blast (blast beat with china cymbal accents), metal groove (a
half-time groove with double kick fills), and metal gallop (the
classic triplet-feel gallop rhythm).
**4 fills:** metal (double kick 16ths with descending toms), metal triplet
(double kick triplets with snare accents), metal blast (alternating
snare/kick 32nds into half-time crash), metal cascade (descending snare
roll → kick roll → alternating → crash ending).
.. code-block:: python
score = Score("4/4", bpm=200)
score.drums("metal blast", repeats=4)
score.drums("metal blast", repeats=8, fill="metal cascade", fill_every=4)
Cajón
~~~~~
@@ -468,15 +481,20 @@ The cajón is a box-shaped percussion instrument from Peru, now
ubiquitous in acoustic and unplugged settings worldwide. Players sit
on the box and strike the front face with their hands.
**2 sounds** -- slap (sharp, snare-like) and tap (bass-like).
**3 sounds** -- bass (deep center thump), slap (sharp, snare-like edge
hit with wire buzz), and tap (light finger tap).
**3 patterns:** cajon (basic groove), cajon rumba (flamenco-style rumba),
and cajon folk (folk/acoustic pattern).
**3 fills:** cajon flam (slaps accelerating into bass hits), cajon rumble
(fast taps building to slap accents), cajon breakdown (syncopated
bass-slap groove).
.. code-block:: python
score = Score("4/4", bpm=100)
score.drums("cajon", repeats=4)
score.drums("cajon", repeats=8, fill="cajon flam", fill_every=4)
MIDI Export
-----------
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.36.4"
version = "0.37.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.36.4"
__version__ = "0.37.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+47 -20
View File
@@ -2890,29 +2890,27 @@ def _synth_djembe_tone(n_samples):
def _synth_djembe_slap(n_samples):
"""Djembe slap — edge strike with fingers spread, sharp crack.
The highest, sharpest djembe sound. Fingers fan out on contact
creating a loud crack with minimal sustain.
The highest, sharpest djembe sound. A dry, high-pitched pop from
goatskin membrane — NOT a snare. Tight attack, very short decay,
skin character rather than wire rattle.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Sharp crack — mostly noise
crack_len = min(int(SAMPLE_RATE * 0.02), n_samples)
crack = _noise(crack_len) * _exp_decay(crack_len, 100) * 1.0
# Brief high-pitched ring
ring = numpy.sin(2 * numpy.pi * 600 * t) * _exp_decay(n_samples, 25) * 0.4
ring2 = numpy.sin(2 * numpy.pi * 1200 * t) * 0.2 * _exp_decay(n_samples, 35)
# Brief membrane pop
thump_len = min(int(SAMPLE_RATE * 0.02), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [300, 2000], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
# High membrane pop — goatskin resonance, much higher than snare
pop = numpy.sin(2 * numpy.pi * 900 * t) * _exp_decay(n_samples, 50) * 0.5
pop2 = numpy.sin(2 * numpy.pi * 1600 * t) * _exp_decay(n_samples, 60) * 0.25
pop3 = numpy.sin(2 * numpy.pi * 2400 * t) * _exp_decay(n_samples, 80) * 0.12
# Very short filtered click — hand-on-skin transient, not noise rattle
click_len = min(int(SAMPLE_RATE * 0.008), n_samples)
click_raw = _noise(click_len)
if click_len > 20:
bl, al = scipy.signal.butter(2, 1800 / (SAMPLE_RATE / 2), btype='high')
click = scipy.signal.lfilter(bl, al, numpy.pad(click_raw, (0, max(0, n_samples - click_len))))[:click_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 80) * 0.8
result = ring + ring2
result[:crack_len] += crack
result[:thump_len] += thump
return numpy.tanh(result * 1.7)
click = click_raw
click *= _exp_decay(click_len, 150) * 0.6
result = pop + pop2 + pop3
result[:click_len] += click
return numpy.tanh(result * 1.5)
def _synth_guiro(n_samples):
@@ -4661,6 +4659,35 @@ def render_score(score):
part_stereo[fade_start:start, ch] *= fade
_last_hit_start[sound_id] = start
# Cross-choke: a new hit on one sound dampens the ring of
# related sounds on the same instrument (e.g. djembe slap
# kills the bass resonance, closed hat kills open hat).
_CHOKE_GROUPS = {
# Djembe — any strike dampens the others
DrumSound.DJEMBE_BASS.value: (DrumSound.DJEMBE_TONE.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_TONE.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_SLAP.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_TONE.value),
# Hi-hats — closed chokes open
DrumSound.CLOSED_HAT.value: (DrumSound.OPEN_HAT.value,),
DrumSound.PEDAL_HAT.value: (DrumSound.OPEN_HAT.value,),
# Cajón — slap dampens bass ring
DrumSound.CAJON_SLAP.value: (DrumSound.CAJON_BASS.value,),
DrumSound.CAJON_TAP.value: (DrumSound.CAJON_BASS.value,),
# Doumbek — tek/ka dampen dum
DrumSound.DOUMBEK_TEK.value: (DrumSound.DOUMBEK_DUM.value,),
DrumSound.DOUMBEK_KA.value: (DrumSound.DOUMBEK_DUM.value,),
}
choke_targets = _CHOKE_GROUPS.get(sound_id, ())
for target_id in choke_targets:
if target_id in _last_hit_start:
prev_start = _last_hit_start[target_id]
fade_len = min(int(SAMPLE_RATE * 0.004), max(0, start - prev_start))
if fade_len > 0 and start > 0:
fade = numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
fade_start = max(0, start - fade_len)
for ch in range(2):
part_stereo[fade_start:start, ch] *= fade
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
+240
View File
@@ -388,6 +388,24 @@ class Duration(Enum):
DOTTED_QUARTER = 1.5
TRIPLET_QUARTER = 2 / 3
# Arithmetic — lets you write ``Duration.WHOLE * 2`` → 8.0 beats.
def __mul__(self, other):
return self.value * other
def __rmul__(self, other):
return self.value * other
def __truediv__(self, other):
return self.value / other
def __add__(self, other):
if isinstance(other, Duration):
return self.value + other.value
return self.value + other
def __radd__(self, other):
return other + self.value
class TimeSignature:
"""A musical time signature like 4/4 or 6/8."""
@@ -1889,6 +1907,74 @@ Pattern._PRESETS["soli"] = dict(
],
)
# Dununba — heavy bass-driven rhythm (accompaniment djembe part)
Pattern._PRESETS["dununba"] = dict(
name="dununba",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 110), _h(JB, 0.5, 95),
_h(JT, 1.0, 75), _h(JB, 1.5, 100),
_h(JB, 2.0, 108), _h(JT, 2.5, 70),
_h(JB, 3.0, 105), _h(JB, 3.5, 90), _h(JT, 3.75, 65),
],
)
# Tiriba — joyful Susu rhythm from Guinea
Pattern._PRESETS["tiriba"] = dict(
name="tiriba",
time_signature="4/4",
beats=4.0,
hits=[
_h(JT, 0.0, 85), _h(JS, 0.25, 95), _h(JT, 0.5, 80),
_h(JB, 1.0, 100), _h(JT, 1.5, 75),
_h(JS, 2.0, 92), _h(JT, 2.25, 78), _h(JT, 2.5, 80),
_h(JB, 3.0, 105), _h(JS, 3.5, 88), _h(JT, 3.75, 72),
],
)
# Yankadi — gentle greeting/welcome rhythm from Guinea
Pattern._PRESETS["yankadi"] = dict(
name="yankadi",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 90), _h(JT, 0.5, 70),
_h(JT, 1.0, 72), _h(JS, 1.5, 85),
_h(JB, 2.0, 88), _h(JT, 2.5, 68),
_h(JS, 3.0, 82), _h(JT, 3.5, 65),
],
)
# Djansa — fast Malinke dance rhythm
Pattern._PRESETS["djansa"] = dict(
name="djansa",
time_signature="4/4",
beats=4.0,
hits=[
_h(JS, 0.0, 100), _h(JT, 0.25, 72), _h(JT, 0.5, 70),
_h(JB, 0.75, 95),
_h(JS, 1.0, 98), _h(JT, 1.25, 68), _h(JB, 1.5, 92),
_h(JS, 2.0, 102), _h(JT, 2.25, 75), _h(JT, 2.5, 72),
_h(JB, 2.75, 90),
_h(JS, 3.0, 105), _h(JT, 3.25, 70), _h(JB, 3.5, 95),
_h(JS, 3.75, 88),
],
)
# Mendiani — women's dance rhythm, celebratory
Pattern._PRESETS["mendiani"] = dict(
name="mendiani",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 100), _h(JT, 0.25, 65), _h(JS, 0.5, 90),
_h(JT, 1.0, 70), _h(JB, 1.5, 95), _h(JT, 1.75, 68),
_h(JS, 2.0, 92), _h(JT, 2.5, 72), _h(JS, 2.75, 85),
_h(JB, 3.0, 105), _h(JT, 3.25, 65), _h(JS, 3.5, 95),
],
)
# ── Fill presets ──────────────────────────────────────────────────────────
Pattern._FILLS["rock"] = dict(
@@ -2274,6 +2360,160 @@ Pattern._FILLS["tabla call"] = dict(
],
)
# ── Djembe fills ─────────────────────────────────────────────────────────
# Djembe call — bass-tone-slap conversation building to climax
Pattern._FILLS["djembe call"] = dict(
name="djembe call fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 100), _h(JT, 0.25, 70), _h(JT, 0.5, 72),
_h(JS, 0.75, 90),
_h(JB, 1.0, 95), _h(JT, 1.25, 68), _h(JS, 1.5, 88),
_h(JT, 1.75, 75),
_h(JS, 2.0, 100), _h(JS, 2.25, 95), _h(JT, 2.5, 78),
_h(JB, 2.75, 105),
_h(JS, 3.0, 110), _h(JT, 3.25, 80), _h(JS, 3.5, 112),
_h(JB, 3.75, 120),
],
)
# Djembe roll — rapid slaps accelerating into bass
Pattern._FILLS["djembe roll"] = dict(
name="djembe roll fill",
time_signature="4/4",
beats=4.0,
hits=[
# Accelerating slap roll
*[_h(JS, i * 0.125, 50 + i * 4) for i in range(16)],
# Bass accents punching through
_h(JB, 2.0, 105), _h(JB, 2.5, 108),
_h(JB, 3.0, 112), _h(JT, 3.25, 85),
_h(JB, 3.5, 115), _h(JS, 3.75, 100),
],
)
# Djembe break — syncopated West African-style break
Pattern._FILLS["djembe break"] = dict(
name="djembe break fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 105), _h(JT, 0.25, 65), _h(JS, 0.5, 90),
_h(JT, 0.75, 70), _h(JB, 1.0, 100),
_h(JS, 1.25, 85), _h(JS, 1.5, 88),
_h(JB, 1.75, 95), _h(JT, 2.0, 72),
_h(JS, 2.25, 92), _h(JB, 2.5, 108),
_h(JT, 2.75, 68), _h(JS, 2.875, 55),
_h(JB, 3.0, 115), _h(JS, 3.25, 100),
_h(JB, 3.5, 118), _h(JB, 3.75, 120),
],
)
# ── Cajón fills ──────────────────────────────────────────────────────────
# Cajón flam run — slaps accelerating into bass hit
Pattern._FILLS["cajon flam"] = dict(
name="cajon flam fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(CSL, 0.0, 95), _h(CT, 0.125, 45), _h(CSL, 0.25, 90),
_h(CB, 0.5, 100), _h(CT, 0.75, 50),
_h(CSL, 1.0, 88), _h(CT, 1.125, 42), _h(CSL, 1.25, 92),
_h(CT, 1.5, 55), _h(CSL, 1.75, 85),
_h(CB, 2.0, 105), _h(CSL, 2.25, 75), _h(CT, 2.5, 48),
_h(CSL, 2.75, 80), _h(CT, 2.875, 40),
_h(CB, 3.0, 110), _h(CSL, 3.25, 90), _h(CSL, 3.5, 95),
_h(CB, 3.75, 120),
],
)
# Cajón rumble — fast taps building to slap accents
Pattern._FILLS["cajon rumble"] = dict(
name="cajon rumble fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(CT, i * 0.125, 35 + i * 3) for i in range(16)],
_h(CSL, 2.0, 95), _h(CSL, 2.5, 100),
_h(CB, 3.0, 108), _h(CSL, 3.25, 88),
_h(CB, 3.5, 112), _h(CSL, 3.75, 95),
],
)
# Cajón breakdown — syncopated bass-slap groove
Pattern._FILLS["cajon breakdown"] = dict(
name="cajon breakdown fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(CB, 0.0, 100), _h(CT, 0.25, 45), _h(CSL, 0.5, 85),
_h(CB, 1.0, 95), _h(CSL, 1.25, 78), _h(CT, 1.5, 50),
_h(CSL, 1.75, 82),
_h(CB, 2.0, 105), _h(CT, 2.125, 40), _h(CT, 2.25, 42),
_h(CSL, 2.5, 90), _h(CT, 2.75, 48),
_h(CB, 3.0, 115), _h(CSL, 3.25, 95),
_h(CB, 3.5, 110), _h(CSL, 3.75, 100),
],
)
# ── Metal fills (using metal kit) ────────────────────────────────────────
# Metal triplet — double kick triplets with snare accents
Pattern._FILLS["metal triplet"] = dict(
name="metal triplet fill",
time_signature="4/4",
beats=4.0,
hits=[
# Triplet kick pattern (12 kicks across 4 beats = triplet 8ths)
*[_h(MK, i * (1/3), 95 + (i % 3 == 0) * 15) for i in range(12)],
# Snare accents on downbeats
_h(MS, 0.0, 110), _h(MS, 1.0, 105),
_h(MS, 2.0, 110), _h(MS, 3.0, 115),
# Hat on upbeats
_h(MH, 0.5, 60), _h(MH, 1.5, 60),
_h(MH, 2.5, 65), _h(MH, 3.5, 70),
],
)
# Metal blastbeat variant — alternating snare/kick 32nds
Pattern._FILLS["metal blast"] = dict(
name="metal blast fill",
time_signature="4/4",
beats=4.0,
hits=[
# Alternating kick-snare at 32nd note speed for 2 beats
*[_h(MK if i % 2 == 0 else MS, i * 0.125, 100 + i) for i in range(16)],
# Then crash into half-time for 2 beats
_h(MK, 2.0, 120), _h(MS, 2.5, 115),
_h(MK, 3.0, 120), _h(MH, 3.25, 80),
_h(MS, 3.5, 120),
],
)
# Metal cascade — descending snare/kick rolls
Pattern._FILLS["metal cascade"] = dict(
name="metal cascade fill",
time_signature="4/4",
beats=4.0,
hits=[
# Fast snare roll beat 1
*[_h(MS, i * 0.125, 80 + i * 5) for i in range(8)],
# Double kick beat 2
*[_h(MK, 1.0 + i * 0.125, 90 + i * 3) for i in range(8)],
# Alternating beat 3
_h(MS, 2.0, 105), _h(MK, 2.125, 95),
_h(MS, 2.25, 108), _h(MK, 2.375, 98),
_h(MS, 2.5, 110), _h(MK, 2.625, 100),
_h(MS, 2.75, 112), _h(MK, 2.875, 102),
# Crash ending
_h(MK, 3.0, 120), _h(MS, 3.0, 120),
_h(MK, 3.5, 120), _h(MS, 3.5, 120),
],
)
class Part:
"""A named voice within a Score, with its own synth, envelope, and effects.
+13
View File
@@ -4869,6 +4869,19 @@ def test_duration_values():
assert abs(Duration.TRIPLET_QUARTER.value - 2 / 3) < 1e-9
def test_duration_arithmetic():
# Multiplication
assert Duration.WHOLE * 2 == 8.0
assert 2 * Duration.HALF == 4.0
assert Duration.QUARTER * 3 == 3.0
# Division
assert Duration.WHOLE / 2 == 2.0
# Addition
assert Duration.HALF + Duration.QUARTER == 3.0
assert Duration.HALF + 1.0 == 3.0
assert 1.0 + Duration.HALF == 3.0
def test_time_signature_from_string_4_4():
ts = TimeSignature.from_string("4/4")
assert ts.beats == 4
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.36.4"
version = "0.37.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },