mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb923f6c76 | |||
| 59e3338892 | |||
| 8cf4145c15 |
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
@@ -909,6 +909,84 @@ def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return (peak * wave).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 +1289,7 @@ class Synth(Enum):
|
||||
UPRIGHT_BASS = "upright_bass_synth"
|
||||
TIMPANI = "timpani_synth"
|
||||
SAXOPHONE = "saxophone_synth"
|
||||
GRANULAR = "granular_synth"
|
||||
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
|
||||
SITAR = "sitar_synth"
|
||||
ELECTRIC_GUITAR = "electric_guitar_synth"
|
||||
@@ -1233,6 +1312,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,
|
||||
"acoustic_guitar_synth": acoustic_guitar_wave,
|
||||
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
|
||||
}
|
||||
|
||||
@@ -241,6 +241,16 @@ INSTRUMENTS = {
|
||||
"vel_to_filter": 3000,
|
||||
"analog": 0.3,
|
||||
},
|
||||
"granular_pad": {
|
||||
"synth": "granular_synth", "envelope": "pad",
|
||||
"reverb": 0.4, "reverb_type": "cathedral",
|
||||
"analog": 0.3,
|
||||
},
|
||||
"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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user