Acoustic guitar synth with body resonance, fix strum

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 01:38:35 -04:00
parent 64ef7f0803
commit c6bbfae7e6
3 changed files with 61 additions and 22 deletions
+56 -2
View File
@@ -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,
}
+4 -19
View File
@@ -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
+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) == 15
assert len(Synth) == 16
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000