mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b98f7bd77 | |||
| e0a1ce9d18 | |||
| de7575fe0a | |||
| 665a6f5de5 |
@@ -2,6 +2,55 @@
|
||||
|
||||
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
|
||||
duration for held notes (`_hold=True`), causing `Part.total_beats` and
|
||||
`Score.duration_ms` to overcount. A part with `hold(Sa, WHOLE * 4)` followed
|
||||
by `add(Pa, QUARTER)` would report 17 beats instead of 1. Now held notes
|
||||
return 0 beats, matching the renderer which already skipped advancing the
|
||||
timeline for held notes.
|
||||
|
||||
## 0.40.7
|
||||
|
||||
- **Expose missing Synth enum entries** — rhodes, wurlitzer, vibraphone,
|
||||
pipe organ, and choir wave functions were already implemented but not
|
||||
accessible via the Synth enum. Now available as `Synth.RHODES`,
|
||||
`Synth.WURLITZER`, `Synth.VIBRAPHONE`, `Synth.PIPE_ORGAN`, `Synth.CHOIR`.
|
||||
|
||||
## 0.40.6
|
||||
|
||||
- **Saxophone presets cleaned up** — removed lowpass filters and vel_to_filter
|
||||
from all sax instrument presets (saxophone, alto_sax, tenor_sax, bari_sax).
|
||||
The saxophone wave function already shapes its own spectrum; the extra
|
||||
filters were dulling the tone.
|
||||
|
||||
## 0.40.5
|
||||
|
||||
- **Saxophone synth overhaul** — reed nonlinearity (asymmetric soft clipping),
|
||||
|
||||
@@ -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"), 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
|
||||
---------------------------------
|
||||
|
||||
|
||||
+157
-3
@@ -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.
|
||||
|
||||
<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
|
||||
--------------
|
||||
|
||||
@@ -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
|
||||
|
||||
<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
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.40.5"
|
||||
version = "0.40.9"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.40.5"
|
||||
__version__ = "0.40.9"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
+331
-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)
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -2453,6 +2754,16 @@ class Synth(Enum):
|
||||
TINGSHA = "tingsha_synth"
|
||||
SINGING_BOWL_STRIKE = "singing_bowl_strike_synth"
|
||||
SINGING_BOWL_RING = "singing_bowl_ring_synth"
|
||||
RHODES = "rhodes_synth"
|
||||
WURLITZER = "wurlitzer_synth"
|
||||
VIBRAPHONE = "vibraphone_synth"
|
||||
PIPE_ORGAN = "pipe_organ_synth"
|
||||
CHOIR = "choir_synth"
|
||||
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."""
|
||||
@@ -2487,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:
|
||||
@@ -2503,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.
|
||||
@@ -2510,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
|
||||
]
|
||||
|
||||
@@ -2528,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:
|
||||
@@ -2540,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:
|
||||
@@ -2562,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::
|
||||
|
||||
@@ -2571,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)
|
||||
@@ -5572,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+108
-9
@@ -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",
|
||||
@@ -380,22 +478,19 @@ INSTRUMENTS = {
|
||||
# ── Woodwinds (continued) ──
|
||||
"saxophone": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"humanize": 0.15, "vel_to_filter": 1500,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"alto_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"humanize": 0.15, "vel_to_filter": 1800,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"tenor_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.15, "vel_to_filter": 1200,
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"bari_sax": {
|
||||
"synth": "saxophone_synth", "envelope": "bowed",
|
||||
"lowpass": 2000,
|
||||
"humanize": 0.15, "vel_to_filter": 800,
|
||||
"sub_osc": 0.15,
|
||||
"humanize": 0.15, "sub_osc": 0.15,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -478,6 +573,8 @@ class Note:
|
||||
|
||||
@property
|
||||
def beats(self) -> float:
|
||||
if self._hold:
|
||||
return 0.0
|
||||
return self.duration.value
|
||||
|
||||
|
||||
@@ -2855,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
|
||||
@@ -2903,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] = []
|
||||
|
||||
+3
-3
@@ -5333,7 +5333,7 @@ def test_supersaw_wave():
|
||||
@needs_portaudio
|
||||
def test_all_synths_in_enum():
|
||||
from pytheory.play import Synth
|
||||
assert len(Synth) == 46
|
||||
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) == 46
|
||||
assert len(Synth) == 56
|
||||
|
||||
|
||||
def test_all_synths_render_and_enum_match():
|
||||
|
||||
Reference in New Issue
Block a user