Electric guitar synth, cab sim, strumming, world drums, metal kit

- Electric guitar: Karplus-Strong + magnetic pickup comb filter
- Cabinet simulation: speaker rolloff + presence bump (tames fizz)
- 6 guitar presets: clean, crunch, distorted, orange, metal
- Part.strum(): fretboard fingering lookup with down/up strumming
- Sitar synth: jawari buzz + chikari sympathetic strings
- Dhol, dholak, mridangam, djembe synthesis (membrane noise)
- Metal drum kit (kick click, bright snare, tight hats)
- 11 world patterns + 4 metal patterns + 7 tabla patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:25:53 -04:00
parent 267b7284ba
commit 406e5d7e54
4 changed files with 401 additions and 12 deletions
+19
View File
@@ -2,6 +2,25 @@
All notable changes to PyTheory are documented here.
## 0.33.1
- **Electric guitar synth** — Karplus-Strong with magnetic pickup comb filter
simulation (single-coil honk, proper sustain)
- **Speaker cabinet simulation** — steep rolloff above 4-5kHz with presence
bump. Makes distorted guitar sound warm instead of fizzy.
- **6 guitar presets:** electric_guitar, clean_guitar, crunch_guitar,
distorted_guitar, orange_crunch, metal_guitar — all with proper cab sim
- **Sitar synth** — Karplus-Strong with jawari bridge buzz, chikari
sympathetic strings, variable damping
- **Guitar strumming** — `Part.strum("Am", Duration.HALF)` with
fretboard fingering lookup, down/up direction, adjustable strum speed
- **World drums:** dhol (bhangra, chaal), dholak (qawwali, folk),
mridangam (adi talam, korvai), djembe (standard, kuku, soli)
— all with bandpass-filtered membrane noise for realistic drum head sound
- **Metal drum kit** — clicky kick, bright snare, tight hats
with 4 patterns (double kick, metal blast, metal groove, metal gallop)
- 15 synth waveforms, 10 envelopes, 40+ instrument presets
## 0.33.0
- **Non-12-TET support** — `TET(n)` factory creates any equal temperament
+173 -3
View File
@@ -310,6 +310,63 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * wave).astype(numpy.int16)
def electric_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Electric guitar — Karplus-Strong through magnetic pickup simulation.
Models a steel string vibrating over a magnetic pickup:
1. Karplus-Strong plucked string (brighter than acoustic)
2. Pickup comb filter — a magnetic pickup at 1/4 string length
cancels the 4th harmonic and boosts the 2nd, creating the
characteristic electric guitar "honk"
3. Slightly longer sustain than acoustic (no body absorption)
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Initial pluck — steel string, bright pick attack
buf = rng.uniform(-1.0, 1.0, period).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
# Karplus-Strong with slightly less damping than acoustic
for i in range(n_samples):
out[i] = buf[i % period]
next_idx = (i + 1) % period
# Less damping = more sustain (steel string, no wood body absorbing)
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9993
# Magnetic pickup simulation — comb filter at pickup position.
# A pickup at 1/4 of the string length cancels the 4th harmonic
# and creates the characteristic electric guitar midrange honk.
pickup_pos = period // 4
if pickup_pos > 0 and pickup_pos < n_samples:
pickup = numpy.zeros(n_samples, dtype=numpy.float64)
pickup[pickup_pos:] = out[:-pickup_pos]
# Subtract delayed version = comb filter (pickup sampling)
out = out - pickup * 0.3
# Slight single-coil brightness boost
# High-shelf boost above 2kHz using a simple 1-pole
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
brightness = numpy.zeros(n_samples, dtype=numpy.float64)
if n_samples > 1:
alpha = 0.15
brightness[0] = out[0]
for i in range(1, n_samples):
brightness[i] = alpha * (out[i] - out[i-1]) + (1 - alpha) * brightness[i-1]
out = out + brightness * 0.2
# Normalize
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
def sitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Sitar — Karplus-Strong with jawari bridge buzz and sympathetic strings.
@@ -490,6 +547,7 @@ class Synth(Enum):
ORGAN = "organ_synth"
STRINGS = "strings_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
def __call__(self, hz, **kwargs):
"""Make Synth members callable — dispatches to the wave function."""
@@ -503,6 +561,7 @@ _SYNTH_FUNCTIONS = {
"pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave,
"pluck_synth": pluck_wave, "organ_synth": organ_wave,
"strings_synth": strings_wave, "sitar_synth": sitar_wave,
"electric_guitar_synth": electric_guitar_wave,
}
@@ -1256,6 +1315,68 @@ def _synth_mridangam_tha(n_samples):
return out
def _synth_metal_kick(n_samples):
"""Metal kick — punchy with beater click. Double-bass ready.
Tight low end with a beater click for definition. Not thin —
needs the low-end weight to anchor the mix alongside bass guitar.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Pitch sweep — fast attack, tight body
freq = 50 + 120 * numpy.exp(-60 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 9) * 0.9
# Beater click — present but not harsh
click_len = min(int(SAMPLE_RATE * 0.012), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 200) * 0.7
# Sub punch — gives it weight
sub_len = min(int(SAMPLE_RATE * 0.06), n_samples)
sub = _sine_f32(50, sub_len) * _exp_decay(sub_len, 20) * 0.5
# Membrane thump
thump_len = min(int(SAMPLE_RATE * 0.03), n_samples)
thump = _noise(thump_len) * _exp_decay(thump_len, 60) * 0.4
body[:sub_len] += sub
body[:click_len] += click
body[:thump_len] += thump
return numpy.tanh(body * 1.5).astype(numpy.float32)
def _synth_metal_snare(n_samples):
"""Metal snare — bright crack, tight, cutting.
High-tuned, cranked snare wires, lots of attack. Needs to cut
through double kicks and wall-of-gain guitars.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Higher pitched body than rock snare — tuned tight
body = numpy.sin(2 * numpy.pi * 280 * t) * _exp_decay(n_samples, 30) * 0.5
# Snare wire rattle — shorter, tighter than rock
wire = _noise(n_samples) * _exp_decay(n_samples, 25) * 0.7
# Bandpass the wire for presence
bl, al = scipy.signal.butter(2, [2000, 8000], btype='band', fs=SAMPLE_RATE)
wire = scipy.signal.lfilter(bl, al, wire).astype(numpy.float32) * 1.5
# Hard stick crack
crack_len = min(int(SAMPLE_RATE * 0.005), n_samples)
crack = _noise(crack_len) * _exp_decay(crack_len, 400) * 1.5
result = body + wire
result[:crack_len] += crack
return numpy.tanh(result * 1.8).astype(numpy.float32)
def _synth_metal_hat(n_samples):
"""Metal hi-hat — ultra tight, precise, machine-gun ready."""
n = min(n_samples, int(SAMPLE_RATE * 0.02)) # 20ms — very tight
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
metallic = (numpy.sin(2 * numpy.pi * 7000 * t) * 0.3 +
numpy.sin(2 * numpy.pi * 9500 * t) * 0.25 +
numpy.sin(2 * numpy.pi * 13000 * t) * 0.2)
noise = _noise(n) * 0.5
wave = (metallic + noise) * _exp_decay(n, 150)
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(wave * 1.5)
return out
def _synth_djembe_bass(n_samples):
"""Djembe bass — open palm strike in center of goatskin head.
@@ -1425,6 +1546,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),
# Metal kit
DrumSound.METAL_KICK.value: lambda n: _synth_metal_kick(n),
DrumSound.METAL_SNARE.value: lambda n: _synth_metal_snare(n),
DrumSound.METAL_HAT.value: lambda n: _synth_metal_hat(n),
}
renderer = _dispatch.get(sound_value, lambda n: _synth_clave(n))
@@ -2257,6 +2382,41 @@ def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4,
return (samples * (1 - mix) + wet.astype(numpy.float32) * mix).astype(numpy.float32)
def _apply_cabinet(samples, brightness=0.5, sample_rate=SAMPLE_RATE):
"""Guitar speaker cabinet simulation.
A real guitar cabinet (4x12, 2x12, 1x12) rolls off everything
above ~5kHz sharply. This is what makes distorted guitar sound
warm and musical instead of fizzy and harsh. Without cab sim,
distorted guitar sounds like a broken radio.
Also adds a presence bump around 2-3kHz (the "cut" frequency)
and rolls off sub-bass below 80Hz (speakers can't reproduce it).
Args:
samples: Float32 numpy array.
brightness: 0.0 = dark (jazz combo), 0.5 = normal, 1.0 = bright.
"""
# Highpass at 80Hz — speakers don't go that low
if len(samples) > 10:
bl, al = scipy.signal.butter(2, 80, btype='high', fs=sample_rate)
samples = scipy.signal.lfilter(bl, al, samples).astype(numpy.float32)
# Steep lowpass — the cabinet rolloff. This is the magic.
cutoff = 3500 + brightness * 2000 # 3.5kHz (dark) to 5.5kHz (bright)
if cutoff < sample_rate / 2:
bl, al = scipy.signal.butter(3, cutoff, btype='low', fs=sample_rate)
samples = scipy.signal.lfilter(bl, al, samples).astype(numpy.float32)
# Presence bump at 2-3kHz (the "cut through the mix" frequency)
center = 2500
bw = 800
if center + bw < sample_rate / 2:
bp, ap = scipy.signal.butter(2, [center - bw, center + bw],
btype='band', fs=sample_rate)
presence = scipy.signal.lfilter(bp, ap, samples).astype(numpy.float32)
samples = samples + presence * 0.3 * brightness
return samples
def _apply_distortion(samples, drive=1.0, mix=1.0):
"""Apply soft-clip distortion (tanh waveshaping).
@@ -2288,8 +2448,8 @@ def _apply_distortion(samples, drive=1.0, mix=1.0):
def _apply_effects_with_params(samples, params, skip_reverb=False):
"""Apply effects using a params dict. Used for both static and automated rendering."""
# Signal chain: saturation → tremolo → distortion → chorus → phaser
# → highpass → lowpass → delay → reverb
# Signal chain: saturation → tremolo → distortion → cabinet → chorus
# → phaser → highpass → lowpass → delay → reverb
if params.get("saturation", 0) > 0:
samples = _apply_saturation(samples, amount=params["saturation"])
if params.get("tremolo_depth", 0) > 0:
@@ -2299,6 +2459,9 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
samples = _apply_distortion(samples,
drive=params.get("distortion_drive", 3.0),
mix=params["distortion_mix"])
if params.get("cabinet", 0) > 0:
samples = _apply_cabinet(samples,
brightness=params.get("cabinet_brightness", 0.5))
if params.get("chorus_mix", 0) > 0:
samples = _apply_chorus(samples,
mix=params["chorus_mix"],
@@ -2344,6 +2507,8 @@ def _apply_part_effects(samples, part):
"chorus_depth": part.chorus_depth,
"phaser_mix": part.phaser_mix,
"phaser_rate": part.phaser_rate,
"cabinet": part.cabinet,
"cabinet_brightness": part.cabinet_brightness,
"highpass": part.highpass,
"highpass_q": part.highpass_q,
"lowpass": part.lowpass,
@@ -2822,7 +2987,8 @@ def render_score(score):
part_buf[seg_start:seg_end] = segment
else:
has_fx = (part.saturation > 0 or part.tremolo_depth > 0
or part.distortion_mix > 0 or part.chorus_mix > 0
or part.distortion_mix > 0 or part.cabinet > 0
or part.chorus_mix > 0
or part.phaser_mix > 0 or part.highpass > 0
or part.lowpass > 0 or part.delay_mix > 0
or part.reverb_mix > 0)
@@ -2910,6 +3076,10 @@ def render_score(score):
DrumSound.DJEMBE_BASS.value: 0.0,
DrumSound.DJEMBE_TONE.value: 0.1,
DrumSound.DJEMBE_SLAP.value: -0.1,
# Metal kit
DrumSound.METAL_KICK.value: 0.0,
DrumSound.METAL_SNARE.value: 0.0,
DrumSound.METAL_HAT.value: 0.3,
}
# Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.)
+208 -8
View File
@@ -144,20 +144,51 @@ INSTRUMENTS = {
# ── Plucked ──
"acoustic_guitar": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 4000,
"humanize": 0.2,
"lowpass": 3000,
"humanize": 0.2, "saturation": 0.05,
},
"electric_guitar": {
"synth": "saw", "envelope": "pluck",
"detune": 5, "lowpass": 3500,
"synth": "electric_guitar_synth", "envelope": "none",
"cabinet": 1.0, "cabinet_brightness": 0.6,
"humanize": 0.15,
},
"clean_guitar": {
"synth": "electric_guitar_synth", "envelope": "none",
"cabinet": 1.0, "cabinet_brightness": 0.7,
"chorus": 0.15, "chorus_rate": 1.0,
"reverb": 0.2, "reverb_type": "spring",
"humanize": 0.15,
},
"crunch_guitar": {
"synth": "electric_guitar_synth", "envelope": "none",
"saturation": 0.3,
"distortion": 0.5, "distortion_drive": 4.0,
"cabinet": 1.0, "cabinet_brightness": 0.5,
"humanize": 0.15,
},
"distorted_guitar": {
"synth": "saw", "envelope": "pluck",
"detune": 8, "distortion": 0.6, "distortion_drive": 5.0,
"lowpass": 3000, "saturation": 0.3,
"synth": "electric_guitar_synth", "envelope": "none",
"saturation": 0.3,
"distortion": 0.7, "distortion_drive": 5.0,
"cabinet": 1.0, "cabinet_brightness": 0.5,
"humanize": 0.15,
},
"orange_crunch": {
"synth": "electric_guitar_synth", "envelope": "none",
"saturation": 0.4,
"distortion": 0.7, "distortion_drive": 6.0,
"cabinet": 1.0, "cabinet_brightness": 0.4,
"humanize": 0.15,
},
"metal_guitar": {
"synth": "electric_guitar_synth", "envelope": "none",
"saturation": 0.35,
"distortion": 0.8, "distortion_drive": 7.0,
"cabinet": 1.0, "cabinet_brightness": 0.5,
"highpass": 80,
"detune": 4,
"humanize": 0.1,
},
"bass_guitar": {
"synth": "triangle", "envelope": "pluck",
"lowpass": 1000,
@@ -405,6 +436,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)
# Metal kit — tighter, punchier, more attack
METAL_KICK = 105 # clicky, punchy, tight
METAL_SNARE = 106 # crack, bright, cutting
METAL_HAT = 107 # tight, short, precise
class _Hit:
@@ -1432,6 +1467,72 @@ Pattern._PRESETS["tabla solo"] = dict(
],
)
# ── Metal kit patterns ────────────────────────────────────────────────────
MK = DrumSound.METAL_KICK
MS = DrumSound.METAL_SNARE
MH = DrumSound.METAL_HAT
# Metal double kick — the classic thrash/death metal beat
Pattern._PRESETS["double kick"] = dict(
name="double kick",
time_signature="4/4",
beats=4.0,
hits=[
# Double kick 16ths, snare on 2 and 4, tight hats
*[_h(MK, i * 0.25) for i in range(16)],
_h(MS, 1.0), _h(MS, 3.0),
*[_h(MH, i * 0.5) for i in range(8)],
],
)
# Metal blast — blast beat with metal kit sounds
Pattern._PRESETS["metal blast"] = dict(
name="metal blast",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(MK, i * 0.25) for i in range(16)],
*[_h(MS, i * 0.25) for i in range(16)],
*[_h(MH, i * 0.25) for i in range(16)],
],
)
# Metal groove — half time with double kick fills
Pattern._PRESETS["metal groove"] = dict(
name="metal groove",
time_signature="4/4",
beats=4.0,
hits=[
_h(MK, 0.0), _h(MH, 0.0),
_h(MH, 0.5),
_h(MS, 1.0), _h(MH, 1.0),
_h(MK, 1.5), _h(MH, 1.5),
_h(MK, 2.0), _h(MH, 2.0),
_h(MK, 2.25),
_h(MK, 2.5), _h(MH, 2.5),
_h(MK, 2.75),
_h(MS, 3.0), _h(MH, 3.0),
_h(MH, 3.5),
],
)
# Metal gallop — the classic Iron Maiden triplet feel
Pattern._PRESETS["metal gallop"] = dict(
name="metal gallop",
time_signature="4/4",
beats=4.0,
hits=[
_h(MK, 0.0), _h(MH, 0.0),
_h(MK, 0.33), _h(MK, 0.67),
_h(MS, 1.0), _h(MH, 1.0),
_h(MK, 1.33), _h(MK, 1.67),
_h(MK, 2.0), _h(MH, 2.0),
_h(MK, 2.33), _h(MK, 2.67),
_h(MS, 3.0), _h(MH, 3.0),
_h(MK, 3.33), _h(MK, 3.67),
],
)
# Tabla tiri-kita — rapid 16th-note dayan patter
Pattern._PRESETS["tiri kita"] = dict(
name="tiri kita",
@@ -1903,6 +2004,8 @@ class Part:
tremolo_rate: float = 5.0,
phaser: float = 0.0,
phaser_rate: float = 0.5,
cabinet: float = 0.0,
cabinet_brightness: float = 0.5,
fm_ratio: float = 2.0,
fm_index: float = 3.0):
self.name = name
@@ -1946,9 +2049,12 @@ class Part:
self.tremolo_rate = tremolo_rate
self.phaser_mix = phaser
self.phaser_rate = phaser_rate
self.cabinet = cabinet
self.cabinet_brightness = cabinet_brightness
self.fm_ratio = fm_ratio
self.fm_index = fm_index
self._system = "western" # default, overridden by Score.part()
self._fretboard = None # set by Score.part(fretboard=...)
self.notes: list[Note] = []
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
@@ -2032,6 +2138,7 @@ class Part:
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
"delay_feedback": self.delay_feedback,
"phaser_mix": self.phaser_mix, "phaser_rate": self.phaser_rate,
"cabinet": self.cabinet, "cabinet_brightness": self.cabinet_brightness,
"highpass": self.highpass, "highpass_q": self.highpass_q,
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
"distortion_mix": self.distortion_mix,
@@ -2249,6 +2356,94 @@ class Part:
return self
def strum(self, chord_name: str, duration=Duration.QUARTER, *,
direction: str = "down", velocity: int = 100,
strum_time: float = 0.08) -> "Part":
"""Strum a chord using the part's fretboard fingering.
Looks up the chord on the fretboard, gets the fingering, and
adds each string as a rapid sequence with tiny time offsets —
like a real guitar strum. Muted strings are skipped.
Args:
chord_name: Chord name (e.g. ``"Am"``, ``"G"``, ``"D"``).
duration: Total duration of the strum (default QUARTER).
direction: ``"down"`` (low→high, default) or ``"up"`` (high→low).
velocity: Base velocity (each string gets slight variation).
strum_time: Time in beats for the full strum sweep
(default 0.03 = very fast). Larger values = slower,
more audible strum. Try 0.1 for a lazy strum.
Returns:
Self for chaining.
Example::
>>> guitar = score.part("guitar", instrument="acoustic_guitar",
... fretboard=Fretboard.guitar())
>>> guitar.strum("Am", Duration.HALF)
>>> guitar.strum("G", Duration.HALF, direction="up")
"""
if self._fretboard is None:
raise ValueError(
"Cannot strum without a fretboard. "
"Set fretboard= when creating the part."
)
from .charts import CHARTS
# Get the fingering
system_name = self._system if isinstance(self._system, str) else "western"
if system_name in CHARTS:
chart = CHARTS[system_name]
else:
chart = CHARTS["western"]
if chord_name in chart:
fingering = chart[chord_name].fingering(fretboard=self._fretboard)
else:
# Try fretboard.chord() as fallback
fingering = self._fretboard.chord(chord_name)
# Get the sounding tones (skips muted strings)
tones = fingering.tones # list of Tone objects, high to low
if not tones:
self.rest(duration)
return self
# Order: down strum = low to high (reverse since tones are high-to-low)
if direction == "down":
strum_tones = list(reversed(tones))
else:
strum_tones = list(tones)
if hasattr(duration, 'value'):
total_beats = duration.value
else:
total_beats = float(duration)
# Build a Chord from the fingering tones so all strings ring together
from .chords import Chord as ChordClass
chord_obj = ChordClass(tones=strum_tones)
# Add grace notes for the strum sweep — short individual string
# hits before the full chord, creating the audible "sweep"
n_strings = len(strum_tones)
per_string = strum_time / max(1, n_strings) if n_strings > 1 else 0
import random as _rnd
# Grace notes: each string except the last gets a quiet hit
# Lower velocity than the main chord to avoid pick noise buildup
grace_vel = max(1, int(velocity * 0.5))
for i in range(n_strings - 1):
vel = max(1, min(127, grace_vel + _rnd.randint(-5, 5)))
self.add(strum_tones[i], per_string, velocity=vel)
# Full chord rings for the remaining duration
ring_beats = max(0.1, total_beats - strum_time)
self.add(chord_obj, ring_beats, velocity=velocity)
return self
@property
def is_drums(self) -> bool:
"""True if this part contains drum hits."""
@@ -2451,8 +2646,11 @@ class Score:
tremolo_rate: float = None,
phaser: float = None,
phaser_rate: float = None,
cabinet: float = None,
cabinet_brightness: float = None,
fm_ratio: float = None,
fm_index: float = None) -> Part:
fm_index: float = None,
fretboard=None) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
@@ -2561,6 +2759,7 @@ class Score:
"saturation": saturation,
"tremolo_depth": tremolo_depth, "tremolo_rate": tremolo_rate,
"phaser": phaser, "phaser_rate": phaser_rate,
"cabinet": cabinet, "cabinet_brightness": cabinet_brightness,
"fm_ratio": fm_ratio, "fm_index": fm_index,
}
for k, v in _locals.items():
@@ -2571,6 +2770,7 @@ class Score:
p = Part(name, **merged)
p._system = self.system
p._fretboard = fretboard
self.parts[name] = p
return p
+1 -1
View File
@@ -5320,7 +5320,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 14
assert len(Synth) == 15
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000