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" },