diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f780b6..ae80f2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,32 @@
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
- **Fix hold() inflating duration** — `Note.beats` was returning the full
diff --git a/demo_new_synths.py b/demo_new_synths.py
new file mode 100644
index 0000000..c6fa762
--- /dev/null
+++ b/demo_new_synths.py
@@ -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!")
diff --git a/docs/guide/playback.rst b/docs/guide/playback.rst
index c669c55..d1b2370 100644
--- a/docs/guide/playback.rst
+++ b/docs/guide/playback.rst
@@ -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"), 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
---------------------------------
diff --git a/docs/guide/synths.rst b/docs/guide/synths.rst
index 5d78fa7..49c1914 100644
--- a/docs/guide/synths.rst
+++ b/docs/guide/synths.rst
@@ -1,7 +1,7 @@
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
files needed.
@@ -249,6 +249,114 @@ produces a natural chorus/vibrato effect built into the waveform itself.
+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
--------------
@@ -440,11 +548,11 @@ Dedicated Instrument Synths
--------------------------
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
modeling, and specialized envelopes -- to capture the character of a
specific acoustic instrument. These are the waveforms that bring the
-total count to 41.
+total count to 56.
Piano Synth
~~~~~~~~~~~
@@ -495,6 +603,38 @@ picked up by an electrostatic pickup. More nasal, reedy, and biting
+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
~~~~~~~~~~~~~~~~
@@ -1127,6 +1267,12 @@ bagpipe, singing_bowl, singing_bowl_ring, tingsha
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
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,
timpani, crotales
@@ -1169,3 +1315,11 @@ Some practical combos worth memorizing:
long reverb and you're scoring a nature documentary.
- ``saw`` + ``pluck`` = **funk stab.** Short, sharp, bright. The
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.
diff --git a/pyproject.toml b/pyproject.toml
index 90c4f0a..1cf4d1a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
-version = "0.40.8"
+version = "0.40.9"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
diff --git a/pytheory/__init__.py b/pytheory/__init__.py
index 418046e..3b6178a 100644
--- a/pytheory/__init__.py
+++ b/pytheory/__init__.py
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
-__version__ = "0.40.7"
+__version__ = "0.40.9"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
diff --git a/pytheory/play.py b/pytheory/play.py
index 615d97b..8a74aa6 100644
--- a/pytheory/play.py
+++ b/pytheory/play.py
@@ -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)
+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):
"""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)
+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):
"""Vibraphone — struck aluminum bars with motor-driven tremolo.
@@ -2458,6 +2759,11 @@ class Synth(Enum):
VIBRAPHONE = "vibraphone_synth"
PIPE_ORGAN = "pipe_organ_synth"
CHOIR = "choir_synth"
+ MELLOTRON = "mellotron_synth"
+ HARD_SYNC = "hard_sync"
+ RING_MOD = "ring_mod"
+ WAVEFOLD = "wavefold"
+ DRIFT = "drift"
def __call__(self, hz, **kwargs):
"""Make Synth members callable — dispatches to the wave function."""
@@ -2492,11 +2798,14 @@ _SYNTH_FUNCTIONS = {
"tingsha_synth": tingsha_wave,
"singing_bowl_strike_synth": singing_bowl_strike_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,
- envelope=Envelope.PIANO):
+ envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord to a NumPy sample array.
Args:
@@ -2508,6 +2817,9 @@ def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
t: Duration in milliseconds.
envelope: ADSR envelope preset. Use ``Envelope.NONE`` for raw
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:
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)
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:
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
]
@@ -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,
- envelope=Envelope.PIANO):
+ envelope=Envelope.PIANO, **synth_kw):
"""Play a tone or chord through the speakers.
Args:
@@ -2545,19 +2857,22 @@ def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000,
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
Use ``Envelope.NONE`` for raw waveform.
+ **synth_kw: Extra keyword arguments forwarded to the synth wave
+ function (e.g. ``tape="flute"`` for Mellotron).
Example::
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_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,
- 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,
- envelope=Envelope.PIANO):
+ envelope=Envelope.PIANO, **synth_kw):
"""Render a tone or chord and save it as a WAV file.
Args:
@@ -2567,6 +2882,8 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
synth: Waveform type.
t: Duration in milliseconds (default 1000).
envelope: ADSR envelope preset (default ``Envelope.PIANO``).
+ **synth_kw: Extra keyword arguments forwarded to the synth wave
+ function.
Example::
@@ -2576,7 +2893,7 @@ def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000,
import scipy.io.wavfile
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
# Convert to 16-bit PCM
pcm = (normalized * 32767).astype(numpy.int16)
@@ -5577,8 +5894,8 @@ def render_score(score):
env_tuple = _resolve_envelope(part.envelope)
# Use part swing if set, otherwise score swing
effective_swing = part.swing if part.swing is not None else score.swing
- # Build synth-specific kwargs (e.g. FM ratio/index)
- synth_kwargs = {}
+ # Build synth-specific kwargs (e.g. FM ratio/index, tape, folds)
+ synth_kwargs = dict(getattr(part, 'synth_kw', None) or {})
if part.synth in ("fm",):
synth_kwargs["mod_ratio"] = part.fm_ratio
synth_kwargs["mod_index"] = part.fm_index
diff --git a/pytheory/repl.py b/pytheory/repl.py
index a93bbe0..427a01f 100644
--- a/pytheory/repl.py
+++ b/pytheory/repl.py
@@ -806,6 +806,12 @@ _INSTRUMENT_NAMES = [
# Synth presets
"synth_lead", "synth_pad", "synth_bass", "acid_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
"vibraphone", "marimba", "xylophone", "glockenspiel", "tubular_bells", "timpani",
# Woodwinds (continued)
diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py
index ecae19e..1d0abef 100644
--- a/pytheory/rhythm.py
+++ b/pytheory/rhythm.py
@@ -342,12 +342,110 @@ INSTRUMENTS = {
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
},
"808_bass": {
- "synth": "sine", "envelope": "pluck",
+ "synth": "sine", "envelope": "piano",
"distortion": 0.4, "distortion_drive": 2.5,
"lowpass": 200, "lowpass_q": 1.5,
"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 ──
"vibraphone": {
"synth": "vibraphone_synth", "envelope": "none",
@@ -2854,7 +2952,8 @@ class Part:
analog: float = 0.0,
ensemble: int = 1,
fm_ratio: float = 2.0,
- fm_index: float = 3.0):
+ fm_index: float = 3.0,
+ synth_kw: dict = None):
self.name = name
self.synth = synth
self.envelope = envelope
@@ -2902,6 +3001,7 @@ class Part:
self.ensemble = ensemble
self.fm_ratio = fm_ratio
self.fm_index = fm_index
+ self.synth_kw = synth_kw or {}
self._system = "western" # default, overridden by Score.part()
self._fretboard = None # set by Score.part(fretboard=...)
self.notes: list[Note] = []
diff --git a/test_pytheory.py b/test_pytheory.py
index 405b4b7..d8e9c2e 100644
--- a/test_pytheory.py
+++ b/test_pytheory.py
@@ -5333,7 +5333,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
- assert len(Synth) == 51
+ assert len(Synth) == 56
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@@ -6540,7 +6540,7 @@ def test_instrument_808_bass():
assert p.lowpass == 200
assert p.lowpass_q == 1.5
assert p.synth == "sine"
- assert p.envelope == "pluck"
+ assert p.envelope == "piano"
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
@@ -7155,7 +7155,7 @@ def test_score_system_propagates():
def test_synth_enum_count():
from pytheory.play import Synth
- assert len(Synth) == 51
+ assert len(Synth) == 56
def test_all_synths_render_and_enum_match():
diff --git a/uv.lock b/uv.lock
index 1436d47..6b2a609 100644
--- a/uv.lock
+++ b/uv.lock
@@ -690,7 +690,7 @@ wheels = [
[[package]]
name = "pytheory"
-version = "0.40.7"
+version = "0.40.9"
source = { editable = "." }
dependencies = [
{ name = "rich" },