mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 14:50:18 +00:00
Improved strings_synth, highpass filter, bowed envelope
- Rewrite strings_wave with additive synthesis: natural 1/n harmonic rolloff shaped by body resonance curve, per-harmonic phase randomization, delayed vibrato onset, bow pressure variation - Add highpass filter (12dB/oct biquad) to signal chain and Part API - Add BOWED envelope (40ms attack with bite) for string instruments - Update string presets to use strings_synth + bowed envelope Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+107
-37
@@ -243,42 +243,71 @@ def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
|
||||
|
||||
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""String ensemble — filtered saw with body resonance formants.
|
||||
"""Bowed string — additive synthesis with natural harmonic rolloff.
|
||||
|
||||
Goes beyond raw sawtooth by modeling the resonant body of a
|
||||
stringed instrument. Two formant peaks (at ~500 Hz and ~1500 Hz)
|
||||
shape the spectrum the way a violin or cello body does — boosting
|
||||
certain frequencies and cutting others.
|
||||
|
||||
The result is warmer and more "wooden" than a raw saw wave,
|
||||
with the characteristic nasal quality of bowed strings.
|
||||
Models bowed string physics:
|
||||
- Additive harmonics with 1/n rolloff shaped by body resonance
|
||||
- Delayed vibrato (develops ~200ms in, like a real player)
|
||||
- Subtle bow pressure variation (amplitude modulation)
|
||||
- Per-harmonic phase randomization for natural timbre
|
||||
- Gentle spectral tilt to avoid synthetic brightness
|
||||
"""
|
||||
# Base: sawtooth (all harmonics, like a bowed string)
|
||||
length = SAMPLE_RATE / float(hz)
|
||||
omega = numpy.pi * 2 / length
|
||||
xvalues = numpy.arange(int(length)) * omega
|
||||
onecycle = scipy.signal.sawtooth(xvalues, width=1)
|
||||
wave = numpy.resize(onecycle, (n_samples,)).astype(numpy.float64)
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Body resonance formants — two bandpass peaks
|
||||
# Formant 1: ~500 Hz (body resonance)
|
||||
f1 = 500
|
||||
bw1 = 200
|
||||
b1, a1 = scipy.signal.butter(2, [max(20, f1 - bw1), f1 + bw1],
|
||||
btype='band', fs=SAMPLE_RATE)
|
||||
formant1 = scipy.signal.lfilter(b1, a1, wave)
|
||||
# Delayed vibrato: ramps in over ~200ms, like a real bow
|
||||
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
|
||||
vib_depth = hz * 0.003 # ~5 cents
|
||||
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
|
||||
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
|
||||
|
||||
# Formant 2: ~1500 Hz (bridge/top plate)
|
||||
f2 = 1500
|
||||
bw2 = 400
|
||||
b2, a2 = scipy.signal.butter(2, [f2 - bw2, f2 + bw2],
|
||||
btype='band', fs=SAMPLE_RATE)
|
||||
formant2 = scipy.signal.lfilter(b2, a2, wave)
|
||||
# Additive synthesis — build harmonics with natural rolloff
|
||||
nyquist = SAMPLE_RATE / 2.0
|
||||
n_harmonics = min(40, int(nyquist / hz))
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
# Mix: original (attenuated) + formants
|
||||
mixed = wave * 0.3 + formant1 * 0.4 + formant2 * 0.3
|
||||
# Body resonance curve — emphasizes certain harmonic regions
|
||||
# Modeled after violin/cello response: peaks around 300Hz, 1kHz, 2.5kHz
|
||||
def body_response(f):
|
||||
"""Approximate string instrument body resonance."""
|
||||
r = 1.0
|
||||
# Main air resonance (~280 Hz for violin, scales with pitch)
|
||||
air_f = max(200, min(400, hz * 1.5))
|
||||
r += 0.6 * numpy.exp(-((f - air_f) / 100) ** 2)
|
||||
# Wood resonance (~1 kHz)
|
||||
r += 0.4 * numpy.exp(-((f - 1000) / 300) ** 2)
|
||||
# Bridge resonance (~2.5 kHz) — the "presence" peak
|
||||
r += 0.3 * numpy.exp(-((f - 2500) / 500) ** 2)
|
||||
return r
|
||||
|
||||
return (peak * mixed).astype(numpy.int16)
|
||||
for n in range(1, n_harmonics + 1):
|
||||
f_n = hz * n
|
||||
if f_n >= nyquist:
|
||||
break
|
||||
# Amplitude: 1/n rolloff (sawtooth-like) shaped by body
|
||||
amp = (1.0 / n) * body_response(f_n)
|
||||
# Even harmonics slightly weaker (bowing point ~1/8 from bridge)
|
||||
if n % 2 == 0:
|
||||
amp *= 0.85
|
||||
# Random phase per harmonic — prevents the "buzzy" coherent-phase sound
|
||||
phi = rng.uniform(0, 2 * numpy.pi)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * (f_n * t + vibrato * n / hz) + phi)
|
||||
|
||||
# Normalize
|
||||
max_val = numpy.abs(wave).max()
|
||||
if max_val > 0:
|
||||
wave /= max_val
|
||||
|
||||
# Subtle bow pressure variation — slow amplitude wobble
|
||||
bow_pressure = 1.0 + 0.03 * numpy.sin(2 * numpy.pi * 3.7 * t)
|
||||
wave *= bow_pressure
|
||||
|
||||
# Gentle lowpass — real instruments don't have infinite bandwidth
|
||||
cutoff = min(10000, hz * 10)
|
||||
bl, al = scipy.signal.butter(2, cutoff, btype='low', fs=SAMPLE_RATE)
|
||||
wave = scipy.signal.lfilter(bl, al, wave)
|
||||
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
|
||||
@@ -346,6 +375,7 @@ class Envelope(Enum):
|
||||
PLUCK = (0.002, 0.15, 0.0, 0.1)
|
||||
PAD = (0.4, 0.2, 0.7, 0.5)
|
||||
STRINGS = (0.15, 0.1, 0.8, 0.3)
|
||||
BOWED = (0.04, 0.08, 0.75, 0.25)
|
||||
BELL = (0.001, 0.3, 0.0, 0.5)
|
||||
STACCATO = (0.005, 0.05, 0.0, 0.02)
|
||||
|
||||
@@ -1458,6 +1488,41 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
|
||||
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_highpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
|
||||
"""Apply a 2nd-order Butterworth highpass filter (12 dB/octave).
|
||||
|
||||
Removes low-frequency content below the cutoff. Useful for cleaning
|
||||
up mud from pads, keeping bass parts from masking each other, or
|
||||
thinning out a sound.
|
||||
|
||||
Args:
|
||||
samples: Float32 numpy array.
|
||||
cutoff: Cutoff frequency in Hz.
|
||||
q: Resonance / Q factor (default 0.707 = Butterworth flat).
|
||||
sample_rate: Sample rate in Hz.
|
||||
|
||||
Returns:
|
||||
Float32 array with filter applied.
|
||||
"""
|
||||
if cutoff <= 0 or cutoff >= sample_rate / 2:
|
||||
return samples
|
||||
|
||||
w0 = 2 * numpy.pi * cutoff / sample_rate
|
||||
alpha = numpy.sin(w0) / (2 * q)
|
||||
|
||||
b0 = (1 + numpy.cos(w0)) / 2
|
||||
b1 = -(1 + numpy.cos(w0))
|
||||
b2 = (1 + numpy.cos(w0)) / 2
|
||||
a0 = 1 + alpha
|
||||
a1 = -2 * numpy.cos(w0)
|
||||
a2 = 1 - alpha
|
||||
|
||||
b = numpy.array([b0/a0, b1/a0, b2/a0])
|
||||
a = numpy.array([1.0, a1/a0, a2/a0])
|
||||
|
||||
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
|
||||
|
||||
|
||||
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
|
||||
sample_rate=SAMPLE_RATE):
|
||||
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
|
||||
@@ -1534,7 +1599,7 @@ 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: distortion → chorus → lowpass → delay → reverb
|
||||
# Signal chain: distortion → chorus → highpass → lowpass → delay → reverb
|
||||
if params.get("distortion_mix", 0) > 0:
|
||||
samples = _apply_distortion(samples,
|
||||
drive=params.get("distortion_drive", 3.0),
|
||||
@@ -1544,6 +1609,9 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
|
||||
mix=params["chorus_mix"],
|
||||
rate=params.get("chorus_rate", 1.5),
|
||||
depth=params.get("chorus_depth", 0.003))
|
||||
if params.get("highpass", 0) > 0:
|
||||
samples = _apply_highpass(samples, params["highpass"],
|
||||
params.get("highpass_q", 0.707))
|
||||
if params.get("lowpass", 0) > 0:
|
||||
samples = _apply_lowpass(samples, params["lowpass"],
|
||||
params.get("lowpass_q", 0.707))
|
||||
@@ -1573,6 +1641,8 @@ def _apply_part_effects(samples, part):
|
||||
"chorus_mix": part.chorus_mix,
|
||||
"chorus_rate": part.chorus_rate,
|
||||
"chorus_depth": part.chorus_depth,
|
||||
"highpass": part.highpass,
|
||||
"highpass_q": part.highpass_q,
|
||||
"lowpass": part.lowpass,
|
||||
"lowpass_q": part.lowpass_q,
|
||||
"delay_mix": part.delay_mix,
|
||||
@@ -1973,8 +2043,8 @@ def render_score(score):
|
||||
params = part._get_params_at(seg_start_beat)
|
||||
segment = part_buf[seg_start:seg_end].copy()
|
||||
has_fx = any(params.get(k, 0) > 0 for k in
|
||||
["distortion_mix", "chorus_mix", "lowpass",
|
||||
"delay_mix", "reverb_mix"])
|
||||
["distortion_mix", "chorus_mix", "highpass",
|
||||
"lowpass", "delay_mix", "reverb_mix"])
|
||||
if has_fx:
|
||||
segment = _apply_effects_with_params(segment, params)
|
||||
# Apply volume automation
|
||||
@@ -1983,9 +2053,9 @@ def render_score(score):
|
||||
segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment
|
||||
part_buf[seg_start:seg_end] = segment
|
||||
else:
|
||||
has_fx = (part.lowpass > 0 or part.delay_mix > 0
|
||||
or part.reverb_mix > 0 or part.distortion_mix > 0
|
||||
or part.chorus_mix > 0)
|
||||
has_fx = (part.highpass > 0 or part.lowpass > 0
|
||||
or part.delay_mix > 0 or part.reverb_mix > 0
|
||||
or part.distortion_mix > 0 or part.chorus_mix > 0)
|
||||
if has_fx:
|
||||
part_buf = _apply_part_effects(part_buf, part)
|
||||
# Apply sidechain compression if enabled
|
||||
@@ -2092,7 +2162,7 @@ def render_score(score):
|
||||
part_stereo[start:start + hit_len] += panned
|
||||
|
||||
# Apply this drum Part's effects
|
||||
has_drum_fx = (drum_part.lowpass > 0 or drum_part.delay_mix > 0
|
||||
has_drum_fx = (drum_part.highpass > 0 or drum_part.lowpass > 0 or drum_part.delay_mix > 0
|
||||
or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0
|
||||
or drum_part.chorus_mix > 0)
|
||||
if has_drum_fx:
|
||||
|
||||
+14
-8
@@ -45,23 +45,23 @@ INSTRUMENTS = {
|
||||
|
||||
# ── Strings ──
|
||||
"violin": {
|
||||
"synth": "triangle", "envelope": "strings",
|
||||
"lowpass": 5000,
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 5000,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"viola": {
|
||||
"synth": "triangle", "envelope": "strings",
|
||||
"lowpass": 3500,
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 3500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"cello": {
|
||||
"synth": "triangle", "envelope": "strings",
|
||||
"lowpass": 2500,
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 2500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"contrabass": {
|
||||
"synth": "triangle", "envelope": "strings",
|
||||
"lowpass": 1500,
|
||||
"synth": "strings_synth", "envelope": "bowed",
|
||||
"detune": 2, "lowpass": 1500,
|
||||
"humanize": 0.1,
|
||||
},
|
||||
"string_ensemble": {
|
||||
@@ -1571,6 +1571,7 @@ class Part:
|
||||
reverb_type: str = "algorithmic",
|
||||
delay: float = 0.0, delay_time: float = 0.375,
|
||||
delay_feedback: float = 0.4,
|
||||
highpass: float = 0.0, highpass_q: float = 0.707,
|
||||
lowpass: float = 0.0, lowpass_q: float = 0.707,
|
||||
distortion: float = 0.0, distortion_drive: float = 3.0,
|
||||
legato: bool = False, glide: float = 0.0,
|
||||
@@ -1597,6 +1598,8 @@ class Part:
|
||||
self.delay_mix = delay
|
||||
self.delay_time = delay_time
|
||||
self.delay_feedback = delay_feedback
|
||||
self.highpass = highpass
|
||||
self.highpass_q = highpass_q
|
||||
self.lowpass = lowpass
|
||||
self.lowpass_q = lowpass_q
|
||||
self.distortion_mix = distortion
|
||||
@@ -1687,6 +1690,7 @@ class Part:
|
||||
"reverb_type": self.reverb_type,
|
||||
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
|
||||
"delay_feedback": self.delay_feedback,
|
||||
"highpass": self.highpass, "highpass_q": self.highpass_q,
|
||||
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
|
||||
"distortion_mix": self.distortion_mix,
|
||||
"distortion_drive": self.distortion_drive,
|
||||
@@ -2075,6 +2079,7 @@ class Score:
|
||||
reverb_type: str = None,
|
||||
delay: float = None, delay_time: float = None,
|
||||
delay_feedback: float = None,
|
||||
highpass: float = None, highpass_q: float = None,
|
||||
lowpass: float = None, lowpass_q: float = None,
|
||||
distortion: float = None, distortion_drive: float = None,
|
||||
legato: bool = None, glide: float = None,
|
||||
@@ -2179,6 +2184,7 @@ class Score:
|
||||
"reverb_type": reverb_type,
|
||||
"delay": delay, "delay_time": delay_time,
|
||||
"delay_feedback": delay_feedback,
|
||||
"highpass": highpass, "highpass_q": highpass_q,
|
||||
"lowpass": lowpass, "lowpass_q": lowpass_q,
|
||||
"distortion": distortion, "distortion_drive": distortion_drive,
|
||||
"legato": legato, "glide": glide,
|
||||
|
||||
+4
-3
@@ -4248,7 +4248,7 @@ def test_parallel_modes_g_major():
|
||||
@needs_portaudio
|
||||
def test_envelope_enum_presets():
|
||||
from pytheory.play import Envelope
|
||||
assert len(Envelope) == 8
|
||||
assert len(Envelope) == 9
|
||||
for e in Envelope:
|
||||
a, d, s, r = e.value
|
||||
assert a >= 0
|
||||
@@ -6470,10 +6470,11 @@ def test_instrument_violin():
|
||||
from pytheory import Score
|
||||
score = Score("4/4", bpm=120)
|
||||
p = score.part("v", instrument="violin")
|
||||
assert p.synth == "triangle"
|
||||
assert p.envelope == "strings"
|
||||
assert p.synth == "strings_synth"
|
||||
assert p.envelope == "bowed"
|
||||
assert p.humanize == 0.15
|
||||
assert p.lowpass == 5000
|
||||
assert p.detune == 2
|
||||
|
||||
|
||||
def test_instrument_override():
|
||||
|
||||
Reference in New Issue
Block a user