Compare commits

..

5 Commits

Author SHA1 Message Date
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
kennethreitz f570e226cd v0.36.4: Harmonium, doumbek, tabla fills, Part.hold()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:11:06 -04:00
kennethreitz 0c5c3abedc Harmonium synth, doumbek drums (3 sounds, 4 patterns, 2 fills)
- Harmonium: single free reed, nasal midrange, bellows swell.
  The sound of kirtan and qawwali.
- Doumbek (darbuka): dum (center bass), tek (edge sharp), ka (muted).
  4 patterns: maqsoum, baladi, saidi, ayoub.
  2 fills: doumbek roll, doumbek accent.
- 42 synth waveforms total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:05:46 -04:00
kennethreitz 35d07b984b Docs: add tabla fills to drums.rst
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:57:08 -04:00
kennethreitz aec7723ee6 5 tabla fills: tihai, chakkardar, tiri kita, bayan, tabla call
Tihai (3x crescendo landing on sam), chakkardar (32nd triplet
cascade), tiri kita (rapid 16th dayan burst), bayan (bass bends),
tabla call (dayan/bayan call-and-response).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:56:19 -04:00
8 changed files with 328 additions and 6 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to PyTheory are documented here.
## 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
+9
View File
@@ -357,6 +357,15 @@ the pitch upward.
and light classical), tabla solo, and tiri kita (fast ornamental
pattern).
**5 fills:** tihai (3x crescendo landing on sam), chakkardar (32nd
triplet cascade into slam), tiri kita (rapid 16th-note dayan burst),
bayan (deep bass bends showcase), tabla call (dayan/bayan call-and-response).
.. code-block:: python
score.drums("teental", repeats=4, fill="tihai")
score.drums("keherwa", repeats=4, fill="chakkardar")
.. code-block:: python
score = Score("4/4", bpm=80)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.36.3"
version = "0.36.5"
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.3"
__version__ = "0.36.5"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+97 -1
View File
@@ -1237,6 +1237,47 @@ def steel_drum_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * wave).astype(numpy.int16)
def harmonium_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Harmonium — Indian pump organ, single free reed per note.
Unlike accordion (doubled musette reeds), the harmonium has one
reed per note — no beating, just a pure, nasal, reedy tone.
Constant bellows pressure, warm but slightly buzzy. The sound
of kirtan, qawwali, and devotional music.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Single reed — odd harmonics stronger (like clarinet but warmer)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = (1.0 / n) * (1.0 if n % 2 == 1 else 0.5)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
# Bellows pressure — gentle swell, slower than accordion
bellows = 0.9 + 0.1 * numpy.sin(2 * numpy.pi * 0.5 * t)
wave *= bellows
# Nasal character — slight midrange boost
import scipy.signal as _sig
center = min(1200, hz * 3)
lo = max(20, int(center - 300))
hi = min(SAMPLE_RATE // 2 - 1, int(center + 300))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
nasal = _sig.lfilter(bp, ap, wave) * 0.2
wave += nasal
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def accordion_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Accordion — bellows-driven free reeds.
@@ -1794,6 +1835,7 @@ class Synth(Enum):
THEREMIN = "theremin_synth"
KALIMBA = "kalimba_synth"
STEEL_DRUM = "steel_drum_synth"
HARMONIUM = "harmonium_synth"
ACCORDION = "accordion_synth"
DIDGERIDOO = "didgeridoo_synth"
BAGPIPE = "bagpipe_synth"
@@ -1825,7 +1867,7 @@ _SYNTH_FUNCTIONS = {
"granular_synth": granular_wave, "vocal_synth": vocal_wave,
"pedal_steel_synth": pedal_steel_wave, "theremin_synth": theremin_wave,
"kalimba_synth": kalimba_wave, "steel_drum_synth": steel_drum_wave,
"accordion_synth": accordion_wave, "didgeridoo_synth": didgeridoo_wave,
"harmonium_synth": harmonium_wave, "accordion_synth": accordion_wave, "didgeridoo_synth": didgeridoo_wave,
"bagpipe_synth": bagpipe_wave,
"banjo_synth": banjo_wave, "mandolin_synth": mandolin_wave,
"ukulele_synth": ukulele_wave,
@@ -2584,6 +2626,52 @@ def _synth_mridangam_tha(n_samples):
return out
def _synth_doumbek_dum(n_samples):
"""Doumbek Dum — open center strike, deep and round."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
freq = 80 + 40 * numpy.exp(-25 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 8) * 0.8
thump_len = min(int(SAMPLE_RATE * 0.04), n_samples)
import scipy.signal as _sig
thump = _noise(thump_len)
if thump_len > 20:
bl, al = _sig.butter(2, [50, 250], btype='band', fs=SAMPLE_RATE)
thump = _sig.lfilter(bl, al, numpy.pad(thump, (0, max(0, n_samples - thump_len))))[:thump_len].astype(numpy.float32)
thump *= _exp_decay(thump_len, 22) * 0.7
body[:thump_len] += thump
return numpy.tanh(body * 1.3).astype(numpy.float32)
def _synth_doumbek_tek(n_samples):
"""Doumbek Tek — sharp edge strike, bright and cutting."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
ring = numpy.sin(2 * numpy.pi * 400 * t) * _exp_decay(n_samples, 22) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 900 * t) * 0.3 * _exp_decay(n_samples, 30)
click_len = min(int(SAMPLE_RATE * 0.005), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 300) * 0.9
import scipy.signal as _sig
if click_len > 10:
bl, al = _sig.butter(2, [2000, min(8000, SAMPLE_RATE // 2 - 1)], btype='band', fs=SAMPLE_RATE)
click = _sig.lfilter(bl, al, numpy.pad(click, (0, max(0, n_samples - click_len))))[:click_len].astype(numpy.float32)
result = ring + ring2
result[:click_len] += click
return numpy.tanh(result * 1.8).astype(numpy.float32)
def _synth_doumbek_ka(n_samples):
"""Doumbek Ka — muted edge slap, short and dry."""
n = min(n_samples, int(SAMPLE_RATE * 0.04))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
body = numpy.sin(2 * numpy.pi * 350 * t) * _exp_decay(n, 30) * 0.4
slap = _noise(min(80, n)) * _exp_decay(min(80, n), 200) * 0.7
result = body
result[:min(80, n)] += slap
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.5)
return out
def _synth_cajon_bass(n_samples):
"""Cajón bass — palm strike on center of the face.
@@ -2914,6 +3002,10 @@ def _render_drum_hit(sound_value, n_samples):
DrumSound.DJEMBE_BASS.value: lambda n: _synth_djembe_bass(n),
DrumSound.DJEMBE_TONE.value: lambda n: _synth_djembe_tone(n),
DrumSound.DJEMBE_SLAP.value: lambda n: _synth_djembe_slap(n),
# Doumbek
DrumSound.DOUMBEK_DUM.value: lambda n: _synth_doumbek_dum(n),
DrumSound.DOUMBEK_TEK.value: lambda n: _synth_doumbek_tek(n),
DrumSound.DOUMBEK_KA.value: lambda n: _synth_doumbek_ka(n),
# Cajon
DrumSound.CAJON_BASS.value: lambda n: _synth_cajon_bass(n),
DrumSound.CAJON_SLAP.value: lambda n: _synth_cajon_slap(n),
@@ -4510,6 +4602,10 @@ def render_score(score):
DrumSound.DJEMBE_BASS.value: 0.0,
DrumSound.DJEMBE_TONE.value: 0.1,
DrumSound.DJEMBE_SLAP.value: -0.1,
# Doumbek
DrumSound.DOUMBEK_DUM.value: 0.0,
DrumSound.DOUMBEK_TEK.value: 0.1,
DrumSound.DOUMBEK_KA.value: -0.1,
# Cajon — centered (single instrument)
DrumSound.CAJON_BASS.value: 0.0,
DrumSound.CAJON_SLAP.value: 0.0,
+199
View File
@@ -213,6 +213,11 @@ INSTRUMENTS = {
"synth": "steel_drum_synth", "envelope": "none",
"reverb": 0.3, "reverb_type": "plate",
},
"harmonium": {
"synth": "harmonium_synth", "envelope": "organ",
"reverb": 0.2, "reverb_type": "taj_mahal",
"humanize": 0.15,
},
"accordion": {
"synth": "accordion_synth", "envelope": "organ",
"humanize": 0.15,
@@ -383,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."""
@@ -528,6 +551,10 @@ class DrumSound(Enum):
DJEMBE_BASS = 102 # open bass (center of head)
DJEMBE_TONE = 103 # open tone (edge, fingers together)
DJEMBE_SLAP = 104 # slap (edge, fingers spread, sharp crack)
# Doumbek (darbuka) sounds
DOUMBEK_DUM = 112 # center of head, deep bass
DOUMBEK_TEK = 113 # edge of head, sharp high
DOUMBEK_KA = 114 # muted edge slap
# Cajon sounds
CAJON_BASS = 108 # center of face, deep thump
CAJON_SLAP = 109 # top edge, snare wires buzz
@@ -1563,6 +1590,61 @@ Pattern._PRESETS["tabla solo"] = dict(
],
)
# ── Doumbek patterns ──────────────────────────────────────────────────────
DKD = DrumSound.DOUMBEK_DUM
DKT = DrumSound.DOUMBEK_TEK
DKK = DrumSound.DOUMBEK_KA
# Maqsoum — the most common Arabic rhythm (4/4)
Pattern._PRESETS["maqsoum"] = dict(
name="maqsoum",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 85), _h(DKT, 0.5, 65),
_h(DKT, 1.0, 68), _h(DKD, 1.5, 80),
_h(DKT, 2.0, 65), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKT, 3.5, 62),
],
)
# Baladi — heavy, earthy, belly dance
Pattern._PRESETS["baladi"] = dict(
name="baladi",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 88), _h(DKD, 0.5, 78),
_h(DKT, 1.0, 70), _h(DKD, 1.5, 82),
_h(DKT, 2.0, 68), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKK, 3.25, 45), _h(DKT, 3.5, 65),
],
)
# Saidi — Upper Egyptian, strong and driving
Pattern._PRESETS["saidi"] = dict(
name="saidi",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 88), _h(DKT, 0.5, 65),
_h(DKD, 1.0, 82), _h(DKD, 1.5, 78),
_h(DKT, 2.0, 70), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKT, 3.5, 62),
],
)
# Ayoub — simple 2/4, trance-like repetition
Pattern._PRESETS["ayoub"] = dict(
name="ayoub",
time_signature="2/4",
beats=2.0,
hits=[
_h(DKD, 0.0, 85), _h(DKK, 0.5, 45),
_h(DKT, 1.0, 70), _h(DKT, 1.5, 62),
],
)
# ── Cajón patterns ────────────────────────────────────────────────────────
CB = DrumSound.CAJON_BASS
CSL = DrumSound.CAJON_SLAP
@@ -2093,6 +2175,123 @@ Pattern._FILLS["second line"] = dict(
],
)
# ── Doumbek fills ────────────────────────────────────────────────────────
_DKD = DrumSound.DOUMBEK_DUM
_DKT = DrumSound.DOUMBEK_TEK
_DKK = DrumSound.DOUMBEK_KA
# Doumbek roll — rapid teks building to dum
Pattern._FILLS["doumbek roll"] = dict(
name="doumbek roll fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(_DKT, i * 0.125, 40 + i * 4) for i in range(16)],
_h(_DKD, 2.0, 100), _h(_DKT, 2.25, 65), _h(_DKT, 2.5, 68),
_h(_DKD, 3.0, 110), _h(_DKD, 3.25, 105),
_h(_DKD, 3.5, 115), _h(_DKT, 3.75, 80),
],
)
# Doumbek accent — syncopated dum-tek-ka pattern
Pattern._FILLS["doumbek accent"] = dict(
name="doumbek accent fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_DKD, 0.0, 95), _h(_DKT, 0.25, 65), _h(_DKK, 0.5, 50),
_h(_DKT, 0.75, 68), _h(_DKD, 1.0, 90),
_h(_DKT, 1.5, 72), _h(_DKK, 1.75, 52), _h(_DKD, 2.0, 100),
_h(_DKT, 2.25, 68), _h(_DKT, 2.5, 70), _h(_DKT, 2.75, 72),
_h(_DKD, 3.0, 110), _h(_DKD, 3.5, 115),
],
)
# ── Tabla fills ──────────────────────────────────────────────────────────
_TNA = DrumSound.TABLA_NA
_TDH = DrumSound.TABLA_DHA
_TTT = DrumSound.TABLA_TIT
_TKE = DrumSound.TABLA_KE
_TGB = DrumSound.TABLA_GE_BEND
_TGE = DrumSound.TABLA_GE
_TTI = DrumSound.TABLA_TIN
_T3 = 1.0 / 12.0
# Tihai — the classic 3x pattern landing on sam
Pattern._FILLS["tihai"] = dict(
name="tihai fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TDH, 0.0, 105), _h(_TNA, 0.25, 72), _h(_TTT, 0.5, 48),
_h(_TKE, 0.75, 52), _h(_TDH, 1.0, 100),
_h(_TDH, 1.25, 110), _h(_TNA, 1.5, 78), _h(_TTT, 1.75, 52),
_h(_TKE, 2.0, 55), _h(_TDH, 2.25, 105),
_h(_TDH, 2.5, 118), _h(_TNA, 2.75, 82), _h(_TTT, 3.0, 58),
_h(_TKE, 3.25, 60), _h(_TDH, 3.5, 127),
],
)
# Chakkardar — 32nd triplet cascade into slam
Pattern._FILLS["chakkardar"] = dict(
name="chakkardar fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(_TTT, i * _T3, 32 + i * 3) for i in range(12)],
_h(_TDH, 1.0, 115), _h(_TGB, 1.5, 108),
*[_h(_TTT, 2.0 + i * _T3, 35 + i * 3) for i in range(12)],
_h(_TDH, 3.0, 120), _h(_TDH, 3.25, 115),
_h(_TGB, 3.5, 120), _h(_TDH, 3.75, 127),
],
)
# Tiri kita fill — rapid 16th note dayan burst
Pattern._FILLS["tiri kita"] = dict(
name="tiri kita fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TTT, 0.0, 50), _h(_TTT, 0.125, 38), _h(_TKE, 0.25, 48),
_h(_TNA, 0.5, 72), _h(_TTT, 0.75, 42),
_h(_TDH, 1.0, 95), _h(_TTT, 1.25, 38), _h(_TTT, 1.5, 42),
_h(_TKE, 1.75, 48), _h(_TNA, 2.0, 75),
_h(_TTT, 2.25, 40), _h(_TTT, 2.5, 45), _h(_TKE, 2.75, 50),
_h(_TDH, 3.0, 100), _h(_TNA, 3.25, 70),
_h(_TDH, 3.5, 110), _h(_TGB, 3.75, 105),
],
)
# Bayan showcase — deep bass bends
Pattern._FILLS["bayan"] = dict(
name="bayan fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TGB, 0.0, 100), _h(_TNA, 0.5, 65),
_h(_TGE, 1.0, 85), _h(_TGB, 1.5, 105),
_h(_TNA, 2.0, 70), _h(_TKE, 2.25, 48),
_h(_TGB, 2.5, 110), _h(_TDH, 3.0, 115),
_h(_TGB, 3.5, 120),
],
)
# Call and response — dayan speaks, bayan answers
Pattern._FILLS["tabla call"] = dict(
name="tabla call fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TNA, 0.0, 105), _h(_TNA, 0.25, 55), _h(_TTT, 0.5, 38),
_h(_TNA, 0.75, 100),
_h(_TGE, 1.0, 95), _h(_TGE, 1.25, 48), _h(_TGB, 1.5, 90),
_h(_TNA, 2.0, 108), _h(_TTT, 2.125, 30), _h(_TTT, 2.25, 35),
_h(_TNA, 2.5, 100),
_h(_TGB, 3.0, 112), _h(_TKE, 3.25, 48),
_h(_TDH, 3.5, 120),
],
)
class Part:
"""A named voice within a Score, with its own synth, envelope, and effects.
+15 -2
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
@@ -5320,7 +5333,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 41
assert len(Synth) == 42
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@@ -7142,7 +7155,7 @@ def test_score_system_propagates():
def test_synth_enum_count():
from pytheory.play import Synth
assert len(Synth) == 41
assert len(Synth) == 42
def test_all_synths_render_and_enum_match():
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.36.3"
version = "0.36.5"
source = { editable = "." }
dependencies = [
{ name = "numeral" },