mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b29b33524f | |||
| 25f25c1f23 | |||
| 3f1d632285 | |||
| 1938037458 | |||
| f7c05e1b31 | |||
| c375785bb9 | |||
| 9ebd54b7fc | |||
| ce68ad8f19 | |||
| f402e76480 | |||
| 4d3c7e0d6c |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-36
@@ -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)
|
||||
@@ -1950,44 +1952,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 +2016,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)
|
||||
|
||||
+425
-27
@@ -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,66 @@ 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 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, *, synth: str = "sine",
|
||||
envelope: str = "piano", volume: float = 0.5,
|
||||
reverb: float = 0.0, reverb_decay: float = 1.0,
|
||||
@@ -1904,46 +1969,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 +2267,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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user