From 5dd1c5e15df8beed1210c606121ff23ea4ea3856 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 22:00:49 -0400 Subject: [PATCH] v0.32.0: 8 new synth features, highpass filter, preset overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter envelope, velocity→brightness, sub-oscillator, tremolo, saturation, noise layer, phaser, configurable FM. Highpass filter. Bowed and mallet envelopes. Improved strings_synth with additive synthesis. All 38 instrument presets sanity-checked and enhanced. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 23 ++++ docs/guide/effects.rst | 247 +++++++++++++++++++++++++++++++++++-- docs/guide/synths.rst | 8 +- docs/index.rst | 10 +- examples/songs.py | 91 +++++++++++++- pyproject.toml | 2 +- pytheory/__init__.py | 2 +- pytheory/play.py | 271 ++++++++++++++++++++++++++++++++++++++--- pytheory/rhythm.py | 147 ++++++++++++++++++---- test_pytheory.py | 4 +- uv.lock | 2 +- 11 files changed, 745 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa64fb3..4f4090f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to PyTheory are documented here. +## 0.32.0 + +- **8 new synth engine features:** + - Filter envelope: per-note lowpass sweep (`filter_amount`, `filter_attack`, `filter_decay`, `filter_sustain`) + - Velocity → brightness: harder notes = brighter filter (`vel_to_filter`) + - Sub-oscillator: octave-below sine for bass weight (`sub_osc`) + - Tremolo: amplitude LFO modulation (`tremolo_depth`, `tremolo_rate`) + - Saturation: even-harmonic tape/tube warmth (`saturation`) + - Noise layer: per-note breath/air texture (`noise_mix`) + - Phaser: swept allpass filter chain (`phaser`, `phaser_rate`) + - Configurable FM: `fm_ratio` and `fm_index` params +- **Highpass filter** (12 dB/oct biquad) on any part +- **2 new envelopes:** `bowed` (bow attack with sustain), `mallet` (strike with ringing sustain) +- **Improved `strings_synth`:** additive synthesis with body resonance curve, per-harmonic phase randomization, delayed vibrato onset, bow pressure variation +- **Instrument preset overhaul:** every preset sanity-checked against real instrument behavior + - Mallet instruments (vibraphone, celesta, music box, glockenspiel, tubular bells) now ring properly + - Trumpet uses sustaining envelope instead of pluck + - Woodwinds have breath noise, brass has velocity brightness + - Bass instruments have sub-oscillators, synth presets have filter envelopes + - Piano has velocity-to-brightness and subtle hammer noise +- Signal chain: saturation → tremolo → distortion → chorus → phaser → highpass → lowpass → delay → reverb +- Song #21: Cinematic Showcase (Orchestral) + ## 0.31.0 - 3 new synth engines: Karplus-Strong pluck, Hammond organ, string ensemble with body formants diff --git a/docs/guide/effects.rst b/docs/guide/effects.rst index e6d3093..922d63e 100644 --- a/docs/guide/effects.rst +++ b/docs/guide/effects.rst @@ -32,13 +32,26 @@ It's a well-tested order that sounds good by default. Effects are applied in this fixed order:: - Signal --> Distortion --> Chorus --> Lowpass Filter --> Delay --> Reverb --> Mix + Signal --> Saturation --> Tremolo --> Distortion --> Chorus --> Phaser + --> Highpass --> Lowpass --> Delay --> Reverb --> Mix -- **Distortion** first: drives the raw signal before filtering (like - plugging a guitar into a fuzz pedal before the amp). -- **Chorus** second: thickens the distorted signal. -- **Lowpass** third: shapes the tone (like a tone knob on an amp). -- **Delay** fourth: echoes the shaped signal (tap delay / tape echo). +Additionally, these per-note effects are applied before the part effects chain: + +- **Sub-oscillator**: octave-below sine mixed in at the oscillator stage +- **Noise layer**: filtered noise mixed per-note for breath/transients +- **Filter envelope**: per-note lowpass sweep (attack/decay/sustain) +- **Velocity → brightness**: harder velocity = brighter filter cutoff + +Part-level effects: + +- **Saturation** first: subtle even-harmonic warmth (tape/tube color). +- **Tremolo** second: amplitude LFO modulation. +- **Distortion** third: drives the signal before filtering. +- **Chorus** fourth: thickens the signal. +- **Phaser** fifth: swept allpass notches. +- **Highpass** sixth: removes low-frequency mud. +- **Lowpass** seventh: shapes the tone (like a tone knob on an amp). +- **Delay** eighth: echoes the shaped signal (tap delay / tape echo). - **Reverb** last: places everything in a space (room / hall). Distortion @@ -498,6 +511,221 @@ whole mix will gasp for air: delay=0.2, ) +Saturation +---------- + +Saturation is the warm, subtle harmonic enhancement of analog tape +machines and tube preamps. Unlike distortion (which uses ``tanh`` and +adds harsh odd harmonics), saturation uses a polynomial waveshaper +that adds even harmonics -- 2nd and 4th -- which the ear perceives as +warmth and fullness. It's why records mixed through a Neve console +sound "bigger" than the same mix done in the box. + +Parameters: + +- ``saturation``: Amount, 0.0--1.0 (default 0, off). + + - 0.05--0.15 = subtle analog warmth (tape machine) + - 0.2--0.4 = noticeable color (tube preamp) + - 0.5+ = heavy coloring + +.. code-block:: python + + # Warm up a bass + bass = score.part("bass", synth="saw", saturation=0.2) + + # Glue a string ensemble + strings = score.part("strings", instrument="string_ensemble", + saturation=0.1) + +Tremolo +------- + +Amplitude modulation by a sine LFO. The classic vibrating-amp sound. +Essential for vibraphone (the rotating discs in the resonator tubes), +Rhodes electric piano, and surf guitar. Not to be confused with +vibrato (pitch modulation). + +Parameters: + +- ``tremolo_depth``: Modulation depth, 0.0--1.0 (default 0, off). +- ``tremolo_rate``: LFO speed in Hz (default 5.0). + + - 3--5 Hz = classic tremolo + - 5--7 Hz = vibraphone motor speed + - 8+ Hz = ring-mod territory + +.. code-block:: python + + # Classic Fender amp tremolo + guitar = score.part("guitar", synth="saw", envelope="pluck", + tremolo_depth=0.3, tremolo_rate=4.0) + + # Vibraphone with motor + vib = score.part("vib", instrument="vibraphone") # built in + +Phaser +------ + +A chain of allpass filters whose center frequencies are swept by an +LFO, creating moving notches in the spectrum. The classic "jet +engine" or "underwater" effect. Think Small Stone, MXR Phase 90, or +the intro to "Eruption." Different from chorus -- chorus adds a +detuned copy, phaser cancels specific frequencies. + +Parameters: + +- ``phaser``: Wet/dry mix, 0.0--1.0 (default 0, off). +- ``phaser_rate``: LFO sweep speed in Hz (default 0.5). + + - 0.1--0.3 = slow, lush sweep + - 0.5--1.0 = classic phaser + - 2.0+ = fast, Leslie-like + +.. code-block:: python + + # Slow sweep on a pad + pad = score.part("pad", synth="supersaw", envelope="pad", + phaser=0.4, phaser_rate=0.2) + + # Leslie sim on organ (built in) + organ = score.part("organ", instrument="organ") + +Highpass Filter +--------------- + +The opposite of lowpass -- removes low-frequency content below the +cutoff. Useful for cleaning up mud from pads, keeping multiple bass +parts from masking each other, or thinning out a sound to sit better +in a mix. + +Parameters: + +- ``highpass``: Cutoff frequency in Hz (0 = off). + + - 80--150 Hz = clean up sub rumble + - 200--400 Hz = thin out a pad + - 500+ Hz = telephone / radio effect + +- ``highpass_q``: Resonance / Q factor (default 0.707). + +.. code-block:: python + + # Clean up sub rumble from a pad + pad = score.part("pad", synth="supersaw", highpass=120) + + # Thin out rhythm guitar to leave room for bass + rhythm = score.part("rhythm", synth="saw", highpass=250) + +Filter Envelope +--------------- + +A per-note lowpass filter whose cutoff sweeps over time. This is the +core of subtractive synthesis -- the reason a Moog bass goes "bwow" +instead of "boop." The filter opens on the attack and closes during +decay, giving each note a distinctive timbral shape. + +Parameters: + +- ``filter_amount``: Sweep range in Hz (0 = off). How far the filter + opens above the base cutoff. +- ``filter_attack``: Time to reach peak cutoff, in seconds (default 0.01). +- ``filter_decay``: Time to fall to sustain level (default 0.3). +- ``filter_sustain``: Sustain level as fraction of amount, 0.0--1.0 + (default 0.0 = filter closes completely after decay). + +.. code-block:: python + + # Classic synth bass "bwow" + bass = score.part("bass", instrument="synth_bass") # built in + + # Acid squelch + acid = score.part("acid", instrument="acid_bass") # built in + + # Custom filter sweep on a lead + lead = score.part("lead", synth="saw", + filter_amount=4000, filter_attack=0.01, + filter_decay=0.4, filter_sustain=0.1) + +Velocity to Brightness +~~~~~~~~~~~~~~~~~~~~~~ + +Real instruments get brighter when played harder. ``vel_to_filter`` +maps note velocity to filter cutoff boost, so louder notes have more +high-frequency content. + +- ``vel_to_filter``: Cutoff boost in Hz at max velocity (default 0, off). + +.. code-block:: python + + # Piano: soft = mellow, loud = bright + piano = score.part("piano", instrument="piano") # built in + + # Manual: custom velocity mapping on a lead + lead = score.part("lead", synth="saw", vel_to_filter=3000) + +Sub-Oscillator +-------------- + +An octave-below sine wave mixed in with the main oscillator. Adds +low-end weight without muddiness -- the sub fills in the fundamental +while the main oscillator provides harmonic character above. + +- ``sub_osc``: Mix level, 0.0--1.0 (default 0, off). + + - 0.1--0.2 = subtle weight (tuba, bass guitar) + - 0.3--0.5 = heavy sub (808, synth bass) + +.. code-block:: python + + # Fat 808 kick-bass + bass = score.part("bass", instrument="808_bass") # built in + + # Add weight to any part + lead = score.part("lead", synth="saw", sub_osc=0.3) + +Noise Layer +----------- + +White noise mixed into each note, following the same amplitude +envelope. Adds breath for woodwinds, hammer/felt noise for piano, +bow rosin for strings, and attack transients for percussion. + +- ``noise_mix``: Mix level, 0.0--1.0 (default 0, off). + + - 0.02--0.04 = subtle texture (strings, piano) + - 0.05--0.08 = noticeable breath (woodwinds) + - 0.1+ = heavy air/texture + +.. code-block:: python + + # Breathy flute + flute = score.part("flute", instrument="flute") # noise_mix=0.08 + + # Add air to any synth + pad = score.part("pad", synth="supersaw", noise_mix=0.05) + +Configurable FM +--------------- + +The FM synth now accepts ``fm_ratio`` and ``fm_index`` parameters, +letting you dial in specific FM timbres instead of using the defaults. + +- ``fm_ratio``: Modulator frequency as multiple of carrier (default 2.0). + Integer ratios = harmonic timbres; non-integer = metallic/inharmonic. +- ``fm_index``: Modulation depth (default 3.0). Higher = more harmonics. + +.. code-block:: python + + # Warm electric piano (low ratio, low index) + ep = score.part("ep", synth="fm", fm_ratio=1.0, fm_index=1.5) + + # Bright metallic bell (high ratio, high index) + bell = score.part("bell", synth="fm", fm_ratio=3.5, fm_index=5.0) + + # Glockenspiel + glock = score.part("glock", instrument="glockenspiel") # built in + Automation ---------- @@ -528,9 +756,10 @@ processes each section independently: lead.set(lowpass=4000, distortion=0.7, reverb=0.3) lead.arpeggio("Gm", bars=4, pattern="updown", octaves=2) -Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``reverb``, -``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``, -``distortion``, ``distortion_drive``, ``chorus``, ``volume``. +Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``highpass``, +``reverb``, ``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``, +``distortion``, ``distortion_drive``, ``chorus``, ``phaser``, ``phaser_rate``, +``saturation``, ``tremolo_depth``, ``tremolo_rate``, ``volume``. LFO Automation -------------- diff --git a/docs/guide/synths.rst b/docs/guide/synths.rst index 1627df8..f79d6d6 100644 --- a/docs/guide/synths.rst +++ b/docs/guide/synths.rst @@ -1,7 +1,7 @@ Synthesizers ============ -PyTheory includes 10 built-in waveforms and 8 ADSR envelope presets. +PyTheory includes 13 built-in waveforms and 10 ADSR envelope presets. Every sound is generated from scratch -- no samples or external audio files needed. @@ -247,6 +247,8 @@ PyTheory includes 8 presets: play(tone, envelope=Envelope.ORGAN) # Instant on/off, no shaping play(tone, envelope=Envelope.BELL) # Instant attack, long ring play(tone, envelope=Envelope.STRINGS) # Gradual bow attack + play(tone, envelope=Envelope.BOWED) # Bow bite into sustain + play(tone, envelope=Envelope.MALLET) # Strike with ringing sustain play(tone, envelope=Envelope.STACCATO) # Short and punchy play(tone, envelope=Envelope.NONE) # Raw waveform, no shaping @@ -260,8 +262,10 @@ Name Character ``"pluck"`` Sharp attack, fast decay -- guitar pick, harp ``"pad"`` Slow fade in, lush sustain -- strings, synth pads ``"organ"`` Instant on/off -- Hammond organ, no shaping -``"bell"`` Instant attack, long ring -- vibraphone, tubular +``"bell"`` Instant attack, no sustain -- short metallic ring ``"strings"`` Gradual bow attack -- orchestral strings, slow +``"bowed"`` Bow bite into sustain -- solo strings, brass +``"mallet"`` Strike with ringing sustain -- vibraphone, celesta ``"staccato"`` Short and punchy -- funk stabs, percussive hits ``"none"`` Raw waveform, no amplitude shaping at all =============== ================================================ diff --git a/docs/index.rst b/docs/index.rst index 48f00c7..c8986b2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -78,11 +78,13 @@ What's Inside - **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity, swing, humanize, tempo changes, song sections with repeat - **Synthesis** — 13 waveforms (including Karplus-Strong pluck, Hammond organ, - string ensemble), 8 envelopes, 38 instrument presets, detune, stereo - pan/spread, 58 drum patterns (stereo panned), 21 fills + bowed string), 10 envelopes, 38 instrument presets, configurable FM, + sub-oscillator, noise layer, filter envelope, velocity-to-brightness, + detune, stereo pan/spread, 58 drum patterns (stereo panned), 21 fills - **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay, - lowpass (with resonance), distortion, chorus, sidechain compression, - automation, LFOs. Master bus compressor/limiter + lowpass/highpass (with resonance), distortion, saturation, chorus, + phaser, tremolo, sidechain compression, automation, LFOs. Master bus + compressor/limiter - **Instruments** — 25 presets with fingering generation - **Output** — stereo playback, WAV export, MIDI import/export - **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo`` diff --git a/examples/songs.py b/examples/songs.py index e8c80e6..ace10fa 100644 --- a/examples/songs.py +++ b/examples/songs.py @@ -1116,6 +1116,94 @@ def temple_bell(): play_song(score) +def cinematic_showcase(): + """Cinematic orchestral showcase — tubular bells, strings, organ, harp, acid bass.""" + score = Score("4/4", bpm=100) + + # Tubular bells — dramatic intro + bells = score.part("bells", instrument="tubular_bells", + reverb=0.5, reverb_type="cathedral") + bells.add("A3", Duration.WHOLE) + for _ in range(7): + bells.rest(Duration.WHOLE) + + # String ensemble — lush wide pad + strings = score.part("strings", instrument="string_ensemble", + reverb=0.4, reverb_type="hall") + strings.rest(Duration.WHOLE) + for sym in ["Am", "F", "C", "G", "Dm", "Am", "E"]: + strings.add(Chord.from_symbol(sym), Duration.WHOLE) + + # Cello — deep foundation + cello = score.part("cello", instrument="cello", + reverb=0.3, reverb_type="hall") + cello.rest(Duration.WHOLE) + for n in ["A2", "F2", "C3", "G2", "D3", "A2", "E2"]: + cello.add(n, Duration.WHOLE) + + # Violin — legato melody enters bar 3 + violin = score.part("violin", instrument="violin", + reverb=0.25, reverb_type="hall", legato=True) + violin.rest(Duration.WHOLE) + violin.rest(Duration.WHOLE) + for note, dur in [ + ("E5", Duration.HALF), ("C5", Duration.HALF), + ("D5", Duration.QUARTER), ("E5", Duration.QUARTER), ("G5", Duration.HALF), + ("A5", Duration.HALF), ("G5", Duration.QUARTER), ("E5", Duration.QUARTER), + ("F5", Duration.WHOLE), + ("E5", Duration.HALF), ("D5", Duration.HALF), + ("C5", Duration.HALF), ("B4", Duration.HALF), + ("A4", Duration.WHOLE), + ]: + violin.add(note, dur) + + # Organ — enters halfway, cathedral weight + organ = score.part("organ", instrument="organ", + reverb=0.3, reverb_type="cathedral") + for _ in range(4): + organ.rest(Duration.WHOLE) + for sym in ["Dm", "Am", "E", "Am"]: + organ.add(Chord.from_symbol(sym), Duration.WHOLE) + + # Harp — arpeggiated flourishes bars 3-4 + harp = score.part("harp", instrument="harp") + harp.rest(Duration.WHOLE) + harp.rest(Duration.WHOLE) + for n in ["A3", "C4", "E4", "A4", "C5", "E5", "A5", "E5", + "G3", "B3", "D4", "G4", "B4", "D5", "G5", "D5"]: + harp.add(n, Duration.EIGHTH) + for _ in range(4): + harp.rest(Duration.WHOLE) + + # Vibraphone — shimmer in last bars with delay + vib = score.part("vib", instrument="vibraphone", + delay=0.25, delay_time=0.375, delay_feedback=0.35) + for _ in range(5): + vib.rest(Duration.WHOLE) + for note, dur in [ + ("E5", Duration.QUARTER), ("D5", Duration.QUARTER), + ("C5", Duration.QUARTER), ("A4", Duration.QUARTER), + ("B4", Duration.HALF), ("E5", Duration.HALF), + ("A5", Duration.WHOLE), + ]: + vib.add(note, dur) + + # Acid bass — gritty texture bars 4-5 + acid = score.part("acid", instrument="acid_bass") + for _ in range(3): + acid.rest(Duration.WHOLE) + for n in ["C2", "C2", "E2", "G2", "G2", "G2", "A2", "E2", + "D2", "D2", "F2", "A2", "A2", "A2", "E2", "E2"]: + acid.add(n, Duration.EIGHTH) + for _ in range(2): + acid.rest(Duration.WHOLE) + + # Half time drums + score.drums("half time", repeats=8) + + play_song(score, "Cinematic Showcase — A minor") + + SONGS = { "1": ("Bossa Nova in A minor", bossa_nova_girl), "2": ("Bebop in Bb major", bebop_in_bb), @@ -1137,6 +1225,7 @@ SONGS = { "18": ("Glass and Silk (Sine+Triangle)", glass_and_silk), "19": ("Dance Party at the Reitz House", dance_party), "20": ("Temple Bell (Japanese)", temple_bell), + "21": ("Cinematic Showcase (Orchestral)", cinematic_showcase), } if __name__ == "__main__": @@ -1150,7 +1239,7 @@ if __name__ == "__main__": print(f" {key:>2}. {name}") print() - choice = input(" Pick a song (1-20, or 'all'): ").strip() + choice = input(" Pick a song (1-21, or 'all'): ").strip() print() if choice == "all": diff --git a/pyproject.toml b/pyproject.toml index f7991cb..1c12168 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.31.0" +version = "0.32.0" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index b116ca8..7d12a2c 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.31.0" +__version__ = "0.32.0" from .tones import Tone, Interval from .systems import System, SYSTEMS diff --git a/pytheory/play.py b/pytheory/play.py index 0dfbf0e..264b1d9 100644 --- a/pytheory/play.py +++ b/pytheory/play.py @@ -377,6 +377,7 @@ class Envelope(Enum): STRINGS = (0.15, 0.1, 0.8, 0.3) BOWED = (0.04, 0.08, 0.75, 0.25) BELL = (0.001, 0.3, 0.0, 0.5) + MALLET = (0.002, 0.05, 0.6, 0.8) STACCATO = (0.005, 0.05, 0.0, 0.02) @@ -1568,6 +1569,171 @@ def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003, return samples * (1 - mix * 0.5) + wet * mix * 0.5 +def _apply_filter_envelope(samples, base_cutoff, amount, f_attack, f_decay, + f_sustain, q=0.707, vel_cutoff_boost=0.0, + sample_rate=SAMPLE_RATE): + """Apply a per-note filter envelope — cutoff sweeps over time. + + This is the core of subtractive synthesis: the filter opens on the + attack and closes during decay, giving notes a characteristic + "bwow" or "wah" shape. + + Uses block-based processing (64-sample blocks) with biquad coefficient + interpolation for efficiency and smooth sweeps. + """ + n = len(samples) + if n == 0 or amount <= 0: + return samples + + block_size = 64 + out = numpy.empty_like(samples) + + # Build the filter cutoff envelope + a_samps = int(f_attack * sample_rate) + d_samps = int(f_decay * sample_rate) + sustain_level = f_sustain * amount + + cutoff_env = numpy.full(n, sustain_level + base_cutoff + vel_cutoff_boost, + dtype=numpy.float64) + # Attack ramp: 0 → amount + if a_samps > 0: + a_end = min(a_samps, n) + cutoff_env[:a_end] = (base_cutoff + vel_cutoff_boost + + numpy.linspace(0, amount, a_end)) + # Decay ramp: amount → sustain_level + if d_samps > 0: + d_start = min(a_samps, n) + d_end = min(a_samps + d_samps, n) + if d_end > d_start: + cutoff_env[d_start:d_end] = (base_cutoff + vel_cutoff_boost + + numpy.linspace(amount, sustain_level, + d_end - d_start)) + + # Clamp cutoff to valid range + cutoff_env = numpy.clip(cutoff_env, 20.0, sample_rate / 2 - 1) + + # Block-based biquad processing with varying cutoff + # State variables for the filter + x1 = x2 = y1 = y2 = 0.0 + pos = 0 + while pos < n: + end = min(pos + block_size, n) + block = samples[pos:end] + # Use cutoff at block midpoint + mid = (pos + end) // 2 + fc = cutoff_env[mid] + # Compute biquad coefficients + w0 = 2 * numpy.pi * fc / sample_rate + sin_w0 = numpy.sin(w0) + cos_w0 = numpy.cos(w0) + alpha = sin_w0 / (2 * q) + b0 = (1 - cos_w0) / 2 + b1 = 1 - cos_w0 + b2 = (1 - cos_w0) / 2 + a0 = 1 + alpha + a1 = -2 * cos_w0 + a2 = 1 - alpha + # Normalize + b0 /= a0; b1 /= a0; b2 /= a0 + a1 /= a0; a2 /= a0 + # Process block sample by sample (maintaining state) + out_block = numpy.empty(len(block), dtype=numpy.float32) + for i in range(len(block)): + x0 = float(block[i]) + y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2 + out_block[i] = y0 + x2 = x1; x1 = x0 + y2 = y1; y1 = y0 + out[pos:end] = out_block + pos = end + + return out + + +def _apply_saturation(samples, amount=0.5): + """Apply tape/tube saturation — subtle even-harmonic warmth. + + Unlike distortion (tanh, odd harmonics), saturation uses a + polynomial waveshaper that adds 2nd and 4th harmonics — the + warm, pleasing character of analog tape and tube preamps. + """ + if amount <= 0: + return samples + # Asymmetric polynomial: x + k*x^2 adds even harmonics + driven = samples + amount * samples * samples + # Normalize to prevent gain buildup + driven = driven / (1.0 + amount) + # Soft clip any overs + return numpy.clip(driven, -1.0, 1.0).astype(numpy.float32) + + +def _apply_tremolo(samples, depth=0.5, rate=5.0, sample_rate=SAMPLE_RATE): + """Apply tremolo — amplitude modulation by a sine LFO. + + The classic vibrating amp sound. Essential for vibraphone, + electric guitar, and organ Leslie speaker simulation. + """ + if depth <= 0: + return samples + t = numpy.arange(len(samples), dtype=numpy.float64) / sample_rate + lfo = 1.0 - depth * 0.5 * (1.0 + numpy.sin(2 * numpy.pi * rate * t)) + return (samples * lfo).astype(numpy.float32) + + +def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4, + sample_rate=SAMPLE_RATE): + """Apply phaser — swept allpass filter chain. + + Creates moving notches in the frequency spectrum by passing + the signal through a chain of allpass filters whose center + frequencies are modulated by an LFO. Classic effect for + electric piano, pads, and guitar. + """ + if mix <= 0: + return samples + n = len(samples) + block_size = 64 + t = numpy.arange(n, dtype=numpy.float64) / sample_rate + + # LFO sweeps center frequency between 200Hz and 4000Hz (log scale) + lfo = 0.5 + 0.5 * numpy.sin(2 * numpy.pi * rate * t) + center_freqs = 200.0 * (20.0 ** lfo) # 200Hz to 4000Hz + + # Process through allpass stages + wet = samples.copy().astype(numpy.float64) + for _stage in range(stages): + out = numpy.empty(n, dtype=numpy.float64) + # Allpass state + x1 = x2 = y1 = y2 = 0.0 + pos = 0 + while pos < n: + end = min(pos + block_size, n) + mid = (pos + end) // 2 + fc = center_freqs[mid] + # Allpass biquad coefficients + w0 = 2 * numpy.pi * fc / sample_rate + alpha = numpy.sin(w0) / 2.0 # Q=0.5 for wide sweep + cos_w0 = numpy.cos(w0) + b0 = 1 - alpha + b1 = -2 * cos_w0 + b2 = 1 + alpha + a0 = 1 + alpha + a1 = -2 * cos_w0 + a2 = 1 - alpha + b0 /= a0; b1 /= a0; b2 /= a0 + a1 /= a0; a2 /= a0 + for i in range(pos, end): + x0 = wet[i] + y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2 + out[i] = y0 + x2 = x1; x1 = x0 + y2 = y1; y1 = y0 + pos = end + wet = out + + return (samples * (1 - mix) + wet.astype(numpy.float32) * mix).astype(numpy.float32) + + def _apply_distortion(samples, drive=1.0, mix=1.0): """Apply soft-clip distortion (tanh waveshaping). @@ -1599,7 +1765,13 @@ def _apply_distortion(samples, drive=1.0, mix=1.0): def _apply_effects_with_params(samples, params, skip_reverb=False): """Apply effects using a params dict. Used for both static and automated rendering.""" - # Signal chain: distortion → chorus → highpass → lowpass → delay → reverb + # Signal chain: saturation → tremolo → distortion → chorus → phaser + # → highpass → lowpass → delay → reverb + if params.get("saturation", 0) > 0: + samples = _apply_saturation(samples, amount=params["saturation"]) + if params.get("tremolo_depth", 0) > 0: + samples = _apply_tremolo(samples, depth=params["tremolo_depth"], + rate=params.get("tremolo_rate", 5.0)) if params.get("distortion_mix", 0) > 0: samples = _apply_distortion(samples, drive=params.get("distortion_drive", 3.0), @@ -1609,6 +1781,9 @@ def _apply_effects_with_params(samples, params, skip_reverb=False): mix=params["chorus_mix"], rate=params.get("chorus_rate", 1.5), depth=params.get("chorus_depth", 0.003)) + if params.get("phaser_mix", 0) > 0: + samples = _apply_phaser(samples, mix=params["phaser_mix"], + rate=params.get("phaser_rate", 0.5)) if params.get("highpass", 0) > 0: samples = _apply_highpass(samples, params["highpass"], params.get("highpass_q", 0.707)) @@ -1636,11 +1811,16 @@ def _apply_effects_with_params(samples, params, skip_reverb=False): def _apply_part_effects(samples, part): """Apply all effects configured on a Part to a float32 buffer.""" params = { + "saturation": part.saturation, + "tremolo_depth": part.tremolo_depth, + "tremolo_rate": part.tremolo_rate, "distortion_mix": part.distortion_mix, "distortion_drive": part.distortion_drive, "chorus_mix": part.chorus_mix, "chorus_rate": part.chorus_rate, "chorus_depth": part.chorus_depth, + "phaser_mix": part.phaser_mix, + "phaser_rate": part.phaser_rate, "highpass": part.highpass, "highpass_q": part.highpass_q, "lowpass": part.lowpass, @@ -1786,11 +1966,17 @@ def _total_samples_from_tempo_map(total_beats, tempo_map): def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, synth_fn, envelope_tuple, volume, bpm, swing=0.0, tempo_map=None, humanize=0.0, - detune=0.0, spread=0.0, stereo_buf=None): + detune=0.0, spread=0.0, stereo_buf=None, + sub_osc=0.0, noise_mix=0.0, + filter_attack=0.01, filter_decay=0.3, + filter_sustain=0.0, filter_amount=0.0, + vel_to_filter=0.0, filter_q=0.707, + synth_kwargs=None): """Render a list of Notes into an existing buffer at the correct positions.""" import random as _rnd a, d, s, r = envelope_tuple + _skw = synth_kwargs or {} beat_pos = 0.0 for note_index, note in enumerate(notes): if note.tone is not None: @@ -1817,8 +2003,14 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, pitches = [t.pitch() for t in note.tone.tones] else: pitches = [note.tone.pitch()] - # Render oscillators - waves = [synth_fn(hz, n_samples=n_samples) for hz in pitches] + # Render oscillators (pass synth_kwargs for FM etc.) + waves = [synth_fn(hz, n_samples=n_samples, **_skw) + for hz in pitches] + # Sub-oscillator: octave-below sine + if sub_osc > 0: + for hz in pitches: + sub = sine_wave(hz / 2, n_samples=n_samples) + waves.append(sub) # Detune: add oscillators shifted by ±cents detune_up = None detune_down = None @@ -1828,8 +2020,8 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, for hz in pitches: hz_up = hz * (2 ** (detune / 1200)) hz_down = hz * (2 ** (-detune / 1200)) - up_waves.append(synth_fn(hz_up, n_samples=n_samples)) - down_waves.append(synth_fn(hz_down, n_samples=n_samples)) + up_waves.append(synth_fn(hz_up, n_samples=n_samples, **_skw)) + down_waves.append(synth_fn(hz_down, n_samples=n_samples, **_skw)) if spread > 0 and stereo_buf is not None: # Spread: detuned oscillators go to opposite channels detune_up = sum(w.astype(numpy.float32) for w in up_waves) / SAMPLE_PEAK @@ -1838,14 +2030,44 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples, waves.extend(up_waves + down_waves) n_osc = len(waves) mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * max(1, n_osc)) + # Mix sub-oscillator with appropriate gain + if sub_osc > 0: + # Sub was already included in waves; scale the mix + # Boost the sub contribution relative to main + sub_count = len(pitches) + main_count = n_osc - sub_count + if main_count > 0: + # Re-render: main only + sub at controlled level + main_waves = waves[:n_osc - sub_count] + sub_waves = waves[n_osc - sub_count:] + main_mix = sum(w.astype(numpy.float32) for w in main_waves) / (SAMPLE_PEAK * max(1, len(main_waves))) + sub_mix = sum(w.astype(numpy.float32) for w in sub_waves) / (SAMPLE_PEAK * max(1, len(sub_waves))) + mixed = main_mix * (1.0 - sub_osc * 0.3) + sub_mix * sub_osc * 0.3 + # Noise layer: add noise following the note + if noise_mix > 0: + noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32) + mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5 + # Amplitude envelope if a > 0 or d > 0 or s < 1.0 or r > 0: mixed = _apply_envelope(mixed, a, d, s, r) - # Apply per-note velocity scaling + humanize velocity + # Per-note velocity vel = getattr(note, 'velocity', 100) if humanize > 0.0: vel_jitter = int(humanize * 15) vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter))) vel_scale = vel / 127.0 + # Filter envelope (per-note subtractive filter sweep) + if filter_amount > 0: + base_cut = 200.0 # base cutoff before envelope opens it + vel_boost = vel_to_filter * vel_scale if vel_to_filter > 0 else 0.0 + mixed = _apply_filter_envelope( + mixed, base_cut, filter_amount, + filter_attack, filter_decay, filter_sustain, + q=filter_q, vel_cutoff_boost=vel_boost) + elif vel_to_filter > 0: + # Velocity brightness without filter envelope + vel_cutoff = vel_to_filter * vel_scale + 1000 + mixed = _apply_lowpass(mixed, vel_cutoff, q=filter_q) end = min(start + len(mixed), total_samples) buf[start:end] += mixed[:end - start] * volume * vel_scale # Spread detuned oscillators into stereo L/R @@ -2010,6 +2232,11 @@ 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 = {} + if part.synth in ("fm",): + synth_kwargs["mod_ratio"] = part.fm_ratio + synth_kwargs["mod_index"] = part.fm_index if part.legato: _render_legato_to_buf( part.notes, part_buf, samples_per_beat, total_samples, @@ -2025,7 +2252,16 @@ def render_score(score): humanize=part.humanize, detune=part.detune, spread=part.spread, - stereo_buf=stereo_buf) + stereo_buf=stereo_buf, + sub_osc=part.sub_osc, + noise_mix=part.noise_mix, + filter_attack=part.filter_attack, + filter_decay=part.filter_decay, + filter_sustain=part.filter_sustain, + filter_amount=part.filter_amount, + vel_to_filter=part.vel_to_filter, + filter_q=part.lowpass_q, + synth_kwargs=synth_kwargs) # Apply effects — segmented if automation exists auto_points = part._get_automation_points() @@ -2043,8 +2279,10 @@ def render_score(score): params = part._get_params_at(seg_start_beat) segment = part_buf[seg_start:seg_end].copy() has_fx = any(params.get(k, 0) > 0 for k in - ["distortion_mix", "chorus_mix", "highpass", - "lowpass", "delay_mix", "reverb_mix"]) + ["saturation", "tremolo_depth", + "distortion_mix", "chorus_mix", "phaser_mix", + "highpass", "lowpass", "delay_mix", + "reverb_mix"]) if has_fx: segment = _apply_effects_with_params(segment, params) # Apply volume automation @@ -2053,9 +2291,11 @@ def render_score(score): segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment part_buf[seg_start:seg_end] = segment else: - has_fx = (part.highpass > 0 or part.lowpass > 0 - or part.delay_mix > 0 or part.reverb_mix > 0 - or part.distortion_mix > 0 or part.chorus_mix > 0) + has_fx = (part.saturation > 0 or part.tremolo_depth > 0 + or part.distortion_mix > 0 or part.chorus_mix > 0 + or part.phaser_mix > 0 or part.highpass > 0 + or part.lowpass > 0 or part.delay_mix > 0 + or part.reverb_mix > 0) if has_fx: part_buf = _apply_part_effects(part_buf, part) # Apply sidechain compression if enabled @@ -2162,7 +2402,10 @@ def render_score(score): part_stereo[start:start + hit_len] += panned # Apply this drum Part's effects - has_drum_fx = (drum_part.highpass > 0 or drum_part.lowpass > 0 or drum_part.delay_mix > 0 + has_drum_fx = (drum_part.saturation > 0 or drum_part.tremolo_depth > 0 + or drum_part.phaser_mix > 0 + or drum_part.highpass > 0 or drum_part.lowpass > 0 + or drum_part.delay_mix > 0 or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0 or drum_part.chorus_mix > 0) if has_drum_fx: diff --git a/pytheory/rhythm.py b/pytheory/rhythm.py index 7be2391..5041383 100644 --- a/pytheory/rhythm.py +++ b/pytheory/rhythm.py @@ -15,30 +15,36 @@ INSTRUMENTS = { # ── Keys ── "piano": { "synth": "fm", "envelope": "piano", + "fm_ratio": 1.0, "fm_index": 1.5, "detune": 5, "chorus": 0.1, "chorus_rate": 0.3, - "lowpass": 6000, + "lowpass": 6000, "saturation": 0.1, + "vel_to_filter": 3000, "noise_mix": 0.02, }, "electric_piano": { # Rhodes/Wurlitzer "synth": "fm", "envelope": "piano", + "fm_ratio": 1.0, "fm_index": 2.0, "detune": 6, "chorus": 0.2, "chorus_rate": 1.0, - "lowpass": 4000, + "lowpass": 4000, "saturation": 0.15, + "tremolo_depth": 0.15, "tremolo_rate": 4.5, }, "organ": { "synth": "organ_synth", "envelope": "organ", "chorus": 0.2, "chorus_rate": 5.5, "lowpass": 5000, + "phaser": 0.15, "phaser_rate": 0.4, }, "harpsichord": { "synth": "pluck_synth", "envelope": "none", "lowpass": 3500, }, "celesta": { - "synth": "fm", "envelope": "bell", + "synth": "fm", "envelope": "mallet", + "fm_ratio": 3.0, "fm_index": 5.0, "lowpass": 8000, "reverb": 0.3, "reverb_type": "plate", }, "music_box": { - "synth": "sine", "envelope": "bell", + "synth": "sine", "envelope": "mallet", "lowpass": 6000, "reverb": 0.25, "reverb_type": "plate", }, @@ -47,73 +53,86 @@ INSTRUMENTS = { "violin": { "synth": "strings_synth", "envelope": "bowed", "detune": 2, "lowpass": 5000, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 1500, + "noise_mix": 0.03, }, "viola": { "synth": "strings_synth", "envelope": "bowed", "detune": 2, "lowpass": 3500, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 1200, + "noise_mix": 0.03, }, "cello": { "synth": "strings_synth", "envelope": "bowed", "detune": 2, "lowpass": 2500, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 1000, + "noise_mix": 0.02, }, "contrabass": { "synth": "strings_synth", "envelope": "bowed", "detune": 2, "lowpass": 1500, - "humanize": 0.1, + "humanize": 0.1, "vel_to_filter": 800, + "sub_osc": 0.15, }, "string_ensemble": { "synth": "strings_synth", "envelope": "pad", "detune": 10, "spread": 0.5, "chorus": 0.2, "chorus_rate": 0.5, "lowpass": 4000, + "noise_mix": 0.02, "saturation": 0.05, }, # ── Woodwinds ── "flute": { "synth": "sine", "envelope": "strings", "lowpass": 4000, - "humanize": 0.2, + "humanize": 0.2, "noise_mix": 0.08, + "vel_to_filter": 2000, }, "clarinet": { "synth": "square", "envelope": "strings", "lowpass": 3000, - "humanize": 0.15, + "humanize": 0.15, "noise_mix": 0.05, + "vel_to_filter": 1500, }, "oboe": { "synth": "saw", "envelope": "strings", "lowpass": 3500, "lowpass_q": 1.2, - "humanize": 0.15, + "humanize": 0.15, "noise_mix": 0.04, + "vel_to_filter": 1000, }, "bassoon": { "synth": "saw", "envelope": "strings", "lowpass": 2000, - "humanize": 0.15, + "humanize": 0.15, "noise_mix": 0.04, + "vel_to_filter": 800, }, # ── Brass ── "trumpet": { - "synth": "saw", "envelope": "pluck", + "synth": "saw", "envelope": "bowed", "detune": 3, "lowpass": 4000, "lowpass_q": 1.1, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 2000, + "saturation": 0.1, }, "trombone": { "synth": "saw", "envelope": "strings", "detune": 3, "lowpass": 2500, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 1500, + "saturation": 0.1, }, "french_horn": { "synth": "saw", "envelope": "strings", "detune": 4, "lowpass": 2000, "chorus": 0.1, - "humanize": 0.15, + "humanize": 0.15, "vel_to_filter": 1200, + "saturation": 0.1, }, "tuba": { "synth": "saw", "envelope": "strings", "detune": 3, "lowpass": 1200, - "humanize": 0.1, + "humanize": 0.1, "vel_to_filter": 600, + "sub_osc": 0.2, }, "brass_ensemble": { "synth": "saw", "envelope": "strings", @@ -136,18 +155,18 @@ INSTRUMENTS = { "distorted_guitar": { "synth": "saw", "envelope": "pluck", "detune": 8, "distortion": 0.6, "distortion_drive": 5.0, - "lowpass": 3000, + "lowpass": 3000, "saturation": 0.3, "humanize": 0.15, }, "bass_guitar": { "synth": "triangle", "envelope": "pluck", "lowpass": 1000, - "humanize": 0.1, + "humanize": 0.1, "sub_osc": 0.2, }, "upright_bass": { - "synth": "sine", "envelope": "pluck", + "synth": "triangle", "envelope": "pluck", "lowpass": 800, - "humanize": 0.15, + "humanize": 0.15, "saturation": 0.1, }, "harp": { "synth": "pluck_synth", "envelope": "none", @@ -170,49 +189,65 @@ INSTRUMENTS = { "synth": "saw", "envelope": "pluck", "detune": 8, "lowpass": 3000, "delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3, + "filter_attack": 0.01, "filter_decay": 0.3, + "filter_sustain": 0.2, "filter_amount": 3000, }, "synth_pad": { "synth": "supersaw", "envelope": "pad", "detune": 12, "spread": 0.6, "chorus": 0.2, + "phaser": 0.3, "phaser_rate": 0.3, + "sub_osc": 0.2, }, "synth_bass": { "synth": "saw", "envelope": "pluck", "lowpass": 800, "lowpass_q": 1.3, + "filter_attack": 0.005, "filter_decay": 0.2, + "filter_sustain": 0.0, "filter_amount": 2000, + "sub_osc": 0.4, }, "acid_bass": { "synth": "saw", "envelope": "pad", "legato": True, "glide": 0.03, "distortion": 0.7, "distortion_drive": 8.0, "lowpass": 800, "lowpass_q": 5.0, + "filter_attack": 0.005, "filter_decay": 0.15, + "filter_sustain": 0.0, "filter_amount": 4000, + "vel_to_filter": 3000, }, "808_bass": { "synth": "sine", "envelope": "pluck", "distortion": 0.4, "distortion_drive": 2.5, "lowpass": 200, "lowpass_q": 1.5, + "sub_osc": 0.5, "saturation": 0.2, }, # ── Percussion / Mallet ── "vibraphone": { - "synth": "fm", "envelope": "bell", + "synth": "fm", "envelope": "mallet", + "fm_ratio": 1.0, "fm_index": 1.0, "lowpass": 5000, + "tremolo_depth": 0.3, "tremolo_rate": 5.5, "reverb": 0.3, "reverb_type": "plate", }, "marimba": { - "synth": "sine", "envelope": "pluck", + "synth": "sine", "envelope": "mallet", "lowpass": 3000, }, "xylophone": { "synth": "fm", "envelope": "pluck", + "fm_ratio": 3.0, "fm_index": 5.0, "lowpass": 6000, }, "glockenspiel": { - "synth": "fm", "envelope": "bell", + "synth": "fm", "envelope": "mallet", + "fm_ratio": 4.0, "fm_index": 6.0, "lowpass": 8000, "reverb": 0.2, }, "tubular_bells": { - "synth": "fm", "envelope": "bell", + "synth": "fm", "envelope": "mallet", + "fm_ratio": 2.0, "fm_index": 3.0, "reverb": 0.4, "reverb_type": "cathedral", }, } @@ -1583,7 +1618,22 @@ class Part: sidechain_release: float = 0.1, detune: float = 0.0, pan: float = 0.0, - spread: float = 0.0): + spread: float = 0.0, + # ── New synth engine params ── + sub_osc: float = 0.0, + noise_mix: float = 0.0, + filter_attack: float = 0.01, + filter_decay: float = 0.3, + filter_sustain: float = 0.0, + filter_amount: float = 0.0, + vel_to_filter: float = 0.0, + saturation: float = 0.0, + tremolo_depth: float = 0.0, + tremolo_rate: float = 5.0, + phaser: float = 0.0, + phaser_rate: float = 0.5, + fm_ratio: float = 2.0, + fm_index: float = 3.0): self.name = name self.synth = synth self.envelope = envelope @@ -1612,6 +1662,21 @@ class Part: self.detune = detune self.pan = pan self.spread = spread + # New synth engine params + self.sub_osc = sub_osc + self.noise_mix = noise_mix + self.filter_attack = filter_attack + self.filter_decay = filter_decay + self.filter_sustain = filter_sustain + self.filter_amount = filter_amount + self.vel_to_filter = vel_to_filter + self.saturation = saturation + self.tremolo_depth = tremolo_depth + self.tremolo_rate = tremolo_rate + self.phaser_mix = phaser + self.phaser_rate = phaser_rate + self.fm_ratio = fm_ratio + self.fm_index = fm_index self.notes: list[Note] = [] self._drum_hits: list[_Hit] = [] self._drum_pattern_beats: float = 0.0 @@ -1676,6 +1741,8 @@ class Part: mapped["distortion_mix"] = v elif k == "chorus": mapped["chorus_mix"] = v + elif k == "phaser": + mapped["phaser_mix"] = v else: mapped[k] = v self._automation.append((beat_pos, mapped)) @@ -1686,10 +1753,13 @@ class Part: # Start with initial values params = { "volume": self.volume, + "saturation": self.saturation, + "tremolo_depth": self.tremolo_depth, "tremolo_rate": self.tremolo_rate, "reverb_mix": self.reverb_mix, "reverb_decay": self.reverb_decay, "reverb_type": self.reverb_type, "delay_mix": self.delay_mix, "delay_time": self.delay_time, "delay_feedback": self.delay_feedback, + "phaser_mix": self.phaser_mix, "phaser_rate": self.phaser_rate, "highpass": self.highpass, "highpass_q": self.highpass_q, "lowpass": self.lowpass, "lowpass_q": self.lowpass_q, "distortion_mix": self.distortion_mix, @@ -2091,7 +2161,22 @@ class Score: sidechain_release: float = None, detune: float = None, pan: float = None, - spread: float = None) -> Part: + spread: float = None, + # New synth engine params + sub_osc: float = None, + noise_mix: float = None, + filter_attack: float = None, + filter_decay: float = None, + filter_sustain: float = None, + filter_amount: float = None, + vel_to_filter: float = None, + saturation: float = None, + tremolo_depth: float = None, + tremolo_rate: float = None, + phaser: float = None, + phaser_rate: float = None, + fm_ratio: float = None, + fm_index: float = None) -> Part: """Create a named part with its own synth voice and effects. Args: @@ -2193,6 +2278,14 @@ class Score: "swing": swing, "humanize": humanize, "sidechain": sidechain, "sidechain_release": sidechain_release, "detune": detune, "pan": pan, "spread": spread, + "sub_osc": sub_osc, "noise_mix": noise_mix, + "filter_attack": filter_attack, "filter_decay": filter_decay, + "filter_sustain": filter_sustain, "filter_amount": filter_amount, + "vel_to_filter": vel_to_filter, + "saturation": saturation, + "tremolo_depth": tremolo_depth, "tremolo_rate": tremolo_rate, + "phaser": phaser, "phaser_rate": phaser_rate, + "fm_ratio": fm_ratio, "fm_index": fm_index, } for k, v in _locals.items(): if v is not None: diff --git a/test_pytheory.py b/test_pytheory.py index 968480c..b83cc61 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -4248,7 +4248,7 @@ def test_parallel_modes_g_major(): @needs_portaudio def test_envelope_enum_presets(): from pytheory.play import Envelope - assert len(Envelope) == 9 + assert len(Envelope) == 10 for e in Envelope: a, d, s, r = e.value assert a >= 0 @@ -6513,7 +6513,7 @@ def test_instrument_effects(): assert p.reverb_mix == 0.3 assert p.reverb_type == "plate" assert p.synth == "fm" - assert p.envelope == "bell" + assert p.envelope == "mallet" def test_instrument_808_bass(): diff --git a/uv.lock b/uv.lock index 71c44cb..f6db8ad 100644 --- a/uv.lock +++ b/uv.lock @@ -707,7 +707,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.31.0" +version = "0.32.0" source = { editable = "." } dependencies = [ { name = "numeral" },