From c6bbfae7e6212da0b878eeb522f6d691fceeb598 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Fri, 27 Mar 2026 01:38:35 -0400 Subject: [PATCH] Acoustic guitar synth with body resonance, fix strum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New acoustic_guitar_synth: Karplus-Strong with wooden body resonance (3 formant peaks at 110/250/500 Hz), warmer initial noise, gentle rolloff. Sounds woody, not harsh. - Strum renders as a single chord hit — no more exposed grace notes that sounded digital. Clean, full chord sound. - 16 synth waveforms total Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/play.py | 58 ++++++++++++++++++++++++++++++++++++++++++++-- pytheory/rhythm.py | 23 ++++-------------- test_pytheory.py | 2 +- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/pytheory/play.py b/pytheory/play.py index 80b2052..5a3d128 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -310,6 +310,59 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): return (peak * wave).astype(numpy.int16) +def acoustic_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): + """Acoustic guitar — Karplus-Strong with wooden body resonance. + + Models a steel string exciting a resonant wooden body: + 1. Karplus-Strong plucked string (softer initial noise than pure KS) + 2. Body resonance — bandpass filters at the guitar body's natural + frequencies (~100Hz air cavity, ~250Hz top plate, ~500Hz back) + 3. Warmer, rounder attack than electric (fingers vs pickup) + """ + period = int(SAMPLE_RATE / hz) + if period < 2: + period = 2 + + rng = numpy.random.default_rng(int(hz * 100) % 2**31) + + # Softer initial noise — nylon/steel string, not a harsh burst + buf = rng.uniform(-0.8, 0.8, period).astype(numpy.float64) + # Warm the initial burst — lowpass the noise slightly + for j in range(2): + for k in range(period - 1): + buf[k] = 0.6 * buf[k] + 0.4 * buf[k + 1] + + out = numpy.zeros(n_samples, dtype=numpy.float64) + + # Karplus-Strong with moderate decay + for i in range(n_samples): + out[i] = buf[i % period] + next_idx = (i + 1) % period + buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9988 + + # Body resonance — three formant peaks modeling the guitar body + # These interact with the string harmonics to create the "woody" tone + resonances = numpy.zeros(n_samples, dtype=numpy.float64) + for center, bw, gain in [(110, 60, 0.4), (250, 80, 0.3), (500, 120, 0.2)]: + lo = max(20, center - bw) + hi = min(SAMPLE_RATE // 2 - 1, center + bw) + if lo < hi: + bp, ap = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE) + resonances += scipy.signal.lfilter(bp, ap, out) * gain + + out = out * 0.6 + resonances + + # Gentle rolloff above 5kHz (no brightness of electric pickup) + bl, al = scipy.signal.butter(2, 5000, btype='low', fs=SAMPLE_RATE) + out = scipy.signal.lfilter(bl, al, out) + + mx = numpy.abs(out).max() + if mx > 0: + out /= mx + + return (peak * out).astype(numpy.int16) + + def electric_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): """Electric guitar — Karplus-Strong through magnetic pickup simulation. @@ -546,6 +599,7 @@ class Synth(Enum): PLUCK = "pluck_synth" ORGAN = "organ_synth" STRINGS = "strings_synth" + ACOUSTIC_GUITAR = "acoustic_guitar_synth" SITAR = "sitar_synth" ELECTRIC_GUITAR = "electric_guitar_synth" @@ -560,8 +614,8 @@ _SYNTH_FUNCTIONS = { "noise": noise_wave, "supersaw": supersaw_wave, "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, + "strings_synth": strings_wave, "acoustic_guitar_synth": acoustic_guitar_wave, + "sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave, } diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 6e3321e..d5b961a 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -145,8 +145,7 @@ INSTRUMENTS = { # ── Plucked ── "acoustic_guitar": { - "synth": "pluck_synth", "envelope": "none", - "lowpass": 3000, + "synth": "acoustic_guitar_synth", "envelope": "none", "humanize": 0.2, "saturation": 0.05, }, "electric_guitar": { @@ -2429,26 +2428,12 @@ class Part: else: total_beats = float(duration) - # Build a Chord from the fingering tones so all strings ring together + # Build a Chord — all strings ring together through the + # shared body resonance, like a real guitar 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) + self.add(chord_obj, total_beats, velocity=velocity) return self diff --git a/test_pytheory.py b/test_pytheory.py index 5b09bc7..c51ca5c 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -5320,7 +5320,7 @@ def test_supersaw_wave(): @needs_portaudio def test_all_synths_in_enum(): from pytheory.play import Synth - assert len(Synth) == 15 + assert len(Synth) == 16 for s in Synth: wave = s(440, n_samples=1000) assert len(wave) == 1000