Compare commits

..

20 Commits

Author SHA1 Message Date
kennethreitz 5dd1c5e15d v0.32.0: 8 new synth features, highpass filter, preset overhaul
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) <noreply@anthropic.com>
2026-03-26 22:00:49 -04:00
kennethreitz e46732fb5a Improved strings_synth, highpass filter, bowed envelope
- Rewrite strings_wave with additive synthesis: natural 1/n harmonic
  rolloff shaped by body resonance curve, per-harmonic phase
  randomization, delayed vibrato onset, bow pressure variation
- Add highpass filter (12dB/oct biquad) to signal chain and Part API
- Add BOWED envelope (40ms attack with bite) for string instruments
- Update string presets to use strings_synth + bowed envelope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:38:15 -04:00
kennethreitz 833ab56857 Fix solo string instruments: clean triangle, no detune
Solo violin/viola/cello/contrabass now use triangle + strings envelope
(clean, clear). String ensemble keeps strings_synth + detune for
thick ensemble textures. Solo instruments need clarity, not width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:26:35 -04:00
kennethreitz 6b2b1e201e Update index.rst with 13 synths, 38 instrument presets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:20:46 -04:00
kennethreitz f9c81fe05f v0.31.0: 3 new synths, 38 instrument presets
- Karplus-Strong pluck (physical modeling for guitar/harp/koto)
- Hammond organ (additive drawbar synthesis)
- String ensemble (filtered saw with body resonance formants)
- 38 instrument presets: score.part("lead", instrument="violin")
- Demo updated with pluck_synth, organ_synth, strings_synth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:18:27 -04:00
kennethreitz 931ec905c3 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) <noreply@anthropic.com>
2026-03-26 21:15:56 -04:00
kennethreitz 799ffbdac9 Add MIT LICENSE file
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:44 -04:00
kennethreitz b29b33524f v0.30.0: Drums as Parts, split drums, kick-only sidechain, MIDI import
- Drums are real Parts with full effects pipeline
- split=True creates kick/snare/hats/toms/cymbals/percussion Parts
- Sidechain triggers on kick only
- Score.from_midi() imports Standard MIDI Files
- Document split drums workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:27:10 -04:00
kennethreitz 25f25c1f23 Split drums into separate Parts: kick, snare, hats, toms, cymbals, percussion
score.drums("rock", split=True) creates independent Parts per group.
Each gets its own effects chain. set_drum_effects() applies to all.
Sidechain triggers on kick only. Render loop handles multiple drum Parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:26:08 -04:00
kennethreitz 3f1d632285 Sidechain triggers on kick only, not all drum hits
Hi-hats and snares no longer duck the pad — only the kick does.
This is how sidechain compression works in real mixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:22:20 -04:00
kennethreitz 1938037458 Update changelog: drums as Part
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:20:05 -04:00
kennethreitz f7c05e1b31 Drums are now a real Part — same effects pipeline, zero duplication
_drum_hits and _drum_pattern_beats proxy through score.parts['drums'].
Drum Part goes through _apply_part_effects like any other Part.
set_drum_effects() is now sugar over the Part's attributes.
All 789 tests pass with no API changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:17:05 -04:00
kennethreitz c375785bb9 Update changelog for drum bus effects
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:12:06 -04:00
kennethreitz 9ebd54b7fc Add drum bus effects — same engine as parts, zero duplication
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
Uses _apply_effects_with_params on each stereo channel.
Supports all effects: reverb, delay, lowpass, distortion, chorus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:11:40 -04:00
kennethreitz ce68ad8f19 Update changelog for v0.29.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:00 -04:00
kennethreitz f402e76480 Rename song.py → songs.py, polish all 20 songs with effects
Every song now has: stereo panning, convolution reverb (plate/cathedral),
humanize (0.2), detune (8-12) on pads, sidechain on electronic tracks,
lowpass on bass, delay on leads. No melodies changed — just better sound.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:03:15 -04:00
kennethreitz 4d3c7e0d6c v0.29.0: MIDI import — Score.from_midi()
Load any Standard MIDI File into a Score. Zero-dependency parser
handles Type 0 and Type 1 files. Each channel becomes a Part,
channel 10 becomes drum hits. Roundtrip with save_midi works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:25:19 -04:00
kennethreitz 5a74a6f715 v0.28.3: Better demo songs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:39:14 -04:00
kennethreitz 5416674858 Rewrite demo songs: stereo, effects, humanize, 8 moods
Each demo now uses pan, detune, spread, convolution reverb,
sidechain, humanize, velocity accents, genre-matched fills.
Added Dub and Temple moods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:38:19 -04:00
kennethreitz 9a5f305ac6 Add CLAUDE.md with release process and music preferences
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:05:24 -04:00
17 changed files with 2396 additions and 260 deletions
+68
View File
@@ -2,6 +2,74 @@
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
- 38 instrument presets: `score.part("lead", instrument="violin")`
- Keys, strings, woodwinds, brass, plucked, synth, and mallet categories
- 13 total synth waveforms
## 0.30.0
- Drums are a real Part — same effects pipeline as any voice
- `score.drums("rock", split=True)` splits kit into kick/snare/hats/toms/cymbals/percussion Parts
- Each split Part gets independent effects (reverb on snare, LP on hats, etc.)
- `set_drum_effects()` applies to all drum Parts (split or not)
- Sidechain triggers on kick only — hats and snare don't duck the pad
- MIDI import via `Score.from_midi(path)`
## 0.29.3
- Drums are now a real Part — same effects pipeline as any other voice, zero code duplication
- `score.parts["drums"]` is a standard Part with reverb, delay, lowpass, etc.
- `set_drum_effects()` is sugar over the Part's attributes
## 0.29.2
- Add `score.set_drum_effects()` — reverb, delay, lowpass, distortion, chorus on the drum bus
- Same effects engine as parts, zero code duplication
## 0.29.1
- Rename song.py → songs.py
- Polish all 20 example songs with stereo, convolution reverb, humanize, detune, sidechain
## 0.29.0
- Add `Score.from_midi(path)` — import any Standard MIDI File into a Score
- Minimal zero-dependency MIDI parser (Type 0 and Type 1)
- Each channel becomes a named Part, channel 10 becomes drum hits
- Tempo, time signature, velocities, and note durations preserved
- Roundtrip: save_midi → from_midi works
## 0.28.3
- Rewrite `pytheory demo` — 8 moods with stereo, effects, humanize, convolution reverb, sidechain
- Added Dub and Temple moods
## 0.28.2
- Lower drum_humanize default to 0.15 — tighter, more professional feel
+38
View File
@@ -0,0 +1,38 @@
# Claude Code Instructions
## Release Process
When releasing to PyPI, always do all three:
1. **Tag the commit**: `git tag v0.X.Y`
2. **Push the tag**: `git push origin --tags`
3. **Create a GitHub release**: `gh release create v0.X.Y --title "v0.X.Y: Short description" --notes "Release notes" --latest`
Don't forget to update `CHANGELOG.md` *before* the release commit.
## Version Bumping
- `pyproject.toml` and `pytheory/__init__.py` must match
- Run `uv lock` after changing the version
- Patch releases (0.X.Y) for bug fixes and small additions
- Minor releases (0.X.0) for new features
## Testing
```
uv run python -m pytest test_pytheory.py -x -q --tb=short -m "not slow"
```
## Publishing
```
uv build && uv publish --token <token> dist/pytheory-0.X.Y*
```
## Music Preferences
- Detune: keep at 8-15, don't go above 25
- Humanize: 0.2 is the sweet spot for melodic parts
- Drum humanize: 0.15 default is good
- No swing unless specifically asked
- Sine and triangle are underrated — use them more
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kenneth Reitz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -29,6 +29,50 @@ Score:
The default is 0.15 — just enough to feel alive without sounding loose.
Drums Are Parts
~~~~~~~~~~~~~~~~
Drums are a real Part — the same as any melodic voice. You can set
effects on them the same way:
.. code-block:: python
score.drums("rock", repeats=4)
score.parts["drums"].reverb_mix = 0.2
score.parts["drums"].reverb_type = "plate"
Or use the shorthand:
.. code-block:: python
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
Split Drums
~~~~~~~~~~~
For maximum control, split the kit into separate Parts — kick, snare,
hats, toms, cymbals, and percussion — each with independent effects:
.. code-block:: python
score.drums("rock", repeats=4, split=True)
# Now each group is its own Part
score.parts["snare"].reverb_mix = 0.3
score.parts["snare"].reverb_type = "plate"
score.parts["hats"].lowpass = 7000
score.parts["kick"] # dry, no effects
# set_drum_effects still works — applies to all drum Parts
score.set_drum_effects(reverb=0.1)
This is how real studios work — the snare gets its own reverb send,
the hats get their own EQ, the kick stays dry and punchy. Now you
can do the same thing in Python.
Sidechain compression triggers on kick hits only — hi-hats and snares
don't duck the pad.
Every drum sound is stereo-panned like a real kit — kick and snare
center, hi-hat right, crash left, toms spread across the field,
percussion instruments placed naturally. Put on headphones and you'll
+238 -9
View File
@@ -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
--------------
+30
View File
@@ -177,3 +177,33 @@ Optional synth, envelope, and gap parameters:
play_progression(chords, t=2000, envelope=Envelope.PAD)
That's the workflow: hear it, tweak it, hear it again. When it sounds right, export to WAV or MIDI and take it somewhere bigger.
MIDI Import
-----------
Load any Standard MIDI File into a Score — then play it through
PyTheory's synth engine with effects, or analyze the theory:
.. code-block:: python
from pytheory import Score
from pytheory.play import play_score
score = Score.from_midi("song.mid")
# See what's inside
for name, part in score.parts.items():
print(f"{name}: {len(part.notes)} notes")
# Change the synth and add effects
score.parts["ch1"].synth = "saw"
score.parts["ch1"].reverb_mix = 0.3
play_score(score)
Each MIDI channel becomes a named Part (``ch1``, ``ch2``, etc.).
Channel 10 (drums) becomes drum hits. Tempo, time signature,
note durations, and velocities are all preserved.
Download any MIDI file from the internet, load it, play it through
the synth engine with reverb and delay. That's the whole idea.
+1 -1
View File
@@ -233,7 +233,7 @@ drum voices with stereo panning.
mandolin family, violin family, banjo, harp, oud, sitar, erhu, and
more) with chord fingering generation and scale diagrams.
**Output** — stereo playback, WAV export, MIDI export.
**Output** — stereo playback, WAV export, MIDI import/export.
**Interface** — REPL with tab completion (``pytheory repl``), CLI with
15 commands. ``pytheory demo``, ``pytheory key``, ``pytheory chord``,
+89 -2
View File
@@ -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
=============== ================================================
@@ -341,6 +345,89 @@ Reverb is also stereo — the left and right channels get different
early reflection patterns, so the reverb tail occupies real space
in the stereo field rather than sitting dead center.
Physical Modeling
-----------------
Three synths go beyond traditional waveform synthesis into physical
modeling territory — they simulate how real instruments produce sound.
Karplus-Strong Pluck
~~~~~~~~~~~~~~~~~~~~
A burst of noise fed into a short delay line. The delay length sets
the pitch, the feedback filter models the string decaying. This is
how every physical modeling synth since 1983 does plucked strings.
It sounds genuinely like a real guitar, harp, or koto.
.. code-block:: python
guitar = score.part("guitar", synth="pluck_synth")
harp = score.part("harp", instrument="harp") # uses pluck_synth
Hammond Organ
~~~~~~~~~~~~~
Additive synthesis with drawbar harmonics — sine waves at the
fundamental plus 2nd, 3rd, 4th, 5th, 6th, and 8th harmonics mixed
at musical levels. Warm, round, unmistakably organ.
.. code-block:: python
organ = score.part("organ", synth="organ_synth")
String Ensemble
~~~~~~~~~~~~~~~
Filtered sawtooth with body resonance formants at ~500 Hz and ~1500 Hz,
modeling the way a violin or cello body shapes the sound. Warmer and
more "wooden" than a raw saw wave.
.. code-block:: python
violin = score.part("violin", synth="strings_synth")
Instrument Presets
------------------
Instead of choosing synth + envelope + effects manually, use an
instrument preset — 38 predefined combinations that approximate real
instruments:
.. code-block:: python
piano = score.part("piano", instrument="piano")
violin = score.part("violin", instrument="violin")
guitar = score.part("guitar", instrument="acoustic_guitar")
organ = score.part("organ", instrument="organ")
bass = score.part("bass", instrument="upright_bass")
Available instruments:
**Keys**: piano, electric_piano, organ, harpsichord, celesta, music_box
**Strings**: violin, viola, cello, contrabass, string_ensemble
**Woodwinds**: flute, clarinet, oboe, bassoon
**Brass**: trumpet, trombone, french_horn, tuba, brass_ensemble
**Plucked**: acoustic_guitar, electric_guitar, distorted_guitar,
bass_guitar, upright_bass, harp, sitar, koto
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells
Explicit kwargs override preset defaults:
.. code-block:: python
# Piano with extra reverb
piano = score.part("piano", instrument="piano", reverb=0.5)
# Violin panned left
violin = score.part("v", instrument="violin", pan=-0.4)
Choosing Synth and Envelope Combos
----------------------------------
+8 -5
View File
@@ -77,13 +77,16 @@ What's Inside
numbers), scale recommendation, modulation, voice leading
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
swing, humanize, tempo changes, song sections with repeat
- **Synthesis** — 10 waveforms, 8 envelopes, detune, stereo pan/spread,
58 drum patterns (stereo panned), 21 fills
- **Synthesis** — 13 waveforms (including Karplus-Strong pluck, Hammond organ,
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, MIDI export
- **Output** — stereo playback, WAV export, MIDI import/export
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``
- **AI-friendly** — Claude Code can compose
and play music through PyTheory from natural language
+257 -82
View File
@@ -46,12 +46,17 @@ def bossa_nova_girl():
score.drums("bossa nova", repeats=4)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
volume=0.3, reverb=0.4, reverb_decay=1.8)
volume=0.3, pan=-0.3,
reverb=0.4, reverb_decay=1.8, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
volume=0.45, delay=0.25, delay_time=0.32,
delay_feedback=0.35, reverb=0.2)
volume=0.45, pan=0.3,
delay=0.25, delay_time=0.32, delay_feedback=0.35,
reverb=0.2, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, lowpass=600)
volume=0.45, pan=0.0, lowpass=600,
humanize=0.15)
for sym in ["Am", "Am", "Dm", "Dm", "E7", "E7", "Am", "Am"]:
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -86,11 +91,18 @@ def bebop_in_bb():
score.drums("bebop", repeats=8, fill="jazz", fill_every=8)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
volume=0.25, reverb=0.35, reverb_decay=1.2)
volume=0.25, pan=-0.3,
reverb=0.35, reverb_decay=1.2, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
volume=0.4, lowpass=4000, lowpass_q=1.1)
volume=0.4, pan=0.25,
lowpass=4000, lowpass_q=1.1,
delay=0.15, delay_time=0.19, delay_feedback=0.25,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="triangle", envelope="pluck",
volume=0.4, lowpass=900)
volume=0.4, pan=0.0, lowpass=500,
humanize=0.15)
for sym in ["Bb", "Gm", "Cm", "F7"] * 2:
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -133,12 +145,17 @@ def salsa_descarga():
score.drums("salsa", repeats=8, fill="salsa", fill_every=4)
pads = score.part("pads", synth="pwm_slow", envelope="pad",
volume=0.2, reverb=0.3, lowpass=2000)
volume=0.2, pan=-0.35,
reverb=0.3, reverb_type="plate", lowpass=2000,
detune=10, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
volume=0.4, delay=0.2, delay_time=0.167,
delay_feedback=0.3)
volume=0.4, pan=0.3,
delay=0.2, delay_time=0.167, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="pulse", envelope="pluck",
volume=0.45, lowpass=500, lowpass_q=1.3)
volume=0.45, pan=0.0, lowpass=500, lowpass_q=1.3,
humanize=0.15)
for sym in ["Em7b5", "A7", "Dm7", "Bbmaj7"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -175,12 +192,19 @@ def afrobeat_groove():
score.drums("afrobeat", repeats=8, fill="afrobeat", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.2, reverb=0.4, reverb_decay=2.0,
lowpass=3000)
volume=0.2, pan=-0.3,
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
lowpass=3000, detune=10, spread=0.6,
humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
volume=0.4, lowpass=3000, lowpass_q=1.0)
volume=0.4, pan=0.3,
lowpass=3000, lowpass_q=1.0,
delay=0.2, delay_time=0.26, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.5, lowpass=500)
volume=0.5, pan=0.0, lowpass=500,
humanize=0.15)
for sym in ["Em", "Am", "D", "C"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -213,13 +237,18 @@ def reggae_one_drop():
score.drums("reggae", repeats=8, fill="reggae", fill_every=8)
chords = score.part("chords", synth="square", envelope="staccato",
volume=0.2, reverb=0.5, reverb_decay=2.0,
lowpass=2000)
volume=0.2, pan=-0.4,
reverb=0.5, reverb_decay=2.0, reverb_type="cathedral",
lowpass=2000, detune=8,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
volume=0.4, delay=0.35, delay_time=0.5625,
delay_feedback=0.45, reverb=0.3)
volume=0.4, pan=0.3,
delay=0.35, delay_time=0.5625, delay_feedback=0.45,
reverb=0.3, reverb_type="cathedral",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, lowpass=400, lowpass_q=1.3)
volume=0.55, pan=0.0, lowpass=400, lowpass_q=1.3,
humanize=0.15)
for sym in ["G", "C", "D", "C"] * 2:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -254,12 +283,18 @@ def funk_workout():
score.drums("funk", repeats=8, fill="funk", fill_every=4)
chords = score.part("chords", synth="square", envelope="staccato",
volume=0.25, lowpass=2500, reverb=0.15)
volume=0.25, pan=-0.4,
lowpass=2500, reverb=0.15, reverb_type="plate",
sidechain=0.4, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
volume=0.4, lowpass=3500, lowpass_q=1.5,
delay=0.15, delay_time=0.15, delay_feedback=0.25)
volume=0.4, pan=0.35,
lowpass=3500, lowpass_q=1.5,
delay=0.15, delay_time=0.15, delay_feedback=0.25,
reverb=0.1, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="pulse", envelope="pluck",
volume=0.5, lowpass=600, lowpass_q=1.2)
volume=0.5, pan=0.0, lowpass=600, lowpass_q=1.2,
humanize=0.15)
for sym in ["Em", "Am", "D", "B7"] * 2:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -298,13 +333,17 @@ def blues_shuffle():
score.drums("12/8 blues", repeats=6)
chords = score.part("chords", synth="fm", envelope="piano",
volume=0.3, reverb=0.3, reverb_decay=1.5)
volume=0.3, pan=-0.3,
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
volume=0.45, reverb=0.3, reverb_decay=1.2,
volume=0.45, pan=0.25,
reverb=0.3, reverb_decay=1.2, reverb_type="plate",
delay=0.2, delay_time=0.43, delay_feedback=0.3,
lowpass=3500)
lowpass=3500, humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.5, lowpass=500)
volume=0.5, pan=0.0, lowpass=500,
humanize=0.15)
for sym in ["A", "A", "D", "D", "E7", "A"]:
chords.add(Chord.from_symbol(sym), Duration.DOTTED_HALF)
@@ -343,13 +382,18 @@ def samba_de_janeiro():
score.drums("samba", repeats=8, fill="samba", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.2, reverb=0.45, reverb_decay=2.0,
lowpass=4000)
volume=0.2, pan=-0.3,
reverb=0.45, reverb_decay=2.0, reverb_type="plate",
lowpass=4000, detune=10, spread=0.5,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
volume=0.45, delay=0.2, delay_time=0.176,
delay_feedback=0.3)
volume=0.45, pan=0.3,
delay=0.2, delay_time=0.176, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, lowpass=700)
volume=0.45, pan=0.0, lowpass=500,
humanize=0.15)
for sym in ["G", "Em", "Am", "D7"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -383,12 +427,17 @@ def jazz_waltz():
score.drums("waltz", repeats=16)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
volume=0.3, reverb=0.4, reverb_decay=2.0)
volume=0.3, pan=-0.3,
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
volume=0.4, reverb=0.3, reverb_decay=1.5,
delay=0.2, delay_time=0.4, delay_feedback=0.3)
volume=0.4, pan=0.25,
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
delay=0.2, delay_time=0.4, delay_feedback=0.3,
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.4, lowpass=600)
volume=0.4, pan=0.0, lowpass=500,
humanize=0.15)
for _ in range(2):
for sym in ["Fmaj7", "Gm", "C7", "Fmaj7"]:
@@ -423,14 +472,19 @@ def house_anthem():
score.drums("house", repeats=8, fill="house", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.25, reverb=0.5, reverb_decay=2.5,
lowpass=5000)
volume=0.25, pan=-0.3,
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
lowpass=5000, detune=12, spread=0.7,
sidechain=0.6, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="staccato",
volume=0.35, lowpass=2000, lowpass_q=2.0,
delay=0.2, delay_time=0.242,
delay_feedback=0.35)
volume=0.35, pan=0.3,
lowpass=2000, lowpass_q=2.0,
delay=0.2, delay_time=0.242, delay_feedback=0.35,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, lowpass=300)
volume=0.55, pan=0.0, lowpass=300,
sidechain=0.5, humanize=0.15)
for sym in ["Cm", "Ab", "Bb", "Cm"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -480,16 +534,22 @@ def dub_kingston():
score.drums("dub", repeats=8)
chords = score.part("chords", synth="square", envelope="staccato",
volume=0.2, reverb=0.6, reverb_decay=2.5,
lowpass=1500, lowpass_q=0.9)
volume=0.2, pan=-0.4,
reverb=0.6, reverb_decay=2.5, reverb_type="cathedral",
lowpass=1500, lowpass_q=0.9, detune=8,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
volume=0.4, delay=0.45, delay_time=0.625,
delay_feedback=0.5, reverb=0.35, reverb_decay=2.0)
volume=0.4, pan=0.3,
delay=0.45, delay_time=0.625, delay_feedback=0.5,
reverb=0.35, reverb_decay=2.0, reverb_type="cathedral",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, lowpass=400, lowpass_q=1.5)
volume=0.6, pan=0.0, lowpass=400, lowpass_q=1.5,
humanize=0.15)
siren = score.part("siren", synth="pwm_slow", envelope="pad",
volume=0.15, reverb=0.7, reverb_decay=3.0,
lowpass=1200)
volume=0.15, pan=0.5,
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
lowpass=1200, detune=10)
for sym in ["Am", "Am", "Dm", "Dm", "Am", "Am", "Em", "Am"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -526,14 +586,19 @@ def techno_minimal():
score.drums("techno", repeats=8, fill="house", fill_every=8)
pad = score.part("pad", synth="supersaw", envelope="pad",
volume=0.2, reverb=0.5, reverb_decay=3.0,
lowpass=3000)
volume=0.2, pan=-0.3,
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
lowpass=3000, detune=12, spread=0.7,
sidechain=0.6, humanize=0.2)
lead = score.part("lead", synth="pwm_fast", envelope="staccato",
volume=0.35, lowpass=1500, lowpass_q=3.0,
delay=0.3, delay_time=0.231,
delay_feedback=0.4)
volume=0.35, pan=0.3,
lowpass=1500, lowpass_q=3.0,
delay=0.3, delay_time=0.231, delay_feedback=0.4,
reverb=0.1, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, lowpass=250)
volume=0.55, pan=0.0, lowpass=250,
sidechain=0.5, humanize=0.15)
for sym in ["Fm", "Db", "Eb", "Fm"] * 2:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -560,12 +625,17 @@ def gospel_shuffle():
score.drums("gospel", repeats=8, fill="buildup", fill_every=8)
organ = score.part("organ", synth="fm", envelope="organ",
volume=0.3, reverb=0.45, reverb_decay=2.0)
volume=0.3, pan=-0.3,
reverb=0.45, reverb_decay=2.0, reverb_type="cathedral",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
volume=0.4, delay=0.2, delay_time=0.278,
delay_feedback=0.3, reverb=0.2)
volume=0.4, pan=0.3,
delay=0.2, delay_time=0.278, delay_feedback=0.3,
reverb=0.2, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, lowpass=500)
volume=0.45, pan=0.0, lowpass=500,
humanize=0.15)
for sym in ["C", "Am", "F", "G"] * 2:
organ.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -618,18 +688,23 @@ def dub_delay_madness():
score._drum_hits.append(_Hit(DrumSound.RIMSHOT, offset + 3.5, 60))
chords = score.part("skank", synth="square", envelope="staccato",
volume=0.15, reverb=0.7, reverb_decay=3.0,
lowpass=1200)
volume=0.15, pan=-0.4,
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
lowpass=1200, detune=8, humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, lowpass=350, lowpass_q=1.5)
volume=0.6, pan=0.0, lowpass=350, lowpass_q=1.5,
humanize=0.15)
siren = score.part("siren", synth="pwm_slow", envelope="pad",
volume=0.12, reverb=0.8, reverb_decay=4.0,
volume=0.12, pan=0.5,
reverb=0.8, reverb_decay=4.0, reverb_type="cathedral",
delay=0.4, delay_time=0.88, delay_feedback=0.6,
lowpass=900)
lowpass=900, detune=10)
# Melodica stabs — sparse, lots of delay
melodica = score.part("melodica", synth="triangle", envelope="pluck",
volume=0.35, delay=0.6, delay_time=0.66,
delay_feedback=0.55, reverb=0.5, reverb_decay=2.5)
volume=0.35, pan=0.3,
delay=0.6, delay_time=0.66, delay_feedback=0.55,
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
humanize=0.2)
for sym in ["Em", "Em", "Am", "Am", "Em", "Em", "Bm", "Em"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -667,13 +742,18 @@ def drum_and_bass():
score.drums("drum and bass", repeats=8, fill="buildup", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.25, reverb=0.5, reverb_decay=2.5,
lowpass=4000)
volume=0.25, pan=-0.3,
reverb=0.5, reverb_decay=2.5, reverb_type="plate",
lowpass=4000, detune=10, spread=0.6,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
volume=0.4, delay=0.3, delay_time=0.172,
delay_feedback=0.4, reverb=0.25)
volume=0.4, pan=0.3,
delay=0.3, delay_time=0.172, delay_feedback=0.4,
reverb=0.25, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, lowpass=300)
volume=0.55, pan=0.0, lowpass=300,
humanize=0.15)
for sym in ["Am", "F", "C", "G"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -708,18 +788,24 @@ def drake_vibes():
score.drums("trap", repeats=8, fill="trap", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
volume=0.2, reverb=0.5, reverb_decay=3.0,
lowpass=2500)
volume=0.2, pan=-0.25,
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
lowpass=2500, detune=12, spread=0.6,
sidechain=0.4, humanize=0.2)
bells = score.part("bells", synth="fm", envelope="bell",
volume=0.3, reverb=0.4, reverb_decay=2.0,
delay=0.25, delay_time=0.44,
delay_feedback=0.35)
volume=0.3, pan=0.4,
reverb=0.4, reverb_decay=2.0, reverb_type="plate",
delay=0.25, delay_time=0.44, delay_feedback=0.35,
humanize=0.2)
lead = score.part("lead", synth="pwm_slow", envelope="strings",
volume=0.35, reverb=0.3, lowpass=2000,
delay=0.2, delay_time=0.88, delay_feedback=0.3)
volume=0.35, pan=-0.2,
reverb=0.3, reverb_type="cathedral", lowpass=2000,
delay=0.2, delay_time=0.88, delay_feedback=0.3,
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, lowpass=200, lowpass_q=1.8,
distortion=0.4, distortion_drive=2.0)
volume=0.6, pan=0.0, lowpass=200, lowpass_q=1.8,
distortion=0.4, distortion_drive=2.0,
sidechain=0.3, humanize=0.15)
for sym in ["Ebm", "B", "Gb", "Db"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -1030,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),
@@ -1051,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__":
@@ -1064,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":
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.28.2"
version = "0.32.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+3 -3
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.28.2"
__version__ = "0.32.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
@@ -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",
]
+99 -47
View File
@@ -228,75 +228,127 @@ def cmd_demo(args):
moods = [
{"name": "Bossa Nova", "key": ("A", "minor"), "drums": "bossa nova",
"bpm": 140, "prog": ("i", "iv", "V", "i"),
"lead_synth": "triangle", "pad_synth": "fm"},
"fill": "bossa nova", "bpm": 140,
"prog": ("i", "iv", "V", "i"),
"lead": ("pluck_synth", "none", 0.2, -0.1),
"pad": ("fm", "pad", -0.2),
"bass_lp": 600, "reverb_type": "plate"},
{"name": "Jazz Club", "key": ("Bb", "major"), "drums": "jazz",
"bpm": 105, "prog": ("I", "vi", "ii", "V"),
"lead_synth": "saw", "pad_synth": "fm"},
{"name": "Afrobeat Groove", "key": ("E", "minor"), "drums": "afrobeat",
"bpm": 115, "prog": ("i", "iv", "V", "i"),
"lead_synth": "saw", "pad_synth": "supersaw"},
{"name": "House Pump", "key": ("C", "minor"), "drums": "house",
"bpm": 124, "prog": ("i", "IV", "V", "i"),
"lead_synth": "saw", "pad_synth": "supersaw"},
{"name": "Reggae One-Drop", "key": ("G", "major"), "drums": "reggae",
"bpm": 80, "prog": ("I", "IV", "V", "IV"),
"lead_synth": "triangle", "pad_synth": "pwm_slow"},
{"name": "Funk Workout", "key": ("E", "minor"), "drums": "funk",
"bpm": 100, "prog": ("i", "iv", "V", "i"),
"lead_synth": "saw", "pad_synth": "square"},
"fill": "jazz", "bpm": 108,
"prog": ("I", "vi", "ii", "V"),
"lead": ("triangle", "strings", 0.3, 0.2),
"pad": ("fm", "piano", -0.3),
"bass_lp": 700, "reverb_type": "plate"},
{"name": "Afrobeat", "key": ("E", "minor"), "drums": "afrobeat",
"fill": "afrobeat", "bpm": 115,
"prog": ("i", "iv", "V", "i"),
"lead": ("saw", "pluck", 0.15, 0.3),
"pad": ("supersaw", "pad", 0.0),
"bass_lp": 500, "reverb_type": "cathedral"},
{"name": "House", "key": ("C", "minor"), "drums": "house",
"fill": "house", "bpm": 124,
"prog": ("i", "IV", "V", "i"),
"lead": ("saw", "staccato", 0.2, 0.4),
"pad": ("supersaw", "pad", 0.0),
"bass_lp": 300, "reverb_type": "plate"},
{"name": "Reggae", "key": ("G", "major"), "drums": "reggae",
"fill": "reggae", "bpm": 80,
"prog": ("I", "IV", "V", "IV"),
"lead": ("pluck_synth", "none", 0.25, 0.15),
"pad": ("organ_synth", "organ", -0.3),
"bass_lp": 400, "reverb_type": "cathedral"},
{"name": "Funk", "key": ("E", "minor"), "drums": "funk",
"fill": "funk", "bpm": 100,
"prog": ("i", "iv", "V", "i"),
"lead": ("saw", "pluck", 0.15, 0.3),
"pad": ("square", "staccato", -0.4),
"bass_lp": 500, "reverb_type": "plate"},
{"name": "Dub", "key": ("A", "minor"), "drums": "dub",
"fill": "reggae", "bpm": 72,
"prog": ("i", "iv", "i", "V"),
"lead": ("triangle", "strings", 0.4, 0.2),
"pad": ("pwm_slow", "pad", -0.3),
"bass_lp": 350, "reverb_type": "cathedral"},
{"name": "Temple", "key": ("E", "minor"), "drums": "bolero",
"fill": "bossa nova", "bpm": 65,
"prog": ("i", "iv", "V", "i"),
"lead": ("pluck_synth", "none", 0.3, 0.2),
"pad": ("strings_synth", "pad", 0.0),
"bass_lp": 200, "reverb_type": "taj_mahal"},
]
mood = random.choice(moods)
tonic, mode = mood["key"]
key = Key(tonic, mode)
chords = key.progression(*mood["prog"])
lead_synth, lead_env, lead_reverb, lead_pan = mood["lead"]
pad_synth, pad_env, pad_pan = mood["pad"]
score = Score("4/4", bpm=mood["bpm"], swing=random.uniform(0.1, 0.4))
score.drums(mood["drums"], repeats=4, fill=random.choice(Pattern.list_fills()))
score = Score("4/4", bpm=mood["bpm"], drum_humanize=0.15)
score.drums(mood["drums"], repeats=4, fill=mood["fill"])
pad = score.part("pad", synth=mood["pad_synth"], envelope="pad",
volume=0.25, reverb=0.4, reverb_decay=2.0,
chorus=0.2)
lead = score.part("lead", synth=mood["lead_synth"], envelope="pluck",
volume=0.4, delay=0.25, delay_time=0.375,
delay_feedback=0.35, reverb=0.2,
lowpass=3000, humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, lowpass=500)
pad = score.part(
"pad", synth=pad_synth, envelope=pad_env,
volume=0.2, pan=pad_pan,
detune=10, spread=0.5,
reverb=0.4, reverb_type=mood["reverb_type"],
chorus=0.2,
sidechain=0.4 if mood["bpm"] > 100 else 0.0,
)
lead = score.part(
"lead", synth=lead_synth, envelope=lead_env,
volume=0.4, pan=lead_pan,
delay=0.2, delay_time=round(30 / mood["bpm"], 3),
delay_feedback=0.35,
reverb=lead_reverb, reverb_type=mood["reverb_type"],
lowpass=3500,
humanize=0.2,
)
bass = score.part(
"bass", synth="sine", envelope="pluck",
volume=0.45, pan=0.0,
lowpass=mood["bass_lp"],
humanize=0.15,
)
for chord in chords * 2:
pad.add(chord, Duration.WHOLE)
# Generate a melody from scale tones
# Melody: chord tones with passing tones, rests for breathing
scale_tones = [t.name for t in key.scale.tones[:-1]]
for chord in chords:
for i, chord in enumerate(chords):
chord_tones = [t.name for t in chord.tones]
for _ in range(4):
if random.random() < 0.2:
lead.rest(random.choice([0.5, 1.0]))
beats_left = 4.0
while beats_left > 0.5:
if random.random() < 0.25:
r = random.choice([0.5, 1.0, 1.5])
r = min(r, beats_left)
lead.rest(r)
beats_left -= r
else:
n = random.choice(chord_tones if random.random() < 0.6 else scale_tones)
oct = random.choice([4, 5])
dur = random.choice([0.5, 0.67, 1.0])
lead.add(f"{n}{oct}", dur, velocity=random.randint(60, 120))
n = random.choice(chord_tones if random.random() < 0.65 else scale_tones)
oct = 5 if random.random() < 0.6 else 4
dur = random.choice([0.67, 1.0, 1.5, 2.0])
dur = min(dur, beats_left)
vel = random.randint(65, 105)
lead.add(f"{n}{oct}", dur, velocity=vel)
beats_left -= dur
root = chords[0].root
if root:
for chord in chords * 2:
r = chord.root
if r:
bass.add(f"{r.name}2", Duration.QUARTER, velocity=100)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=60)
fifth = r.add(7)
bass.add(f"{fifth.name}2", Duration.QUARTER, velocity=70)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=80)
# Bass: root-fifth with velocity accents
for chord in chords * 2:
r = chord.root
if r:
fifth = r.add(7)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=95)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=55)
bass.add(f"{fifth.name}2", Duration.QUARTER, velocity=65)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=75)
prog_str = "".join(c.symbol or str(c) for c in chords)
print(f"{mood['name']}")
print(f" {tonic} {mode} | {mood['bpm']} bpm")
print(f" {prog_str}")
print(f" {mood['drums']} drums | {mood['lead_synth']} lead | {mood['pad_synth']} pad")
print(f" {mood['drums']} | {lead_synth} lead | {pad_synth} pad | {mood['reverb_type']} reverb")
print()
play_score(score)
+473 -49
View File
@@ -190,6 +190,126 @@ 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):
"""Bowed string — additive synthesis with natural harmonic rolloff.
Models bowed string physics:
- Additive harmonics with 1/n rolloff shaped by body resonance
- Delayed vibrato (develops ~200ms in, like a real player)
- Subtle bow pressure variation (amplitude modulation)
- Per-harmonic phase randomization for natural timbre
- Gentle spectral tilt to avoid synthetic brightness
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Delayed vibrato: ramps in over ~200ms, like a real bow
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
vib_depth = hz * 0.003 # ~5 cents
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
# Additive synthesis — build harmonics with natural rolloff
nyquist = SAMPLE_RATE / 2.0
n_harmonics = min(40, int(nyquist / hz))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Body resonance curve — emphasizes certain harmonic regions
# Modeled after violin/cello response: peaks around 300Hz, 1kHz, 2.5kHz
def body_response(f):
"""Approximate string instrument body resonance."""
r = 1.0
# Main air resonance (~280 Hz for violin, scales with pitch)
air_f = max(200, min(400, hz * 1.5))
r += 0.6 * numpy.exp(-((f - air_f) / 100) ** 2)
# Wood resonance (~1 kHz)
r += 0.4 * numpy.exp(-((f - 1000) / 300) ** 2)
# Bridge resonance (~2.5 kHz) — the "presence" peak
r += 0.3 * numpy.exp(-((f - 2500) / 500) ** 2)
return r
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= nyquist:
break
# Amplitude: 1/n rolloff (sawtooth-like) shaped by body
amp = (1.0 / n) * body_response(f_n)
# Even harmonics slightly weaker (bowing point ~1/8 from bridge)
if n % 2 == 0:
amp *= 0.85
# Random phase per harmonic — prevents the "buzzy" coherent-phase sound
phi = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n * t + vibrato * n / hz) + phi)
# Normalize
max_val = numpy.abs(wave).max()
if max_val > 0:
wave /= max_val
# Subtle bow pressure variation — slow amplitude wobble
bow_pressure = 1.0 + 0.03 * numpy.sin(2 * numpy.pi * 3.7 * t)
wave *= bow_pressure
# Gentle lowpass — real instruments don't have infinite bandwidth
cutoff = min(10000, hz * 10)
bl, al = scipy.signal.butter(2, cutoff, btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
return (peak * wave).astype(numpy.int16)
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
"""Apply an ADSR amplitude envelope to a sample array.
@@ -255,7 +375,9 @@ class Envelope(Enum):
PLUCK = (0.002, 0.15, 0.0, 0.1)
PAD = (0.4, 0.2, 0.7, 0.5)
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)
@@ -291,6 +413,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 +427,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,
}
@@ -1362,6 +1489,41 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_highpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
"""Apply a 2nd-order Butterworth highpass filter (12 dB/octave).
Removes low-frequency content below the cutoff. Useful for cleaning
up mud from pads, keeping bass parts from masking each other, or
thinning out a sound.
Args:
samples: Float32 numpy array.
cutoff: Cutoff frequency in Hz.
q: Resonance / Q factor (default 0.707 = Butterworth flat).
sample_rate: Sample rate in Hz.
Returns:
Float32 array with filter applied.
"""
if cutoff <= 0 or cutoff >= sample_rate / 2:
return samples
w0 = 2 * numpy.pi * cutoff / sample_rate
alpha = numpy.sin(w0) / (2 * q)
b0 = (1 + numpy.cos(w0)) / 2
b1 = -(1 + numpy.cos(w0))
b2 = (1 + numpy.cos(w0)) / 2
a0 = 1 + alpha
a1 = -2 * numpy.cos(w0)
a2 = 1 - alpha
b = numpy.array([b0/a0, b1/a0, b2/a0])
a = numpy.array([1.0, a1/a0, a2/a0])
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
sample_rate=SAMPLE_RATE):
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
@@ -1407,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).
@@ -1438,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 → 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),
@@ -1448,6 +1781,12 @@ 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))
if params.get("lowpass", 0) > 0:
samples = _apply_lowpass(samples, params["lowpass"],
params.get("lowpass_q", 0.707))
@@ -1472,11 +1811,18 @@ 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,
"lowpass_q": part.lowpass_q,
"delay_mix": part.delay_mix,
@@ -1620,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:
@@ -1651,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
@@ -1662,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
@@ -1672,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
@@ -1836,12 +2224,19 @@ def render_score(score):
# Named parts — each rendered to own buffer for per-part effects
_pending_sidechain = []
for part in score.parts.values():
if part.is_drums:
continue # drums are rendered separately via _drum_hits
if part.notes:
part_buf = numpy.zeros(total_samples, dtype=numpy.float32)
synth_fn = _resolve_synth(part.synth)
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,
@@ -1857,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()
@@ -1875,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", "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
@@ -1885,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.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
@@ -1950,44 +2358,60 @@ def render_score(score):
DrumSound.MARACAS.value: 0.3,
}
# Drum hits — render to mono sidechain trigger + stereo output
# Render all drum Parts (may be one "drums" or split into kick/snare/hats/etc.)
import random as _drum_rnd
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain
drum_buf = numpy.zeros(total_samples, dtype=numpy.float32) # mono for sidechain (kick only)
drum_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
drum_swing = score.swing
drum_humanize = getattr(score, '_drum_humanize', 0.3) # subtle by default
for hit in score._drum_hits:
pos = hit.position
if drum_swing > 0:
beat_frac = pos % 1.0
if 0.1 < beat_frac < 0.9:
pos += drum_swing * 0.15
if has_tempo_changes:
start = _beat_to_sample(pos, tempo_map)
else:
start = int(pos * samples_per_beat)
# Humanize: random timing jitter + velocity variation
if drum_humanize > 0:
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
start += _drum_rnd.randint(-max_offset, max_offset)
start = max(0, start)
if start >= total_samples or start < 0:
continue
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
vel = hit.velocity
if drum_humanize > 0:
vel_jitter = int(drum_humanize * 10)
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
mono_hit = wave * vel_scale * 0.7
# Mono sidechain trigger (always center)
drum_buf[start:start + hit_len] += mono_hit
# Stereo panned output
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
panned = _pan_to_stereo(mono_hit, pan)
drum_stereo[start:start + hit_len] += panned
drum_humanize = getattr(score, '_drum_humanize', 0.15)
drum_parts = [p for p in score.parts.values() if p.is_drums]
for drum_part in drum_parts:
part_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
for hit in drum_part._drum_hits:
pos = hit.position
if drum_swing > 0:
beat_frac = pos % 1.0
if 0.1 < beat_frac < 0.9:
pos += drum_swing * 0.15
if has_tempo_changes:
start = _beat_to_sample(pos, tempo_map)
else:
start = int(pos * samples_per_beat)
if drum_humanize > 0:
max_offset = int(drum_humanize * 0.03 * samples_per_beat)
start += _drum_rnd.randint(-max_offset, max_offset)
start = max(0, start)
if start >= total_samples or start < 0:
continue
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
vel = hit.velocity
if drum_humanize > 0:
vel_jitter = int(drum_humanize * 10)
vel = max(1, min(127, vel + _drum_rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
mono_hit = wave * vel_scale * 0.7
# Sidechain trigger — kick only
if hit.sound.value == DrumSound.KICK.value:
drum_buf[start:start + hit_len] += mono_hit
# Stereo panned output for this drum Part
pan = _DRUM_PAN.get(hit.sound.value, 0.0)
panned = _pan_to_stereo(mono_hit, pan)
part_stereo[start:start + hit_len] += panned
# Apply this drum Part's effects
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:
for ch in range(2):
part_stereo[:, ch] = _apply_part_effects(part_stereo[:, ch], drum_part)
drum_stereo += part_stereo
# Apply sidechain compression to parts that request it
for part, part_buf in _pending_sidechain:
@@ -2001,7 +2425,7 @@ def render_score(score):
if score.notes:
stereo_buf += _pan_to_stereo(buf, 0.0)
# Drums: stereo panned
# Drums: stereo panned (with effects already applied)
stereo_buf += drum_stereo
# Master bus compressor/limiter (per channel)
+821 -58
View File
@@ -7,6 +7,252 @@ 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",
"fm_ratio": 1.0, "fm_index": 1.5,
"detune": 5, "chorus": 0.1, "chorus_rate": 0.3,
"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, "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": "mallet",
"fm_ratio": 3.0, "fm_index": 5.0,
"lowpass": 8000,
"reverb": 0.3, "reverb_type": "plate",
},
"music_box": {
"synth": "sine", "envelope": "mallet",
"lowpass": 6000,
"reverb": 0.25, "reverb_type": "plate",
},
# ── Strings ──
"violin": {
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 5000,
"humanize": 0.15, "vel_to_filter": 1500,
"noise_mix": 0.03,
},
"viola": {
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 3500,
"humanize": 0.15, "vel_to_filter": 1200,
"noise_mix": 0.03,
},
"cello": {
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 2500,
"humanize": 0.15, "vel_to_filter": 1000,
"noise_mix": 0.02,
},
"contrabass": {
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 1500,
"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, "noise_mix": 0.08,
"vel_to_filter": 2000,
},
"clarinet": {
"synth": "square", "envelope": "strings",
"lowpass": 3000,
"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, "noise_mix": 0.04,
"vel_to_filter": 1000,
},
"bassoon": {
"synth": "saw", "envelope": "strings",
"lowpass": 2000,
"humanize": 0.15, "noise_mix": 0.04,
"vel_to_filter": 800,
},
# ── Brass ──
"trumpet": {
"synth": "saw", "envelope": "bowed",
"detune": 3, "lowpass": 4000, "lowpass_q": 1.1,
"humanize": 0.15, "vel_to_filter": 2000,
"saturation": 0.1,
},
"trombone": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 2500,
"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, "vel_to_filter": 1200,
"saturation": 0.1,
},
"tuba": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 1200,
"humanize": 0.1, "vel_to_filter": 600,
"sub_osc": 0.2,
},
"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, "saturation": 0.3,
"humanize": 0.15,
},
"bass_guitar": {
"synth": "triangle", "envelope": "pluck",
"lowpass": 1000,
"humanize": 0.1, "sub_osc": 0.2,
},
"upright_bass": {
"synth": "triangle", "envelope": "pluck",
"lowpass": 800,
"humanize": 0.15, "saturation": 0.1,
},
"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,
"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": "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": "mallet",
"lowpass": 3000,
},
"xylophone": {
"synth": "fm", "envelope": "pluck",
"fm_ratio": 3.0, "fm_index": 5.0,
"lowpass": 6000,
},
"glockenspiel": {
"synth": "fm", "envelope": "mallet",
"fm_ratio": 4.0, "fm_index": 6.0,
"lowpass": 8000,
"reverb": 0.2,
},
"tubular_bells": {
"synth": "fm", "envelope": "mallet",
"fm_ratio": 2.0, "fm_index": 3.0,
"reverb": 0.4, "reverb_type": "cathedral",
},
}
class Duration(Enum):
"""Note durations in beats (quarter note = 1 beat)."""
@@ -1360,6 +1606,7 @@ class Part:
reverb_type: str = "algorithmic",
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
highpass: float = 0.0, highpass_q: float = 0.707,
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,
@@ -1371,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
@@ -1386,6 +1648,8 @@ class Part:
self.delay_mix = delay
self.delay_time = delay_time
self.delay_feedback = delay_feedback
self.highpass = highpass
self.highpass_q = highpass_q
self.lowpass = lowpass
self.lowpass_q = lowpass_q
self.distortion_mix = distortion
@@ -1398,7 +1662,24 @@ 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
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100) -> "Part":
@@ -1460,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))
@@ -1470,10 +1753,14 @@ 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,
"distortion_drive": self.distortion_drive,
@@ -1690,12 +1977,21 @@ class Part:
return self
@property
def is_drums(self) -> bool:
"""True if this part contains drum hits."""
return len(self._drum_hits) > 0
@property
def total_beats(self) -> float:
return sum(n.beats for n in self.notes)
note_beats = sum(n.beats for n in self.notes)
if self._drum_hits:
drum_beats = self._drum_pattern_beats
return max(note_beats, drum_beats)
return note_beats
def __len__(self):
return len(self.notes)
return len(self.notes) + len(self._drum_hits)
def __iter__(self):
return iter(self.notes)
@@ -1786,34 +2082,110 @@ class Score:
self._drum_humanize = drum_humanize
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
self._tempo_changes: list[tuple[float, int]] = []
self._sections: dict[str, Section] = {}
self._current_section: Optional[Section] = None
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 _ensure_drums_part(self) -> Part:
"""Get or create the drums Part."""
if "drums" not in self.parts:
self.parts["drums"] = Part("drums", synth="sine", volume=0.7)
return self.parts["drums"]
@property
def _drum_hits(self) -> list:
"""Proxy: drum hits live on the drums Part."""
return self._ensure_drums_part()._drum_hits
@property
def _drum_pattern_beats(self) -> float:
"""Proxy: drum pattern beats live on the drums Part."""
return self._ensure_drums_part()._drum_pattern_beats
@_drum_pattern_beats.setter
def _drum_pattern_beats(self, value: float):
self._ensure_drums_part()._drum_pattern_beats = value
@property
def drum_effects(self) -> dict:
"""Proxy: drum effects are just the drums Part's effect settings."""
p = self._ensure_drums_part()
return {
"reverb_mix": p.reverb_mix, "reverb_decay": p.reverb_decay,
"reverb_type": p.reverb_type,
"delay_mix": p.delay_mix, "delay_time": p.delay_time,
"delay_feedback": p.delay_feedback,
"lowpass": p.lowpass, "lowpass_q": p.lowpass_q,
"distortion_mix": p.distortion_mix,
"distortion_drive": p.distortion_drive,
"chorus_mix": p.chorus_mix,
}
def set_drum_effects(self, **kwargs) -> "Score":
"""Set effects on all drum parts.
When drums are split, applies to every drum Part (kick, snare,
hats, etc.). When not split, applies to the single drums Part.
Example::
score.set_drum_effects(reverb=0.2, reverb_type="plate")
"""
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix"}
drum_parts = [p for p in self.parts.values() if p.is_drums]
if not drum_parts:
drum_parts = [self._ensure_drums_part()]
for p in drum_parts:
for k, v in kwargs.items():
attr = param_map.get(k, k)
setattr(p, attr, v)
return self
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,
highpass: float = None, highpass_q: 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,
# 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:
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"``.
@@ -1861,23 +2233,81 @@ 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,
"highpass": highpass, "highpass_q": highpass_q,
"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,
"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:
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.
@@ -1904,46 +2334,85 @@ class Score:
fill_pattern = Pattern.fill(name)
return self.add_pattern(fill_pattern, repeats=1)
def drums(self, preset: str, repeats: int = 4, fill: str = None,
fill_every: int = None) -> "Score":
"""Add a drum pattern by preset name, with optional auto-fills.
Shorthand for ``score.add_pattern(Pattern.preset(name), repeats=n)``.
# Drum sound groups for split mode
_DRUM_GROUPS = {
"kick": {DrumSound.KICK.value},
"snare": {DrumSound.SNARE.value, DrumSound.RIMSHOT.value, DrumSound.CLAP.value},
"hats": {DrumSound.CLOSED_HAT.value, DrumSound.OPEN_HAT.value, DrumSound.PEDAL_HAT.value},
"toms": {DrumSound.LOW_TOM.value, DrumSound.MID_TOM.value, DrumSound.HIGH_TOM.value},
"cymbals": {DrumSound.CRASH.value, DrumSound.RIDE.value, DrumSound.RIDE_BELL.value},
"percussion": {DrumSound.COWBELL.value, DrumSound.CLAVE.value, DrumSound.SHAKER.value,
DrumSound.TAMBOURINE.value, DrumSound.CONGA_HIGH.value, DrumSound.CONGA_LOW.value,
DrumSound.BONGO_HIGH.value, DrumSound.BONGO_LOW.value, DrumSound.TIMBALE_HIGH.value,
DrumSound.TIMBALE_LOW.value, DrumSound.AGOGO_HIGH.value, DrumSound.AGOGO_LOW.value,
DrumSound.GUIRO.value, DrumSound.MARACAS.value},
}
def drums(self, preset: str, repeats: int = 4, fill: str = None,
fill_every: int = None, split: bool = False) -> "Score":
"""Add a drum pattern by preset name, with optional auto-fills.
Args:
preset: Pattern preset name (e.g. ``"bossa nova"``, ``"rock"``).
repeats: Number of times to repeat (default 4).
fill: Optional fill name. When provided, groove bars are
periodically replaced with the named fill pattern.
fill_every: Replace every Nth bar with a fill. If *fill* is
provided but *fill_every* is not, defaults to filling only
the last bar.
fill: Optional fill name.
fill_every: Replace every Nth bar with a fill.
split: If True, create separate Parts for kick, snare, hats,
toms, cymbals, and percussion each with independent
effects. Access via ``score.parts["kick"]``, etc.
Returns:
Self for chaining.
Example::
>>> score = Score("4/4", bpm=140)
>>> score.drums("bossa nova", repeats=4)
>>> score.drums("rock", repeats=4, split=True)
>>> score.parts["snare"].reverb_mix = 0.3
>>> score.parts["hats"].lowpass = 6000
"""
if fill is None:
return self.add_pattern(Pattern.preset(preset), repeats=repeats)
self.add_pattern(Pattern.preset(preset), repeats=repeats)
else:
groove = Pattern.preset(preset)
fill_pattern = Pattern.fill(fill)
if fill_every is None:
fill_every = repeats
for bar in range(1, repeats + 1):
if bar % fill_every == 0:
self.add_pattern(fill_pattern, repeats=1)
else:
self.add_pattern(groove, repeats=1)
groove = Pattern.preset(preset)
fill_pattern = Pattern.fill(fill)
if split:
self._split_drums()
if fill_every is None:
# Fill only the last bar
fill_every = repeats
for bar in range(1, repeats + 1):
if bar % fill_every == 0:
self.add_pattern(fill_pattern, repeats=1)
else:
self.add_pattern(groove, repeats=1)
return self
def _split_drums(self):
"""Move drum hits from the 'drums' Part into separate group Parts."""
drums_part = self.parts.get("drums")
if not drums_part:
return
all_hits = list(drums_part._drum_hits)
pattern_beats = drums_part._drum_pattern_beats
drums_part._drum_hits.clear()
drums_part._drum_pattern_beats = 0.0
for group_name, sound_values in self._DRUM_GROUPS.items():
group_hits = [h for h in all_hits if h.sound.value in sound_values]
if group_hits:
if group_name not in self.parts:
self.parts[group_name] = Part(group_name, synth="sine", volume=0.7)
p = self.parts[group_name]
p._drum_hits.extend(group_hits)
p._drum_pattern_beats = max(p._drum_pattern_beats, pattern_beats)
# Remove empty drums Part
if not drums_part._drum_hits and "drums" in self.parts:
del self.parts["drums"]
def add(self, tone_or_chord, duration=Duration.QUARTER) -> "Score":
"""Add a note to the default (unnamed) part.
@@ -2163,3 +2632,297 @@ class Score:
f.write(b"MTrk")
f.write(struct.pack(">I", len(events)))
f.write(events)
# ── MIDI Import ──────────────────────────────────────────────────────
@classmethod
def from_midi(cls, path, synth="sine", envelope="pluck") -> "Score":
"""Import a Standard MIDI File into a Score.
Reads notes, tempo, and time signature from any Type 0 or Type 1
MIDI file. Each MIDI channel becomes a named Part. Channel 10
(drums) becomes drum hits.
Args:
path: Path to a .mid file.
synth: Default synth for all parts (default "sine").
envelope: Default envelope for all parts (default "pluck").
Returns:
A Score with Parts populated from the MIDI data.
Example::
>>> score = Score.from_midi("song.mid")
>>> score.parts["ch1"].synth = "saw"
>>> score.parts["ch1"].reverb_mix = 0.3
"""
midi = _parse_midi(path)
# Compute BPM from tempo (microseconds per beat)
bpm = round(60_000_000 / midi["tempo"])
# Build time signature string
ts_num, ts_den = midi["time_sig"]
ts_str = f"{ts_num}/{ts_den}"
score = cls(time_signature=ts_str, bpm=bpm)
tpb = midi["ticks_per_beat"]
# Build reverse DrumSound lookup: MIDI note number -> DrumSound
_drum_by_note = {}
for ds in DrumSound:
# First one wins (SHAKER and MARACAS both map to 70)
if ds.value not in _drum_by_note:
_drum_by_note[ds.value] = ds
# Collect note events per channel from all tracks
# Each entry: (abs_tick, 'on'/'off', pitch, velocity)
channel_events: dict[int, list] = {}
for track_events in midi["tracks"]:
for ev in track_events:
abs_tick, etype, channel, data = ev
if etype in ("note_on", "note_off"):
if channel not in channel_events:
channel_events[channel] = []
channel_events[channel].append(ev)
for ch in sorted(channel_events.keys()):
events = sorted(channel_events[ch], key=lambda e: e[0])
is_drum = (ch == 9) # channel 10 in 0-indexed
if is_drum:
# Convert to _Hit objects
for ev in events:
abs_tick, etype, channel, data = ev
if etype == "note_on" and data["velocity"] > 0:
pitch = data["pitch"]
beat_pos = abs_tick / tpb
velocity = data["velocity"]
drum_sound = _drum_by_note.get(pitch)
if drum_sound is not None:
score._drum_hits.append(
_Hit(drum_sound, beat_pos, velocity))
else:
# Melodic channel: pair note_on/note_off to get durations
active: dict[int, tuple] = {} # pitch -> (on_tick, velocity)
completed = [] # (beat_pos, pitch, velocity, duration_beats)
for ev in events:
abs_tick, etype, channel_num, data = ev
pitch = data["pitch"]
vel = data["velocity"]
if etype == "note_on" and vel > 0:
active[pitch] = (abs_tick, vel)
else:
# note_off or note_on with vel=0
if pitch in active:
on_tick, on_vel = active.pop(pitch)
dur_ticks = abs_tick - on_tick
if dur_ticks > 0:
beat_pos = on_tick / tpb
dur_beats = dur_ticks / tpb
completed.append(
(beat_pos, pitch, on_vel, dur_beats))
if not completed:
continue
completed.sort(key=lambda x: (x[0], x[1]))
part_name = f"ch{ch + 1}"
part = score.part(part_name, synth=synth, envelope=envelope)
# Walk through notes, inserting rests for gaps
cursor = 0.0 # current beat position
for beat_pos, pitch, velocity, dur_beats in completed:
gap = beat_pos - cursor
if gap > 0.001: # tolerance for floating point
part.notes.append(Rest(_RawDuration(gap)))
from .tones import Tone
tone = Tone.from_midi(pitch)
part.notes.append(
Note(tone=tone, duration=_RawDuration(dur_beats),
velocity=velocity))
cursor = beat_pos + dur_beats
return score
# ── MIDI File Parser ─────────────────────────────────────────────────────
def _read_vlq(data, pos):
"""Read a MIDI variable-length quantity.
Returns:
(value, new_pos) tuple.
"""
value = 0
while True:
byte = data[pos]
value = (value << 7) | (byte & 0x7F)
pos += 1
if not (byte & 0x80):
break
return value, pos
def _parse_midi(path):
"""Parse a Standard MIDI File (Type 0 or Type 1).
Returns a dict with:
- ticks_per_beat: int
- tempo: int (microseconds per beat, default 500000 = 120 bpm)
- time_sig: (numerator, denominator)
- tracks: list of lists of events
Each event is a tuple: (abs_tick, type_str, channel, data_dict)
where type_str is 'note_on' or 'note_off' and data_dict has
'pitch' and 'velocity' keys.
"""
with open(path, "rb") as f:
raw = f.read()
pos = 0
# ── Header chunk ──
if raw[pos:pos + 4] != b"MThd":
raise ValueError("Not a MIDI file (missing MThd header)")
pos += 4
header_len = struct.unpack(">I", raw[pos:pos + 4])[0]
pos += 4
fmt, num_tracks, ticks_per_beat = struct.unpack(">HHH", raw[pos:pos + 6])
pos += header_len # usually 6
if fmt > 1:
raise ValueError(f"MIDI format {fmt} not supported (only 0 and 1)")
tempo = 500000 # default 120 BPM
time_sig = (4, 4) # default
tracks = []
# ── Track chunks ──
for _ in range(num_tracks):
if raw[pos:pos + 4] != b"MTrk":
raise ValueError("Expected MTrk chunk")
pos += 4
track_len = struct.unpack(">I", raw[pos:pos + 4])[0]
pos += 4
track_end = pos + track_len
track_events = []
abs_tick = 0
running_status = 0
while pos < track_end:
# Read delta time
delta, pos = _read_vlq(raw, pos)
abs_tick += delta
# Read event
byte = raw[pos]
if byte == 0xFF:
# Meta event
pos += 1
meta_type = raw[pos]
pos += 1
meta_len, pos = _read_vlq(raw, pos)
meta_data = raw[pos:pos + meta_len]
pos += meta_len
if meta_type == 0x51 and meta_len == 3:
# Tempo: 3 bytes, microseconds per beat
tempo = (meta_data[0] << 16) | (meta_data[1] << 8) | meta_data[2]
elif meta_type == 0x58 and meta_len >= 2:
# Time signature: nn dd cc bb
ts_num = meta_data[0]
ts_den = 2 ** meta_data[1]
time_sig = (ts_num, ts_den)
# End of track (0x2F) and others: just skip
elif byte == 0xF0 or byte == 0xF7:
# SysEx event
pos += 1
sysex_len, pos = _read_vlq(raw, pos)
pos += sysex_len
elif byte & 0x80:
# Channel message with status byte
status = byte
running_status = status
pos += 1
msg_type = status & 0xF0
channel = status & 0x0F
if msg_type == 0x90:
# Note On
pitch = raw[pos]; pos += 1
vel = raw[pos]; pos += 1
if vel == 0:
track_events.append(
(abs_tick, "note_off", channel,
{"pitch": pitch, "velocity": 0}))
else:
track_events.append(
(abs_tick, "note_on", channel,
{"pitch": pitch, "velocity": vel}))
elif msg_type == 0x80:
# Note Off
pitch = raw[pos]; pos += 1
vel = raw[pos]; pos += 1
track_events.append(
(abs_tick, "note_off", channel,
{"pitch": pitch, "velocity": vel}))
elif msg_type in (0xA0, 0xB0, 0xE0):
# Aftertouch, Control Change, Pitch Bend: 2 data bytes
pos += 2
elif msg_type in (0xC0, 0xD0):
# Program Change, Channel Pressure: 1 data byte
pos += 1
else:
# Unknown channel message, skip 2 bytes as safe default
pos += 2
else:
# Running status (no status byte, reuse previous)
if running_status == 0:
# No previous status, skip byte
pos += 1
continue
msg_type = running_status & 0xF0
channel = running_status & 0x0F
if msg_type == 0x90:
pitch = raw[pos]; pos += 1
vel = raw[pos]; pos += 1
if vel == 0:
track_events.append(
(abs_tick, "note_off", channel,
{"pitch": pitch, "velocity": 0}))
else:
track_events.append(
(abs_tick, "note_on", channel,
{"pitch": pitch, "velocity": vel}))
elif msg_type == 0x80:
pitch = raw[pos]; pos += 1
vel = raw[pos]; pos += 1
track_events.append(
(abs_tick, "note_off", channel,
{"pitch": pitch, "velocity": vel}))
elif msg_type in (0xA0, 0xB0, 0xE0):
pos += 2
elif msg_type in (0xC0, 0xD0):
pos += 1
else:
pos += 2
tracks.append(track_events)
return {
"ticks_per_beat": ticks_per_beat,
"tempo": tempo,
"time_sig": time_sig,
"tracks": tracks,
}
+204 -2
View File
@@ -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) == 8
assert len(Envelope) == 10
for e in Envelope:
a, d, s, r = e.value
assert a >= 0
@@ -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
@@ -6324,3 +6324,205 @@ def test_recommend_fitness_descending():
results = Scale.recommend("C", "D", "E", "F#", "G")
for i in range(len(results) - 1):
assert results[i][2] >= results[i + 1][2]
# ── MIDI Import (Score.from_midi) ────────────────────────────────────────
def test_from_midi_basic(tmp_path):
"""Create a simple MIDI with save_midi, re-import with from_midi."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.add(Tone.from_string("G4"), Duration.QUARTER)
midi_path = str(tmp_path / "basic.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
# Should have at least one part with notes
assert len(imported.parts) >= 1
total_notes = sum(
1 for p in imported.parts.values()
for n in p.notes if n.tone is not None
)
assert total_notes == 3
def test_from_midi_tempo(tmp_path):
"""Verify BPM is preserved through save/import."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=140)
score.add(Tone.from_string("A4"), Duration.QUARTER)
midi_path = str(tmp_path / "tempo.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.bpm == 140
def test_from_midi_roundtrip(tmp_path):
"""Save a progression as MIDI, import it, check parts/notes."""
from pytheory import Score, Duration, Tone
score = Score("3/4", bpm=100)
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("D4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.QUARTER)
score.add(Tone.from_string("F4"), Duration.QUARTER)
midi_path = str(tmp_path / "roundtrip.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.bpm == 100
assert imported.time_signature == TimeSignature(3, 4)
total_notes = sum(
1 for p in imported.parts.values()
for n in p.notes if n.tone is not None
)
assert total_notes == 4
def test_from_midi_velocity(tmp_path):
"""Verify velocity is preserved through save/import."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
# save_midi uses a fixed velocity param, default 100
score.add(Tone.from_string("C4"), Duration.QUARTER)
score.add(Tone.from_string("E4"), Duration.HALF)
midi_path = str(tmp_path / "velocity.mid")
score.save_midi(midi_path, velocity=80)
imported = Score.from_midi(midi_path)
sounding = [
n for p in imported.parts.values()
for n in p.notes if n.tone is not None
]
assert len(sounding) == 2
for n in sounding:
assert n.velocity == 80
def test_from_midi_drums(tmp_path):
"""Verify drum hits survive a roundtrip."""
from pytheory import Score, Pattern
score = Score("4/4", bpm=120)
score.add_pattern(Pattern.preset("rock"), repeats=1)
midi_path = str(tmp_path / "drums.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert len(imported._drum_hits) > 0
def test_from_midi_time_signature(tmp_path):
"""Verify time signature is preserved."""
from pytheory import Score, Duration, Tone
score = Score("6/8", bpm=150)
score.add(Tone.from_string("C4"), Duration.QUARTER)
midi_path = str(tmp_path / "timesig.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
assert imported.time_signature == TimeSignature(6, 8)
assert imported.bpm == 150
def test_from_midi_note_durations(tmp_path):
"""Verify note durations are approximately preserved."""
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4"), Duration.WHOLE) # 4 beats
score.add(Tone.from_string("E4"), Duration.HALF) # 2 beats
midi_path = str(tmp_path / "durations.mid")
score.save_midi(midi_path)
imported = Score.from_midi(midi_path)
sounding = [
n for p in imported.parts.values()
for n in p.notes if n.tone is not None
]
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 == "bowed"
assert p.humanize == 0.15
assert p.lowpass == 5000
assert p.detune == 2
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 == "mallet"
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"
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.28.2"
version = "0.32.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },