mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user