diff --git a/CHANGELOG.md b/CHANGELOG.md
index b185703..dc716ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
All notable changes to PyTheory are documented here.
+## 0.40.3
+
+- **Crotales synth** — tuned bronze discs with long ring and bright harmonics
+- **Tingsha synth** — paired Tibetan cymbals with beating from two detuned discs
+- **Rain stick** — cascading pebbles (steep and slow/shallow variants)
+- **Ocean drum** — steel beads rolling inside a frame drum, surf wash
+- **Cabasa** — metal bead chain on cylinder, bright metallic scrape
+- **Wind chimes** — multiple suspended metal tubes ringing at random offsets
+- **Finger cymbal** — single zill tap, bright metallic ping
+- `crotales`, `tingsha`, `singing_bowl`, `singing_bowl_ring` instrument presets
+- Audio demos in docs for all new sounds
+
## 0.40.2
- **Master compressor dialed back** — threshold raised from 0.5 to 0.7,
diff --git a/docs/_static/audio/cabasa.wav b/docs/_static/audio/cabasa.wav
new file mode 100644
index 0000000..8ad97eb
Binary files /dev/null and b/docs/_static/audio/cabasa.wav differ
diff --git a/docs/_static/audio/finger_cymbal.wav b/docs/_static/audio/finger_cymbal.wav
new file mode 100644
index 0000000..ef2f6a0
Binary files /dev/null and b/docs/_static/audio/finger_cymbal.wav differ
diff --git a/docs/_static/audio/ocean_drum.wav b/docs/_static/audio/ocean_drum.wav
new file mode 100644
index 0000000..9172c2c
Binary files /dev/null and b/docs/_static/audio/ocean_drum.wav differ
diff --git a/docs/_static/audio/rainstick.wav b/docs/_static/audio/rainstick.wav
new file mode 100644
index 0000000..01f176a
Binary files /dev/null and b/docs/_static/audio/rainstick.wav differ
diff --git a/docs/_static/audio/rainstick_slow.wav b/docs/_static/audio/rainstick_slow.wav
new file mode 100644
index 0000000..54fc58a
Binary files /dev/null and b/docs/_static/audio/rainstick_slow.wav differ
diff --git a/docs/_static/audio/synth_crotales.wav b/docs/_static/audio/synth_crotales.wav
new file mode 100644
index 0000000..c330259
Binary files /dev/null and b/docs/_static/audio/synth_crotales.wav differ
diff --git a/docs/_static/audio/synth_tingsha.wav b/docs/_static/audio/synth_tingsha.wav
new file mode 100644
index 0000000..abc7f77
Binary files /dev/null and b/docs/_static/audio/synth_tingsha.wav differ
diff --git a/docs/_static/audio/wind_chimes.wav b/docs/_static/audio/wind_chimes.wav
new file mode 100644
index 0000000..7832b55
Binary files /dev/null and b/docs/_static/audio/wind_chimes.wav differ
diff --git a/docs/generate_audio.py b/docs/generate_audio.py
index 576baf4..63e2c1a 100644
--- a/docs/generate_audio.py
+++ b/docs/generate_audio.py
@@ -858,6 +858,68 @@ def gen_synth_granular():
render("synth_granular", score)
+def gen_synth_crotales():
+ score = Score("4/4", bpm=60)
+ p = score.part("demo", synth="crotales_synth", envelope="none",
+ volume=0.5, reverb=0.3)
+ for n in ["C6", "E6", "G6", "C7", "G6", "E6", "C6"]:
+ p.add(n, Duration.HALF, velocity=80)
+ render("synth_crotales", score)
+
+
+def gen_synth_tingsha():
+ score = Score("4/4", bpm=40)
+ p = score.part("demo", synth="tingsha_synth", envelope="none",
+ volume=0.5, reverb=0.4)
+ for n in ["E5", "A5", "E6", "A5"]:
+ p.add(n, Duration.WHOLE, velocity=75)
+ render("synth_tingsha", score)
+
+
+def gen_rainstick():
+ score = Score("4/4", bpm=60)
+ p = score.part("demo", synth="sine", volume=1.0)
+ p.hit(DrumSound.RAINSTICK, Duration.WHOLE * 3, velocity=90)
+ render("rainstick", score)
+
+
+def gen_rainstick_slow():
+ score = Score("4/4", bpm=60)
+ p = score.part("demo", synth="sine", volume=1.0)
+ p.hit(DrumSound.RAINSTICK_SLOW, Duration.WHOLE * 4, velocity=85)
+ render("rainstick_slow", score)
+
+
+def gen_ocean_drum():
+ score = Score("4/4", bpm=60)
+ p = score.part("demo", synth="sine", volume=1.0)
+ p.hit(DrumSound.OCEAN_DRUM, Duration.WHOLE * 3, velocity=85)
+ render("ocean_drum", score)
+
+
+def gen_cabasa():
+ score = Score("4/4", bpm=100)
+ p = score.part("demo", synth="sine", volume=1.0)
+ for _ in range(16):
+ p.hit(DrumSound.CABASA, Duration.EIGHTH, velocity=100)
+ render("cabasa", score)
+
+
+def gen_wind_chimes():
+ score = Score("4/4", bpm=60)
+ p = score.part("demo", synth="sine", volume=1.0)
+ p.hit(DrumSound.WIND_CHIMES, Duration.WHOLE * 3, velocity=85)
+ render("wind_chimes", score)
+
+
+def gen_finger_cymbal():
+ score = Score("4/4", bpm=80)
+ p = score.part("demo", synth="sine", volume=1.0)
+ for _ in range(8):
+ p.hit(DrumSound.FINGER_CYMBAL, Duration.QUARTER, velocity=85)
+ render("finger_cymbal", score)
+
+
def gen_synth_singing_bowl_strike():
score = Score("4/4", bpm=40)
p = score.part("demo", synth="singing_bowl_strike_synth", envelope="none",
@@ -1078,8 +1140,16 @@ GENERATORS = [
gen_synth_mandolin,
gen_synth_ukulele,
gen_synth_granular,
+ gen_synth_crotales,
+ gen_synth_tingsha,
gen_synth_singing_bowl_strike,
gen_synth_singing_bowl_ring,
+ gen_rainstick,
+ gen_rainstick_slow,
+ gen_ocean_drum,
+ gen_cabasa,
+ gen_wind_chimes,
+ gen_finger_cymbal,
gen_arpeggio,
gen_legato_glide,
gen_acid_house,
diff --git a/docs/guide/synths.rst b/docs/guide/synths.rst
index 0dfc0dd..5d78fa7 100644
--- a/docs/guide/synths.rst
+++ b/docs/guide/synths.rst
@@ -930,6 +930,40 @@ Parameters (passed as synth kwargs):
+Crotales
+~~~~~~~~
+
+Small tuned bronze discs (antique cymbals) struck with brass mallets.
+Bright, crystalline, bell-like tone with strong upper harmonics that
+rings for a long time. Nearly harmonic partials give crotales their
+penetrating brilliance — they cut through any orchestra.
+
+.. code-block:: python
+
+ crotales = score.part("crotales", synth="crotales_synth", envelope="none",
+ reverb=0.3)
+
+.. raw:: html
+
+
+
+Tingsha
+~~~~~~~
+
+Two small Tibetan cymbals joined by a cord, clashed together. Both discs
+ring at slightly different frequencies, producing a bright ping with
+pronounced beating — the wavering interference between the two is the
+whole character of the sound.
+
+.. code-block:: python
+
+ tingsha = score.part("tingsha", synth="tingsha_synth", envelope="none",
+ reverb=0.4)
+
+.. raw:: html
+
+
+
Singing Bowl (Strike)
~~~~~~~~~~~~~~~~~~~~~
@@ -963,6 +997,76 @@ and out as the bowl resonates.
+Rain Stick
+~~~~~~~~~~
+
+Cascading pebbles through a cactus tube with internal pins. Two variants:
+steep angle (fast cascade) and shallow angle (slow trickle).
+
+.. code-block:: python
+
+ p.hit(DrumSound.RAINSTICK, Duration.WHOLE * 3) # steep — fast cascade
+ p.hit(DrumSound.RAINSTICK_SLOW, Duration.WHOLE * 4) # shallow — gentle trickle
+
+.. raw:: html
+
+
+
+
+Ocean Drum
+~~~~~~~~~~
+
+Steel beads rolling inside a frame drum — tilting produces a smooth surf wash.
+
+.. code-block:: python
+
+ p.hit(DrumSound.OCEAN_DRUM, Duration.WHOLE * 3)
+
+.. raw:: html
+
+
+
+Cabasa
+~~~~~~
+
+Metal bead chain scraped against a textured cylinder — brighter and
+more metallic than a shaker.
+
+.. code-block:: python
+
+ p.hit(DrumSound.CABASA, Duration.EIGHTH)
+
+.. raw:: html
+
+
+
+Wind Chimes
+~~~~~~~~~~~
+
+Suspended metal tubes struck by hand or breeze. Each tube rings at
+its own pitch with slight time offsets.
+
+.. code-block:: python
+
+ p.hit(DrumSound.WIND_CHIMES, Duration.WHOLE * 3)
+
+.. raw:: html
+
+
+
+Finger Cymbal
+~~~~~~~~~~~~~
+
+Single small cymbal tap (zill) — bright metallic ping.
+
+.. code-block:: python
+
+ p.hit(DrumSound.FINGER_CYMBAL, Duration.HALF)
+
+.. raw:: html
+
+
+
Analog Oscillator Drift
~~~~~~~~~~~~~~~~~~~~~~~~
@@ -1018,13 +1122,13 @@ distorted_guitar, orange_crunch, metal_guitar, bass_guitar, upright_bass,
harp, sitar, koto, banjo, mandolin, mandola, ukulele
**World/Exotic**: pedal_steel, theremin, kalimba, steel_drum, didgeridoo,
-bagpipe, singing_bowl, singing_bowl_ring
+bagpipe, singing_bowl, singing_bowl_ring, tingsha
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
granular_pad, granular_texture, vocal, choir
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells,
-timpani
+timpani, crotales
Explicit kwargs override preset defaults:
diff --git a/pyproject.toml b/pyproject.toml
index 9d2b2b9..a609b81 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
-version = "0.40.2"
+version = "0.40.3"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
diff --git a/pytheory/__init__.py b/pytheory/__init__.py
index 8bcda4c..edca49c 100644
--- a/pytheory/__init__.py
+++ b/pytheory/__init__.py
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
-__version__ = "0.40.2"
+__version__ = "0.40.3"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
diff --git a/pytheory/play.py b/pytheory/play.py
index 38037b9..4a29f5c 100644
--- a/pytheory/play.py
+++ b/pytheory/play.py
@@ -2048,6 +2048,99 @@ def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * out).astype(numpy.int16)
+def crotales_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
+ """Crotales — small tuned bronze discs struck with brass mallets.
+
+ Antique cymbals. Bright, crystalline, bell-like tone that rings
+ for a very long time. The partials are nearly harmonic (closer
+ to a bell than a bar) with strong upper harmonics that give
+ crotales their penetrating brilliance. Played in the octave
+ above written — they cut through any orchestra.
+ """
+ t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
+ rng = numpy.random.default_rng(int(hz * 100) % 2**31)
+
+ wave = numpy.zeros(n_samples, dtype=numpy.float64)
+
+ # Bronze disc modes — nearly harmonic, very bright.
+ # Higher partials are stronger than in most percussion,
+ # which is what gives crotales their cutting brilliance.
+ # (ratio, amplitude, decay_rate)
+ disc_modes = [
+ (1.0, 1.0, 0.3), # fundamental — rings for ages
+ (2.0, 0.6, 0.4), # octave — strong
+ (3.01, 0.35, 0.6), # near-12th — slight inharmonicity
+ (4.03, 0.25, 0.9), # double octave
+ (5.06, 0.15, 1.3), # bright
+ (6.1, 0.08, 2.0), # shimmer
+ (8.15, 0.04, 3.0), # sparkle at the top
+ ]
+
+ for ratio, amp, decay_rate in disc_modes:
+ f = hz * ratio
+ if f >= SAMPLE_RATE / 2:
+ break
+ phase = rng.uniform(0, 2 * numpy.pi)
+ mode_decay = numpy.exp(-decay_rate * t)
+ wave += amp * numpy.sin(2 * numpy.pi * f * t + phase) * mode_decay
+
+ # Hard mallet strike — brass on bronze, bright transient
+ strike_len = min(int(SAMPLE_RATE * 0.002), n_samples)
+ strike_t = numpy.linspace(0, 1, strike_len)
+ strike = 0.5 * numpy.sin(2 * numpy.pi * hz * 8 * strike_t) * numpy.exp(-strike_t * 25)
+ wave[:strike_len] += strike
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx
+
+ return (peak * wave).astype(numpy.int16)
+
+
+def tingsha_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
+ """Tingsha — two small Tibetan cymbals clashed together on a cord.
+
+ When the pair strikes, both discs ring simultaneously at slightly
+ different frequencies (no two are identical), producing a bright
+ ping with pronounced beating. The sound is thinner and higher
+ than a singing bowl — a clear, cutting tone that fades over a
+ few seconds. The two-disc interference is the whole character.
+ """
+ t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
+ rng = numpy.random.default_rng(int(hz * 100) % 2**31)
+
+ # Two discs at slightly different pitches — this IS the tingsha sound
+ detune = hz * 0.008 # ~14 cents apart, creates ~3-4 Hz beat at middle C
+ disc_a = numpy.sin(2 * numpy.pi * (hz - detune) * t)
+ disc_b = numpy.sin(2 * numpy.pi * (hz + detune) * t + rng.uniform(0, 2 * numpy.pi))
+ wave = (disc_a + disc_b) * 0.5
+
+ # Upper partials — both discs, slightly different inharmonicity
+ for ratio, amp, dec in [(2.72, 0.3, 5.0), (5.1, 0.12, 10.0), (8.3, 0.05, 18.0)]:
+ if hz * ratio >= SAMPLE_RATE / 2:
+ break
+ p1 = rng.uniform(0, 2 * numpy.pi)
+ p2 = rng.uniform(0, 2 * numpy.pi)
+ wave += amp * numpy.sin(2 * numpy.pi * hz * ratio * 0.998 * t + p1) * numpy.exp(-dec * t)
+ wave += amp * numpy.sin(2 * numpy.pi * hz * ratio * 1.002 * t + p2) * numpy.exp(-dec * t)
+
+ # Decay — medium ring, not as long as a singing bowl
+ decay = numpy.exp(-1.8 * t)
+ wave *= decay
+
+ # Clash transient — metal on metal, sharper than a mallet hit
+ clash_len = min(int(SAMPLE_RATE * 0.003), n_samples)
+ clash = rng.uniform(-0.4, 0.4, clash_len).astype(numpy.float64)
+ clash *= numpy.exp(-numpy.linspace(0, 20, clash_len))
+ wave[:clash_len] += clash
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx
+
+ return (peak * wave).astype(numpy.int16)
+
+
def singing_bowl_strike_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Singing bowl strike — mallet hit that excites all modes at once.
@@ -2287,6 +2380,8 @@ class Synth(Enum):
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
+ CROTALES = "crotales_synth"
+ TINGSHA = "tingsha_synth"
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
SINGING_BOWL_RING = "singing_bowl_ring_synth"
@@ -2319,6 +2414,8 @@ _SYNTH_FUNCTIONS = {
"ukulele_synth": ukulele_wave,
"acoustic_guitar_synth": acoustic_guitar_wave,
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
+ "crotales_synth": crotales_wave,
+ "tingsha_synth": tingsha_wave,
"singing_bowl_strike_synth": singing_bowl_strike_wave,
"singing_bowl_ring_synth": singing_bowl_ring_wave,
}
@@ -3575,6 +3672,227 @@ def _synth_guiro(n_samples):
return wave
+def _synth_rainstick_slow(n_samples):
+ """Rain stick (shallow angle): slow trickle, longer cascade, sparser impacts."""
+ wave = numpy.zeros(n_samples, dtype=numpy.float32)
+ rng = numpy.random.default_rng(77)
+
+ cascade_len = min(n_samples, int(SAMPLE_RATE * 4.0))
+ n_pebbles = 800
+ # More uniform distribution — shallow angle means steadier flow
+ positions = rng.beta(1.2, 1.8, n_pebbles) * cascade_len
+ positions = positions.astype(int)
+
+ for pos in positions:
+ if pos >= n_samples - 100:
+ continue
+ peb_len = rng.integers(25, 90)
+ end = min(pos + peb_len, n_samples)
+ actual = end - pos
+ click = rng.uniform(-1.0, 1.0, actual).astype(numpy.float32)
+ click *= numpy.exp(-numpy.linspace(0, 10, actual).astype(numpy.float32))
+ click *= rng.uniform(0.03, 0.18)
+ wave[pos:end] += click
+
+ t = numpy.arange(cascade_len, dtype=numpy.float32) / SAMPLE_RATE
+ body = numpy.sin(2 * numpy.pi * 160 * t) * 0.04
+ body *= numpy.exp(-0.8 * t)
+ wave[:cascade_len] += body
+
+ full_env = numpy.ones(n_samples, dtype=numpy.float32)
+ fade_len = min(int(SAMPLE_RATE * 1.2), n_samples)
+ if fade_len > 0 and cascade_len > fade_len:
+ full_env[cascade_len - fade_len:cascade_len] = numpy.linspace(
+ 1.0, 0.0, fade_len).astype(numpy.float32)
+ full_env[cascade_len:] = 0.0
+ wave *= full_env
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx * 1.5
+ return wave
+
+
+def _synth_ocean_drum(n_samples):
+ """Ocean drum: steel beads rolling inside a frame drum — surf wash.
+
+ Tilt the drum and the beads cascade across the internal head,
+ producing a smooth wash that sounds like ocean waves.
+ """
+ wave = numpy.zeros(n_samples, dtype=numpy.float32)
+ rng = numpy.random.default_rng(55)
+
+ wash_len = min(n_samples, int(SAMPLE_RATE * 2.5))
+ t = numpy.arange(wash_len, dtype=numpy.float32) / SAMPLE_RATE
+
+ # Dense bead noise — smoother than rain stick (steel beads on drum head)
+ noise = rng.standard_normal(wash_len).astype(numpy.float32)
+ # Bandpass to ~1-6kHz — beads on mylar head
+ import scipy.signal as _sig
+ bp, ap = _sig.butter(2, [1000, 6000], btype='band', fs=SAMPLE_RATE)
+ noise = _sig.lfilter(bp, ap, noise).astype(numpy.float32)
+
+ # Swell envelope — wave comes in, peaks, recedes
+ swell = numpy.abs(numpy.sin(numpy.pi * t / t[-1])) ** 0.7 if wash_len > 0 else noise
+ noise *= swell * 0.5
+
+ # Drum body resonance
+ body = numpy.sin(2 * numpy.pi * 120 * t) * 0.08 * swell
+
+ wave[:wash_len] = noise + body
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx * 1.3
+ return wave
+
+
+def _synth_cabasa(n_samples):
+ """Cabasa: metal bead chain scraped against a cylinder.
+
+ Brighter and more metallic than a shaker — the beads are steel
+ chain wrapped around a textured metal cylinder.
+ """
+ n = min(n_samples, int(SAMPLE_RATE * 0.08))
+ t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
+ rng = numpy.random.default_rng(33)
+
+ # Metallic noise — brighter than shaker
+ noise = rng.standard_normal(n).astype(numpy.float32)
+ # High-pass to emphasize the metallic chain sound
+ env = numpy.exp(-25 * t) + 0.4 * numpy.exp(-6 * t)
+ wave = noise * env * 0.5
+ # Metal bead resonances
+ wave += numpy.sin(2 * numpy.pi * 7500 * t) * 0.12 * numpy.exp(-30 * t)
+ wave += numpy.sin(2 * numpy.pi * 9200 * t) * 0.08 * numpy.exp(-35 * t)
+
+ out = numpy.zeros(n_samples, dtype=numpy.float32)
+ out[:n] = wave
+ return out
+
+
+def _synth_wind_chimes(n_samples):
+ """Wind chimes: multiple suspended metal tubes ringing at random intervals.
+
+ Each tube has its own pitch and decay. A hand strike or breeze
+ sets several ringing at once with slight time offsets.
+ """
+ wave = numpy.zeros(n_samples, dtype=numpy.float32)
+ rng = numpy.random.default_rng(22)
+
+ chime_len = min(n_samples, int(SAMPLE_RATE * 3.0))
+ t = numpy.arange(chime_len, dtype=numpy.float32) / SAMPLE_RATE
+
+ # 6-8 tubes at different pitches — pentatonic-ish spread
+ tube_freqs = [1200, 1450, 1700, 2000, 2400, 2850, 3300]
+ for freq in tube_freqs:
+ # Each tube starts at a random offset (breeze hits them at different times)
+ offset = rng.integers(0, int(SAMPLE_RATE * 0.3))
+ if offset >= chime_len:
+ continue
+ tube_t = t[offset:]
+ tube_local = tube_t - tube_t[0]
+ # Tube mode with slight inharmonicity
+ tone = numpy.sin(2 * numpy.pi * freq * tube_local) * 0.2
+ tone += numpy.sin(2 * numpy.pi * freq * 2.73 * tube_local) * 0.06
+ # Each tube decays independently
+ decay = numpy.exp(-rng.uniform(2.0, 4.0) * tube_local)
+ tone *= decay
+ # Slight amplitude variation
+ tone *= rng.uniform(0.5, 1.0)
+ wave[offset:chime_len] += tone[:chime_len - offset].astype(numpy.float32)
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx
+ return wave
+
+
+def _synth_finger_cymbal(n_samples):
+ """Finger cymbal (zill): single small cymbal tap — bright metallic ping."""
+ n = min(n_samples, int(SAMPLE_RATE * 0.8))
+ t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
+ rng = numpy.random.default_rng(11)
+
+ # High-pitched metallic modes
+ wave = numpy.sin(2 * numpy.pi * 3200 * t).astype(numpy.float32) * 0.5
+ wave += numpy.sin(2 * numpy.pi * 3210 * t).astype(numpy.float32) * 0.5 # beating pair
+ wave += numpy.sin(2 * numpy.pi * 7800 * t).astype(numpy.float32) * 0.15 * numpy.exp(-8 * t).astype(numpy.float32)
+ wave += numpy.sin(2 * numpy.pi * 12500 * t).astype(numpy.float32) * 0.06 * numpy.exp(-15 * t).astype(numpy.float32)
+
+ wave *= numpy.exp(-3.0 * t).astype(numpy.float32)
+
+ # Tap transient
+ tap_len = min(int(SAMPLE_RATE * 0.001), n)
+ wave[:tap_len] += rng.uniform(-0.2, 0.2, tap_len).astype(numpy.float32)
+
+ out = numpy.zeros(n_samples, dtype=numpy.float32)
+ out[:n] = wave
+ mx = numpy.abs(out).max()
+ if mx > 0:
+ out /= mx
+ return out
+
+
+def _synth_rainstick(n_samples):
+ """Rain stick: cascading pebbles through a cactus tube with internal pins.
+
+ Hundreds of tiny seed/pebble impacts falling through the tube,
+ each one a brief high-frequency click with a hint of resonance
+ from the hollow body. The density tapers off as gravity runs out.
+ """
+ wave = numpy.zeros(n_samples, dtype=numpy.float32)
+ rng = numpy.random.default_rng(42)
+
+ # Duration of the cascade — up to 2.5 seconds
+ cascade_len = min(n_samples, int(SAMPLE_RATE * 2.5))
+
+ # Generate random pebble impacts — denser at the start, sparse at the end
+ n_pebbles = 800
+ # Positions weighted toward the beginning (gravity)
+ positions = rng.beta(1.5, 3.0, n_pebbles) * cascade_len
+ positions = positions.astype(int)
+
+ for pos in positions:
+ if pos >= n_samples - 100:
+ continue
+ # Each pebble: tiny noise click with random pitch resonance
+ peb_len = rng.integers(20, 80)
+ end = min(pos + peb_len, n_samples)
+ actual = end - pos
+
+ # Noise click
+ click = rng.uniform(-1.0, 1.0, actual).astype(numpy.float32)
+ # Fast decay
+ click *= numpy.exp(-numpy.linspace(0, 12, actual).astype(numpy.float32))
+ # Random amplitude — some pebbles louder than others
+ click *= rng.uniform(0.05, 0.25)
+ wave[pos:end] += click
+
+ # Tube body resonance — hollow cactus, low rumble underneath
+ t = numpy.arange(cascade_len, dtype=numpy.float32) / SAMPLE_RATE
+ body = numpy.sin(2 * numpy.pi * 180 * t) * 0.06
+ body *= numpy.exp(-1.5 * t)
+ # Modulate body resonance by the cascade density
+ env = numpy.exp(-1.2 * t)
+ body *= env
+ wave[:cascade_len] += body
+
+ # Overall envelope — smooth fade
+ full_env = numpy.ones(n_samples, dtype=numpy.float32)
+ fade_len = min(int(SAMPLE_RATE * 0.8), n_samples)
+ if fade_len > 0 and cascade_len > fade_len:
+ full_env[cascade_len - fade_len:cascade_len] = numpy.linspace(
+ 1.0, 0.0, fade_len).astype(numpy.float32)
+ full_env[cascade_len:] = 0.0
+ wave *= full_env
+
+ mx = numpy.abs(wave).max()
+ if mx > 0:
+ wave /= mx * 1.5 # leave headroom
+ return wave
+
+
def _render_drum_hit(sound_value, n_samples):
"""Render a single drum sound to a float32 array.
@@ -3669,6 +3987,13 @@ def _render_drum_hit(sound_value, n_samples):
DrumSound.BASS_3.value: lambda n: _synth_march_bass(n, pitch=62),
DrumSound.BASS_4.value: lambda n: _synth_march_bass(n, pitch=52),
DrumSound.BASS_5.value: lambda n: _synth_march_bass(n, pitch=42),
+ # Effects / world
+ DrumSound.RAINSTICK.value: lambda n: _synth_rainstick(n),
+ DrumSound.RAINSTICK_SLOW.value: lambda n: _synth_rainstick_slow(n),
+ DrumSound.OCEAN_DRUM.value: lambda n: _synth_ocean_drum(n),
+ DrumSound.CABASA.value: lambda n: _synth_cabasa(n),
+ DrumSound.WIND_CHIMES.value: lambda n: _synth_wind_chimes(n),
+ DrumSound.FINGER_CYMBAL.value: lambda n: _synth_finger_cymbal(n),
}
renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n))
diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py
index fd5b558..a77325e 100644
--- a/pytheory/rhythm.py
+++ b/pytheory/rhythm.py
@@ -265,6 +265,16 @@ INSTRUMENTS = {
"lowpass": 4500,
"humanize": 0.2,
},
+ "crotales": {
+ "synth": "crotales_synth", "envelope": "none",
+ "reverb": 0.3,
+ "humanize": 0.2,
+ },
+ "tingsha": {
+ "synth": "tingsha_synth", "envelope": "none",
+ "reverb": 0.4,
+ "humanize": 0.2,
+ },
"singing_bowl": {
"synth": "singing_bowl_strike_synth", "envelope": "none",
"reverb": 0.5,
@@ -595,6 +605,13 @@ class DrumSound(Enum):
BASS_3 = 126 # middle
BASS_4 = 127 # fourth
BASS_5 = 80 # lowest (biggest) bass drum
+ # Effects / world percussion
+ RAINSTICK = 81 # cascading pebbles through cactus tube (steep angle)
+ RAINSTICK_SLOW = 128 # gentle trickle (shallow angle)
+ OCEAN_DRUM = 82 # tilting drum with steel beads — surf wash
+ CABASA = 83 # metal bead chain wrapped around cylinder
+ WIND_CHIMES = 84 # suspended metal tubes struck by wind/hand
+ FINGER_CYMBAL = 85 # single small cymbal tap (zill)
class _DrumTone:
diff --git a/uv.lock b/uv.lock
index 625e8ee..b493bb2 100644
--- a/uv.lock
+++ b/uv.lock
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "pytheory"
-version = "0.40.2"
+version = "0.40.3"
source = { editable = "." }
dependencies = [
{ name = "rich" },