From 931ec905c36530c4c5f5545fd0cb1499f7d79161 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 21:15:56 -0400 Subject: [PATCH] Add 3 new synths + 38 instrument presets New synths: - pluck_synth: Karplus-Strong physical modeling (guitar, harp, koto) - organ_synth: Hammond-style additive drawbar synthesis - strings_synth: Filtered saw with body resonance formants 38 instrument presets across 7 categories: keys, strings, woodwinds, brass, plucked, synth, percussion/mallet. Each preset combines synth, envelope, and effects to approximate real instruments. score.part("lead", instrument="violin") Score.list_instruments() Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/__init__.py | 4 +- pytheory/play.py | 96 +++++++++++++ pytheory/rhythm.py | 326 +++++++++++++++++++++++++++++++++++++++---- test_pytheory.py | 76 +++++++++- 4 files changed, 469 insertions(+), 33 deletions(-) diff --git a/pytheory/__init__.py b/pytheory/__init__.py index edd7e5b..0b4e519 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -8,7 +8,7 @@ from .scales import TonedScale, Key, PROGRESSIONS from .chords import Chord, Fretboard, analyze_progression from .charts import CHARTS, Fingering, charts_for_fretboard -from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern +from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern, INSTRUMENTS from .rhythm import Note as RhythmNote # rhythm.Note (tone + duration pairing) from .play import (play, save, save_midi, play_progression, play_pattern, @@ -25,5 +25,5 @@ __all__ = [ "play", "save", "save_midi", "play_progression", "play_pattern", "play_score", "Synth", "Envelope", "Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part", - "DrumSound", "Pattern", "Section", + "DrumSound", "Pattern", "Section", "INSTRUMENTS", ] diff --git a/pytheory/play.py b/pytheory/play.py index 0c3ae5d..aa82f30 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -190,6 +190,97 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): return pwm_wave(hz, peak, n_samples, lfo_rate=3.0) +def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): + """Karplus-Strong plucked string synthesis. + + A burst of noise is fed into a short delay line with feedback — + the delay length determines the pitch, and the feedback filter + determines the decay. This is how every physical modeling synth + since 1983 does plucked strings. It sounds genuinely like a real + guitar, harp, or koto — not a synth approximation. + + The algorithm: fill a buffer with random noise the length of one + period, then repeatedly average adjacent samples. The averaging + acts as a lowpass filter, gradually removing high harmonics — + exactly what a real vibrating string does as energy dissipates. + """ + period = int(SAMPLE_RATE / hz) + if period < 2: + period = 2 + # Initial noise burst — the "pluck" + buf = numpy.random.uniform(-1.0, 1.0, period).astype(numpy.float64) + out = numpy.zeros(n_samples, dtype=numpy.float64) + for i in range(n_samples): + out[i] = buf[i % period] + # Averaging filter: smooth adjacent samples (Karplus-Strong) + buf[i % period] = 0.5 * (buf[i % period] + buf[(i + 1) % period]) * 0.998 + return (peak * out).astype(numpy.int16) + + +def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): + """Hammond organ — additive synthesis with drawbar harmonics. + + A real Hammond B3 has 9 drawbars that mix sine waves at different + harmonics. This models the classic "full" registration with all + drawbars pulled: fundamental, 2nd, 3rd, 4th, 5th, 6th, and 8th + harmonics at musical levels. + + The result is warm, rich, and unmistakably organ — somewhere + between a sine wave and a square wave, with that characteristic + hollow roundness. + """ + t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE + # Drawbar levels (inspired by 888800000 — full even harmonics) + wave = (numpy.sin(2 * numpy.pi * hz * t) * 1.0 + # 16' fundamental + numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.8 + # 8' + numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.6 + # 5 1/3' + numpy.sin(2 * numpy.pi * hz * 4 * t) * 0.5 + # 4' + numpy.sin(2 * numpy.pi * hz * 5 * t) * 0.3 + # 2 2/3' + numpy.sin(2 * numpy.pi * hz * 6 * t) * 0.25 + # 2' + numpy.sin(2 * numpy.pi * hz * 8 * t) * 0.15) # 1 3/5' + wave /= 3.5 # normalize + return (peak * wave).astype(numpy.int16) + + +def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE): + """String ensemble — filtered saw with body resonance formants. + + Goes beyond raw sawtooth by modeling the resonant body of a + stringed instrument. Two formant peaks (at ~500 Hz and ~1500 Hz) + shape the spectrum the way a violin or cello body does — boosting + certain frequencies and cutting others. + + The result is warmer and more "wooden" than a raw saw wave, + with the characteristic nasal quality of bowed strings. + """ + # Base: sawtooth (all harmonics, like a bowed string) + length = SAMPLE_RATE / float(hz) + omega = numpy.pi * 2 / length + xvalues = numpy.arange(int(length)) * omega + onecycle = scipy.signal.sawtooth(xvalues, width=1) + wave = numpy.resize(onecycle, (n_samples,)).astype(numpy.float64) + + # Body resonance formants — two bandpass peaks + # Formant 1: ~500 Hz (body resonance) + f1 = 500 + bw1 = 200 + b1, a1 = scipy.signal.butter(2, [max(20, f1 - bw1), f1 + bw1], + btype='band', fs=SAMPLE_RATE) + formant1 = scipy.signal.lfilter(b1, a1, wave) + + # Formant 2: ~1500 Hz (bridge/top plate) + f2 = 1500 + bw2 = 400 + b2, a2 = scipy.signal.butter(2, [f2 - bw2, f2 + bw2], + btype='band', fs=SAMPLE_RATE) + formant2 = scipy.signal.lfilter(b2, a2, wave) + + # Mix: original (attenuated) + formants + mixed = wave * 0.3 + formant1 * 0.4 + formant2 * 0.3 + + return (peak * mixed).astype(numpy.int16) + + def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE): """Apply an ADSR amplitude envelope to a sample array. @@ -291,6 +382,9 @@ class Synth(Enum): SUPERSAW = "supersaw" PWM_SLOW = "pwm_slow" PWM_FAST = "pwm_fast" + PLUCK = "pluck_synth" + ORGAN = "organ_synth" + STRINGS = "strings_synth" def __call__(self, hz, **kwargs): """Make Synth members callable — dispatches to the wave function.""" @@ -302,6 +396,8 @@ _SYNTH_FUNCTIONS = { "square": square_wave, "pulse": pulse_wave, "fm": fm_wave, "noise": noise_wave, "supersaw": supersaw_wave, "pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave, + "pluck_synth": pluck_wave, "organ_synth": organ_wave, + "strings_synth": strings_wave, } diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index d27af89..41f23af 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -7,6 +7,217 @@ from enum import Enum from typing import Optional +# ── Instrument presets ──────────────────────────────────────────────────────── +# Predefined combinations of synth, envelope, effects, and parameters that +# approximate real instruments. Used by ``Score.part(instrument=...)``. + +INSTRUMENTS = { + # ── Keys ── + "piano": { + "synth": "fm", "envelope": "piano", + "detune": 5, "chorus": 0.1, "chorus_rate": 0.3, + "lowpass": 6000, + }, + "electric_piano": { # Rhodes/Wurlitzer + "synth": "fm", "envelope": "piano", + "detune": 6, "chorus": 0.2, "chorus_rate": 1.0, + "lowpass": 4000, + }, + "organ": { + "synth": "organ_synth", "envelope": "organ", + "chorus": 0.2, "chorus_rate": 5.5, + "lowpass": 5000, + }, + "harpsichord": { + "synth": "pluck_synth", "envelope": "none", + "lowpass": 3500, + }, + "celesta": { + "synth": "fm", "envelope": "bell", + "lowpass": 8000, + "reverb": 0.3, "reverb_type": "plate", + }, + "music_box": { + "synth": "sine", "envelope": "bell", + "lowpass": 6000, + "reverb": 0.25, "reverb_type": "plate", + }, + + # ── Strings ── + "violin": { + "synth": "strings_synth", "envelope": "strings", + "detune": 4, "lowpass": 5000, + "humanize": 0.15, + }, + "viola": { + "synth": "strings_synth", "envelope": "strings", + "detune": 4, "lowpass": 3500, + "humanize": 0.15, + }, + "cello": { + "synth": "strings_synth", "envelope": "strings", + "detune": 4, "lowpass": 2500, + "humanize": 0.15, + }, + "contrabass": { + "synth": "strings_synth", "envelope": "strings", + "detune": 3, "lowpass": 1500, + "humanize": 0.1, + }, + "string_ensemble": { + "synth": "strings_synth", "envelope": "pad", + "detune": 12, "spread": 0.6, + "chorus": 0.2, "chorus_rate": 0.5, + "lowpass": 4000, + }, + + # ── Woodwinds ── + "flute": { + "synth": "sine", "envelope": "strings", + "lowpass": 4000, + "humanize": 0.2, + }, + "clarinet": { + "synth": "square", "envelope": "strings", + "lowpass": 3000, + "humanize": 0.15, + }, + "oboe": { + "synth": "saw", "envelope": "strings", + "lowpass": 3500, "lowpass_q": 1.2, + "humanize": 0.15, + }, + "bassoon": { + "synth": "saw", "envelope": "strings", + "lowpass": 2000, + "humanize": 0.15, + }, + + # ── Brass ── + "trumpet": { + "synth": "saw", "envelope": "pluck", + "detune": 3, "lowpass": 4000, "lowpass_q": 1.1, + "humanize": 0.15, + }, + "trombone": { + "synth": "saw", "envelope": "strings", + "detune": 3, "lowpass": 2500, + "humanize": 0.15, + }, + "french_horn": { + "synth": "saw", "envelope": "strings", + "detune": 4, "lowpass": 2000, + "chorus": 0.1, + "humanize": 0.15, + }, + "tuba": { + "synth": "saw", "envelope": "strings", + "detune": 3, "lowpass": 1200, + "humanize": 0.1, + }, + "brass_ensemble": { + "synth": "saw", "envelope": "strings", + "detune": 10, "spread": 0.4, + "lowpass": 3000, + "chorus": 0.15, + }, + + # ── Plucked ── + "acoustic_guitar": { + "synth": "pluck_synth", "envelope": "none", + "lowpass": 4000, + "humanize": 0.2, + }, + "electric_guitar": { + "synth": "saw", "envelope": "pluck", + "detune": 5, "lowpass": 3500, + "humanize": 0.15, + }, + "distorted_guitar": { + "synth": "saw", "envelope": "pluck", + "detune": 8, "distortion": 0.6, "distortion_drive": 5.0, + "lowpass": 3000, + "humanize": 0.15, + }, + "bass_guitar": { + "synth": "triangle", "envelope": "pluck", + "lowpass": 1000, + "humanize": 0.1, + }, + "upright_bass": { + "synth": "sine", "envelope": "pluck", + "lowpass": 800, + "humanize": 0.15, + }, + "harp": { + "synth": "pluck_synth", "envelope": "none", + "lowpass": 5000, + "reverb": 0.3, "reverb_type": "plate", + }, + "sitar": { + "synth": "saw", "envelope": "pluck", + "detune": 12, "lowpass": 3000, "lowpass_q": 1.5, + "humanize": 0.2, + }, + "koto": { + "synth": "pluck_synth", "envelope": "none", + "lowpass": 4000, + "reverb": 0.2, + }, + + # ── Synth presets ── + "synth_lead": { + "synth": "saw", "envelope": "pluck", + "detune": 8, "lowpass": 3000, + "delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3, + }, + "synth_pad": { + "synth": "supersaw", "envelope": "pad", + "detune": 12, "spread": 0.6, + "chorus": 0.2, + }, + "synth_bass": { + "synth": "saw", "envelope": "pluck", + "lowpass": 800, "lowpass_q": 1.3, + }, + "acid_bass": { + "synth": "saw", "envelope": "pad", + "legato": True, "glide": 0.03, + "distortion": 0.7, "distortion_drive": 8.0, + "lowpass": 800, "lowpass_q": 5.0, + }, + "808_bass": { + "synth": "sine", "envelope": "pluck", + "distortion": 0.4, "distortion_drive": 2.5, + "lowpass": 200, "lowpass_q": 1.5, + }, + + # ── Percussion / Mallet ── + "vibraphone": { + "synth": "fm", "envelope": "bell", + "lowpass": 5000, + "reverb": 0.3, "reverb_type": "plate", + }, + "marimba": { + "synth": "sine", "envelope": "pluck", + "lowpass": 3000, + }, + "xylophone": { + "synth": "fm", "envelope": "pluck", + "lowpass": 6000, + }, + "glockenspiel": { + "synth": "fm", "envelope": "bell", + "lowpass": 8000, + "reverb": 0.2, + }, + "tubular_bells": { + "synth": "fm", "envelope": "bell", + "reverb": 0.4, "reverb_type": "cathedral", + }, +} + + class Duration(Enum): """Note durations in beats (quarter note = 1 beat).""" @@ -1857,28 +2068,34 @@ class Score: setattr(p, attr, v) return self - def part(self, name: str, *, synth: str = "sine", - envelope: str = "piano", volume: float = 0.5, - reverb: float = 0.0, reverb_decay: float = 1.0, - reverb_type: str = "algorithmic", - delay: float = 0.0, delay_time: float = 0.375, - delay_feedback: float = 0.4, - lowpass: float = 0.0, lowpass_q: float = 0.707, - distortion: float = 0.0, distortion_drive: float = 3.0, - legato: bool = False, glide: float = 0.0, - chorus: float = 0.0, chorus_rate: float = 1.5, - chorus_depth: float = 0.003, + def part(self, name: str, *, instrument: str = None, + synth: str = None, envelope: str = None, + volume: float = None, + reverb: float = None, reverb_decay: float = None, + reverb_type: str = None, + delay: float = None, delay_time: float = None, + delay_feedback: float = None, + lowpass: float = None, lowpass_q: float = None, + distortion: float = None, distortion_drive: float = None, + legato: bool = None, glide: float = None, + chorus: float = None, chorus_rate: float = None, + chorus_depth: float = None, swing: Optional[float] = None, - humanize: float = 0.0, - sidechain: float = 0.0, - sidechain_release: float = 0.1, - detune: float = 0.0, - pan: float = 0.0, - spread: float = 0.0) -> Part: + humanize: float = None, + sidechain: float = None, + sidechain_release: float = None, + detune: float = None, + pan: float = None, + spread: float = None) -> Part: """Create a named part with its own synth voice and effects. Args: name: Part name (e.g. ``"lead"``, ``"bass"``, ``"pads"``). + instrument: Instrument preset name (e.g. ``"piano"``, + ``"violin"``, ``"808_bass"``). See :data:`INSTRUMENTS` + for the full list. When set, the preset's synth, envelope, + and effects are used as defaults; any explicit keyword + argument still overrides the preset value. synth: Waveform — ``"sine"``, ``"saw"``, ``"triangle"``, ``"square"``, ``"pulse"``, ``"fm"``, ``"noise"``, ``"supersaw"``, ``"pwm_slow"``, ``"pwm_fast"``. @@ -1926,23 +2143,72 @@ class Score: lead = score.part("lead", synth="saw", envelope="pluck", reverb=0.3, delay=0.25, lowpass=3000) + + # Or use an instrument preset: + piano = score.part("keys", instrument="piano") """ - p = Part(name, synth=synth, envelope=envelope, volume=volume, - reverb=reverb, reverb_decay=reverb_decay, - reverb_type=reverb_type, - delay=delay, delay_time=delay_time, - delay_feedback=delay_feedback, - lowpass=lowpass, lowpass_q=lowpass_q, - distortion=distortion, distortion_drive=distortion_drive, - legato=legato, glide=glide, - chorus=chorus, chorus_rate=chorus_rate, - chorus_depth=chorus_depth, - swing=swing, humanize=humanize, - sidechain=sidechain, sidechain_release=sidechain_release, - detune=detune, pan=pan, spread=spread) + # Default values for all Part parameters. + _defaults = { + "synth": "sine", "envelope": "piano", "volume": 0.5, + "reverb": 0.0, "reverb_decay": 1.0, "reverb_type": "algorithmic", + "delay": 0.0, "delay_time": 0.375, "delay_feedback": 0.4, + "lowpass": 0.0, "lowpass_q": 0.707, + "distortion": 0.0, "distortion_drive": 3.0, + "legato": False, "glide": 0.0, + "chorus": 0.0, "chorus_rate": 1.5, "chorus_depth": 0.003, + "swing": None, "humanize": 0.0, + "sidechain": 0.0, "sidechain_release": 0.1, + "detune": 0.0, "pan": 0.0, "spread": 0.0, + } + + # If an instrument preset is specified, layer it on top of defaults. + if instrument is not None: + preset = INSTRUMENTS.get(instrument) + if preset is None: + raise ValueError( + f"Unknown instrument: {instrument!r}. " + f"Use Score.list_instruments() to see available presets." + ) + _defaults.update(preset) + + # Collect explicitly-provided kwargs (non-None) and override defaults. + explicit = {} + _locals = { + "synth": synth, "envelope": envelope, "volume": volume, + "reverb": reverb, "reverb_decay": reverb_decay, + "reverb_type": reverb_type, + "delay": delay, "delay_time": delay_time, + "delay_feedback": delay_feedback, + "lowpass": lowpass, "lowpass_q": lowpass_q, + "distortion": distortion, "distortion_drive": distortion_drive, + "legato": legato, "glide": glide, + "chorus": chorus, "chorus_rate": chorus_rate, + "chorus_depth": chorus_depth, + "swing": swing, "humanize": humanize, + "sidechain": sidechain, "sidechain_release": sidechain_release, + "detune": detune, "pan": pan, "spread": spread, + } + for k, v in _locals.items(): + if v is not None: + explicit[k] = v + + merged = {**_defaults, **explicit} + + p = Part(name, **merged) self.parts[name] = p return p + @classmethod + def list_instruments(cls) -> list: + """Return a sorted list of available instrument preset names. + + Example:: + + Score.list_instruments() + # ['808_bass', 'acid_bass', 'acoustic_guitar', ...] + """ + return sorted(INSTRUMENTS.keys()) + def add_pattern(self, pattern, repeats: int = 1) -> "Score": """Add a drum pattern to this score. diff --git a/test_pytheory.py b/test_pytheory.py index 2153f13..eedeff1 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -5312,7 +5312,7 @@ def test_supersaw_wave(): @needs_portaudio def test_all_synths_in_enum(): from pytheory.play import Synth - assert len(Synth) == 10 + assert len(Synth) == 13 for s in Synth: wave = s(440, n_samples=1000) assert len(wave) == 1000 @@ -6451,3 +6451,77 @@ def test_from_midi_note_durations(tmp_path): assert len(sounding) == 2 assert abs(sounding[0].beats - 4.0) < 0.01 assert abs(sounding[1].beats - 2.0) < 0.01 + + +# ── Instrument presets ──────────────────────────────────────────────────────── + +def test_instrument_piano(): + from pytheory import Score, Duration + score = Score("4/4", bpm=120) + p = score.part("p", instrument="piano") + assert p.synth == "fm" + assert p.envelope == "piano" + assert p.detune == 5 + assert p.lowpass == 6000 + assert p.chorus_mix == 0.1 + + +def test_instrument_violin(): + from pytheory import Score + score = Score("4/4", bpm=120) + p = score.part("v", instrument="violin") + assert p.synth == "strings_synth" + assert p.envelope == "strings" + assert p.humanize == 0.15 + assert p.lowpass == 5000 + + +def test_instrument_override(): + from pytheory import Score + score = Score("4/4", bpm=120) + # Explicit synth overrides the preset's "fm" + p = score.part("p", instrument="piano", synth="saw") + assert p.synth == "saw" + # Other preset values still apply + assert p.envelope == "piano" + assert p.detune == 5 + + +def test_instrument_unknown_raises(): + from pytheory import Score + score = Score("4/4", bpm=120) + with pytest.raises(ValueError, match="Unknown instrument"): + score.part("x", instrument="kazoo") + + +def test_list_instruments(): + from pytheory import Score, INSTRUMENTS + result = Score.list_instruments() + assert isinstance(result, list) + assert result == sorted(result) + assert "piano" in result + assert "violin" in result + assert "808_bass" in result + assert len(result) == len(INSTRUMENTS) + + +def test_instrument_effects(): + from pytheory import Score + score = Score("4/4", bpm=120) + p = score.part("c", instrument="celesta") + assert p.reverb_mix == 0.3 + assert p.reverb_type == "plate" + assert p.synth == "fm" + assert p.envelope == "bell" + + +def test_instrument_808_bass(): + from pytheory import Score + score = Score("4/4", bpm=120) + p = score.part("b", instrument="808_bass") + assert p.distortion_mix == 0.4 + assert p.distortion_drive == 2.5 + assert p.lowpass == 200 + assert p.lowpass_q == 1.5 + assert p.synth == "sine" + assert p.envelope == "pluck"