Compare commits

...

9 Commits

Author SHA1 Message Date
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
13 changed files with 863 additions and 138 deletions
+23
View File
@@ -2,6 +2,29 @@
All notable changes to PyTheory are documented here.
## 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
+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.2"
version = "0.29.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.28.2"
__version__ = "0.29.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+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": ("triangle", "strings", 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": ("triangle", "strings", 0.25, 0.15),
"pad": ("pwm_slow", "pad", -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": ("triangle", "pluck", 0.3, 0.2),
"pad": ("sine", "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)
+13 -1
View File
@@ -1836,6 +1836,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)
@@ -1989,6 +1991,16 @@ def render_score(score):
panned = _pan_to_stereo(mono_hit, pan)
drum_stereo[start:start + hit_len] += panned
# Apply drum Part effects through the same pipeline as any other Part
drums_part = score.parts.get("drums")
if drums_part:
has_drum_fx = (drums_part.lowpass > 0 or drums_part.delay_mix > 0
or drums_part.reverb_mix > 0 or drums_part.distortion_mix > 0
or drums_part.chorus_mix > 0)
if has_drum_fx:
for ch in range(2):
drum_stereo[:, ch] = _apply_part_effects(drum_stereo[:, ch], drums_part)
# Apply sidechain compression to parts that request it
for part, part_buf in _pending_sidechain:
part_buf = _apply_sidechain(
@@ -2001,7 +2013,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)
+361 -4
View File
@@ -1399,6 +1399,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 +1692,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,12 +1797,63 @@ 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 _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 the drum bus.
The drums Part is a real Part set effects the same way
you would on any other part.
Example::
score.set_drum_effects(reverb=0.2, reverb_type="plate")
"""
p = self._ensure_drums_part()
param_map = {"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix"}
for k, v in kwargs.items():
attr = param_map.get(k, k)
setattr(p, attr, v)
return self
def part(self, name: str, *, synth: str = "sine",
envelope: str = "piano", volume: float = 0.5,
reverb: float = 0.0, reverb_decay: float = 1.0,
@@ -1904,6 +1966,7 @@ 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.
@@ -2163,3 +2226,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,
}
+127
View File
@@ -6324,3 +6324,130 @@ 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
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.28.2"
version = "0.29.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },