Compare commits

..

6 Commits

Author SHA1 Message Date
kennethreitz 751d5a49b8 Cleaner vocal synth: less static, click-free note transitions
- Jitter reduced (0.3% → 0.1%), shimmer reduced (2% → 0.8%)
- Breath noise halved (0.08 → 0.04), mix 85/15 → 92/8
- 10ms fade in/out on every vocal note prevents clicks
- Smoother syllable transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:17:12 -04:00
kennethreitz 6a836dd891 Overhaul vocal synth: LF glottal model, 5 formants, jitter/shimmer
- LF glottal pulse: asymmetric open/close phase (not sines)
- 5 parallel formant filters per vowel (Peterson & Barney data)
- Jitter (0.3% pitch irregularity) + shimmer (2% amplitude)
- Much more voice-like than previous version
- Consonant onsets preserved

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:13:50 -04:00
kennethreitz 1f888e2b21 Vocal/formant synth with choir preset
Formant synthesis: glottal buzz source through parallel bandpass
filters at vowel resonance frequencies. Supports 5 vowels (A E I O U)
with consonant onsets (plosives, sibilants, nasals, fricatives,
liquids, aspirates, glides). Per-note lyrics via Part.add(lyric=).

Best for choir pads — vowel sounds with cathedral reverb and detune.
Consonant synthesis is rudimentary (noise bursts, not real speech).

Presets: vocal (solo), choir (detuned ensemble).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 12:10:54 -04:00
kennethreitz fb923f6c76 v0.35.1: Granular synthesis engine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:50:32 -04:00
kennethreitz 59e3338892 Granular synthesis engine with presets
Grain cloud synthesis: source waveform chopped into tiny overlapping
grains (40ms, 50/sec) with Hanning windows, random scatter, and
per-grain pitch variation. Creates textures impossible with other
synthesis. Two presets: granular_pad, granular_texture.
30 synth waveforms total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:47:52 -04:00
kennethreitz 8cf4145c15 Docs: timpani, saxophone, Part.roll(), update waveform counts
- Add timpani and saxophone synth sections to synths.rst
- Add rolls section to sequencing.rst with examples
- Update waveform count: 27 → 29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:38:46 -04:00
10 changed files with 348 additions and 13 deletions
+4 -1
View File
@@ -21,7 +21,10 @@ All notable changes to PyTheory are documented here.
decrescendo rolls on any instrument
- **Vibrato tuning** — all instruments reduced to 0.001 depth for cleaner
ensemble sound
- 29 synth waveforms, 838 tests
- **Granular synthesis** — grain cloud engine with scatter, pitch
variation, and Hanning-windowed grains. Two presets: granular_pad,
granular_texture.
- 30 synth waveforms, 838 tests
## 0.34.0
+30
View File
@@ -620,6 +620,36 @@ Three bend types:
- ``"late"`` — holds the starting pitch for 60%, bends in the last 40%.
The classic blues "curl."
Rolls
-----
Rapid repeated notes with a velocity ramp — perfect for timpani
rolls, snare rolls, tremolo on any instrument. The velocity ramps
from ``velocity_start`` to ``velocity_end`` for crescendo or
decrescendo effects.
.. code-block:: python
# Timpani crescendo roll
timp = score.part("timp", instrument="timpani")
timp.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=110)
timp.add("C3", Duration.HALF, velocity=127) # big accent
# Snare roll with 32nd notes
snare = score.part("snare", synth="noise", envelope="pluck")
snare.roll("C4", Duration.HALF, speed=0.125,
velocity_start=40, velocity_end=100)
# Decrescendo (loud to quiet)
timp.roll("G2", Duration.WHOLE, velocity_start=100, velocity_end=30)
Parameters:
- ``velocity_start``: Starting velocity (default 40).
- ``velocity_end``: Ending velocity (default 100).
- ``speed``: Note subdivision (default ``Duration.SIXTEENTH``).
Use ``0.125`` for 32nd notes, ``Duration.EIGHTH`` for 8th notes.
Tuning Systems
--------------
+55 -3
View File
@@ -1,7 +1,7 @@
Synthesizers
============
PyTheory includes 27 built-in waveforms and 10 ADSR envelope presets.
PyTheory includes 30 built-in waveforms and 10 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
@@ -390,11 +390,11 @@ Dedicated Instrument Synths
--------------------------
Beyond the classic and physical modeling waveforms, PyTheory includes
14 dedicated instrument synths. Each one uses tailored synthesis
17 dedicated instrument synths. Each one uses tailored synthesis
techniques -- additive harmonics, formant shaping, body resonance
modeling, and specialized envelopes -- to capture the character of a
specific acoustic instrument. These are the waveforms that bring the
total count to 27.
total count to 30.
Piano Synth
~~~~~~~~~~~
@@ -535,6 +535,58 @@ bridge, producing a shimmering, metallic sustain.
sitar = score.part("sitar", synth="sitar_synth")
Timpani Synth
~~~~~~~~~~~~~
Large kettle drum with definite pitch. Inharmonic membrane modes
(1.0, 1.5, 1.99, 2.44), felt mallet attack, copper kettle resonance.
Use ``Part.roll()`` for crescendo timpani rolls.
.. code-block:: python
timp = score.part("timp", synth="timpani_synth")
timp.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=110)
Saxophone Synth
~~~~~~~~~~~~~~~
Single reed through a conical brass bore. All harmonics with strong
mids, reed buzz, and brass body warmth. Four presets: ``saxophone``,
``alto_sax``, ``tenor_sax``, ``bari_sax``.
.. code-block:: python
sax = score.part("sax", instrument="tenor_sax")
Granular Synth
~~~~~~~~~~~~~~
Grain cloud synthesis — chops a source waveform into tiny overlapping
grains (10-200ms), each windowed and optionally pitch/time scattered.
Creates textures impossible with other synthesis: frozen tones,
shimmering clouds, evolving pads, glitchy stutters.
.. code-block:: python
# Atmospheric granular pad
pad = score.part("pad", instrument="granular_pad")
# Granular with filter envelope sweep + resonance
texture = score.part("texture", synth="granular_synth", envelope="pad",
filter_amount=4000, filter_attack=0.5,
filter_decay=1.5, filter_sustain=0.3,
lowpass=600, lowpass_q=3.0,
reverb=0.5, reverb_type="taj_mahal")
Parameters (passed as synth kwargs):
- ``grain_size``: Duration per grain in seconds (default 0.04).
- ``density``: Grains per second (default 50). Higher = denser cloud.
- ``scatter``: Random position jitter 0-1 (default 0.5).
- ``pitch_var``: Per-grain pitch randomization in cents (default 12).
- ``source``: Base waveform — ``"saw"``, ``"sine"``, ``"triangle"``,
``"square"``, ``"noise"``.
Analog Oscillator Drift
~~~~~~~~~~~~~~~~~~~~~~~~
+1 -1
View File
@@ -77,7 +77,7 @@ What's Inside
numbers), scale recommendation, modulation, voice leading
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
swing, humanize, tempo changes, song sections with repeat
- **Synthesis**29 waveforms (including Karplus-Strong pluck, Hammond organ,
- **Synthesis**30 waveforms (including Karplus-Strong pluck, Hammond organ,
bowed string, and 14 dedicated instrument synths), 10 envelopes, 40+
instrument presets, configurable FM, sub-oscillator, noise layer, filter
envelope, velocity-to-brightness, analog oscillator drift, detune, stereo
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.35.0"
version = "0.35.1"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.35.0"
__version__ = "0.35.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+231 -2
View File
@@ -909,6 +909,227 @@ def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * wave).astype(numpy.int16)
def vocal_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lyric="ah"):
"""Vocal/formant synthesis — sings vowel sounds at a given pitch.
Models the human voice with:
1. LF glottal model — asymmetric pulse with sharp closure (not just sines)
2. 5 parallel resonant formant filters (real voice has 5 formant peaks)
3. Jitter + shimmer (natural pitch/amplitude irregularity)
4. Aspiration noise mixed with the glottal source
5. Consonant onsets (plosives, sibilants, nasals, etc.)
"""
import scipy.signal as _sig
# 5-formant table: (F1, F2, F3, F4, F5) frequencies and bandwidths
# Based on Peterson & Barney (1952) measurements, male voice
FORMANTS = {
'a': [(800, 130), (1200, 100), (2500, 140), (3300, 250), (3750, 300)],
'e': [(530, 80), (1850, 100), (2500, 130), (3300, 250), (3750, 300)],
'i': [(280, 60), (2250, 100), (2900, 120), (3350, 250), (3750, 300)],
'o': [(500, 100), (1000, 80), (2500, 140), (3300, 250), (3750, 300)],
'u': ((325, 70), (700, 60), (2530, 140), (3300, 250), (3750, 300)),
}
# Formant gains (relative amplitude per formant)
FGAINS = [1.0, 0.8, 0.5, 0.25, 0.15]
rng = numpy.random.default_rng(int(hz * 100 + len(lyric) * 7) % 2**31)
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Parse vowels from lyric
vowels_in_lyric = [c.lower() for c in lyric if c.lower() in FORMANTS]
if not vowels_in_lyric:
vowels_in_lyric = ['a']
# ── Glottal source: LF model approximation ──
# Asymmetric pulse: slow open phase, sharp closure, then closed phase.
# Much more "voice-like" than a sine or sawtooth.
# Jitter (pitch irregularity) + shimmer (amplitude irregularity)
jitter = rng.normal(0, hz * 0.001, n_samples) # ~0.1% pitch jitter
shimmer = 1.0 + rng.normal(0, 0.008, n_samples) # ~0.8% amp shimmer
# Vibrato
vib = hz * 0.001 * numpy.sin(2 * numpy.pi * 5.5 * t)
inst_freq = hz + vib + jitter
phase = numpy.cumsum(2 * numpy.pi * inst_freq / SAMPLE_RATE)
# LF glottal shape: sharper falling edge via phase shaping
saw = (phase / (2 * numpy.pi)) % 1.0 # 0 to 1 sawtooth
# Asymmetric: slow rise (60%), fast fall (40%)
glottal = numpy.where(saw < 0.6,
numpy.sin(numpy.pi * saw / 0.6), # smooth rise
-numpy.sin(numpy.pi * (saw - 0.6) / 0.4) * 0.8) # sharp fall
glottal *= shimmer
# Aspiration noise (breathiness) — subtle
breath = rng.normal(0, 0.04, n_samples)
source = glottal * 0.92 + breath * 0.08
# ── Formant filtering ──
n_vowels = len(vowels_in_lyric)
out = numpy.zeros(n_samples, dtype=numpy.float64)
if n_vowels == 1:
# Single vowel — filter the whole thing
formants = FORMANTS[vowels_in_lyric[0]]
for (fc, bw), gain in zip(formants, FGAINS):
lo = max(20, fc - bw)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
out += _sig.lfilter(bp, ap, source).astype(numpy.float64) * gain
else:
# Multiple vowels — crossfade formants
samples_per_vowel = n_samples // n_vowels
for vi, vowel in enumerate(vowels_in_lyric):
formants = FORMANTS[vowel]
start = vi * samples_per_vowel
end = n_samples if vi == n_vowels - 1 else start + samples_per_vowel
seg = source[start:end].copy()
seg_out = numpy.zeros_like(seg)
for (fc, bw), gain in zip(formants, FGAINS):
lo = max(20, fc - bw)
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
seg_out += _sig.lfilter(bp, ap, seg).astype(numpy.float64) * gain
# Crossfade
fade = min(int(SAMPLE_RATE * 0.02), len(seg_out) // 4)
if vi > 0 and fade > 0:
seg_out[:fade] *= numpy.linspace(0, 1, fade)
if vi < n_vowels - 1 and fade > 0:
seg_out[-fade:] *= numpy.linspace(1, 0, fade)
out[start:end] += seg_out[:end - start]
# ── Consonant onsets ──
lyric_lower = lyric.lower()
if lyric_lower and lyric_lower[0] not in 'aeiou':
c = lyric_lower[0]
cl = min(int(SAMPLE_RATE * 0.035), n_samples)
if c in 'tdkpb':
burst = rng.uniform(-0.5, 0.5, cl) * numpy.exp(-numpy.linspace(0, 18, cl))
out[:cl] = burst + out[:cl] * 0.2
elif c in 'sz':
sib = rng.uniform(-0.4, 0.4, cl)
if cl > 20:
bl, al = _sig.butter(2, [3000, min(8000, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
sib = _sig.lfilter(bl, al, numpy.pad(sib, (0, max(0, n_samples-cl))))[:cl]
sib *= numpy.exp(-numpy.linspace(0, 10, cl))
out[:cl] = sib * 0.6 + out[:cl] * 0.4
elif c in 'mn':
nl = min(int(SAMPLE_RATE * 0.06), n_samples)
nasal = numpy.sin(2*numpy.pi*250*t[:nl]) * 0.4 * numpy.exp(-numpy.linspace(0, 4, nl))
out[:nl] = nasal + out[:nl] * 0.4
elif c in 'fv':
fric = rng.uniform(-0.25, 0.25, cl) * numpy.exp(-numpy.linspace(0, 12, cl))
out[:cl] = fric * 0.5 + out[:cl] * 0.5
elif c in 'lr':
gl = min(int(SAMPLE_RATE * 0.05), n_samples)
ghz = hz * 0.7 + hz * 0.3 * numpy.linspace(0, 1, gl)
glide = numpy.sin(numpy.cumsum(2*numpy.pi*ghz/SAMPLE_RATE)) * 0.35
out[:gl] = glide + out[:gl] * 0.65
elif c == 'h':
hl = min(int(SAMPLE_RATE * 0.05), n_samples)
asp = rng.uniform(-0.4, 0.4, hl) * numpy.exp(-numpy.linspace(0, 5, hl))
out[:hl] = asp * 0.6 + out[:hl] * 0.4
elif c == 'w':
wl = min(int(SAMPLE_RATE * 0.06), n_samples)
ws = numpy.sin(numpy.cumsum(2*numpy.pi*hz/SAMPLE_RATE*numpy.ones(wl)))
if wl > 20:
bp, ap = _sig.butter(2, [max(20,300), min(800, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
ws = _sig.lfilter(bp, ap, ws)
ws *= numpy.linspace(0.5, 0, wl)
out[:wl] = ws * 0.4 + out[:wl] * 0.6
# Soft edges — prevent clicks at note boundaries
fade_samples = min(int(SAMPLE_RATE * 0.01), n_samples // 4)
if fade_samples > 0:
out[:fade_samples] *= numpy.linspace(0, 1, fade_samples)
out[-fade_samples:] *= numpy.linspace(1, 0, fade_samples)
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
def granular_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
grain_size=0.04, density=50, scatter=0.5,
pitch_var=12, source="saw"):
"""Granular synthesis — clouds of tiny sound grains.
Chops a source waveform into overlapping micro-grains (10-200ms),
each independently windowed and optionally pitch/time scattered.
Creates textures impossible with other synthesis: frozen tones,
shimmering clouds, evolving pads, glitchy stutters.
Args:
hz: Base frequency.
grain_size: Duration of each grain in seconds (default 0.05 = 50ms).
density: Grains per second (default 20). Higher = denser cloud.
scatter: Random position jitter 0-1 (default 0.3). How much each
grain's read position varies from sequential order.
pitch_var: Random pitch variation per grain in cents (default 5).
source: Base waveform — ``"saw"``, ``"sine"``, ``"triangle"``,
``"square"``, ``"noise"`` (default ``"saw"``).
"""
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Generate source material — longer than needed for scatter headroom
src_len = n_samples + int(SAMPLE_RATE * scatter * 2)
src_fns = {
"saw": sawtooth_wave, "sine": sine_wave, "triangle": triangle_wave,
"square": square_wave, "noise": noise_wave,
}
src_fn = src_fns.get(source, sawtooth_wave)
src = src_fn(hz, n_samples=src_len).astype(numpy.float64) / SAMPLE_PEAK
# Grain parameters
grain_samples = max(64, int(grain_size * SAMPLE_RATE))
n_grains = max(1, int(n_samples / SAMPLE_RATE * density))
# Hanning window for each grain (smooth fade in/out, no clicks)
window = numpy.hanning(grain_samples).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_grains):
# Output position — evenly spaced with jitter
base_pos = int(i * n_samples / n_grains)
jitter = int(rng.uniform(-0.5, 0.5) * n_samples / n_grains * 0.3)
out_pos = max(0, min(n_samples - grain_samples, base_pos + jitter))
# Source read position — sequential with scatter
src_pos = int(base_pos * src_len / n_samples)
src_jitter = int(rng.uniform(-scatter, scatter) * grain_samples * 4)
src_pos = max(0, min(src_len - grain_samples, src_pos + src_jitter))
# Per-grain pitch variation via resampling
if pitch_var > 0:
cents = rng.uniform(-pitch_var, pitch_var)
rate = 2 ** (cents / 1200)
read_len = max(2, min(int(grain_samples * rate), src_len - src_pos))
grain_src = src[src_pos:src_pos + read_len]
x_old = numpy.linspace(0, 1, len(grain_src))
x_new = numpy.linspace(0, 1, grain_samples)
grain = numpy.interp(x_new, x_old, grain_src)
else:
end = min(src_pos + grain_samples, src_len)
grain = src[src_pos:end]
if len(grain) < grain_samples:
grain = numpy.pad(grain, (0, grain_samples - len(grain)))
# Apply window and mix
grain *= window[:len(grain)]
end = min(out_pos + len(grain), n_samples)
out[out_pos:end] += grain[:end - out_pos]
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
def acoustic_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Acoustic guitar — Karplus-Strong with wooden body resonance.
@@ -1211,6 +1432,8 @@ class Synth(Enum):
UPRIGHT_BASS = "upright_bass_synth"
TIMPANI = "timpani_synth"
SAXOPHONE = "saxophone_synth"
GRANULAR = "granular_synth"
VOCAL = "vocal_synth"
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
@@ -1233,6 +1456,7 @@ _SYNTH_FUNCTIONS = {
"harpsichord_synth": harpsichord_wave, "cello_synth": cello_wave,
"harp_synth": harp_wave, "upright_bass_synth": upright_bass_wave,
"timpani_synth": timpani_wave, "saxophone_synth": saxophone_wave,
"granular_synth": granular_wave, "vocal_synth": vocal_wave,
"acoustic_guitar_synth": acoustic_guitar_wave,
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
}
@@ -3448,8 +3672,13 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
bent = src_f[idx] * (1 - frac) + src_f[numpy.minimum(idx + 1, src_len - 1)] * frac
waves.append((bent * SAMPLE_PEAK).astype(numpy.int16))
else:
# Render oscillators (pass synth_kwargs for FM etc.)
waves = [synth_fn(hz, n_samples=n_samples, **_skw)
# Per-note kwargs (e.g. lyric for vocal synth)
note_skw = dict(_skw)
note_lyric = getattr(note, 'lyric', '')
if note_lyric:
note_skw['lyric'] = note_lyric
# Render oscillators
waves = [synth_fn(hz, n_samples=n_samples, **note_skw)
for hz in pitches]
# Sub-oscillator: octave-below sine
if sub_osc > 0:
+23 -2
View File
@@ -241,6 +241,26 @@ INSTRUMENTS = {
"vel_to_filter": 3000,
"analog": 0.3,
},
"granular_pad": {
"synth": "granular_synth", "envelope": "pad",
"reverb": 0.4, "reverb_type": "cathedral",
"analog": 0.3,
},
"vocal": {
"synth": "vocal_synth", "envelope": "strings",
"reverb": 0.3, "reverb_type": "hall",
"humanize": 0.15,
},
"choir": {
"synth": "vocal_synth", "envelope": "pad",
"detune": 8, "spread": 0.4,
"reverb": 0.45, "reverb_type": "cathedral",
},
"granular_texture": {
"synth": "granular_synth", "envelope": "none",
"reverb": 0.5, "reverb_type": "taj_mahal",
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
},
"808_bass": {
"synth": "sine", "envelope": "pluck",
"distortion": 0.4, "distortion_drive": 2.5,
@@ -357,6 +377,7 @@ class Note:
velocity: int = 100
bend: float = 0.0
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
lyric: str = "" # syllable for vocal synth
@property
def beats(self) -> float:
@@ -2085,7 +2106,7 @@ class Part:
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth") -> "Part":
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Duration can be a ``Duration`` enum or a raw float (beats).
@@ -2103,7 +2124,7 @@ class Part:
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type))
bend_type=bend_type, lyric=lyric))
return self
def set(self, **params) -> "Part":
+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) == 29
assert len(Synth) == 30
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.35.0"
version = "0.35.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },