Compare commits

..

12 Commits

Author SHA1 Message Date
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
13 changed files with 1372 additions and 182 deletions
+33
View File
@@ -2,6 +2,39 @@
All notable changes to PyTheory are documented here.
## 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
+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
+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``,
+1 -1
View File
@@ -83,7 +83,7 @@ What's Inside
lowpass (with resonance), distortion, chorus, 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
+167 -81
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)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.28.3"
version = "0.30.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.3"
__version__ = "0.30.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",
]
+147 -36
View File
@@ -190,6 +190,97 @@ def pwm_fast_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return pwm_wave(hz, peak, n_samples, lfo_rate=3.0)
def pluck_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Karplus-Strong plucked string synthesis.
A burst of noise is fed into a short delay line with feedback —
the delay length determines the pitch, and the feedback filter
determines the decay. This is how every physical modeling synth
since 1983 does plucked strings. It sounds genuinely like a real
guitar, harp, or koto — not a synth approximation.
The algorithm: fill a buffer with random noise the length of one
period, then repeatedly average adjacent samples. The averaging
acts as a lowpass filter, gradually removing high harmonics —
exactly what a real vibrating string does as energy dissipates.
"""
period = int(SAMPLE_RATE / hz)
if period < 2:
period = 2
# Initial noise burst — the "pluck"
buf = numpy.random.uniform(-1.0, 1.0, period).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_samples):
out[i] = buf[i % period]
# Averaging filter: smooth adjacent samples (Karplus-Strong)
buf[i % period] = 0.5 * (buf[i % period] + buf[(i + 1) % period]) * 0.998
return (peak * out).astype(numpy.int16)
def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Hammond organ — additive synthesis with drawbar harmonics.
A real Hammond B3 has 9 drawbars that mix sine waves at different
harmonics. This models the classic "full" registration with all
drawbars pulled: fundamental, 2nd, 3rd, 4th, 5th, 6th, and 8th
harmonics at musical levels.
The result is warm, rich, and unmistakably organ — somewhere
between a sine wave and a square wave, with that characteristic
hollow roundness.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Drawbar levels (inspired by 888800000 — full even harmonics)
wave = (numpy.sin(2 * numpy.pi * hz * t) * 1.0 + # 16' fundamental
numpy.sin(2 * numpy.pi * hz * 2 * t) * 0.8 + # 8'
numpy.sin(2 * numpy.pi * hz * 3 * t) * 0.6 + # 5 1/3'
numpy.sin(2 * numpy.pi * hz * 4 * t) * 0.5 + # 4'
numpy.sin(2 * numpy.pi * hz * 5 * t) * 0.3 + # 2 2/3'
numpy.sin(2 * numpy.pi * hz * 6 * t) * 0.25 + # 2'
numpy.sin(2 * numpy.pi * hz * 8 * t) * 0.15) # 1 3/5'
wave /= 3.5 # normalize
return (peak * wave).astype(numpy.int16)
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""String ensemble — filtered saw with body resonance formants.
Goes beyond raw sawtooth by modeling the resonant body of a
stringed instrument. Two formant peaks (at ~500 Hz and ~1500 Hz)
shape the spectrum the way a violin or cello body does — boosting
certain frequencies and cutting others.
The result is warmer and more "wooden" than a raw saw wave,
with the characteristic nasal quality of bowed strings.
"""
# Base: sawtooth (all harmonics, like a bowed string)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=1)
wave = numpy.resize(onecycle, (n_samples,)).astype(numpy.float64)
# Body resonance formants — two bandpass peaks
# Formant 1: ~500 Hz (body resonance)
f1 = 500
bw1 = 200
b1, a1 = scipy.signal.butter(2, [max(20, f1 - bw1), f1 + bw1],
btype='band', fs=SAMPLE_RATE)
formant1 = scipy.signal.lfilter(b1, a1, wave)
# Formant 2: ~1500 Hz (bridge/top plate)
f2 = 1500
bw2 = 400
b2, a2 = scipy.signal.butter(2, [f2 - bw2, f2 + bw2],
btype='band', fs=SAMPLE_RATE)
formant2 = scipy.signal.lfilter(b2, a2, wave)
# Mix: original (attenuated) + formants
mixed = wave * 0.3 + formant1 * 0.4 + formant2 * 0.3
return (peak * mixed).astype(numpy.int16)
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
"""Apply an ADSR amplitude envelope to a sample array.
@@ -291,6 +382,9 @@ class Synth(Enum):
SUPERSAW = "supersaw"
PWM_SLOW = "pwm_slow"
PWM_FAST = "pwm_fast"
PLUCK = "pluck_synth"
ORGAN = "organ_synth"
STRINGS = "strings_synth"
def __call__(self, hz, **kwargs):
"""Make Synth members callable — dispatches to the wave function."""
@@ -302,6 +396,8 @@ _SYNTH_FUNCTIONS = {
"square": square_wave, "pulse": pulse_wave, "fm": fm_wave,
"noise": noise_wave, "supersaw": supersaw_wave,
"pwm_slow": pwm_slow_wave, "pwm_fast": pwm_fast_wave,
"pluck_synth": pluck_wave, "organ_synth": organ_wave,
"strings_synth": strings_wave,
}
@@ -1836,6 +1932,8 @@ 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)
@@ -1950,44 +2048,57 @@ 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.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 +2112,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)
+721 -57
View File
@@ -7,6 +7,217 @@ from enum import Enum
from typing import Optional
# ── Instrument presets ────────────────────────────────────────────────────────
# Predefined combinations of synth, envelope, effects, and parameters that
# approximate real instruments. Used by ``Score.part(instrument=...)``.
INSTRUMENTS = {
# ── Keys ──
"piano": {
"synth": "fm", "envelope": "piano",
"detune": 5, "chorus": 0.1, "chorus_rate": 0.3,
"lowpass": 6000,
},
"electric_piano": { # Rhodes/Wurlitzer
"synth": "fm", "envelope": "piano",
"detune": 6, "chorus": 0.2, "chorus_rate": 1.0,
"lowpass": 4000,
},
"organ": {
"synth": "organ_synth", "envelope": "organ",
"chorus": 0.2, "chorus_rate": 5.5,
"lowpass": 5000,
},
"harpsichord": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 3500,
},
"celesta": {
"synth": "fm", "envelope": "bell",
"lowpass": 8000,
"reverb": 0.3, "reverb_type": "plate",
},
"music_box": {
"synth": "sine", "envelope": "bell",
"lowpass": 6000,
"reverb": 0.25, "reverb_type": "plate",
},
# ── Strings ──
"violin": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 5000,
"humanize": 0.15,
},
"viola": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 3500,
"humanize": 0.15,
},
"cello": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 2500,
"humanize": 0.15,
},
"contrabass": {
"synth": "strings_synth", "envelope": "strings",
"detune": 3, "lowpass": 1500,
"humanize": 0.1,
},
"string_ensemble": {
"synth": "strings_synth", "envelope": "pad",
"detune": 12, "spread": 0.6,
"chorus": 0.2, "chorus_rate": 0.5,
"lowpass": 4000,
},
# ── Woodwinds ──
"flute": {
"synth": "sine", "envelope": "strings",
"lowpass": 4000,
"humanize": 0.2,
},
"clarinet": {
"synth": "square", "envelope": "strings",
"lowpass": 3000,
"humanize": 0.15,
},
"oboe": {
"synth": "saw", "envelope": "strings",
"lowpass": 3500, "lowpass_q": 1.2,
"humanize": 0.15,
},
"bassoon": {
"synth": "saw", "envelope": "strings",
"lowpass": 2000,
"humanize": 0.15,
},
# ── Brass ──
"trumpet": {
"synth": "saw", "envelope": "pluck",
"detune": 3, "lowpass": 4000, "lowpass_q": 1.1,
"humanize": 0.15,
},
"trombone": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 2500,
"humanize": 0.15,
},
"french_horn": {
"synth": "saw", "envelope": "strings",
"detune": 4, "lowpass": 2000,
"chorus": 0.1,
"humanize": 0.15,
},
"tuba": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 1200,
"humanize": 0.1,
},
"brass_ensemble": {
"synth": "saw", "envelope": "strings",
"detune": 10, "spread": 0.4,
"lowpass": 3000,
"chorus": 0.15,
},
# ── Plucked ──
"acoustic_guitar": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 4000,
"humanize": 0.2,
},
"electric_guitar": {
"synth": "saw", "envelope": "pluck",
"detune": 5, "lowpass": 3500,
"humanize": 0.15,
},
"distorted_guitar": {
"synth": "saw", "envelope": "pluck",
"detune": 8, "distortion": 0.6, "distortion_drive": 5.0,
"lowpass": 3000,
"humanize": 0.15,
},
"bass_guitar": {
"synth": "triangle", "envelope": "pluck",
"lowpass": 1000,
"humanize": 0.1,
},
"upright_bass": {
"synth": "sine", "envelope": "pluck",
"lowpass": 800,
"humanize": 0.15,
},
"harp": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 5000,
"reverb": 0.3, "reverb_type": "plate",
},
"sitar": {
"synth": "saw", "envelope": "pluck",
"detune": 12, "lowpass": 3000, "lowpass_q": 1.5,
"humanize": 0.2,
},
"koto": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 4000,
"reverb": 0.2,
},
# ── Synth presets ──
"synth_lead": {
"synth": "saw", "envelope": "pluck",
"detune": 8, "lowpass": 3000,
"delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3,
},
"synth_pad": {
"synth": "supersaw", "envelope": "pad",
"detune": 12, "spread": 0.6,
"chorus": 0.2,
},
"synth_bass": {
"synth": "saw", "envelope": "pluck",
"lowpass": 800, "lowpass_q": 1.3,
},
"acid_bass": {
"synth": "saw", "envelope": "pad",
"legato": True, "glide": 0.03,
"distortion": 0.7, "distortion_drive": 8.0,
"lowpass": 800, "lowpass_q": 5.0,
},
"808_bass": {
"synth": "sine", "envelope": "pluck",
"distortion": 0.4, "distortion_drive": 2.5,
"lowpass": 200, "lowpass_q": 1.5,
},
# ── Percussion / Mallet ──
"vibraphone": {
"synth": "fm", "envelope": "bell",
"lowpass": 5000,
"reverb": 0.3, "reverb_type": "plate",
},
"marimba": {
"synth": "sine", "envelope": "pluck",
"lowpass": 3000,
},
"xylophone": {
"synth": "fm", "envelope": "pluck",
"lowpass": 6000,
},
"glockenspiel": {
"synth": "fm", "envelope": "bell",
"lowpass": 8000,
"reverb": 0.2,
},
"tubular_bells": {
"synth": "fm", "envelope": "bell",
"reverb": 0.4, "reverb_type": "cathedral",
},
}
class Duration(Enum):
"""Note durations in beats (quarter note = 1 beat)."""
@@ -1399,6 +1610,8 @@ class Part:
self.pan = pan
self.spread = spread
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":
@@ -1690,12 +1903,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 +2008,94 @@ 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,
lowpass: float = None, lowpass_q: float = None,
distortion: float = None, distortion_drive: float = None,
legato: bool = None, glide: float = None,
chorus: float = None, chorus_rate: float = None,
chorus_depth: float = None,
swing: Optional[float] = None,
humanize: float = 0.0,
sidechain: float = 0.0,
sidechain_release: float = 0.1,
detune: float = 0.0,
pan: float = 0.0,
spread: float = 0.0) -> Part:
humanize: float = None,
sidechain: float = None,
sidechain_release: float = None,
detune: float = None,
pan: float = None,
spread: float = None) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
name: Part name (e.g. ``"lead"``, ``"bass"``, ``"pads"``).
instrument: Instrument preset name (e.g. ``"piano"``,
``"violin"``, ``"808_bass"``). See :data:`INSTRUMENTS`
for the full list. When set, the preset's synth, envelope,
and effects are used as defaults; any explicit keyword
argument still overrides the preset value.
synth: Waveform ``"sine"``, ``"saw"``, ``"triangle"``,
``"square"``, ``"pulse"``, ``"fm"``, ``"noise"``,
``"supersaw"``, ``"pwm_slow"``, ``"pwm_fast"``.
@@ -1861,23 +2143,72 @@ class Score:
lead = score.part("lead", synth="saw", envelope="pluck",
reverb=0.3, delay=0.25, lowpass=3000)
# Or use an instrument preset:
piano = score.part("keys", instrument="piano")
"""
p = Part(name, synth=synth, envelope=envelope, volume=volume,
reverb=reverb, reverb_decay=reverb_decay,
reverb_type=reverb_type,
delay=delay, delay_time=delay_time,
delay_feedback=delay_feedback,
lowpass=lowpass, lowpass_q=lowpass_q,
distortion=distortion, distortion_drive=distortion_drive,
legato=legato, glide=glide,
chorus=chorus, chorus_rate=chorus_rate,
chorus_depth=chorus_depth,
swing=swing, humanize=humanize,
sidechain=sidechain, sidechain_release=sidechain_release,
detune=detune, pan=pan, spread=spread)
# Default values for all Part parameters.
_defaults = {
"synth": "sine", "envelope": "piano", "volume": 0.5,
"reverb": 0.0, "reverb_decay": 1.0, "reverb_type": "algorithmic",
"delay": 0.0, "delay_time": 0.375, "delay_feedback": 0.4,
"lowpass": 0.0, "lowpass_q": 0.707,
"distortion": 0.0, "distortion_drive": 3.0,
"legato": False, "glide": 0.0,
"chorus": 0.0, "chorus_rate": 1.5, "chorus_depth": 0.003,
"swing": None, "humanize": 0.0,
"sidechain": 0.0, "sidechain_release": 0.1,
"detune": 0.0, "pan": 0.0, "spread": 0.0,
}
# If an instrument preset is specified, layer it on top of defaults.
if instrument is not None:
preset = INSTRUMENTS.get(instrument)
if preset is None:
raise ValueError(
f"Unknown instrument: {instrument!r}. "
f"Use Score.list_instruments() to see available presets."
)
_defaults.update(preset)
# Collect explicitly-provided kwargs (non-None) and override defaults.
explicit = {}
_locals = {
"synth": synth, "envelope": envelope, "volume": volume,
"reverb": reverb, "reverb_decay": reverb_decay,
"reverb_type": reverb_type,
"delay": delay, "delay_time": delay_time,
"delay_feedback": delay_feedback,
"lowpass": lowpass, "lowpass_q": lowpass_q,
"distortion": distortion, "distortion_drive": distortion_drive,
"legato": legato, "glide": glide,
"chorus": chorus, "chorus_rate": chorus_rate,
"chorus_depth": chorus_depth,
"swing": swing, "humanize": humanize,
"sidechain": sidechain, "sidechain_release": sidechain_release,
"detune": detune, "pan": pan, "spread": spread,
}
for k, v in _locals.items():
if v is not None:
explicit[k] = v
merged = {**_defaults, **explicit}
p = Part(name, **merged)
self.parts[name] = p
return p
@classmethod
def list_instruments(cls) -> list:
"""Return a sorted list of available instrument preset names.
Example::
Score.list_instruments()
# ['808_bass', 'acid_bass', 'acoustic_guitar', ...]
"""
return sorted(INSTRUMENTS.keys())
def add_pattern(self, pattern, repeats: int = 1) -> "Score":
"""Add a drum pattern to this score.
@@ -1904,46 +2235,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 +2533,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,
}
+202 -1
View File
@@ -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,204 @@ 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 == "strings"
assert p.humanize == 0.15
assert p.lowpass == 5000
def test_instrument_override():
from pytheory import Score
score = Score("4/4", bpm=120)
# Explicit synth overrides the preset's "fm"
p = score.part("p", instrument="piano", synth="saw")
assert p.synth == "saw"
# Other preset values still apply
assert p.envelope == "piano"
assert p.detune == 5
def test_instrument_unknown_raises():
from pytheory import Score
score = Score("4/4", bpm=120)
with pytest.raises(ValueError, match="Unknown instrument"):
score.part("x", instrument="kazoo")
def test_list_instruments():
from pytheory import Score, INSTRUMENTS
result = Score.list_instruments()
assert isinstance(result, list)
assert result == sorted(result)
assert "piano" in result
assert "violin" in result
assert "808_bass" in result
assert len(result) == len(INSTRUMENTS)
def test_instrument_effects():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("c", instrument="celesta")
assert p.reverb_mix == 0.3
assert p.reverb_type == "plate"
assert p.synth == "fm"
assert p.envelope == "bell"
def test_instrument_808_bass():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("b", instrument="808_bass")
assert p.distortion_mix == 0.4
assert p.distortion_drive == 2.5
assert p.lowpass == 200
assert p.lowpass_q == 1.5
assert p.synth == "sine"
assert p.envelope == "pluck"
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.28.3"
version = "0.30.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },