mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de7575fe0a | |||
| 665a6f5de5 | |||
| 63362df697 | |||
| 755b33a63b | |||
| 40901d603d |
@@ -2,6 +2,33 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.40.7
|
||||
|
||||
- **Expose missing Synth enum entries** — rhodes, wurlitzer, vibraphone,
|
||||
pipe organ, and choir wave functions were already implemented but not
|
||||
accessible via the Synth enum. Now available as `Synth.RHODES`,
|
||||
`Synth.WURLITZER`, `Synth.VIBRAPHONE`, `Synth.PIPE_ORGAN`, `Synth.CHOIR`.
|
||||
|
||||
## 0.40.6
|
||||
|
||||
- **Saxophone presets cleaned up** — removed lowpass filters and vel_to_filter
|
||||
from all sax instrument presets (saxophone, alto_sax, tenor_sax, bari_sax).
|
||||
The saxophone wave function already shapes its own spectrum; the extra
|
||||
filters were dulling the tone.
|
||||
|
||||
## 0.40.5
|
||||
|
||||
- **Saxophone synth overhaul** — reed nonlinearity (asymmetric soft clipping),
|
||||
conical bore formant resonances, breath noise with attack envelope, separate
|
||||
reed buzz, key click transient, and sub-harmonic warmth. Vibrato dialed back
|
||||
to subtle, delayed onset.
|
||||
|
||||
## 0.40.4
|
||||
|
||||
- **Distortion overhaul** — multi-stage clipping (preamp → power amp →
|
||||
asymmetric rectifier) replaces single-stage tanh. Crunch, distorted,
|
||||
orange crunch, and metal guitar presets now sound properly driven.
|
||||
|
||||
## 0.40.3
|
||||
|
||||
- **Crotales synth** — tuned bronze discs with long ring and bright harmonics
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.40.3"
|
||||
version = "0.40.7"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.40.3"
|
||||
__version__ = "0.40.7"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
+121
-35
@@ -1187,62 +1187,131 @@ def timpani_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
|
||||
|
||||
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Saxophone — single reed through a conical brass bore.
|
||||
"""Saxophone — single reed driving a conical brass bore.
|
||||
|
||||
The conical bore produces all harmonics (like oboe), but the
|
||||
brass body and larger mouthpiece give a warmer, fatter, more
|
||||
vocal quality. The reed adds a slight buzz. Saxophone is
|
||||
between clarinet (odd harmonics) and oboe (nasal even+odd) —
|
||||
it has everything, with a strong fundamental and rich mids.
|
||||
Models the key acoustic properties of a saxophone:
|
||||
1. Reed-bore interaction — nonlinear clipping creates the characteristic
|
||||
bright, edgy tone (not just additive sines)
|
||||
2. Conical bore formants — vocal-like resonances at ~500, ~1400, ~2300,
|
||||
~3200 Hz that give sax its singing quality
|
||||
3. Breath noise — turbulent airflow through the mouthpiece, strongest
|
||||
at attack and blending into sustained tone
|
||||
4. Sub-harmonic warmth — the conical bore's coupling creates warmth
|
||||
below the fundamental
|
||||
5. Vibrato — delayed onset, ~5 Hz, characteristic of jazz/classical sax
|
||||
"""
|
||||
import scipy.signal as _sig
|
||||
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Vibrato — develops after ~250ms, wider than flute
|
||||
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
|
||||
vib = hz * 0.0012 * vib_onset * numpy.sin(2 * numpy.pi * 5.2 * t)
|
||||
# --- Vibrato: delayed onset, subtle depth ---
|
||||
vib_onset = numpy.clip((t - 0.3) / 0.3, 0.0, 1.0)
|
||||
vib_rate = 5.0 + 0.15 * numpy.sin(2 * numpy.pi * 0.4 * t)
|
||||
vib = hz * 0.0006 * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
|
||||
|
||||
# --- Core tone: sawtooth-like waveform with reed clipping ---
|
||||
# Real sax reed creates a quasi-sawtooth pressure wave, not pure sines.
|
||||
# Build from harmonics with sax-specific spectral envelope, then clip.
|
||||
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
n_harmonics = min(20, int((SAMPLE_RATE / 2) / hz))
|
||||
n_harmonics = min(25, int((SAMPLE_RATE / 2) / hz))
|
||||
|
||||
for n in range(1, n_harmonics + 1):
|
||||
f_n = hz * n
|
||||
if f_n >= SAMPLE_RATE / 2:
|
||||
break
|
||||
# Sax spectral shape: strong fundamental, broad mid peak (3-6),
|
||||
# slower rolloff than oboe (brass body carries harmonics further)
|
||||
# Saxophone spectral envelope from acoustic measurements:
|
||||
# Strong fundamental, nearly-as-strong 2nd and 3rd harmonics,
|
||||
# broad energy peak around harmonics 4-8 (the "body"), then
|
||||
# gradual rolloff — but slower than other woodwinds (brass bore
|
||||
# sustains upper partials).
|
||||
if n == 1:
|
||||
amp = 1.0
|
||||
elif n <= 3:
|
||||
amp = 0.6
|
||||
elif n <= 6:
|
||||
amp = 0.4 * numpy.exp(-0.1 * (n - 4) ** 2)
|
||||
elif n == 2:
|
||||
amp = 0.85
|
||||
elif n == 3:
|
||||
amp = 0.7
|
||||
elif n <= 8:
|
||||
# Broad mid peak — this is the sax "meat"
|
||||
amp = 0.55 * numpy.exp(-0.06 * (n - 5) ** 2)
|
||||
else:
|
||||
amp = 0.2 / n
|
||||
# Slower rolloff than oboe/clarinet
|
||||
amp = 0.35 / (n ** 0.7)
|
||||
|
||||
# Slight even/odd asymmetry — conical bore has all harmonics
|
||||
# but evens are ~10% weaker (midway between cylinder and cone)
|
||||
if n % 2 == 0:
|
||||
amp *= 0.9
|
||||
|
||||
phase = rng.uniform(0, 2 * numpy.pi)
|
||||
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
|
||||
|
||||
# Reed buzz — more present than oboe but still warm
|
||||
reed = rng.normal(0, 0.07, n_samples)
|
||||
# Bandpass the reed noise around 1-3kHz (the "honk" range)
|
||||
import scipy.signal as _sig
|
||||
reed_lo = max(20, int(hz * 2))
|
||||
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 6))
|
||||
# --- Reed nonlinearity: soft clipping ---
|
||||
# The reed closes against the mouthpiece, creating asymmetric clipping
|
||||
# that adds brightness and "edge". This is what makes sax sound like
|
||||
# sax and not a flute.
|
||||
wave_max = numpy.abs(wave).max()
|
||||
if wave_max > 0:
|
||||
wave /= wave_max
|
||||
# Asymmetric soft clip: positive peaks clip harder (reed closure)
|
||||
wave = numpy.tanh(1.8 * wave) * 0.7 + numpy.tanh(2.5 * wave) * 0.3
|
||||
|
||||
# --- Formant resonances: conical bore creates vocal quality ---
|
||||
# These fixed resonances are what make sax sound "vocal" — they
|
||||
# emphasize certain frequency bands regardless of the note played.
|
||||
formant_freqs = [520, 1380, 2300, 3200]
|
||||
formant_bws = [120, 200, 280, 350]
|
||||
formant_gains = [0.25, 0.18, 0.12, 0.08]
|
||||
|
||||
formant_sum = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for fc, bw, gain in zip(formant_freqs, formant_bws, formant_gains):
|
||||
lo = max(20, int(fc - bw))
|
||||
hi = min(SAMPLE_RATE // 2 - 1, int(fc + bw))
|
||||
if lo < hi:
|
||||
bf, af = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
formant_sum += _sig.lfilter(bf, af, wave) * gain
|
||||
wave = wave * 0.7 + formant_sum
|
||||
|
||||
# --- Breath noise: turbulent air through the mouthpiece ---
|
||||
# Strongest at the attack, then settles to a subtle constant hiss
|
||||
# that gives the tone "life" and prevents it from sounding synthetic.
|
||||
breath = rng.normal(0, 1.0, n_samples)
|
||||
# Shape breath noise into the sax's "hiss" band (2-6 kHz)
|
||||
breath_lo = max(20, 2000)
|
||||
breath_hi = min(SAMPLE_RATE // 2 - 1, 6000)
|
||||
if breath_lo < breath_hi:
|
||||
bb, ab = _sig.butter(2, [breath_lo, breath_hi], btype='band', fs=SAMPLE_RATE)
|
||||
breath = _sig.lfilter(bb, ab, breath)
|
||||
# Attack envelope for breath — strong at onset, then quiet
|
||||
breath_env = 0.15 * numpy.exp(-8.0 * t) + 0.03
|
||||
wave += breath * breath_env
|
||||
|
||||
# --- Reed buzz: low-frequency interaction noise ---
|
||||
# Different from breath — this is the "buzz" from reed vibration
|
||||
# against the mouthpiece, centered around the playing frequency.
|
||||
reed_noise = rng.normal(0, 1.0, n_samples)
|
||||
reed_lo = max(20, int(hz * 0.8))
|
||||
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 4))
|
||||
if reed_lo < reed_hi:
|
||||
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
|
||||
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 2.0
|
||||
wave += reed
|
||||
reed_noise = _sig.lfilter(br, ar, reed_noise) * 0.06
|
||||
wave += reed_noise
|
||||
|
||||
# Brass body warmth — low-mid boost
|
||||
center = min(1500, hz * 4)
|
||||
bw = 500
|
||||
lo = max(20, int(center - bw))
|
||||
hi = min(SAMPLE_RATE // 2 - 1, int(center + bw))
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
body = _sig.lfilter(bp, ap, wave) * 0.2
|
||||
wave += body
|
||||
# --- Attack transient: key click + breath burst ---
|
||||
attack_len = min(int(SAMPLE_RATE * 0.015), n_samples)
|
||||
if attack_len > 0:
|
||||
click = rng.uniform(-1.0, 1.0, attack_len)
|
||||
click *= numpy.exp(-numpy.linspace(0, 8, attack_len))
|
||||
wave[:attack_len] += click * 0.12
|
||||
|
||||
# --- Sub-harmonic warmth ---
|
||||
# Conical bore coupling produces energy slightly below fundamental
|
||||
if hz > 80: # only if there's room
|
||||
sub = numpy.sin(2 * numpy.pi * (hz * 0.5) * t) * 0.04
|
||||
sub *= numpy.clip(t / 0.1, 0.0, 1.0) # fade in gently
|
||||
wave += sub
|
||||
|
||||
# --- Final shaping ---
|
||||
mx = numpy.abs(wave).max()
|
||||
if mx > 0:
|
||||
wave /= mx
|
||||
@@ -2384,6 +2453,11 @@ class Synth(Enum):
|
||||
TINGSHA = "tingsha_synth"
|
||||
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
|
||||
SINGING_BOWL_RING = "singing_bowl_ring_synth"
|
||||
RHODES = "rhodes_synth"
|
||||
WURLITZER = "wurlitzer_synth"
|
||||
VIBRAPHONE = "vibraphone_synth"
|
||||
PIPE_ORGAN = "pipe_organ_synth"
|
||||
CHOIR = "choir_synth"
|
||||
|
||||
def __call__(self, hz, **kwargs):
|
||||
"""Make Synth members callable — dispatches to the wave function."""
|
||||
@@ -4902,7 +4976,19 @@ def _apply_distortion(samples, drive=1.0, mix=1.0):
|
||||
"""
|
||||
if mix <= 0 or drive <= 0:
|
||||
return samples
|
||||
driven = numpy.tanh(samples * drive)
|
||||
# Multi-stage gain + clipping like a real amp:
|
||||
# Stage 1: preamp gain — push the signal hard
|
||||
stage1 = numpy.tanh(samples * drive)
|
||||
# Stage 2: power amp — clip again with more gain for sustain and grit
|
||||
stage2 = numpy.tanh(stage1 * drive * 0.5)
|
||||
# Stage 3: at high drive, add asymmetric clipping (tube rectifier sag)
|
||||
if drive > 3.0:
|
||||
# Positive peaks clip harder than negative — asymmetric harmonics
|
||||
driven = numpy.where(stage2 > 0,
|
||||
numpy.tanh(stage2 * 1.5),
|
||||
numpy.tanh(stage2 * 1.2))
|
||||
else:
|
||||
driven = stage2
|
||||
return samples * (1 - mix) + driven * mix
|
||||
|
||||
|
||||
|
||||
+4
-7
@@ -380,22 +380,19 @@ INSTRUMENTS = {
|
||||
# ── Woodwinds (continued) ──
|
||||
"saxophone": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"humanize": 0.15, "vel_to_filter": 1500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"alto_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"humanize": 0.15, "vel_to_filter": 1800,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"tenor_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.15, "vel_to_filter": 1200,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"bari_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"lowpass": 2000,
|
||||
"humanize": 0.15, "vel_to_filter": 800,
|
||||
"sub_osc": 0.15,
|
||||
"humanize": 0.15, "sub_osc": 0.15,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -5333,7 +5333,7 @@ def test_supersaw_wave():
|
||||
@needs_portaudio
|
||||
def test_all_synths_in_enum():
|
||||
from pytheory.play import Synth
|
||||
assert len(Synth) == 42
|
||||
assert len(Synth) == 51
|
||||
for s in Synth:
|
||||
wave = s(440, n_samples=1000)
|
||||
assert len(wave) == 1000
|
||||
@@ -7155,7 +7155,7 @@ def test_score_system_propagates():
|
||||
|
||||
def test_synth_enum_count():
|
||||
from pytheory.play import Synth
|
||||
assert len(Synth) == 42
|
||||
assert len(Synth) == 51
|
||||
|
||||
|
||||
def test_all_synths_render_and_enum_match():
|
||||
|
||||
Reference in New Issue
Block a user