From e46732fb5a3fdc4010f1306ad8ab67b64f00f29f Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 21:38:15 -0400 Subject: [PATCH] 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) --- pytheory/play.py | 144 +++++++++++++++++++++++++++++++++------------ pytheory/rhythm.py | 22 ++++--- test_pytheory.py | 7 ++- 3 files changed, 125 insertions(+), 48 deletions(-) diff --git a/pytheory/play.py b/pytheory/play.py index aa82f30..0dfbf0e 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -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: diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index c4e43ea..7be2391 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -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, diff --git a/test_pytheory.py b/test_pytheory.py index 04659e3..968480c 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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():