mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 06:46:14 +00:00
Mellotron, hard sync, ring mod, wavefold, drift synths + analog presets — v0.40.9
Five new synth waveforms: tape-replay Mellotron (strings/flute/choir tapes with wow, flutter, saturation, 8s fadeout), hard sync oscillator, ring modulation, wavefolding, and analog drift VCO with pitch instability. 14 new instrument presets for Score.part(). Synth kwargs now pass through play()/save()/_render(). 808 bass envelope fixed from pluck to piano. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
All notable changes to PyTheory are documented here.
|
All notable changes to PyTheory are documented here.
|
||||||
|
|
||||||
|
## 0.40.9
|
||||||
|
|
||||||
|
- **Mellotron synth** — tape-replay keyboard with wow/flutter, tape saturation,
|
||||||
|
bandwidth limiting, hiss, and 8-second tape fadeout. Three tape banks via the
|
||||||
|
`tape` parameter: `"strings"` (default), `"flute"`, and `"choir"`.
|
||||||
|
- **Analog oscillator synths** — four new waveform generators for fat, alive,
|
||||||
|
analog-style sounds:
|
||||||
|
- `Synth.HARD_SYNC` — slave oscillator hard-synced to a master (Prophet-5
|
||||||
|
leads). `slave_ratio` parameter controls harmonic content.
|
||||||
|
- `Synth.RING_MOD` — two oscillators multiplied for metallic, bell-like
|
||||||
|
inharmonic tones. `mod_ratio` parameter.
|
||||||
|
- `Synth.WAVEFOLD` — west coast wavefolding (Buchla-style). `folds` parameter
|
||||||
|
sweeps from warm to gnarly.
|
||||||
|
- `Synth.DRIFT` — analog VCO with pitch drift, jitter, and noise floor.
|
||||||
|
`shape` parameter (`"saw"`, `"square"`, `"triangle"`, `"pulse"`) and
|
||||||
|
`drift_amount` for instability level.
|
||||||
|
- **Synth kwargs passthrough** — `play()`, `save()`, and `_render()` now accept
|
||||||
|
`**synth_kw` for forwarding parameters to synth wave functions (e.g.
|
||||||
|
`play(tone, synth=Synth.MELLOTRON, tape="choir")`).
|
||||||
|
- **14 new instrument presets** — `mellotron`, `mellotron_strings`,
|
||||||
|
`mellotron_flute`, `mellotron_choir`, `sync_lead`, `sync_lead_bright`,
|
||||||
|
`ring_mod_bell`, `ring_mod_metallic`, `wavefold_warm`, `wavefold_gnarly`,
|
||||||
|
`drift_saw`, `drift_square`, `analog_pad`, `analog_bass`.
|
||||||
|
- **808 bass envelope fix** — changed from `pluck` (zero sustain, wrong for 808)
|
||||||
|
to `piano` (sharp attack with long decay tail).
|
||||||
|
|
||||||
## 0.40.8
|
## 0.40.8
|
||||||
|
|
||||||
- **Fix hold() inflating duration** — `Note.beats` was returning the full
|
- **Fix hold() inflating duration** — `Note.beats` was returning the full
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Demo the 5 new synths: Mellotron, Hard Sync, Ring Mod, Wavefold, Drift.
|
||||||
|
|
||||||
|
Each synth gets a short musical phrase — not just a scale run — with
|
||||||
|
reverb and rhythmic variety to show off its character.
|
||||||
|
"""
|
||||||
|
from pytheory import Score, Duration, play_score
|
||||||
|
|
||||||
|
EIGHTH = Duration.EIGHTH
|
||||||
|
QUARTER = Duration.QUARTER
|
||||||
|
HALF = Duration.HALF
|
||||||
|
DOTTED_Q = Duration.DOTTED_QUARTER
|
||||||
|
WHOLE = Duration.WHOLE
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mellotron Strings ────────────────────────────────────────────────────────
|
||||||
|
# Strawberry Fields vibes — slow, haunted, with rests that breathe.
|
||||||
|
|
||||||
|
print("=== MELLOTRON STRINGS ===")
|
||||||
|
s = Score("4/4", bpm=72)
|
||||||
|
p = s.part("tape", instrument="mellotron_strings",
|
||||||
|
reverb=0.45, reverb_type="cathedral", reverb_decay=2.0)
|
||||||
|
p.add("G4", HALF).add("B4", QUARTER).add("D5", QUARTER)
|
||||||
|
p.add("C5", DOTTED_Q).add("B4", EIGHTH).add("A4", HALF)
|
||||||
|
p.rest(QUARTER)
|
||||||
|
p.add("G4", DOTTED_Q).add("F#4", EIGHTH).add("G4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mellotron Flute ──────────────────────────────────────────────────────────
|
||||||
|
# Lonely, breathy, with space between phrases.
|
||||||
|
|
||||||
|
print("\n=== MELLOTRON FLUTE ===")
|
||||||
|
s = Score("3/4", bpm=84)
|
||||||
|
p = s.part("flute", instrument="mellotron_flute",
|
||||||
|
reverb=0.5, reverb_type="taj_mahal", reverb_decay=2.5)
|
||||||
|
p.add("E5", HALF).add("D5", QUARTER)
|
||||||
|
p.add("C5", DOTTED_Q).add("B4", EIGHTH).rest(QUARTER)
|
||||||
|
p.add("A4", HALF).add("G4", QUARTER)
|
||||||
|
p.add("A4", HALF).rest(QUARTER)
|
||||||
|
p.add("E5", QUARTER).add("D5", QUARTER).add("C5", QUARTER)
|
||||||
|
p.add("B4", HALF + QUARTER)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Mellotron Choir ──────────────────────────────────────────────────────────
|
||||||
|
# Ghostly pad — slow chords, big reverb.
|
||||||
|
|
||||||
|
print("\n=== MELLOTRON CHOIR ===")
|
||||||
|
s = Score("4/4", bpm=60)
|
||||||
|
p = s.part("choir", instrument="mellotron_choir",
|
||||||
|
reverb=0.6, reverb_type="cathedral", reverb_decay=3.0)
|
||||||
|
p.add("C4", WHOLE)
|
||||||
|
p.add("E4", HALF).add("G4", HALF)
|
||||||
|
p.add("A4", DOTTED_Q).add("G4", EIGHTH).add("F4", HALF)
|
||||||
|
p.add("E4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hard Sync Lead ───────────────────────────────────────────────────────────
|
||||||
|
# Aggressive, punchy — fast 16ths and syncopation.
|
||||||
|
|
||||||
|
print("\n=== HARD SYNC LEAD ===")
|
||||||
|
s = Score("4/4", bpm=128)
|
||||||
|
p = s.part("sync", instrument="sync_lead",
|
||||||
|
reverb=0.25, reverb_type="plate")
|
||||||
|
p.add("E4", EIGHTH).add("E4", EIGHTH).rest(EIGHTH).add("G4", EIGHTH)
|
||||||
|
p.add("A4", QUARTER).add("G4", EIGHTH).add("E4", EIGHTH)
|
||||||
|
p.add("D4", EIGHTH).rest(EIGHTH).add("E4", EIGHTH).add("G4", EIGHTH)
|
||||||
|
p.add("A4", HALF)
|
||||||
|
p.rest(QUARTER).add("B4", EIGHTH).add("A4", EIGHTH)
|
||||||
|
p.add("G4", QUARTER).add("E4", QUARTER).add("D4", HALF)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Hard Sync Bright ─────────────────────────────────────────────────────────
|
||||||
|
# Higher slave ratio — more harmonics, screaming lead.
|
||||||
|
|
||||||
|
print("\n=== HARD SYNC BRIGHT ===")
|
||||||
|
s = Score("4/4", bpm=138)
|
||||||
|
p = s.part("sync2", instrument="sync_lead_bright",
|
||||||
|
reverb=0.2, reverb_type="plate")
|
||||||
|
p.add("A4", EIGHTH).add("C5", EIGHTH).add("D5", QUARTER)
|
||||||
|
p.rest(EIGHTH).add("E5", EIGHTH).add("D5", EIGHTH).add("C5", EIGHTH)
|
||||||
|
p.add("A4", QUARTER).rest(QUARTER).add("G4", EIGHTH).add("A4", EIGHTH)
|
||||||
|
p.add("C5", HALF)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ring Mod Bell ────────────────────────────────────────────────────────────
|
||||||
|
# Shimmery, metallic — sparse hits with long reverb tail.
|
||||||
|
|
||||||
|
print("\n=== RING MOD BELL ===")
|
||||||
|
s = Score("4/4", bpm=66)
|
||||||
|
p = s.part("bell", instrument="ring_mod_bell",
|
||||||
|
reverb=0.6, reverb_type="cave", reverb_decay=3.0)
|
||||||
|
p.add("C5", HALF).rest(QUARTER).add("G4", QUARTER)
|
||||||
|
p.rest(HALF).add("E5", HALF)
|
||||||
|
p.add("D5", QUARTER).rest(QUARTER).add("C5", HALF)
|
||||||
|
p.rest(WHOLE)
|
||||||
|
p.add("G4", QUARTER).add("A4", QUARTER).add("C5", HALF)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ring Mod Metallic ────────────────────────────────────────────────────────
|
||||||
|
# Alien, inharmonic — atonal stabs.
|
||||||
|
|
||||||
|
print("\n=== RING MOD METALLIC ===")
|
||||||
|
s = Score("4/4", bpm=100)
|
||||||
|
p = s.part("metal", instrument="ring_mod_metallic",
|
||||||
|
reverb=0.4, reverb_type="parking_garage", reverb_decay=2.0)
|
||||||
|
p.add("F4", EIGHTH).rest(EIGHTH).add("Ab4", EIGHTH).add("F4", EIGHTH)
|
||||||
|
p.rest(QUARTER).add("Db5", QUARTER).rest(QUARTER)
|
||||||
|
p.add("C5", EIGHTH).add("Ab4", EIGHTH).rest(QUARTER).add("F4", HALF)
|
||||||
|
p.rest(HALF).add("Db5", QUARTER).add("C5", QUARTER)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wavefold Warm ────────────────────────────────────────────────────────────
|
||||||
|
# Gentle folds — round and musical, like a filtered saw with overtones.
|
||||||
|
|
||||||
|
print("\n=== WAVEFOLD WARM ===")
|
||||||
|
s = Score("4/4", bpm=108)
|
||||||
|
p = s.part("fold", instrument="wavefold_warm",
|
||||||
|
reverb=0.3, reverb_type="plate")
|
||||||
|
p.add("A3", QUARTER).add("C4", QUARTER).add("E4", QUARTER).add("A4", QUARTER)
|
||||||
|
p.add("G4", DOTTED_Q).add("E4", EIGHTH).add("C4", HALF)
|
||||||
|
p.add("D4", QUARTER).add("F4", QUARTER).add("A4", HALF)
|
||||||
|
p.add("G4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wavefold Gnarly ──────────────────────────────────────────────────────────
|
||||||
|
# Cranked folds — buzzy, aggressive, with syncopation.
|
||||||
|
|
||||||
|
print("\n=== WAVEFOLD GNARLY ===")
|
||||||
|
s = Score("4/4", bpm=130)
|
||||||
|
p = s.part("gnarly", instrument="wavefold_gnarly",
|
||||||
|
reverb=0.2, reverb_type="spring")
|
||||||
|
p.add("E3", EIGHTH).add("E3", EIGHTH).rest(EIGHTH).add("G3", EIGHTH)
|
||||||
|
p.add("A3", EIGHTH).rest(EIGHTH).add("B3", EIGHTH).add("A3", EIGHTH)
|
||||||
|
p.add("E3", QUARTER).add("G3", EIGHTH).add("A3", EIGHTH).add("B3", QUARTER)
|
||||||
|
p.rest(QUARTER)
|
||||||
|
p.add("E4", EIGHTH).add("D4", EIGHTH).add("B3", QUARTER).add("A3", HALF)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Drift Saw ────────────────────────────────────────────────────────────────
|
||||||
|
# Warm, alive analog saw — the Minimoog pad.
|
||||||
|
|
||||||
|
print("\n=== DRIFT SAW (vintage VCO) ===")
|
||||||
|
s = Score("4/4", bpm=88)
|
||||||
|
p = s.part("drift", instrument="drift_saw",
|
||||||
|
reverb=0.35, reverb_type="taj_mahal", reverb_decay=2.0)
|
||||||
|
p.add("D4", HALF).add("F4", HALF)
|
||||||
|
p.add("A4", DOTTED_Q).add("G4", EIGHTH).add("F4", QUARTER).rest(QUARTER)
|
||||||
|
p.add("D4", QUARTER).add("E4", QUARTER).add("F4", HALF)
|
||||||
|
p.add("D4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Drift Square ─────────────────────────────────────────────────────────────
|
||||||
|
# Hollow, wobbly — 8-bit with analog soul.
|
||||||
|
|
||||||
|
print("\n=== DRIFT SQUARE ===")
|
||||||
|
s = Score("4/4", bpm=110)
|
||||||
|
p = s.part("dsq", instrument="drift_square",
|
||||||
|
reverb=0.25, reverb_type="plate")
|
||||||
|
p.add("C4", EIGHTH).add("E4", EIGHTH).add("G4", QUARTER).add("E4", QUARTER)
|
||||||
|
p.rest(QUARTER)
|
||||||
|
p.add("A4", EIGHTH).add("G4", EIGHTH).add("E4", QUARTER).add("C4", HALF)
|
||||||
|
p.add("D4", QUARTER).add("F4", EIGHTH).add("G4", EIGHTH).add("A4", HALF)
|
||||||
|
p.add("G4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analog Pad ───────────────────────────────────────────────────────────────
|
||||||
|
# Slow, drifting chords — Juno-style lushness.
|
||||||
|
|
||||||
|
print("\n=== ANALOG PAD ===")
|
||||||
|
s = Score("4/4", bpm=70)
|
||||||
|
p = s.part("pad", instrument="analog_pad",
|
||||||
|
reverb=0.5, reverb_type="taj_mahal", reverb_decay=3.0)
|
||||||
|
p.add("A3", WHOLE)
|
||||||
|
p.add("C4", HALF).add("E4", HALF)
|
||||||
|
p.add("F4", WHOLE)
|
||||||
|
p.add("E4", HALF).add("D4", HALF)
|
||||||
|
p.add("C4", WHOLE)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Analog Bass ──────────────────────────────────────────────────────────────
|
||||||
|
# Tight, punchy — Moog bass with filter sweep.
|
||||||
|
|
||||||
|
print("\n=== ANALOG BASS ===")
|
||||||
|
s = Score("4/4", bpm=120)
|
||||||
|
p = s.part("bass", instrument="analog_bass",
|
||||||
|
reverb=0.1, reverb_type="plate")
|
||||||
|
p.add("E2", EIGHTH).add("E2", EIGHTH).rest(EIGHTH).add("G2", EIGHTH)
|
||||||
|
p.add("A2", QUARTER).rest(QUARTER)
|
||||||
|
p.add("E2", EIGHTH).rest(EIGHTH).add("B2", EIGHTH).add("A2", EIGHTH)
|
||||||
|
p.add("G2", QUARTER).add("E2", QUARTER).rest(HALF)
|
||||||
|
p.add("E2", EIGHTH).add("E2", EIGHTH).add("G2", EIGHTH).add("A2", EIGHTH)
|
||||||
|
p.add("B2", QUARTER).add("A2", QUARTER).add("E2", HALF)
|
||||||
|
play_score(s)
|
||||||
|
|
||||||
|
|
||||||
|
print("\nDone!")
|
||||||
@@ -44,6 +44,22 @@ Optional parameters for synth, envelope, and temperament:
|
|||||||
play(Tone.from_string("C4"), synth=Synth.SAW, envelope=Envelope.PLUCK, t=1_000)
|
play(Tone.from_string("C4"), synth=Synth.SAW, envelope=Envelope.PLUCK, t=1_000)
|
||||||
play(Tone.from_string("C4"), temperament="pythagorean", t=1_000)
|
play(Tone.from_string("C4"), temperament="pythagorean", t=1_000)
|
||||||
|
|
||||||
|
Synth-specific parameters are passed through as keyword arguments:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Mellotron with flute tape
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.MELLOTRON, tape="choir", t=2_000)
|
||||||
|
|
||||||
|
# Hard sync with custom slave ratio
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.HARD_SYNC, slave_ratio=2.5)
|
||||||
|
|
||||||
|
# Wavefolding with 4 folds
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.WAVEFOLD, folds=4.0)
|
||||||
|
|
||||||
|
# Drift oscillator with square shape
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.DRIFT, shape="square")
|
||||||
|
|
||||||
play_score() -- Full Arrangements
|
play_score() -- Full Arrangements
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
|
|||||||
+157
-3
@@ -1,7 +1,7 @@
|
|||||||
Synthesizers
|
Synthesizers
|
||||||
============
|
============
|
||||||
|
|
||||||
PyTheory includes 41 built-in waveforms and 10 ADSR envelope presets.
|
PyTheory includes 56 built-in waveforms and 10 ADSR envelope presets.
|
||||||
Every sound is generated from scratch -- no samples or external audio
|
Every sound is generated from scratch -- no samples or external audio
|
||||||
files needed.
|
files needed.
|
||||||
|
|
||||||
@@ -249,6 +249,114 @@ produces a natural chorus/vibrato effect built into the waveform itself.
|
|||||||
|
|
||||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pwm_fast.wav" type="audio/wav"></audio>
|
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pwm_fast.wav" type="audio/wav"></audio>
|
||||||
|
|
||||||
|
Analog Synthesis
|
||||||
|
----------------
|
||||||
|
|
||||||
|
These waveforms model the behavior of real analog hardware — the
|
||||||
|
imperfections, interactions, and nonlinearities that make a room full
|
||||||
|
of vintage synths sound so much more alive than a room full of VSTs.
|
||||||
|
Each one is a different approach to the same question: how do you make
|
||||||
|
a digital oscillator sound like it has a soul?
|
||||||
|
|
||||||
|
Hard Sync
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
A "slave" oscillator is forced to restart its cycle every time a
|
||||||
|
"master" oscillator completes one. The abrupt restart creates bright
|
||||||
|
formant peaks that sweep as the slave ratio changes. This is THE sound
|
||||||
|
of the Prophet-5, Moog Prodigy, and every screaming analog lead since
|
||||||
|
1978.
|
||||||
|
|
||||||
|
**Use for:** aggressive leads, formant sweeps, cutting solos.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
lead = score.part("lead", synth="hard_sync", envelope="pluck")
|
||||||
|
|
||||||
|
# Higher slave ratio = more harmonics, brighter
|
||||||
|
from pytheory import play, Synth, Tone
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.HARD_SYNC, slave_ratio=2.5)
|
||||||
|
|
||||||
|
Ring Modulation
|
||||||
|
~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Two oscillators multiplied together, producing sum and difference
|
||||||
|
frequencies. Unlike FM, ring mod outputs only sidebands — no carrier
|
||||||
|
or modulator fundamental. The result is metallic, bell-like, and often
|
||||||
|
inharmonic. Classic Dalek voice, Stockhausen, and every sci-fi
|
||||||
|
soundtrack.
|
||||||
|
|
||||||
|
**Use for:** metallic bells, alien textures, inharmonic percussion.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
bells = score.part("bells", instrument="ring_mod_bell",
|
||||||
|
reverb=0.5, reverb_type="cave")
|
||||||
|
|
||||||
|
# Non-integer ratios = more inharmonic
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.RING_MOD, mod_ratio=2.1)
|
||||||
|
|
||||||
|
Wavefolding
|
||||||
|
~~~~~~~~~~~
|
||||||
|
|
||||||
|
The heart of west coast synthesis (Buchla, Make Noise, Verbos). A sine
|
||||||
|
wave is amplified past ±1.0, then "folded" — the overflow bounces back
|
||||||
|
instead of clipping. Each fold adds new harmonic pairs. At low fold
|
||||||
|
counts it's warm and round; crank it up and it gets buzzy, gnarly, and
|
||||||
|
alive.
|
||||||
|
|
||||||
|
This sounds completely different from subtractive synthesis — instead of
|
||||||
|
*removing* harmonics with a filter, you're *generating* them by shaping
|
||||||
|
the wave. Pairs beautifully with a lowpass filter after the fold.
|
||||||
|
|
||||||
|
**Use for:** complex leads, evolving textures, west coast basslines.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Warm, musical folding
|
||||||
|
warm = score.part("fold", instrument="wavefold_warm")
|
||||||
|
|
||||||
|
# Cranked and aggressive
|
||||||
|
gnarly = score.part("gnarly", instrument="wavefold_gnarly")
|
||||||
|
|
||||||
|
# Direct control over fold amount
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.WAVEFOLD, folds=3.0)
|
||||||
|
|
||||||
|
Drift Oscillator
|
||||||
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Real analog oscillators are never perfectly stable. Capacitor charging,
|
||||||
|
thermal variations, and component tolerances make the pitch wander
|
||||||
|
slightly. This is what makes a Minimoog sound "fat" and a VST sound
|
||||||
|
"thin" — the constant micro-motion of imperfect hardware.
|
||||||
|
|
||||||
|
The drift oscillator models slow pitch drift (< 1 Hz wander), fast
|
||||||
|
jitter (per-cycle randomness), a soft analog noise floor, and slightly
|
||||||
|
rounded waveform edges. It turns any basic shape into something that
|
||||||
|
breathes.
|
||||||
|
|
||||||
|
**Use for:** analog-style pads, warm basses, vintage leads, any voice
|
||||||
|
that needs to feel "alive."
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Vintage Minimoog-style saw
|
||||||
|
pad = score.part("pad", instrument="drift_saw",
|
||||||
|
reverb=0.35, reverb_type="taj_mahal")
|
||||||
|
|
||||||
|
# Hollow square with analog wobble
|
||||||
|
sq = score.part("sq", instrument="drift_square")
|
||||||
|
|
||||||
|
# Control the shape and instability directly
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.DRIFT,
|
||||||
|
shape="triangle", drift_amount=0.25)
|
||||||
|
|
||||||
|
Drift amount controls how unstable the oscillator is:
|
||||||
|
|
||||||
|
- **0.05** = studio-grade (Sequential, Oberheim)
|
||||||
|
- **0.15** = classic vintage (Minimoog, ARP) — the default
|
||||||
|
- **0.30** = barely-holding-it-together (old SH-101)
|
||||||
|
|
||||||
ADSR Envelopes
|
ADSR Envelopes
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
@@ -440,11 +548,11 @@ Dedicated Instrument Synths
|
|||||||
--------------------------
|
--------------------------
|
||||||
|
|
||||||
Beyond the classic and physical modeling waveforms, PyTheory includes
|
Beyond the classic and physical modeling waveforms, PyTheory includes
|
||||||
31 dedicated instrument synths. Each one uses tailored synthesis
|
36 dedicated instrument synths. Each one uses tailored synthesis
|
||||||
techniques -- additive harmonics, formant shaping, body resonance
|
techniques -- additive harmonics, formant shaping, body resonance
|
||||||
modeling, and specialized envelopes -- to capture the character of a
|
modeling, and specialized envelopes -- to capture the character of a
|
||||||
specific acoustic instrument. These are the waveforms that bring the
|
specific acoustic instrument. These are the waveforms that bring the
|
||||||
total count to 41.
|
total count to 56.
|
||||||
|
|
||||||
Piano Synth
|
Piano Synth
|
||||||
~~~~~~~~~~~
|
~~~~~~~~~~~
|
||||||
@@ -495,6 +603,38 @@ picked up by an electrostatic pickup. More nasal, reedy, and biting
|
|||||||
|
|
||||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wurlitzer.wav" type="audio/wav"></audio>
|
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wurlitzer.wav" type="audio/wav"></audio>
|
||||||
|
|
||||||
|
Mellotron
|
||||||
|
~~~~~~~~~
|
||||||
|
|
||||||
|
The original "sampler" — a 1960s keyboard where each key triggers a
|
||||||
|
strip of magnetic tape with a pre-recorded instrument. The mechanical
|
||||||
|
tape transport gives it a haunted, lo-fi quality that no digital
|
||||||
|
emulation fully captures: pitch wobbles from uneven capstan speed,
|
||||||
|
bandwidth limited to 300 Hz–6 kHz (like a worn cassette), soft tape
|
||||||
|
saturation, and tapes that physically run out after 8 seconds.
|
||||||
|
|
||||||
|
The Mellotron defined the sound of *Strawberry Fields Forever*,
|
||||||
|
*Stairway to Heaven*, and every prog rock record from 1969–1977.
|
||||||
|
|
||||||
|
Three tape banks are available via the ``tape`` parameter:
|
||||||
|
|
||||||
|
- ``"strings"`` (default) — the iconic MkII string section
|
||||||
|
- ``"flute"`` — breathy, haunting solo flute
|
||||||
|
- ``"choir"`` — ghostly vocal pad
|
||||||
|
|
||||||
|
**Use for:** prog rock, haunted textures, vintage orchestral color.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
# Use instrument presets (includes reverb)
|
||||||
|
strings = score.part("strings", instrument="mellotron_strings")
|
||||||
|
flute = score.part("flute", instrument="mellotron_flute")
|
||||||
|
choir = score.part("choir", instrument="mellotron_choir")
|
||||||
|
|
||||||
|
# Or select the tape directly
|
||||||
|
from pytheory import play, Synth, Tone
|
||||||
|
play(Tone.from_string("C4"), synth=Synth.MELLOTRON, tape="choir", t=3000)
|
||||||
|
|
||||||
Vibraphone Synth
|
Vibraphone Synth
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
@@ -1127,6 +1267,12 @@ bagpipe, singing_bowl, singing_bowl_ring, tingsha
|
|||||||
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
|
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
|
||||||
granular_pad, granular_texture, vocal, choir
|
granular_pad, granular_texture, vocal, choir
|
||||||
|
|
||||||
|
**Mellotron**: mellotron, mellotron_strings, mellotron_flute, mellotron_choir
|
||||||
|
|
||||||
|
**Analog**: sync_lead, sync_lead_bright, ring_mod_bell, ring_mod_metallic,
|
||||||
|
wavefold_warm, wavefold_gnarly, drift_saw, drift_square, analog_pad,
|
||||||
|
analog_bass
|
||||||
|
|
||||||
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells,
|
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells,
|
||||||
timpani, crotales
|
timpani, crotales
|
||||||
|
|
||||||
@@ -1169,3 +1315,11 @@ Some practical combos worth memorizing:
|
|||||||
long reverb and you're scoring a nature documentary.
|
long reverb and you're scoring a nature documentary.
|
||||||
- ``saw`` + ``pluck`` = **funk stab.** Short, sharp, bright. The
|
- ``saw`` + ``pluck`` = **funk stab.** Short, sharp, bright. The
|
||||||
sound of Nile Rodgers' right hand.
|
sound of Nile Rodgers' right hand.
|
||||||
|
- ``hard_sync`` + ``pluck`` = **prophet lead.** Bright formant peak
|
||||||
|
that cuts through any mix. The opening riff of every 80s synth solo.
|
||||||
|
- ``wavefold`` + ``organ`` = **west coast bass.** Warm, harmonically
|
||||||
|
rich sine-derivative that pairs beautifully with a lowpass after.
|
||||||
|
- ``drift`` + ``pad`` = **analog pad.** A sawtooth that breathes and
|
||||||
|
wobbles like a real VCO. Add chorus and reverb for Juno vibes.
|
||||||
|
- ``mellotron_synth`` + ``organ`` = **prog strings.** Haunted tape
|
||||||
|
machine. Add cathedral reverb and you're in 1972.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pytheory"
|
name = "pytheory"
|
||||||
version = "0.40.8"
|
version = "0.40.9"
|
||||||
description = "Music Theory for Humans"
|
description = "Music Theory for Humans"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""PyTheory: Music Theory for Humans."""
|
"""PyTheory: Music Theory for Humans."""
|
||||||
|
|
||||||
__version__ = "0.40.7"
|
__version__ = "0.40.9"
|
||||||
|
|
||||||
from .tones import Tone, Interval
|
from .tones import Tone, Interval
|
||||||
from .systems import System, SYSTEMS, TET
|
from .systems import System, SYSTEMS, TET
|
||||||
|
|||||||
+326
-9
@@ -203,6 +203,168 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
|||||||
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
|
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def hard_sync_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||||
|
slave_ratio=1.5):
|
||||||
|
"""Hard-sync oscillator — slave saw reset by master clock.
|
||||||
|
|
||||||
|
The quintessential analog lead sound. A "slave" oscillator runs at
|
||||||
|
a different frequency but is forced to restart its cycle every time
|
||||||
|
the "master" oscillator completes one. The abrupt restart creates
|
||||||
|
bright, harmonically complex formant peaks that sweep as the slave
|
||||||
|
ratio changes.
|
||||||
|
|
||||||
|
This is THE sound of the Prophet-5, Moog Prodigy, and every
|
||||||
|
screaming analog lead since 1978.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
slave_ratio: Slave frequency as a multiple of master.
|
||||||
|
1.0 = unison (plain saw). 1.5–3.0 = sweet spot for leads.
|
||||||
|
Higher = more metallic, ring-mod-like.
|
||||||
|
"""
|
||||||
|
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||||
|
# Master phase ramps 0→1 at hz
|
||||||
|
master_phase = (t * hz) % 1.0
|
||||||
|
# Detect master zero-crossings (phase resets)
|
||||||
|
resets = numpy.diff(master_phase, prepend=master_phase[0]) < -0.5
|
||||||
|
# Slave phase: runs at slave_ratio * hz, but resets with master
|
||||||
|
slave_phase = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||||
|
phase = 0.0
|
||||||
|
slave_freq = hz * slave_ratio
|
||||||
|
dt = 1.0 / SAMPLE_RATE
|
||||||
|
for i in range(n_samples):
|
||||||
|
if resets[i]:
|
||||||
|
phase = 0.0
|
||||||
|
slave_phase[i] = phase
|
||||||
|
phase += slave_freq * dt
|
||||||
|
phase %= 1.0
|
||||||
|
# Slave is a sawtooth: 2*phase - 1
|
||||||
|
wave = 2.0 * slave_phase - 1.0
|
||||||
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def ring_mod_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||||
|
mod_ratio=1.5):
|
||||||
|
"""Ring modulation — two oscillators multiplied together.
|
||||||
|
|
||||||
|
Multiplying two signals produces sum and difference frequencies,
|
||||||
|
creating inharmonic, metallic, bell-like tones. Unlike FM, ring mod
|
||||||
|
produces only sidebands — no carrier or modulator in the output.
|
||||||
|
|
||||||
|
Classic Dalek voice, Stockhausen elektronische Musik, and the
|
||||||
|
metallic clang of every sci-fi soundtrack.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mod_ratio: Modulator frequency as a multiple of carrier.
|
||||||
|
Integer ratios (2, 3) = harmonic (bell-like).
|
||||||
|
Non-integer (1.5, 2.1) = inharmonic (metallic, alien).
|
||||||
|
"""
|
||||||
|
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||||
|
carrier = numpy.sin(2 * numpy.pi * hz * t)
|
||||||
|
modulator = numpy.sin(2 * numpy.pi * hz * mod_ratio * t)
|
||||||
|
wave = carrier * modulator
|
||||||
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def wavefold_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||||
|
folds=3.0):
|
||||||
|
"""Wavefolding — signal folded back on itself for complex harmonics.
|
||||||
|
|
||||||
|
The heart of west coast synthesis (Buchla, Make Noise, Verbos).
|
||||||
|
A sine wave is amplified past ±1.0, then "folded" — the overflow
|
||||||
|
bounces back instead of clipping. Each fold adds a new pair of
|
||||||
|
harmonics. At low fold counts it's warm and round; crank it up
|
||||||
|
and it gets buzzy, gnarly, and alive.
|
||||||
|
|
||||||
|
Sounds completely different from subtractive synthesis — instead
|
||||||
|
of removing harmonics with a filter, you're *generating* them
|
||||||
|
by shaping the wave. Pairs beautifully with a lowpass filter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folds: Drive amount. 1.0 = clean sine. 2–4 = sweet spot.
|
||||||
|
6+ = harsh, buzzy territory.
|
||||||
|
"""
|
||||||
|
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||||
|
wave = numpy.sin(2 * numpy.pi * hz * t) * folds
|
||||||
|
# Triangle-fold: repeatedly reflect at ±1
|
||||||
|
# Uses the mathematical identity for folding
|
||||||
|
wave = 4.0 * numpy.abs((wave / 4.0 + 0.25) % 1.0 - 0.5) - 1.0
|
||||||
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def drift_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||||
|
shape="saw", drift_amount=0.15):
|
||||||
|
"""Analog VCO with pitch drift, instability, and soft noise floor.
|
||||||
|
|
||||||
|
Real analog oscillators are never perfectly stable. Capacitor
|
||||||
|
charging, thermal variations, and component tolerances make the
|
||||||
|
pitch wander slightly. This is what makes a Minimoog sound "fat"
|
||||||
|
and a VST sound "thin" — the constant micro-motion of imperfect
|
||||||
|
hardware.
|
||||||
|
|
||||||
|
Models:
|
||||||
|
- Slow pitch drift (< 1 Hz wander, like warming up)
|
||||||
|
- Fast jitter (subtle per-cycle randomness)
|
||||||
|
- Soft analog noise floor (faint hiss blended in)
|
||||||
|
- Slightly rounded edges (no mathematically perfect transitions)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
shape: Base oscillator — "saw", "square", "triangle", or "pulse".
|
||||||
|
drift_amount: How unstable the oscillator is.
|
||||||
|
0.05 = studio-grade (Sequential, Oberheim).
|
||||||
|
0.15 = classic vintage (Minimoog, ARP).
|
||||||
|
0.3 = barely-holding-it-together (old SH-101).
|
||||||
|
"""
|
||||||
|
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||||
|
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||||
|
|
||||||
|
# Slow pitch drift — 2 LFOs at sub-Hz rates with random phase
|
||||||
|
drift1 = drift_amount * 0.6 * numpy.sin(
|
||||||
|
2 * numpy.pi * 0.07 * t + rng.uniform(0, 2 * numpy.pi))
|
||||||
|
drift2 = drift_amount * 0.4 * numpy.sin(
|
||||||
|
2 * numpy.pi * 0.23 * t + rng.uniform(0, 2 * numpy.pi))
|
||||||
|
# Fast jitter — per-sample noise filtered to ~50 Hz bandwidth
|
||||||
|
jitter_raw = rng.normal(0, drift_amount * 0.08, n_samples)
|
||||||
|
# Simple one-pole lowpass for jitter smoothing
|
||||||
|
alpha = 2 * numpy.pi * 50.0 / SAMPLE_RATE
|
||||||
|
jitter = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||||
|
jitter[0] = jitter_raw[0]
|
||||||
|
for i in range(1, n_samples):
|
||||||
|
jitter[i] = jitter[i-1] + alpha * (jitter_raw[i] - jitter[i-1])
|
||||||
|
|
||||||
|
# Instantaneous frequency with drift (in cents, converted to ratio)
|
||||||
|
cents_offset = drift1 + drift2 + jitter
|
||||||
|
freq_ratio = 2.0 ** (cents_offset / 1200.0)
|
||||||
|
|
||||||
|
# Accumulate phase with varying frequency
|
||||||
|
phase = numpy.cumsum(hz * freq_ratio / SAMPLE_RATE)
|
||||||
|
phase %= 1.0
|
||||||
|
|
||||||
|
# Generate waveform from phase
|
||||||
|
if shape == "square":
|
||||||
|
wave = numpy.where(phase < 0.5, 1.0, -1.0)
|
||||||
|
elif shape == "triangle":
|
||||||
|
wave = 4.0 * numpy.abs(phase - 0.5) - 1.0
|
||||||
|
elif shape == "pulse":
|
||||||
|
wave = numpy.where(phase < 0.25, 1.0, -1.0)
|
||||||
|
else: # saw
|
||||||
|
wave = 2.0 * phase - 1.0
|
||||||
|
|
||||||
|
# Soft edges — gentle lowpass to round off transitions
|
||||||
|
cutoff = min(16000, hz * 12)
|
||||||
|
bl, al = scipy.signal.butter(1, cutoff, btype='low', fs=SAMPLE_RATE)
|
||||||
|
wave = scipy.signal.lfilter(bl, al, wave)
|
||||||
|
|
||||||
|
# Subtle analog noise floor
|
||||||
|
noise = rng.normal(0, 0.005, n_samples)
|
||||||
|
wave = wave + noise
|
||||||
|
|
||||||
|
mx = numpy.abs(wave).max()
|
||||||
|
if mx > 0:
|
||||||
|
wave /= mx
|
||||||
|
|
||||||
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||||
"""Karplus-Strong plucked string synthesis.
|
"""Karplus-Strong plucked string synthesis.
|
||||||
|
|
||||||
@@ -534,6 +696,145 @@ def wurlitzer_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
|||||||
return (peak * wave).astype(numpy.int16)
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
|
def mellotron_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||||
|
tape="strings"):
|
||||||
|
"""Mellotron — tape-replay keyboard from the 1960s.
|
||||||
|
|
||||||
|
Each key triggers a strip of magnetic tape with a pre-recorded
|
||||||
|
instrument — the original "sampler." The mechanical transport gives
|
||||||
|
it a lo-fi, haunted quality that no digital emulation fully captures:
|
||||||
|
|
||||||
|
- Tape flutter: pitch wobbles from uneven capstan speed
|
||||||
|
- Limited bandwidth: 300 Hz–6 kHz, like a worn cassette
|
||||||
|
- Tape saturation: soft compression, rounded transients
|
||||||
|
- 8-second limit: tapes physically run out (we model the fadeout)
|
||||||
|
- Head noise: faint hiss baked into the character
|
||||||
|
|
||||||
|
The Mellotron defined the sound of Strawberry Fields Forever,
|
||||||
|
Stairway to Heaven, and every prog rock record from 1969–1977.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tape: Which tape bank to simulate.
|
||||||
|
"strings" — the iconic MkII string section
|
||||||
|
"flute" — breathy, haunting solo flute
|
||||||
|
"choir" — ghostly vocal pad
|
||||||
|
"""
|
||||||
|
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||||
|
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||||
|
|
||||||
|
# --- Tape flutter: slow wow + faster flutter ---
|
||||||
|
wow = 0.12 * numpy.sin(2 * numpy.pi * 0.4 * t + rng.uniform(0, 6.28))
|
||||||
|
flutter = 0.06 * numpy.sin(2 * numpy.pi * 6.3 * t + rng.uniform(0, 6.28))
|
||||||
|
flutter += 0.03 * numpy.sin(2 * numpy.pi * 9.7 * t + rng.uniform(0, 6.28))
|
||||||
|
# Cents of pitch deviation
|
||||||
|
pitch_cents = wow + flutter
|
||||||
|
freq_ratio = 2.0 ** (pitch_cents / 1200.0)
|
||||||
|
|
||||||
|
# Accumulate phase with flutter
|
||||||
|
inst_freq = hz * freq_ratio
|
||||||
|
phase = numpy.cumsum(inst_freq / SAMPLE_RATE)
|
||||||
|
|
||||||
|
# --- Generate the "tape" source ---
|
||||||
|
nyquist = SAMPLE_RATE / 2.0
|
||||||
|
wave = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||||
|
|
||||||
|
if tape == "flute":
|
||||||
|
# Breathy flute: fundamental + weak odd harmonics + breath noise
|
||||||
|
wave += 0.7 * numpy.sin(2 * numpy.pi * phase)
|
||||||
|
if hz * 3 < nyquist:
|
||||||
|
wave += 0.12 * numpy.sin(2 * numpy.pi * 3 * phase + rng.uniform(0, 6.28))
|
||||||
|
if hz * 5 < nyquist:
|
||||||
|
wave += 0.04 * numpy.sin(2 * numpy.pi * 5 * phase + rng.uniform(0, 6.28))
|
||||||
|
# Breath noise — bandpass filtered around fundamental
|
||||||
|
breath = rng.normal(0, 0.25, n_samples)
|
||||||
|
bw = max(100, hz * 0.3)
|
||||||
|
lo = max(20, hz - bw)
|
||||||
|
hi = min(nyquist * 0.95, hz + bw)
|
||||||
|
bb, ab = scipy.signal.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||||
|
breath = scipy.signal.lfilter(bb, ab, breath)
|
||||||
|
wave += breath
|
||||||
|
elif tape == "choir":
|
||||||
|
# Ghostly vocal pad: formant-shaped harmonics with slow drift
|
||||||
|
formants = [
|
||||||
|
(800, 100), # first formant ~'ah'
|
||||||
|
(1200, 120), # second formant
|
||||||
|
(2500, 200), # third formant
|
||||||
|
]
|
||||||
|
n_harmonics = min(20, int(nyquist / hz))
|
||||||
|
for n in range(1, n_harmonics + 1):
|
||||||
|
f_n = hz * n
|
||||||
|
if f_n >= nyquist:
|
||||||
|
break
|
||||||
|
amp = 1.0 / n
|
||||||
|
# Shape by formant peaks
|
||||||
|
for f_center, f_bw in formants:
|
||||||
|
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
|
||||||
|
p = rng.uniform(0, 2 * numpy.pi)
|
||||||
|
wave += amp * numpy.sin(2 * numpy.pi * n * phase + p)
|
||||||
|
# Slow ensemble drift between "voices"
|
||||||
|
drift = 0.005 * numpy.sin(2 * numpy.pi * 0.15 * t + rng.uniform(0, 6.28))
|
||||||
|
wave2 = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||||
|
phase2 = numpy.cumsum(hz * (1.0 + drift) * freq_ratio / SAMPLE_RATE)
|
||||||
|
for n in range(1, min(8, int(nyquist / hz)) + 1):
|
||||||
|
f_n = hz * n
|
||||||
|
if f_n >= nyquist:
|
||||||
|
break
|
||||||
|
amp = 0.6 / n
|
||||||
|
for f_center, f_bw in formants:
|
||||||
|
amp *= 1.0 + 1.5 * numpy.exp(-((f_n - f_center) / f_bw) ** 2)
|
||||||
|
wave2 += amp * numpy.sin(2 * numpy.pi * n * phase2 + rng.uniform(0, 6.28))
|
||||||
|
wave += wave2
|
||||||
|
else:
|
||||||
|
# Strings (default): layered ensemble with detuned unison
|
||||||
|
n_harmonics = min(25, int(nyquist / hz))
|
||||||
|
# Two "sections" slightly detuned for ensemble width
|
||||||
|
for section_detune in [-1.5, 0, 1.5]:
|
||||||
|
section_hz = hz * (2 ** (section_detune / 1200.0))
|
||||||
|
section_phase = numpy.cumsum(section_hz * freq_ratio / SAMPLE_RATE)
|
||||||
|
for n in range(1, n_harmonics + 1):
|
||||||
|
f_n = section_hz * n
|
||||||
|
if f_n >= nyquist:
|
||||||
|
break
|
||||||
|
# String-like: 1/n rolloff, even harmonics slightly weaker
|
||||||
|
amp = 1.0 / n
|
||||||
|
if n % 2 == 0:
|
||||||
|
amp *= 0.8
|
||||||
|
p = rng.uniform(0, 2 * numpy.pi)
|
||||||
|
wave += amp * numpy.sin(2 * numpy.pi * n * section_phase + p)
|
||||||
|
|
||||||
|
# Normalize before processing
|
||||||
|
mx = numpy.abs(wave).max()
|
||||||
|
if mx > 0:
|
||||||
|
wave /= mx
|
||||||
|
|
||||||
|
# --- Tape bandwidth limiting: 300 Hz – 6 kHz ---
|
||||||
|
lo_cut = min(300, hz * 0.9) # don't cut fundamental
|
||||||
|
hi_cut = min(6000, nyquist * 0.95)
|
||||||
|
if lo_cut < hi_cut and lo_cut > 0:
|
||||||
|
bb, ab = scipy.signal.butter(2, [lo_cut, hi_cut], btype='band', fs=SAMPLE_RATE)
|
||||||
|
wave = scipy.signal.lfilter(bb, ab, wave)
|
||||||
|
|
||||||
|
# --- Tape saturation: soft compression ---
|
||||||
|
wave = numpy.tanh(wave * 1.4) / 1.2
|
||||||
|
|
||||||
|
# --- Tape run-out: gentle fadeout after ~7 seconds ---
|
||||||
|
if n_samples > int(SAMPLE_RATE * 7):
|
||||||
|
fadeout_start = int(SAMPLE_RATE * 7)
|
||||||
|
fadeout_len = n_samples - fadeout_start
|
||||||
|
fade = numpy.linspace(1.0, 0.0, fadeout_len)
|
||||||
|
wave[fadeout_start:] *= fade
|
||||||
|
|
||||||
|
# --- Head noise / tape hiss ---
|
||||||
|
hiss = rng.normal(0, 0.008, n_samples)
|
||||||
|
wave += hiss
|
||||||
|
|
||||||
|
mx = numpy.abs(wave).max()
|
||||||
|
if mx > 0:
|
||||||
|
wave /= mx
|
||||||
|
|
||||||
|
return (peak * wave).astype(numpy.int16)
|
||||||
|
|
||||||
|
|
||||||
def vibraphone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
def vibraphone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||||
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
|
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
|
||||||
|
|
||||||
@@ -2458,6 +2759,11 @@ class Synth(Enum):
|
|||||||
VIBRAPHONE = "vibraphone_synth"
|
VIBRAPHONE = "vibraphone_synth"
|
||||||
PIPE_ORGAN = "pipe_organ_synth"
|
PIPE_ORGAN = "pipe_organ_synth"
|
||||||
CHOIR = "choir_synth"
|
CHOIR = "choir_synth"
|
||||||
|
MELLOTRON = "mellotron_synth"
|
||||||
|
HARD_SYNC = "hard_sync"
|
||||||
|
RING_MOD = "ring_mod"
|
||||||
|
WAVEFOLD = "wavefold"
|
||||||
|
DRIFT = "drift"
|
||||||
|
|
||||||
def __call__(self, hz, **kwargs):
|
def __call__(self, hz, **kwargs):
|
||||||
"""Make Synth members callable — dispatches to the wave function."""
|
"""Make Synth members callable — dispatches to the wave function."""
|
||||||
@@ -2492,11 +2798,14 @@ _SYNTH_FUNCTIONS = {
|
|||||||
"tingsha_synth": tingsha_wave,
|
"tingsha_synth": tingsha_wave,
|
||||||
"singing_bowl_strike_synth": singing_bowl_strike_wave,
|
"singing_bowl_strike_synth": singing_bowl_strike_wave,
|
||||||
"singing_bowl_ring_synth": singing_bowl_ring_wave,
|
"singing_bowl_ring_synth": singing_bowl_ring_wave,
|
||||||
|
"mellotron_synth": mellotron_wave,
|
||||||
|
"hard_sync": hard_sync_wave, "ring_mod": ring_mod_wave,
|
||||||
|
"wavefold": wavefold_wave, "drift": drift_wave,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
||||||
envelope=Envelope.PIANO):
|
envelope=Envelope.PIANO, **synth_kw):
|
||||||
"""Render a tone or chord to a NumPy sample array.
|
"""Render a tone or chord to a NumPy sample array.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -2508,6 +2817,9 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
t: Duration in milliseconds.
|
t: Duration in milliseconds.
|
||||||
envelope: ADSR envelope preset. Use ``Envelope.NONE`` for raw
|
envelope: ADSR envelope preset. Use ``Envelope.NONE`` for raw
|
||||||
output (old behavior).
|
output (old behavior).
|
||||||
|
**synth_kw: Extra keyword arguments forwarded to the synth wave
|
||||||
|
function (e.g. ``tape="flute"`` for Mellotron,
|
||||||
|
``slave_ratio=2.0`` for Hard Sync).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A NumPy int16 array of audio samples.
|
A NumPy int16 array of audio samples.
|
||||||
@@ -2515,10 +2827,10 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
n_samples = int(SAMPLE_RATE * t / 1_000)
|
n_samples = int(SAMPLE_RATE * t / 1_000)
|
||||||
|
|
||||||
if isinstance(tone_or_chord, Tone):
|
if isinstance(tone_or_chord, Tone):
|
||||||
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples)]
|
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)]
|
||||||
else:
|
else:
|
||||||
waves = [
|
waves = [
|
||||||
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
|
synth(tone.pitch(temperament=temperament), n_samples=n_samples, **synth_kw)
|
||||||
for tone in tone_or_chord.tones
|
for tone in tone_or_chord.tones
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2533,7 +2845,7 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
|
|
||||||
|
|
||||||
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
||||||
envelope=Envelope.PIANO):
|
envelope=Envelope.PIANO, **synth_kw):
|
||||||
"""Play a tone or chord through the speakers.
|
"""Play a tone or chord through the speakers.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -2545,19 +2857,22 @@ def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
t: Duration in milliseconds (default 1000).
|
t: Duration in milliseconds (default 1000).
|
||||||
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
|
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
|
||||||
Use ``Envelope.NONE`` for raw waveform.
|
Use ``Envelope.NONE`` for raw waveform.
|
||||||
|
**synth_kw: Extra keyword arguments forwarded to the synth wave
|
||||||
|
function (e.g. ``tape="flute"`` for Mellotron).
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
>>> play(Tone.from_string("A4"), t=1_000)
|
>>> play(Tone.from_string("A4"), t=1_000)
|
||||||
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
|
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
|
||||||
>>> play(tone, envelope=Envelope.PAD, t=3_000)
|
>>> play(tone, envelope=Envelope.PAD, t=3_000)
|
||||||
|
>>> play(tone, synth=Synth.MELLOTRON, tape="choir", t=2_000)
|
||||||
"""
|
"""
|
||||||
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth,
|
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth,
|
||||||
t=t, envelope=envelope), ms=t)
|
t=t, envelope=envelope, **synth_kw), ms=t)
|
||||||
|
|
||||||
|
|
||||||
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
|
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
|
||||||
envelope=Envelope.PIANO):
|
envelope=Envelope.PIANO, **synth_kw):
|
||||||
"""Render a tone or chord and save it as a WAV file.
|
"""Render a tone or chord and save it as a WAV file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -2567,6 +2882,8 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
synth: Waveform type.
|
synth: Waveform type.
|
||||||
t: Duration in milliseconds (default 1000).
|
t: Duration in milliseconds (default 1000).
|
||||||
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
|
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
|
||||||
|
**synth_kw: Extra keyword arguments forwarded to the synth wave
|
||||||
|
function.
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
@@ -2576,7 +2893,7 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
|
|||||||
import scipy.io.wavfile
|
import scipy.io.wavfile
|
||||||
|
|
||||||
samples = _render(tone_or_chord, temperament=temperament, synth=synth,
|
samples = _render(tone_or_chord, temperament=temperament, synth=synth,
|
||||||
t=t, envelope=envelope)
|
t=t, envelope=envelope, **synth_kw)
|
||||||
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
|
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
|
||||||
# Convert to 16-bit PCM
|
# Convert to 16-bit PCM
|
||||||
pcm = (normalized * 32767).astype(numpy.int16)
|
pcm = (normalized * 32767).astype(numpy.int16)
|
||||||
@@ -5577,8 +5894,8 @@ def render_score(score):
|
|||||||
env_tuple = _resolve_envelope(part.envelope)
|
env_tuple = _resolve_envelope(part.envelope)
|
||||||
# Use part swing if set, otherwise score swing
|
# Use part swing if set, otherwise score swing
|
||||||
effective_swing = part.swing if part.swing is not None else score.swing
|
effective_swing = part.swing if part.swing is not None else score.swing
|
||||||
# Build synth-specific kwargs (e.g. FM ratio/index)
|
# Build synth-specific kwargs (e.g. FM ratio/index, tape, folds)
|
||||||
synth_kwargs = {}
|
synth_kwargs = dict(getattr(part, 'synth_kw', None) or {})
|
||||||
if part.synth in ("fm",):
|
if part.synth in ("fm",):
|
||||||
synth_kwargs["mod_ratio"] = part.fm_ratio
|
synth_kwargs["mod_ratio"] = part.fm_ratio
|
||||||
synth_kwargs["mod_index"] = part.fm_index
|
synth_kwargs["mod_index"] = part.fm_index
|
||||||
|
|||||||
@@ -806,6 +806,12 @@ _INSTRUMENT_NAMES = [
|
|||||||
# Synth presets
|
# Synth presets
|
||||||
"synth_lead", "synth_pad", "synth_bass", "acid_bass",
|
"synth_lead", "synth_pad", "synth_bass", "acid_bass",
|
||||||
"granular_pad", "vocal", "choir", "granular_texture", "808_bass",
|
"granular_pad", "vocal", "choir", "granular_texture", "808_bass",
|
||||||
|
# Mellotron
|
||||||
|
"mellotron", "mellotron_strings", "mellotron_flute", "mellotron_choir",
|
||||||
|
# Analog
|
||||||
|
"sync_lead", "sync_lead_bright", "ring_mod_bell", "ring_mod_metallic",
|
||||||
|
"wavefold_warm", "wavefold_gnarly", "drift_saw", "drift_square",
|
||||||
|
"analog_pad", "analog_bass",
|
||||||
# Percussion / Mallet
|
# Percussion / Mallet
|
||||||
"vibraphone", "marimba", "xylophone", "glockenspiel", "tubular_bells", "timpani",
|
"vibraphone", "marimba", "xylophone", "glockenspiel", "tubular_bells", "timpani",
|
||||||
# Woodwinds (continued)
|
# Woodwinds (continued)
|
||||||
|
|||||||
+102
-2
@@ -342,12 +342,110 @@ INSTRUMENTS = {
|
|||||||
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
|
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
|
||||||
},
|
},
|
||||||
"808_bass": {
|
"808_bass": {
|
||||||
"synth": "sine", "envelope": "pluck",
|
"synth": "sine", "envelope": "piano",
|
||||||
"distortion": 0.4, "distortion_drive": 2.5,
|
"distortion": 0.4, "distortion_drive": 2.5,
|
||||||
"lowpass": 200, "lowpass_q": 1.5,
|
"lowpass": 200, "lowpass_q": 1.5,
|
||||||
"sub_osc": 0.5, "saturation": 0.2,
|
"sub_osc": 0.5, "saturation": 0.2,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── Mellotron ──
|
||||||
|
"mellotron": {
|
||||||
|
"synth": "mellotron_synth", "envelope": "organ",
|
||||||
|
"reverb": 0.3, "reverb_type": "plate",
|
||||||
|
"humanize": 0.2,
|
||||||
|
},
|
||||||
|
"mellotron_strings": {
|
||||||
|
"synth": "mellotron_synth", "envelope": "organ",
|
||||||
|
"reverb": 0.3, "reverb_type": "plate",
|
||||||
|
"humanize": 0.2,
|
||||||
|
},
|
||||||
|
"mellotron_flute": {
|
||||||
|
"synth": "mellotron_synth", "envelope": "organ",
|
||||||
|
"synth_kw": {"tape": "flute"},
|
||||||
|
"reverb": 0.35, "reverb_type": "hall",
|
||||||
|
"humanize": 0.2,
|
||||||
|
},
|
||||||
|
"mellotron_choir": {
|
||||||
|
"synth": "mellotron_synth", "envelope": "organ",
|
||||||
|
"synth_kw": {"tape": "choir"},
|
||||||
|
"reverb": 0.4, "reverb_type": "cathedral",
|
||||||
|
"humanize": 0.2,
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Analog oscillator presets ──
|
||||||
|
"sync_lead": {
|
||||||
|
"synth": "hard_sync", "envelope": "pluck",
|
||||||
|
"synth_kw": {"slave_ratio": 1.5},
|
||||||
|
"detune": 8, "lowpass": 4000,
|
||||||
|
"filter_attack": 0.01, "filter_decay": 0.25,
|
||||||
|
"filter_sustain": 0.3, "filter_amount": 3000,
|
||||||
|
"delay": 0.15, "delay_time": 0.2, "delay_feedback": 0.25,
|
||||||
|
"analog": 0.3,
|
||||||
|
},
|
||||||
|
"sync_lead_bright": {
|
||||||
|
"synth": "hard_sync", "envelope": "pluck",
|
||||||
|
"synth_kw": {"slave_ratio": 2.5},
|
||||||
|
"detune": 10, "lowpass": 6000,
|
||||||
|
"filter_attack": 0.005, "filter_decay": 0.2,
|
||||||
|
"filter_sustain": 0.1, "filter_amount": 4000,
|
||||||
|
"analog": 0.3,
|
||||||
|
},
|
||||||
|
"ring_mod_bell": {
|
||||||
|
"synth": "ring_mod", "envelope": "bell",
|
||||||
|
"synth_kw": {"mod_ratio": 2.1},
|
||||||
|
"reverb": 0.4, "reverb_type": "plate",
|
||||||
|
},
|
||||||
|
"ring_mod_metallic": {
|
||||||
|
"synth": "ring_mod", "envelope": "mallet",
|
||||||
|
"synth_kw": {"mod_ratio": 3.7},
|
||||||
|
"reverb": 0.3, "reverb_type": "hall",
|
||||||
|
"delay": 0.2, "delay_time": 0.3, "delay_feedback": 0.3,
|
||||||
|
},
|
||||||
|
"wavefold_warm": {
|
||||||
|
"synth": "wavefold", "envelope": "organ",
|
||||||
|
"synth_kw": {"folds": 2.0},
|
||||||
|
"lowpass": 3000, "lowpass_q": 1.2,
|
||||||
|
"analog": 0.3,
|
||||||
|
},
|
||||||
|
"wavefold_gnarly": {
|
||||||
|
"synth": "wavefold", "envelope": "pluck",
|
||||||
|
"synth_kw": {"folds": 5.0},
|
||||||
|
"lowpass": 2000, "lowpass_q": 2.5,
|
||||||
|
"filter_attack": 0.01, "filter_decay": 0.3,
|
||||||
|
"filter_sustain": 0.1, "filter_amount": 4000,
|
||||||
|
"distortion": 0.3, "distortion_drive": 2.0,
|
||||||
|
"analog": 0.3,
|
||||||
|
},
|
||||||
|
"drift_saw": {
|
||||||
|
"synth": "drift", "envelope": "organ",
|
||||||
|
"synth_kw": {"shape": "saw", "drift_amount": 0.15},
|
||||||
|
"detune": 10,
|
||||||
|
"analog": 0.4,
|
||||||
|
},
|
||||||
|
"drift_square": {
|
||||||
|
"synth": "drift", "envelope": "organ",
|
||||||
|
"synth_kw": {"shape": "square", "drift_amount": 0.15},
|
||||||
|
"detune": 10,
|
||||||
|
"analog": 0.4,
|
||||||
|
},
|
||||||
|
"analog_pad": {
|
||||||
|
"synth": "drift", "envelope": "pad",
|
||||||
|
"synth_kw": {"shape": "saw", "drift_amount": 0.12},
|
||||||
|
"detune": 12, "spread": 0.5,
|
||||||
|
"chorus": 0.2,
|
||||||
|
"lowpass": 2500, "lowpass_q": 1.0,
|
||||||
|
"analog": 0.5,
|
||||||
|
},
|
||||||
|
"analog_bass": {
|
||||||
|
"synth": "drift", "envelope": "pluck",
|
||||||
|
"synth_kw": {"shape": "saw", "drift_amount": 0.1},
|
||||||
|
"lowpass": 600, "lowpass_q": 2.0,
|
||||||
|
"filter_attack": 0.005, "filter_decay": 0.15,
|
||||||
|
"filter_sustain": 0.0, "filter_amount": 2000,
|
||||||
|
"sub_osc": 0.4,
|
||||||
|
"analog": 0.3,
|
||||||
|
},
|
||||||
|
|
||||||
# ── Percussion / Mallet ──
|
# ── Percussion / Mallet ──
|
||||||
"vibraphone": {
|
"vibraphone": {
|
||||||
"synth": "vibraphone_synth", "envelope": "none",
|
"synth": "vibraphone_synth", "envelope": "none",
|
||||||
@@ -2854,7 +2952,8 @@ class Part:
|
|||||||
analog: float = 0.0,
|
analog: float = 0.0,
|
||||||
ensemble: int = 1,
|
ensemble: int = 1,
|
||||||
fm_ratio: float = 2.0,
|
fm_ratio: float = 2.0,
|
||||||
fm_index: float = 3.0):
|
fm_index: float = 3.0,
|
||||||
|
synth_kw: dict = None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.synth = synth
|
self.synth = synth
|
||||||
self.envelope = envelope
|
self.envelope = envelope
|
||||||
@@ -2902,6 +3001,7 @@ class Part:
|
|||||||
self.ensemble = ensemble
|
self.ensemble = ensemble
|
||||||
self.fm_ratio = fm_ratio
|
self.fm_ratio = fm_ratio
|
||||||
self.fm_index = fm_index
|
self.fm_index = fm_index
|
||||||
|
self.synth_kw = synth_kw or {}
|
||||||
self._system = "western" # default, overridden by Score.part()
|
self._system = "western" # default, overridden by Score.part()
|
||||||
self._fretboard = None # set by Score.part(fretboard=...)
|
self._fretboard = None # set by Score.part(fretboard=...)
|
||||||
self.notes: list[Note] = []
|
self.notes: list[Note] = []
|
||||||
|
|||||||
+3
-3
@@ -5333,7 +5333,7 @@ def test_supersaw_wave():
|
|||||||
@needs_portaudio
|
@needs_portaudio
|
||||||
def test_all_synths_in_enum():
|
def test_all_synths_in_enum():
|
||||||
from pytheory.play import Synth
|
from pytheory.play import Synth
|
||||||
assert len(Synth) == 51
|
assert len(Synth) == 56
|
||||||
for s in Synth:
|
for s in Synth:
|
||||||
wave = s(440, n_samples=1000)
|
wave = s(440, n_samples=1000)
|
||||||
assert len(wave) == 1000
|
assert len(wave) == 1000
|
||||||
@@ -6540,7 +6540,7 @@ def test_instrument_808_bass():
|
|||||||
assert p.lowpass == 200
|
assert p.lowpass == 200
|
||||||
assert p.lowpass_q == 1.5
|
assert p.lowpass_q == 1.5
|
||||||
assert p.synth == "sine"
|
assert p.synth == "sine"
|
||||||
assert p.envelope == "pluck"
|
assert p.envelope == "piano"
|
||||||
|
|
||||||
|
|
||||||
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
|
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
|
||||||
@@ -7155,7 +7155,7 @@ def test_score_system_propagates():
|
|||||||
|
|
||||||
def test_synth_enum_count():
|
def test_synth_enum_count():
|
||||||
from pytheory.play import Synth
|
from pytheory.play import Synth
|
||||||
assert len(Synth) == 51
|
assert len(Synth) == 56
|
||||||
|
|
||||||
|
|
||||||
def test_all_synths_render_and_enum_match():
|
def test_all_synths_render_and_enum_match():
|
||||||
|
|||||||
Reference in New Issue
Block a user