mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0911947971 | |||
| c2f748d5f3 | |||
| 7a6942c8e4 | |||
| db7fabf985 | |||
| a07b7e7cea | |||
| 7245cd0e51 | |||
| 9e85a48d0e | |||
| 95b7bd830c | |||
| 150c57ed3d | |||
| d35d2b12f3 | |||
| 2084473788 | |||
| 970c730012 | |||
| 5f94e1939b | |||
| b649b2e659 | |||
| ed6ba2ab9f | |||
| fd317f9cfd | |||
| c57e29fe28 | |||
| 938024bfa2 | |||
| acc92f9a60 | |||
| 0d340dad30 | |||
| 1762500108 | |||
| ac2801d07d | |||
| c49ec27b1b | |||
| 5f4070c4a7 | |||
| 8735393aaa | |||
| 12f15d5138 | |||
| 20fc5e40b8 | |||
| 91d16595b7 | |||
| 54659d39b1 | |||
| 7cb2c166f9 | |||
| ba2038d7ff | |||
| 198fded20e | |||
| 51159e309a | |||
| 54df949089 | |||
| 30c70da468 | |||
| c633bd6f61 | |||
| bc652c37d0 | |||
| 417d9a6908 | |||
| f7d8f08446 | |||
| b8d1fe5e81 | |||
| 2612444146 | |||
| 48a954d063 | |||
| 8a07be23e6 | |||
| d771117d5c | |||
| 1ae9404f07 | |||
| 2e5b18de2e | |||
| 248594fb21 | |||
| 3ce890c54c | |||
| 499c49b6eb | |||
| f85504b456 | |||
| d0624f8b78 | |||
| fb37a7c27b | |||
| fb36e75a42 | |||
| 81b54d2394 | |||
| fa6a3090cb | |||
| 58286ddb69 | |||
| 6d137be9f5 | |||
| 383802a1e1 | |||
| 7f2aeb2395 | |||
| 16b4c7d1fa | |||
| b9ee5c9cde | |||
| 7375d58209 | |||
| 6316a6c910 | |||
| 237cfe171c | |||
| 1751f97617 | |||
| 943a12b3bb | |||
| 04d2de3e70 | |||
| ead42751ef | |||
| 8dee0d00d8 | |||
| de112e0d9f | |||
| f469ad90f8 | |||
| bab7f39304 | |||
| 62557ba534 | |||
| 8b50a9c325 | |||
| 7e9caac70b | |||
| a0756b3172 | |||
| e3dd706032 | |||
| 9b412906bc | |||
| 54e0421997 | |||
| 109343ad30 | |||
| 28e84de566 | |||
| d353d64298 | |||
| 7ee02e7ed2 | |||
| a5c9a46eb2 | |||
| f9c63ec360 | |||
| b9e88b77d8 | |||
| 1910b09132 | |||
| 0c5287450b | |||
| 5ac1873d83 | |||
| 9fafca2b08 | |||
| af044f68ca | |||
| 60f697f846 | |||
| 7d678e364e | |||
| 3a8d829010 | |||
| 2a67906937 | |||
| b9dcad0454 | |||
| db9726168a | |||
| 26af923789 | |||
| 72e18a9bec | |||
| 7d56ed7a2c | |||
| 6efa4f18ce | |||
| 06fc4cabb7 | |||
| d3a93c18b3 | |||
| 0e10359236 | |||
| df00c3436d |
@@ -2,6 +2,86 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 0.40.2
|
||||
|
||||
- **Master compressor dialed back** — threshold raised from 0.5 to 0.7,
|
||||
makeup gain capped at 3x. Sparse arrangements no longer get
|
||||
over-amplified to clipping.
|
||||
|
||||
## 0.40.1
|
||||
|
||||
- **Singing bowl synth** — two variants: strike (mallet hit with chirp
|
||||
and long decay) and ring (rim-rubbed sustained tone with slow build).
|
||||
Inharmonic partials beat against near-degenerate mode pairs for
|
||||
authentic Himalayan bowl shimmer.
|
||||
- `singing_bowl` and `singing_bowl_ring` instrument presets
|
||||
- Audio demos in docs for both variants
|
||||
|
||||
## 0.40.0
|
||||
|
||||
- **Rhodes electric piano synth** — tine + tonebar + electromagnetic
|
||||
pickup model. `electric_piano` preset now uses dedicated `rhodes_synth`
|
||||
instead of FM
|
||||
- **73 audio demos in docs** — every synth, every drum pattern, every
|
||||
code example with `play_score()` now has an embedded audio player
|
||||
- Idiomatic demos: harp arpeggiates, guitars strum, cello bows, sitar
|
||||
drones, strings use ensemble
|
||||
- Trailing silence trimming on all audio exports
|
||||
- Raw waveform demos (no envelope) for classic waveforms
|
||||
|
||||
## 0.39.3
|
||||
|
||||
- **33 audio samples in documentation** — every `play_score()` example
|
||||
now has an embedded stereo audio player. Covers quickstart, sequencing,
|
||||
drums (all world percussion), playback, and cookbook.
|
||||
- **`docs/generate_audio.py`** — renders all doc examples to WAV
|
||||
- Numpy vectorization: cached time arrays, decay envelopes, drum hits;
|
||||
vectorized piano harmonic synthesis
|
||||
- Fixed acid legato example (removed pad envelope, added proper 303 recipe)
|
||||
|
||||
## 0.39.2
|
||||
|
||||
- **Marching percussion** — snare, rimshot, and stick click sounds with
|
||||
high-tension kevlar synthesis and woody-metallic rimshot crack
|
||||
- **`Part.flam()`**, **`Part.diddle()`**, **`Part.cheese()`** — marching
|
||||
rudiment methods for any drum sound
|
||||
- **`Part ensemble=`** — duplicate voices with per-player timing tendencies
|
||||
and micro pitch drift. Works on any Part (drumline, string section, choir).
|
||||
`ensemble=20` for a full snare line, `ensemble=4` for a string quartet.
|
||||
- **Sympathetic resonance** — marching snare buzz builds up with repeated
|
||||
hits, decays during rests (like real snare wire response)
|
||||
- **4 marching patterns** — march, cadence, paradiddle, roll
|
||||
- **Chakradar tabla pattern** — 16-beat tihai of tihais composition
|
||||
- Song #32: Snare Cadence (flams, diddles, cheese, triplets, 32nds)
|
||||
|
||||
## 0.39.1
|
||||
|
||||
- **Chakradar tabla pattern** — 16-beat tihai of tihais composition with
|
||||
3 escalating phrases and a crescendo triplet finale
|
||||
|
||||
## 0.39.0
|
||||
|
||||
- **Dropped `numeral` dependency** — Roman numeral helpers inlined,
|
||||
reducing supply chain surface (#47)
|
||||
- **`Part.ramp()`** — smooth parameter automation with 4 interpolation
|
||||
curves (linear, ease_in, ease_out, ease_in_out)
|
||||
- **Articulations** — staccato, legato, marcato, tenuto, accent, fermata
|
||||
- **Dynamic curves** — crescendo(), decrescendo(), swell(), dynamics()
|
||||
- **`Part.hit()`** — individual drum sounds with articulation support
|
||||
- **Cross-choke drum damping** — djembe, hi-hats, cajón, doumbek
|
||||
- **5 new djembe patterns** + 3 djembe fills (30 fills total)
|
||||
- **6 new drum fills** — 3 cajón, 3 metal
|
||||
- **Duration arithmetic** — multiply, divide, add
|
||||
- **Improved djembe slap** synthesis
|
||||
- Song #31: Acid Tabla
|
||||
|
||||
## 0.38.2
|
||||
|
||||
- **`Part.ramp()`** — smooth parameter automation from current value to
|
||||
target over a duration. Works for lowpass, reverb, distortion, chorus,
|
||||
delay, volume, and any `.set()` parameter. Four interpolation curves:
|
||||
linear, ease_in, ease_out, ease_in_out.
|
||||
|
||||
## 0.38.1
|
||||
|
||||
- **Dynamic curves** — `Part.crescendo()`, `Part.decrescendo()`,
|
||||
|
||||
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
<audio controls style="width: 100%; margin: 0.5em 0 1.5em 0;">
|
||||
<source src="{{ pathto('_static/audio/' + file, 1) }}" type="audio/wav">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -411,6 +411,10 @@ Acid House Track
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/acid_house.wav" type="audio/wav"></audio>
|
||||
|
||||
Dub Reggae with Delay Madness
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -443,6 +447,10 @@ Sparse notes into infinite echo:
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/dub_reggae.wav" type="audio/wav"></audio>
|
||||
|
||||
Jazz Ballad with Humanize
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -480,6 +488,10 @@ The difference between a robot and a musician:
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/jazz_ballad.wav" type="audio/wav"></audio>
|
||||
|
||||
Song with Sections
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -513,6 +525,10 @@ Define once, arrange freely:
|
||||
play_score(score)
|
||||
score.save_midi("my_song.mid")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/song_sections.wav" type="audio/wav"></audio>
|
||||
|
||||
Export Everything to MIDI
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
+103
-2
@@ -10,7 +10,7 @@ the genre -- they tell the listener's body how to move before a single
|
||||
melodic note is played.
|
||||
|
||||
PyTheory includes a complete drum system -- 51 synthesized percussion
|
||||
sounds, 85+ pattern presets across dozens of genres, and 30 fill presets.
|
||||
sounds, 95+ pattern presets across dozens of genres, and 30 fill presets.
|
||||
Every sound is generated from waveforms; no samples needed.
|
||||
|
||||
Drum Sounds
|
||||
@@ -121,10 +121,18 @@ MRIDANGAM_THA (101)
|
||||
|
||||
**Djembe:** DJEMBE_BASS (102), DJEMBE_TONE (103), DJEMBE_SLAP (104)
|
||||
|
||||
**Cajón:** CAJON_SLAP (109), CAJON_TAP (110)
|
||||
**Cajón:** CAJON_BASS (108), CAJON_SLAP (109), CAJON_TAP (110)
|
||||
|
||||
**Metal Kit:** METAL_KICK (105), METAL_SNARE (106), METAL_HAT (107)
|
||||
|
||||
**Marching Snare:** MARCH_SNARE (115), MARCH_RIMSHOT (116), MARCH_CLICK (118)
|
||||
|
||||
**Quads (Tenors):** QUAD_1 (119), QUAD_2 (120), QUAD_3 (121), QUAD_4 (122),
|
||||
QUAD_SPOCK (123)
|
||||
|
||||
**Marching Bass:** BASS_1 (124), BASS_2 (125), BASS_3 (126), BASS_4 (127),
|
||||
BASS_5 (80)
|
||||
|
||||
Drum Synthesis
|
||||
--------------
|
||||
|
||||
@@ -241,6 +249,13 @@ Playing Patterns
|
||||
play_pattern(Pattern.preset("salsa"), repeats=4, bpm=180)
|
||||
play_pattern(Pattern.preset("afrobeat"), repeats=8, bpm=110)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/rock_beat.wav" type="audio/wav"></audio>
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/bossa_nova_pattern.wav" type="audio/wav"></audio>
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/salsa_pattern.wav" type="audio/wav"></audio>
|
||||
<audio controls style="width:100%;margin:0.3em 0 1.5em"><source src="../_static/audio/afrobeat_pattern.wav" type="audio/wav"></audio>
|
||||
|
||||
Fills
|
||||
-----
|
||||
|
||||
@@ -331,6 +346,10 @@ drum pattern and all named parts are mixed together by ``play_score()``:
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/salsa_layered.wav" type="audio/wav"></audio>
|
||||
|
||||
World Percussion
|
||||
----------------
|
||||
|
||||
@@ -374,6 +393,12 @@ bayan (deep bass bends showcase), tabla call (dayan/bayan call-and-response).
|
||||
score = Score("4/4", bpm=80)
|
||||
score.drums("teental", repeats=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/tabla_teental.wav" type="audio/wav"></audio>
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/tabla_keherwa.wav" type="audio/wav"></audio>
|
||||
<audio controls style="width:100%;margin:0.3em 0 1.5em"><source src="../_static/audio/tabla_chakradar.wav" type="audio/wav"></audio>
|
||||
|
||||
Dhol
|
||||
~~~~
|
||||
|
||||
@@ -391,6 +416,10 @@ energetic, and physically impossible to sit still to.
|
||||
score = Score("4/4", bpm=160)
|
||||
score.drums("bhangra", repeats=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/dhol.wav" type="audio/wav"></audio>
|
||||
|
||||
Dholak
|
||||
~~~~~~
|
||||
|
||||
@@ -408,6 +437,10 @@ music) and dholak folk (a general folk groove).
|
||||
score = Score("4/4", bpm=120)
|
||||
score.drums("qawwali", repeats=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/dholak.wav" type="audio/wav"></audio>
|
||||
|
||||
Mridangam
|
||||
~~~~~~~~~
|
||||
|
||||
@@ -426,6 +459,10 @@ and mridangam korvai (a rhythmic cadence pattern).
|
||||
score = Score("4/4", bpm=90)
|
||||
score.drums("adi talam", repeats=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/mridangam.wav" type="audio/wav"></audio>
|
||||
|
||||
Djembe
|
||||
~~~~~~
|
||||
|
||||
@@ -450,6 +487,10 @@ West African-style break).
|
||||
score = Score("4/4", bpm=120)
|
||||
score.drums("djembe", repeats=8, fill="djembe call", fill_every=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/djembe.wav" type="audio/wav"></audio>
|
||||
|
||||
Metal Kit
|
||||
~~~~~~~~~
|
||||
|
||||
@@ -474,6 +515,10 @@ roll → kick roll → alternating → crash ending).
|
||||
score = Score("4/4", bpm=200)
|
||||
score.drums("metal blast", repeats=8, fill="metal cascade", fill_every=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/metal_blast.wav" type="audio/wav"></audio>
|
||||
|
||||
Cajón
|
||||
~~~~~
|
||||
|
||||
@@ -496,6 +541,62 @@ bass-slap groove).
|
||||
score = Score("4/4", bpm=100)
|
||||
score.drums("cajon", repeats=8, fill="cajon flam", fill_every=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/cajon.wav" type="audio/wav"></audio>
|
||||
|
||||
Marching Percussion
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A full drumline — snare, quads (tenors), and pitched bass drums.
|
||||
Every sound is synthesized: kevlar snare heads, aluminum shell ting
|
||||
on the quads, felt-beater thwack on the basses.
|
||||
|
||||
**Snare** -- 3 sounds: MARCH_SNARE (tight kevlar tap), MARCH_RIMSHOT
|
||||
(woody-metallic crack), MARCH_CLICK (stick click for count-offs).
|
||||
|
||||
**Quads** -- 5 sounds: QUAD_1 through QUAD_4 (high to low pitched
|
||||
tenors) plus QUAD_SPOCK (rim click on the shell).
|
||||
|
||||
**Bass drums** -- 5 pitched drums: BASS_1 (highest/smallest) through
|
||||
BASS_5 (lowest/biggest), each with a prominent felt-beater thwack.
|
||||
|
||||
**6 patterns:** march (basic 4/4), cadence (8-beat street beat),
|
||||
march paradiddle, march roll (buzz crescendo), quad sweep (run across
|
||||
all 4 drums), quad groove, bass split (cascading across the line),
|
||||
bass unison (all 5 hit together), drumline (snare + quads + bass).
|
||||
|
||||
**Rudiment methods:** ``Part.flam()``, ``Part.diddle()``, and
|
||||
``Part.cheese()`` for marching rudiments on any drum sound.
|
||||
|
||||
**Ensemble rendering:** ``ensemble=N`` on any Part duplicates the
|
||||
voice with per-player timing tendencies and micro pitch drift.
|
||||
``ensemble=8`` for a snare line, ``ensemble=20`` for a massive section.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Full drumline with ensemble
|
||||
snares = score.part("snares", synth="sine", volume=0.9,
|
||||
reverb=0.2, ensemble=8)
|
||||
quads = score.part("quads", synth="sine", volume=0.5,
|
||||
reverb=0.2, ensemble=4)
|
||||
basses = score.part("basses", synth="sine", volume=0.55,
|
||||
reverb=0.2, ensemble=5)
|
||||
|
||||
snares.flam(DrumSound.MARCH_SNARE, Duration.QUARTER, velocity=120)
|
||||
snares.diddle(DrumSound.MARCH_SNARE, Duration.EIGHTH, velocity=60)
|
||||
|
||||
# Or use patterns
|
||||
score.drums("drumline", repeats=4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/march_snare.wav" type="audio/wav"></audio>
|
||||
|
||||
**Sympathetic resonance:** The marching snare builds up snare wire
|
||||
buzz as hits accumulate, and the buzz decays during rests — just like
|
||||
a real drum.
|
||||
|
||||
MIDI Export
|
||||
-----------
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ the mix louder and punchier:
|
||||
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/playback_basic.wav" type="audio/wav"></audio>
|
||||
|
||||
The render pipeline respects the Score's ``temperament`` and
|
||||
``reference_pitch`` settings, so Baroque or microtonal scores play back
|
||||
at the correct tuning:
|
||||
|
||||
+19
-33
@@ -143,48 +143,34 @@ chords, melody, bass, each with their own synth and effects:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Score, Pattern, Key, Duration, Chord
|
||||
from pytheory import Score, Key, Duration
|
||||
from pytheory.play import play_score
|
||||
|
||||
score = Score("4/4", bpm=140)
|
||||
score.drums("bossa nova", repeats=4)
|
||||
score = Score("4/4", bpm=120)
|
||||
score.drums("rock", repeats=8, fill="rock", fill_every=4)
|
||||
|
||||
chords = score.part(
|
||||
"chords",
|
||||
synth="fm",
|
||||
envelope="pad",
|
||||
reverb=0.4,
|
||||
)
|
||||
lead = score.part(
|
||||
"lead",
|
||||
synth="saw",
|
||||
envelope="pluck",
|
||||
delay=0.3,
|
||||
lowpass=3000,
|
||||
humanize=0.2,
|
||||
)
|
||||
bass = score.part(
|
||||
"bass",
|
||||
synth="sine",
|
||||
lowpass=500,
|
||||
)
|
||||
piano = score.part("piano", instrument="piano", reverb=0.3)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
delay=0.2, reverb=0.2, lowpass=4000)
|
||||
bass = score.part("bass", synth="triangle", lowpass=900)
|
||||
|
||||
key = Key("A", "minor")
|
||||
for chord in key.progression("i", "iv", "V", "i"):
|
||||
chords.add(chord, Duration.WHOLE)
|
||||
chords.add(chord, Duration.WHOLE)
|
||||
for chord in Key("G", "major").progression("I", "V", "vi", "IV") * 2:
|
||||
piano.add(chord, Duration.WHOLE)
|
||||
|
||||
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
|
||||
lead.arpeggio("Dm", bars=2, pattern="updown", octaves=2)
|
||||
lead.set(lowpass=5000, reverb=0.3)
|
||||
lead.arpeggio("E7", bars=2, pattern="up", octaves=2)
|
||||
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
|
||||
lead.add("D5", 1).add("B4", 0.5).add("D5", 0.5)
|
||||
lead.add("G5", 1).add("E5", 1)
|
||||
lead.add("D5", 0.5).add("B4", 0.5).add("A4", 1)
|
||||
lead.add("G4", 2).rest(2)
|
||||
|
||||
for n in ["A2", "E2", "A2", "C3"] * 4:
|
||||
bass.add(n, Duration.QUARTER)
|
||||
for n in ["G2", "G2", "D2", "D2", "E2", "E2", "C2", "C2"] * 2:
|
||||
bass.add(n, Duration.HALF)
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/quickstart.wav" type="audio/wav"></audio>
|
||||
|
||||
Export to Your DAW
|
||||
------------------
|
||||
|
||||
|
||||
+279
-41
@@ -47,6 +47,18 @@ A ``Duration`` represents a note length in beats (quarter note = 1 beat):
|
||||
>>> Duration.TRIPLET_QUARTER.value
|
||||
0.6666666666666666
|
||||
|
||||
Duration supports arithmetic — multiply, divide, and add to create
|
||||
compound durations:
|
||||
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> Duration.WHOLE * 2
|
||||
8.0
|
||||
>>> Duration.HALF + Duration.QUARTER
|
||||
3.0
|
||||
>>> Duration.WHOLE / 2
|
||||
2.0
|
||||
|
||||
Time Signatures
|
||||
---------------
|
||||
|
||||
@@ -149,6 +161,10 @@ Chords work just like tones — pass any ``Chord`` object:
|
||||
for chord in chords:
|
||||
score.add(chord, Duration.WHOLE)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/chords_basic.wav" type="audio/wav"></audio>
|
||||
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> score.measures
|
||||
@@ -232,6 +248,31 @@ Chords and Tone objects work the same way:
|
||||
for note in ["A2", "C3", "E3", "A2", "D2", "F2", "A2", "D2"]:
|
||||
bass.add(note, Duration.QUARTER)
|
||||
|
||||
Polyphonic Hold
|
||||
---------------
|
||||
|
||||
``Part.hold()`` adds a note without advancing the beat position —
|
||||
the next note starts at the *same* time. This enables polyphonic
|
||||
overlap on a single part: piano sustain, sitar drone under melody,
|
||||
guitar strum texture.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
piano = score.part("piano", instrument="piano", reverb=0.3)
|
||||
|
||||
# Hold a C major chord for 8 beats
|
||||
piano.hold("C3", Duration.WHOLE * 2, velocity=60)
|
||||
piano.hold("E3", Duration.WHOLE * 2, velocity=55)
|
||||
piano.hold("G3", Duration.WHOLE * 2, velocity=55)
|
||||
|
||||
# Melody plays simultaneously on top
|
||||
for n in ["E4", "G4", "C5", "G4", "E4", "D4", "C4", "E4"]:
|
||||
piano.add(n, Duration.QUARTER, velocity=80)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/piano_hold.wav" type="audio/wav"></audio>
|
||||
|
||||
Arpeggiator
|
||||
------------
|
||||
|
||||
@@ -280,6 +321,10 @@ Chain arpeggios through a progression:
|
||||
for sym in ["Cm", "Fm", "Abm", "Gm"]:
|
||||
lead.arpeggio(sym, bars=2, pattern="updown", octaves=2)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/arpeggio.wav" type="audio/wav"></audio>
|
||||
|
||||
Combined with legato, glide, distortion, and a resonant lowpass, this
|
||||
produces the classic acid/trance arpeggiator sound.
|
||||
|
||||
@@ -310,12 +355,18 @@ portamento (pitch slides between notes):
|
||||
acid = score.part(
|
||||
"acid",
|
||||
synth="saw",
|
||||
envelope="pad",
|
||||
legato=True,
|
||||
glide=0.04,
|
||||
lowpass=3000,
|
||||
lowpass_q=6.0,
|
||||
distortion=0.3,
|
||||
)
|
||||
acid.add("C2", 0.25).add("C3", 0.25).add("G2", 0.25).add("C2", 0.25)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/legato_glide.wav" type="audio/wav"></audio>
|
||||
|
||||
- ``legato``: If True, no envelope retrigger between notes (default False).
|
||||
- ``glide``: Portamento time in seconds (default 0, instant).
|
||||
0.03--0.05 = quick 303 slide, 0.1--0.2 = slow glide.
|
||||
@@ -323,63 +374,51 @@ portamento (pitch slides between notes):
|
||||
Complete Example
|
||||
----------------
|
||||
|
||||
A full multi-part arrangement built from scratch — bossa nova with FM
|
||||
rhodes, triangle lead, and filtered bass:
|
||||
A full multi-part arrangement — rock beat with piano chords, saw
|
||||
lead, and filtered bass:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Score, Pattern, Key, Duration, Chord
|
||||
from pytheory import Score, Key, Duration, Chord
|
||||
from pytheory.play import play_score
|
||||
|
||||
score = Score("4/4", bpm=140)
|
||||
score.drums("bossa nova", repeats=4)
|
||||
score = Score("4/4", bpm=120)
|
||||
score.drums("rock", repeats=8, fill="rock", fill_every=4)
|
||||
|
||||
# FM rhodes with reverb
|
||||
rhodes = score.part(
|
||||
"rhodes",
|
||||
synth="fm",
|
||||
envelope="piano",
|
||||
volume=0.3,
|
||||
reverb=0.4,
|
||||
reverb_decay=1.8,
|
||||
)
|
||||
# Piano chords with reverb
|
||||
piano = score.part("piano", instrument="piano", volume=0.4, reverb=0.3)
|
||||
|
||||
# Triangle lead with delay
|
||||
# Saw lead with delay
|
||||
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,
|
||||
"lead", synth="saw", envelope="pluck", volume=0.4,
|
||||
delay=0.2, delay_time=0.33, reverb=0.2, lowpass=3000,
|
||||
)
|
||||
|
||||
# Filtered bass
|
||||
bass = score.part(
|
||||
"bass",
|
||||
synth="sine",
|
||||
envelope="pluck",
|
||||
volume=0.45,
|
||||
lowpass=600,
|
||||
)
|
||||
bass = score.part("bass", synth="triangle", envelope="pluck",
|
||||
volume=0.45, lowpass=1200)
|
||||
|
||||
for sym in ["Am", "Am", "Dm", "Dm", "E7", "E7", "Am", "Am"]:
|
||||
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
for chord in Key("G", "major").progression("I", "V", "vi", "IV") * 2:
|
||||
piano.add(chord, Duration.WHOLE)
|
||||
|
||||
for n, d in [
|
||||
("E5", 0.67), ("D5", 0.33), ("C5", 0.67), ("B4", 0.33),
|
||||
("A4", 1), ("C5", 0.67), ("E5", 0.33), ("D5", 0.67), ("C5", 0.33),
|
||||
("A4", 1),
|
||||
]:
|
||||
lead.add(n, d)
|
||||
lead.add("D5", 1).add("B4", 0.5).add("D5", 0.5)
|
||||
lead.add("G5", 1).add("E5", 1)
|
||||
lead.add("D5", 0.5).add("B4", 0.5).add("A4", 1)
|
||||
lead.add("G4", 2).rest(2)
|
||||
lead.add("D5", 1).add("B4", 0.5).add("D5", 0.5)
|
||||
lead.add("G5", 1).add("A5", 1)
|
||||
lead.add("G5", 0.5).add("E5", 0.5).add("D5", 1)
|
||||
lead.add("B4", 2).rest(2)
|
||||
|
||||
for n in ["A2", "E2", "A2", "C3", "D2", "A2", "D2", "F2"]:
|
||||
bass.add(n, Duration.QUARTER)
|
||||
for n in ["G2", "G2", "D2", "D2", "E2", "E2", "C2", "C2"] * 2:
|
||||
bass.add(n, Duration.HALF)
|
||||
|
||||
play_score(score)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/complete_rock.wav" type="audio/wav"></audio>
|
||||
|
||||
Velocity
|
||||
--------
|
||||
|
||||
@@ -399,6 +438,157 @@ The arpeggiator also accepts velocity:
|
||||
|
||||
lead.arpeggio("Am", bars=2, pattern="up", velocity=80)
|
||||
|
||||
Articulations
|
||||
-------------
|
||||
|
||||
Articulations change *how* a note is played — its attack, duration, and
|
||||
weight. A staccato note is short and bouncy. A marcato note hits hard.
|
||||
A legato note melts into the next one. This is the difference between
|
||||
a melody that sounds like a MIDI file and one that sounds like a
|
||||
musician played it.
|
||||
|
||||
Pass ``articulation=`` to ``Part.add()``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
piano.add("C4", Duration.QUARTER, articulation="staccato") # short, bouncy
|
||||
piano.add("D4", Duration.QUARTER, articulation="legato") # smooth, overlaps
|
||||
piano.add("E4", Duration.QUARTER, articulation="marcato") # heavy accent
|
||||
piano.add("F4", Duration.QUARTER, articulation="tenuto") # held, soft attack
|
||||
piano.add("G4", Duration.QUARTER, articulation="accent") # louder
|
||||
piano.add("C5", Duration.HALF, articulation="fermata") # held longer
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/articulations.wav" type="audio/wav"></audio>
|
||||
|
||||
What each articulation does:
|
||||
|
||||
- **staccato** — plays ~40% of the note duration with a quick fade-out. Short and detached.
|
||||
- **legato** — extends ~15% into the next note. Smooth and connected.
|
||||
- **marcato** — 25% velocity boost + sharper attack. Heavy and accented.
|
||||
- **tenuto** — full duration with a softer attack ramp. Held and deliberate.
|
||||
- **accent** — 20% velocity boost, no duration change.
|
||||
- **fermata** — stretches the note 50% longer.
|
||||
|
||||
Articulations work on ``Part.hold()`` and ``Part.hit()`` too.
|
||||
|
||||
Dynamic Curves
|
||||
--------------
|
||||
|
||||
Real music breathes — phrases get louder, get quieter, swell and
|
||||
recede. Dynamic curves let you shape the velocity across a sequence
|
||||
of notes instead of setting each one manually.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Crescendo: quiet to loud
|
||||
piano.crescendo(["C4","D4","E4","F4","G4","A4","B4","C5"],
|
||||
Duration.QUARTER, start_vel=30, end_vel=110)
|
||||
|
||||
# Decrescendo: loud to quiet
|
||||
piano.decrescendo(["C5","B4","A4","G4","F4","E4","D4","C4"],
|
||||
Duration.QUARTER, start_vel=110, end_vel=30)
|
||||
|
||||
# Swell: up then back down (orchestral < > shape)
|
||||
strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"],
|
||||
Duration.QUARTER, low_vel=35, peak_vel=110)
|
||||
|
||||
# Custom curve: explicit velocity per note
|
||||
piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER,
|
||||
velocities=[50, 80, 110, 90])
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/dynamics.wav" type="audio/wav"></audio>
|
||||
|
||||
Four methods:
|
||||
|
||||
- **crescendo()** — linear velocity ramp from ``start_vel`` to ``end_vel``.
|
||||
- **decrescendo()** — same thing, but typically loud to quiet.
|
||||
- **swell()** — ramps up to the midpoint, then back down. The classic
|
||||
orchestral crescendo-decrescendo.
|
||||
- **dynamics()** — the general form. Pass a ``(start, end)`` tuple for
|
||||
a linear ramp, or a list of velocities for a custom curve.
|
||||
|
||||
All four accept ``articulation=`` to combine dynamics with articulations:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Staccato crescendo — bouncy notes getting louder
|
||||
piano.crescendo(["C4","E4","G4","C5","E5","G5","C6","E6"],
|
||||
Duration.EIGHTH, start_vel=40, end_vel=110,
|
||||
articulation="staccato")
|
||||
|
||||
Part.hit() — Manual Drum Placement
|
||||
-----------------------------------
|
||||
|
||||
The pattern system is great for grooves, but sometimes you want to
|
||||
place individual drum hits with full control — articulations, effects,
|
||||
and all. ``Part.hit()`` puts a drum sound into a Part's note stream:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import DrumSound
|
||||
|
||||
kit = score.part("kit", synth="sine", volume=0.7)
|
||||
|
||||
kit.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
|
||||
kit.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH, velocity=60)
|
||||
kit.hit(DrumSound.SNARE, Duration.EIGHTH, articulation="marcato")
|
||||
|
||||
Because hits go through the normal Part renderer, they get humanize,
|
||||
effects, and articulations for free. Use this for custom beats that
|
||||
don't fit a preset pattern, or for one-shot accent hits layered on
|
||||
top of a pattern.
|
||||
|
||||
Rudiments — Flam, Diddle, Cheese
|
||||
---------------------------------
|
||||
|
||||
Marching percussion rudiments as methods on any Part:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import DrumSound
|
||||
|
||||
p = score.part("snares", synth="sine", volume=0.9)
|
||||
|
||||
# Flam: grace note + main hit (gap controls tightness)
|
||||
p.flam(DrumSound.MARCH_SNARE, Duration.QUARTER, velocity=120)
|
||||
|
||||
# Diddle: two equal strokes in one note duration
|
||||
p.diddle(DrumSound.MARCH_SNARE, Duration.EIGHTH, velocity=60)
|
||||
|
||||
# Cheese: flam + diddle combined
|
||||
p.cheese(DrumSound.MARCH_SNARE, Duration.QUARTER, velocity=120)
|
||||
|
||||
Ensemble
|
||||
--------
|
||||
|
||||
Any Part can be rendered as an ensemble — multiple players with
|
||||
per-player timing tendencies and micro pitch drift:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# 8-player snare line
|
||||
snares = score.part("snares", synth="sine", volume=0.9, ensemble=8)
|
||||
|
||||
# 20-player string section
|
||||
strings = score.part("strings", instrument="string_ensemble", ensemble=20)
|
||||
|
||||
# Single player (default)
|
||||
solo = score.part("solo", instrument="violin")
|
||||
|
||||
Each ensemble voice gets a consistent timing personality (some rush,
|
||||
some drag) plus small per-note wobble, and slightly different tuning.
|
||||
The result sounds like a real section — together but alive.
|
||||
|
||||
Solo snare, then an 8-player section plays the same pattern:
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/ensemble.wav" type="audio/wav"></audio>
|
||||
|
||||
Swing and Groove
|
||||
----------------
|
||||
|
||||
@@ -478,6 +668,54 @@ integrate naturally with the rest of the automation system:
|
||||
pad.rest(Duration.WHOLE)
|
||||
pad.rest(Duration.WHOLE)
|
||||
|
||||
Parameter Ramps
|
||||
---------------
|
||||
|
||||
Fades only control volume. ``Part.ramp()`` smoothly sweeps *any*
|
||||
parameter from its current value to a target — filters, reverb,
|
||||
distortion, chorus, delay, anything ``.set()`` accepts. This is how
|
||||
you build filter sweeps, gradual effect sends, and EDM buildups.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
lead = score.part("lead", synth="saw", lowpass=200, lowpass_q=3.0)
|
||||
|
||||
# Open the filter over 8 bars
|
||||
lead.ramp(over=Duration.WHOLE * 8, lowpass=8000)
|
||||
|
||||
# Ramp multiple params at once
|
||||
pad.ramp(over=Duration.WHOLE * 4, reverb=0.5, chorus=0.3)
|
||||
|
||||
# Close the filter with distortion fading in
|
||||
lead.ramp(over=Duration.WHOLE * 4, lowpass=400, distortion=0.5)
|
||||
|
||||
Four interpolation curves:
|
||||
|
||||
- **linear** — constant rate of change (default).
|
||||
- **ease_in** — starts slow, accelerates. Good for buildups.
|
||||
- **ease_out** — starts fast, decelerates. Good for releases.
|
||||
- **ease_in_out** — slow at both ends. Smooth and natural.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# EDM buildup: slow start, accelerating filter sweep
|
||||
lead.ramp(over=Duration.WHOLE * 8, curve="ease_in", lowpass=8000)
|
||||
|
||||
# Smooth reverb wash fading in and settling
|
||||
pad.ramp(over=Duration.WHOLE * 4, curve="ease_in_out", reverb=0.6)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="../_static/audio/filter_ramp.wav" type="audio/wav"></audio>
|
||||
|
||||
``ramp()`` generates automation points every quarter-beat by default.
|
||||
Set ``resolution=0.125`` for smoother curves (every 32nd note), or
|
||||
``resolution=1.0`` for lighter automation (every beat).
|
||||
|
||||
Combine with ``lfo()`` for cyclic modulation and ``ramp()`` for
|
||||
one-shot sweeps — together they cover the full range of parameter
|
||||
automation.
|
||||
|
||||
Humanize
|
||||
--------
|
||||
|
||||
|
||||
+280
-5
@@ -37,6 +37,10 @@ building block of all other waveforms (Fourier's theorem).
|
||||
tone = Tone.from_string("C4", system="western")
|
||||
play(tone, synth=Synth.SINE)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_sine.wav" type="audio/wav"></audio>
|
||||
|
||||
Sawtooth
|
||||
~~~~~~~~
|
||||
|
||||
@@ -50,6 +54,10 @@ Named for its ramp shape.
|
||||
|
||||
play(tone, synth=Synth.SAW)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_saw.wav" type="audio/wav"></audio>
|
||||
|
||||
Triangle
|
||||
~~~~~~~~
|
||||
|
||||
@@ -63,6 +71,10 @@ described as "woody" or "hollow."
|
||||
|
||||
play(tone, synth=Synth.TRIANGLE)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_triangle.wav" type="audio/wav"></audio>
|
||||
|
||||
Square
|
||||
~~~~~~
|
||||
|
||||
@@ -76,6 +88,10 @@ pulse wave with a 50% duty cycle.
|
||||
|
||||
play(tone, synth=Synth.SQUARE)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_square.wav" type="audio/wav"></audio>
|
||||
|
||||
Extended Waveforms
|
||||
------------------
|
||||
|
||||
@@ -98,6 +114,10 @@ the classic NES-style buzzy tone.
|
||||
|
||||
lead = score.part("lead", synth="pulse", envelope="pluck")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pulse.wav" type="audio/wav"></audio>
|
||||
|
||||
FM Synthesis
|
||||
~~~~~~~~~~~~
|
||||
|
||||
@@ -109,18 +129,24 @@ the electric piano in every Whitney Houston ballad, the bass in every
|
||||
Depeche Mode track, the bells in a thousand TV jingles. If you heard
|
||||
pop music in the 80s, you heard FM synthesis.
|
||||
|
||||
**Use for:** electric piano (rhodes), bells, metallic leads, jazz chords.
|
||||
**Use for:** bells, metallic leads, glassy pads, DX7-style sounds.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
rhodes = score.part(
|
||||
"rhodes",
|
||||
bells = score.part(
|
||||
"bells",
|
||||
synth="fm",
|
||||
envelope="piano",
|
||||
envelope="bell",
|
||||
fm_ratio=3.0,
|
||||
fm_index=5.0,
|
||||
volume=0.3,
|
||||
reverb=0.4,
|
||||
)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_fm.wav" type="audio/wav"></audio>
|
||||
|
||||
Noise
|
||||
-----
|
||||
|
||||
@@ -142,6 +168,10 @@ Useful as a texture layer, a percussion source, or a wind/ocean effect.
|
||||
lowpass=2000,
|
||||
)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_noise.wav" type="audio/wav"></audio>
|
||||
|
||||
Ensemble Waveforms
|
||||
------------------
|
||||
|
||||
@@ -174,6 +204,10 @@ supersaw.
|
||||
reverb=0.5,
|
||||
)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_supersaw.wav" type="audio/wav"></audio>
|
||||
|
||||
PWM Slow
|
||||
~~~~~~~~
|
||||
|
||||
@@ -195,6 +229,10 @@ from Boards of Canada to Drake? PWM with a slow LFO.
|
||||
reverb=0.4,
|
||||
)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pwm_slow.wav" type="audio/wav"></audio>
|
||||
|
||||
PWM Fast
|
||||
~~~~~~~~
|
||||
|
||||
@@ -207,6 +245,10 @@ produces a natural chorus/vibrato effect built into the waveform itself.
|
||||
|
||||
lead = score.part("lead", synth="pwm_fast", envelope="pluck", volume=0.5)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pwm_fast.wav" type="audio/wav"></audio>
|
||||
|
||||
ADSR Envelopes
|
||||
--------------
|
||||
|
||||
@@ -375,6 +417,10 @@ at musical levels. Warm, round, unmistakably organ.
|
||||
|
||||
organ = score.part("organ", synth="organ_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_organ.wav" type="audio/wav"></audio>
|
||||
|
||||
String Ensemble
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -386,6 +432,10 @@ more "wooden" than a raw saw wave.
|
||||
|
||||
violin = score.part("violin", synth="strings_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_strings.wav" type="audio/wav"></audio>
|
||||
|
||||
Dedicated Instrument Synths
|
||||
--------------------------
|
||||
|
||||
@@ -407,6 +457,94 @@ soundboard.
|
||||
|
||||
piano = score.part("piano", synth="piano_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_piano.wav" type="audio/wav"></audio>
|
||||
|
||||
Rhodes Electric Piano
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The Fender Rhodes — a rubber-tipped hammer strikes a steel tine
|
||||
next to a tonebar, picked up by an electromagnetic pickup. Warm,
|
||||
bell-like, with a bright metallic attack that mellows into a
|
||||
singing sustain. The sound of jazz clubs, soul, and neo-soul.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
rhodes = score.part("rhodes", synth="rhodes_synth")
|
||||
# Or use the instrument preset (adds tremolo + chorus)
|
||||
rhodes = score.part("rhodes", instrument="electric_piano")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_rhodes.wav" type="audio/wav"></audio>
|
||||
|
||||
Wurlitzer Electric Piano
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The Wurlitzer uses a vibrating steel reed (not a tine like Rhodes)
|
||||
picked up by an electrostatic pickup. More nasal, reedy, and biting
|
||||
— it barks and growls when played hard. Think Supertramp, Ray Charles.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
wurli = score.part("wurli", synth="wurlitzer_synth")
|
||||
wurli = score.part("wurli", instrument="wurlitzer")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_wurlitzer.wav" type="audio/wav"></audio>
|
||||
|
||||
Vibraphone Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Struck aluminum bars with motor-driven tremolo discs. The spinning
|
||||
motor modulates the sound through the resonator tubes, creating the
|
||||
signature vibraphone shimmer. Inharmonic bar modes at 1x, 2.76x, 5.4x.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
vib = score.part("vib", synth="vibraphone_synth")
|
||||
vib = score.part("vib", instrument="vibraphone")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_vibraphone.wav" type="audio/wav"></audio>
|
||||
|
||||
Pipe Organ Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
Multiple ranks of pipes — principal 8', octave 4', fifteenth 2'.
|
||||
Constant air pressure means no dynamics. Wind chiff at the attack.
|
||||
Best with cathedral reverb.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
organ = score.part("organ", synth="pipe_organ_synth")
|
||||
organ = score.part("organ", instrument="pipe_organ")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pipe_organ.wav" type="audio/wav"></audio>
|
||||
|
||||
Choir Synth
|
||||
~~~~~~~~~~~
|
||||
|
||||
Voices singing vowels shaped by formant bandpass filters. The glottal
|
||||
source is filtered through vocal tract resonances — F1, F2, F3, F4 —
|
||||
which is what makes "ah" sound different from "oo". Use ``lyric=``
|
||||
to control the vowel. Best with ``ensemble=`` for a full section.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
choir = score.part("choir", synth="choir_synth")
|
||||
choir = score.part("choir", instrument="choir") # ensemble=6 + cathedral reverb
|
||||
choir.add("C4", Duration.WHOLE, lyric="ah")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_choir.wav" type="audio/wav"></audio>
|
||||
|
||||
Bass Guitar Synth
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -416,6 +554,10 @@ Plucked string model with finger-damped harmonics and low-end warmth.
|
||||
|
||||
bass = score.part("bass", synth="bass_guitar_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_bass_guitar.wav" type="audio/wav"></audio>
|
||||
|
||||
Flute Synth
|
||||
~~~~~~~~~~~~
|
||||
|
||||
@@ -426,6 +568,10 @@ overblowing behavior at higher velocities.
|
||||
|
||||
flute = score.part("flute", synth="flute_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_flute.wav" type="audio/wav"></audio>
|
||||
|
||||
Trumpet Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -436,6 +582,10 @@ velocity, plus a characteristic brassy edge from shaped harmonics.
|
||||
|
||||
trumpet = score.part("trumpet", synth="trumpet_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_trumpet.wav" type="audio/wav"></audio>
|
||||
|
||||
Clarinet Synth
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
@@ -446,6 +596,10 @@ characteristic hollow, woody tone.
|
||||
|
||||
clarinet = score.part("clarinet", synth="clarinet_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_clarinet.wav" type="audio/wav"></audio>
|
||||
|
||||
Oboe Synth
|
||||
~~~~~~~~~~~
|
||||
|
||||
@@ -456,6 +610,10 @@ timbre.
|
||||
|
||||
oboe = score.part("oboe", synth="oboe_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_oboe.wav" type="audio/wav"></audio>
|
||||
|
||||
Marimba Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -466,6 +624,10 @@ that emphasizes the fundamental.
|
||||
|
||||
marimba = score.part("marimba", synth="marimba_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_marimba.wav" type="audio/wav"></audio>
|
||||
|
||||
Harpsichord Synth
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -476,6 +638,10 @@ Plucked-string model with a bright, immediate attack and rapid decay
|
||||
|
||||
harpsi = score.part("harpsi", synth="harpsichord_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_harpsichord.wav" type="audio/wav"></audio>
|
||||
|
||||
Cello Synth
|
||||
~~~~~~~~~~~
|
||||
|
||||
@@ -486,6 +652,10 @@ producing a rich, warm, sustained tone.
|
||||
|
||||
cello = score.part("cello", synth="cello_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_cello.wav" type="audio/wav"></audio>
|
||||
|
||||
Harp Synth
|
||||
~~~~~~~~~~
|
||||
|
||||
@@ -496,6 +666,10 @@ modeling nylon strings on a resonant frame.
|
||||
|
||||
harp = score.part("harp", synth="harp_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_harp.wav" type="audio/wav"></audio>
|
||||
|
||||
Upright Bass Synth
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -505,6 +679,10 @@ Pizzicato double bass with woody body resonance and a thumpy low end.
|
||||
|
||||
bass = score.part("bass", synth="upright_bass_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_upright_bass.wav" type="audio/wav"></audio>
|
||||
|
||||
Acoustic Guitar Synth
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -515,6 +693,10 @@ string decay.
|
||||
|
||||
guitar = score.part("guitar", synth="acoustic_guitar_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_acoustic_guitar.wav" type="audio/wav"></audio>
|
||||
|
||||
Electric Guitar Synth
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -525,6 +707,10 @@ than the acoustic, ready for effects processing.
|
||||
|
||||
eguitar = score.part("eguitar", synth="electric_guitar_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_electric_guitar.wav" type="audio/wav"></audio>
|
||||
|
||||
Sitar Synth
|
||||
~~~~~~~~~~~~
|
||||
|
||||
@@ -535,6 +721,10 @@ bridge, producing a shimmering, metallic sustain.
|
||||
|
||||
sitar = score.part("sitar", synth="sitar_synth")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_sitar.wav" type="audio/wav"></audio>
|
||||
|
||||
Timpani Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -547,6 +737,10 @@ Use ``Part.roll()`` for crescendo timpani rolls.
|
||||
timp = score.part("timp", synth="timpani_synth")
|
||||
timp.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=110)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_timpani.wav" type="audio/wav"></audio>
|
||||
|
||||
Saxophone Synth
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -558,6 +752,10 @@ mids, reed buzz, and brass body warmth. Four presets: ``saxophone``,
|
||||
|
||||
sax = score.part("sax", instrument="tenor_sax")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_saxophone.wav" type="audio/wav"></audio>
|
||||
|
||||
Pedal Steel Synth
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -568,6 +766,10 @@ and long sustain. Pairs naturally with spring reverb.
|
||||
|
||||
steel = score.part("steel", instrument="pedal_steel")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_pedal_steel.wav" type="audio/wav"></audio>
|
||||
|
||||
Theremin Synth
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
@@ -578,6 +780,10 @@ Best used with legato and glide for continuous pitch.
|
||||
|
||||
theremin = score.part("theremin", instrument="theremin")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_theremin.wav" type="audio/wav"></audio>
|
||||
|
||||
Kalimba Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -588,6 +794,10 @@ inharmonic overtones (modes at 1x, 2.92x, 5.4x).
|
||||
|
||||
kalimba = score.part("kalimba", instrument="kalimba")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_kalimba.wav" type="audio/wav"></audio>
|
||||
|
||||
Steel Drum Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -598,6 +808,10 @@ Inharmonic partials at 2.0x, 3.01x, 4.1x, 5.3x.
|
||||
|
||||
pan = score.part("pan", instrument="steel_drum")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_steel_drum.wav" type="audio/wav"></audio>
|
||||
|
||||
Accordion Synth
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -608,6 +822,10 @@ create natural beating. Bellows pressure swell modulates amplitude.
|
||||
|
||||
acc = score.part("acc", instrument="accordion")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_accordion.wav" type="audio/wav"></audio>
|
||||
|
||||
Didgeridoo Synth
|
||||
~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -619,6 +837,10 @@ Best with cave reverb.
|
||||
|
||||
didg = score.part("didg", instrument="didgeridoo")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_didgeridoo.wav" type="audio/wav"></audio>
|
||||
|
||||
Bagpipe Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -629,6 +851,10 @@ peaked around 3-7 (the piercing brightness). No dynamics — always ff.
|
||||
|
||||
pipes = score.part("pipes", instrument="bagpipe")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_bagpipe.wav" type="audio/wav"></audio>
|
||||
|
||||
Banjo Synth
|
||||
~~~~~~~~~~~
|
||||
|
||||
@@ -639,6 +865,10 @@ nasal, ringy resonance with faster decay than guitar.
|
||||
|
||||
banjo = score.part("banjo", instrument="banjo")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_banjo.wav" type="audio/wav"></audio>
|
||||
|
||||
Mandolin Synth
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
@@ -649,6 +879,10 @@ doubled unison strings. Bright, ringing, fast attack.
|
||||
|
||||
mando = score.part("mando", instrument="mandolin")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_mandolin.wav" type="audio/wav"></audio>
|
||||
|
||||
Ukulele Synth
|
||||
~~~~~~~~~~~~~
|
||||
|
||||
@@ -659,6 +893,10 @@ softer attack than guitar, shorter sustain.
|
||||
|
||||
uke = score.part("uke", instrument="ukulele")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_ukulele.wav" type="audio/wav"></audio>
|
||||
|
||||
Granular Synth
|
||||
~~~~~~~~~~~~~~
|
||||
|
||||
@@ -688,6 +926,43 @@ Parameters (passed as synth kwargs):
|
||||
- ``source``: Base waveform — ``"saw"``, ``"sine"``, ``"triangle"``,
|
||||
``"square"``, ``"noise"``.
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_granular.wav" type="audio/wav"></audio>
|
||||
|
||||
Singing Bowl (Strike)
|
||||
~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Tibetan/Himalayan singing bowl struck with a mallet. The impact excites
|
||||
inharmonic partials that ring and slowly beat against each other as
|
||||
near-degenerate mode pairs interfere. Higher modes fade quickly, leaving
|
||||
the fundamental shimmering for seconds.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bowl = score.part("bowl", synth="singing_bowl_strike_synth", envelope="none",
|
||||
reverb=0.4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_singing_bowl_strike.wav" type="audio/wav"></audio>
|
||||
|
||||
Singing Bowl (Ring)
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Rim-rubbed singing bowl — the mallet traces the rim, slowly building the
|
||||
fundamental into a sustained, pulsing tone. Upper harmonics shimmer in
|
||||
and out as the bowl resonates.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
bowl = score.part("bowl", synth="singing_bowl_ring_synth", envelope="none",
|
||||
reverb=0.4)
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.3em 0 0.5em"><source src="../_static/audio/synth_singing_bowl_ring.wav" type="audio/wav"></audio>
|
||||
|
||||
Analog Oscillator Drift
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -743,7 +1018,7 @@ distorted_guitar, orange_crunch, metal_guitar, bass_guitar, upright_bass,
|
||||
harp, sitar, koto, banjo, mandolin, mandola, ukulele
|
||||
|
||||
**World/Exotic**: pedal_steel, theremin, kalimba, steel_drum, didgeridoo,
|
||||
bagpipe
|
||||
bagpipe, singing_bowl, singing_bowl_ring
|
||||
|
||||
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
|
||||
granular_pad, granular_texture, vocal, choir
|
||||
|
||||
+20
-10
@@ -46,23 +46,33 @@ it through your speakers, export MIDI, finish in your DAW:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from pytheory import Score, Pattern, Key, Duration, Chord
|
||||
from pytheory import Score, Key, Duration
|
||||
from pytheory.play import play_score
|
||||
|
||||
score = Score("4/4", bpm=140)
|
||||
score.drums("bossa nova", repeats=4)
|
||||
score = Score("4/4", bpm=120)
|
||||
score.drums("rock", repeats=8, fill="rock", fill_every=4)
|
||||
|
||||
chords = score.part("chords", synth="fm", envelope="pad", reverb=0.4)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck", delay=0.3)
|
||||
bass = score.part("bass", synth="sine", lowpass=500)
|
||||
piano = score.part("piano", instrument="piano", reverb=0.3)
|
||||
lead = score.part("lead", synth="saw", envelope="pluck",
|
||||
delay=0.2, reverb=0.2, lowpass=4000)
|
||||
bass = score.part("bass", synth="triangle", lowpass=900)
|
||||
|
||||
for chord in Key("A", "minor").progression("i", "iv", "V", "i"):
|
||||
chords.add(chord, Duration.WHOLE)
|
||||
for chord in Key("G", "major").progression("I", "V", "vi", "IV") * 2:
|
||||
piano.add(chord, Duration.WHOLE)
|
||||
|
||||
lead.arpeggio("Am", bars=4, pattern="updown", octaves=2)
|
||||
lead.add("D5", 1).add("B4", 0.5).add("D5", 0.5)
|
||||
lead.add("G5", 1).add("E5", 1)
|
||||
lead.add("D5", 0.5).add("B4", 0.5).add("A4", 1)
|
||||
lead.add("G4", 2).rest(2)
|
||||
|
||||
for n in ["G2", "G2", "D2", "D2", "E2", "E2", "C2", "C2"] * 2:
|
||||
bass.add(n, Duration.HALF)
|
||||
|
||||
play_score(score)
|
||||
score.save_midi("sketch.mid")
|
||||
|
||||
.. raw:: html
|
||||
|
||||
<audio controls style="width:100%;margin:0.5em 0 1.5em"><source src="_static/audio/quickstart.wav" type="audio/wav"></audio>
|
||||
|
||||
Or hear a randomly generated track from the command line — different
|
||||
every time::
|
||||
|
||||
+688
-10
@@ -543,7 +543,7 @@ def dub_kingston():
|
||||
volume=0.6, pan=0.0, lowpass=400, lowpass_q=1.5,
|
||||
humanize=0.2)
|
||||
siren = score.part("siren", synth="pwm_slow", envelope="pad",
|
||||
volume=0.15, pan=0.5,
|
||||
volume=0.15, pan=0.5, ensemble=4,
|
||||
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
|
||||
lowpass=1200, detune=10)
|
||||
|
||||
@@ -1124,14 +1124,14 @@ def cinematic_showcase():
|
||||
bells.rest(Duration.WHOLE)
|
||||
|
||||
# String ensemble — lush wide pad
|
||||
strings = score.part("strings", instrument="string_ensemble",
|
||||
strings = score.part("strings", instrument="string_ensemble", ensemble=8,
|
||||
reverb=0.4, reverb_type="hall")
|
||||
strings.rest(Duration.WHOLE)
|
||||
for sym in ["Am", "F", "C", "G", "Dm", "Am", "E"]:
|
||||
strings.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
|
||||
# Cello — deep foundation
|
||||
cello = score.part("cello", instrument="cello",
|
||||
cello = score.part("cello", instrument="cello", ensemble=3,
|
||||
reverb=0.3, reverb_type="hall")
|
||||
cello.rest(Duration.WHOLE)
|
||||
for n in ["A2", "F2", "C3", "G2", "D3", "A2", "E2"]:
|
||||
@@ -1629,7 +1629,7 @@ def epic_bhairav():
|
||||
timp.add(Tone("Pa", octave=2, system=shruti), Duration.HALF, velocity=115)
|
||||
|
||||
# Choir — bar 3
|
||||
choir = score.part("choir", synth="vocal_synth", envelope="pad",
|
||||
choir = score.part("choir", synth="vocal_synth", envelope="pad", ensemble=6,
|
||||
detune=8, spread=0.4, reverb=0.4, reverb_type=REV, volume=0.2)
|
||||
for _ in range(2):
|
||||
choir.rest(Duration.WHOLE)
|
||||
@@ -1651,7 +1651,7 @@ def epic_bhairav():
|
||||
bansuri.add(tone, dur, velocity=vel)
|
||||
|
||||
# Cello — bar 3
|
||||
cello = score.part("cello", instrument="cello", volume=0.22, reverb=0.4, reverb_type=REV)
|
||||
cello = score.part("cello", instrument="cello", volume=0.22, reverb=0.4, reverb_type=REV, ensemble=3)
|
||||
for _ in range(2):
|
||||
cello.rest(Duration.WHOLE)
|
||||
for name, dur, vel in [
|
||||
@@ -1677,7 +1677,7 @@ def epic_bhairav():
|
||||
|
||||
# Strings — bar 13
|
||||
strings = score.part("strings", instrument="string_ensemble", volume=0.18,
|
||||
reverb=0.4, reverb_type=REV)
|
||||
reverb=0.4, reverb_type=REV, ensemble=10)
|
||||
for _ in range(12):
|
||||
strings.rest(Duration.WHOLE)
|
||||
for name, dur, vel in [("Sa", 4.0, 58), ("Ma", 4.0, 62), ("Pa", 4.0, 68), ("Sa", 4.0, 72)]:
|
||||
@@ -1887,7 +1887,7 @@ def ascent():
|
||||
|
||||
# 3: SURFACING (5-8)
|
||||
cello = score.part("cello", instrument="cello", volume=0.22,
|
||||
reverb=0.4, reverb_type=REV)
|
||||
reverb=0.4, reverb_type=REV, ensemble=3)
|
||||
cello.rest(16.0)
|
||||
for note, dur, vel in [("E2",4.0,52),("G2",4.0,55),("B2",4.0,58),("E3",4.0,62)]:
|
||||
cello.add(note, dur, velocity=vel)
|
||||
@@ -1943,7 +1943,7 @@ def ascent():
|
||||
theremin.add(note, dur, velocity=vel)
|
||||
|
||||
strings = score.part("strings", instrument="string_ensemble", volume=0.15,
|
||||
reverb=0.45, reverb_type=REV)
|
||||
reverb=0.45, reverb_type=REV, ensemble=8)
|
||||
strings.rest(40.0)
|
||||
for sym, vel in [("Em",52),("C",55),("Am",58),("B",55),("Em",60),("C",62)]:
|
||||
strings.add(Chord.from_symbol(sym), 4.0, velocity=vel)
|
||||
@@ -2218,7 +2218,7 @@ def pop_rock():
|
||||
cabinet=1.0, cabinet_brightness=0.6,
|
||||
reverb=0.2, reverb_type="plate", pan=0.2)
|
||||
strings = score.part("strings", instrument="string_ensemble", volume=0.12,
|
||||
reverb=0.35, reverb_type="hall")
|
||||
reverb=0.35, reverb_type="hall", ensemble=6)
|
||||
|
||||
prog = ["G", "D", "Em", "C"]
|
||||
|
||||
@@ -2324,6 +2324,681 @@ def sitar_drone():
|
||||
play_song(score, "Sitar Drone — Raga Bhairav (22-Shruti JI, hold() polyphony)")
|
||||
|
||||
|
||||
def acid_tabla():
|
||||
"""Acid Tabla — 303 filter automation meets Indian percussion."""
|
||||
score = Score("4/4", bpm=132)
|
||||
|
||||
# ── House drums ──
|
||||
score.drums("house", repeats=20, fill="house", fill_every=8)
|
||||
score.set_drum_effects(volume=0.45)
|
||||
|
||||
# ── 303 acid bass ──
|
||||
acid = score.part("acid", synth="saw", volume=0.75,
|
||||
legato=True, glide=0.035,
|
||||
distortion=0.35, distortion_drive=4.5,
|
||||
saturation=0.15, humanize=0.05)
|
||||
|
||||
# Intro (4 bars): filter closed, high resonance
|
||||
acid.set(lowpass=600, lowpass_q=12.0)
|
||||
for _ in range(4):
|
||||
for n in ["C3","C3","C2","C3","Eb3","C2","G2","C3"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
|
||||
# Build (4 bars): filter opens
|
||||
acid.ramp(over=Duration.WHOLE * 4, curve="ease_in", lowpass=4500)
|
||||
for _ in range(4):
|
||||
for n in ["C2","G2","C3","Eb3","C2","Bb2","G2","C3"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
|
||||
# Peak (4 bars): wide open, wilder pattern
|
||||
acid.set(lowpass=7000, lowpass_q=7.0)
|
||||
for _ in range(2):
|
||||
for n in ["C2","C3","Eb3","G3","C2","Bb2","G2","Eb3"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
for _ in range(2):
|
||||
for n in ["C2","Eb3","C3","G3","Bb2","C3","G2","C2"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
|
||||
# Tabla section (4 bars): filter pulls back
|
||||
acid.set(lowpass=3000, lowpass_q=5.0)
|
||||
for _ in range(4):
|
||||
for n in ["C2","G2","C3","C2","Eb2","G2","Bb2","C2"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
|
||||
# Outro (4 bars): filter closes
|
||||
acid.ramp(over=Duration.WHOLE * 4, curve="ease_out", lowpass=400, lowpass_q=15.0)
|
||||
for _ in range(4):
|
||||
for n in ["C3","G2","C2","C3","C2","G2","Eb2","C2"]:
|
||||
acid.add(n, Duration.EIGHTH)
|
||||
|
||||
# ── Tabla: enters bar 9, rides through to the end ──
|
||||
tabla = score.part("tabla", synth="sine", volume=0.55, reverb=0.15)
|
||||
|
||||
# 8 bars rest
|
||||
for _ in range(64):
|
||||
tabla.rest(Duration.EIGHTH)
|
||||
|
||||
# Bars 9-12: keherwa groove
|
||||
for _ in range(4):
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=100, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=55)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=50)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=88)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=82)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=52)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=95, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=78)
|
||||
|
||||
# Bars 13-14: busier with 16ths
|
||||
for _ in range(2):
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=105, articulation="marcato")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=52)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=90)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=85)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=58)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=100, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.EIGHTH, velocity=88)
|
||||
|
||||
# Bars 15-16: tihai crescendo ending
|
||||
for vel in [85, 90, 95]:
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=vel, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=int(vel * 0.6))
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=int(vel * 0.75))
|
||||
for vel in [100, 105, 110]:
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=vel, articulation="marcato")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=int(vel * 0.55))
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=int(vel * 0.7))
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.QUARTER, velocity=127, articulation="fermata")
|
||||
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.QUARTER, velocity=110)
|
||||
tabla.rest(Duration.HALF)
|
||||
|
||||
# Bars 17-20: tabla continues through outro, lighter
|
||||
for _ in range(4):
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=85, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=45)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=75)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=70)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=80)
|
||||
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=65)
|
||||
|
||||
# ── Pad: enters at peak, fades during outro ──
|
||||
pad = score.part("pad", synth="supersaw", envelope="pad", volume=0.0,
|
||||
reverb=0.4, chorus=0.2, detune=10, lowpass=2500)
|
||||
for _ in range(32):
|
||||
pad.rest(Duration.QUARTER)
|
||||
pad.ramp(over=Duration.WHOLE * 2, volume=0.18)
|
||||
for sym in ["Cm", "Ab", "Eb", "Bb"] * 3:
|
||||
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
pad.ramp(over=Duration.WHOLE * 2, curve="ease_out", volume=0.0)
|
||||
for sym in ["Cm", "Cm"]:
|
||||
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
|
||||
|
||||
play_song(score, "Acid Tabla — 303 filter automation + tabla (ramp, articulations, Part.hit)")
|
||||
|
||||
|
||||
def snare_cadence():
|
||||
"""Snare Cadence — full drumline with ensemble, flams, diddles, cheese."""
|
||||
score = Score("4/4", bpm=120)
|
||||
|
||||
S = DrumSound.MARCH_SNARE
|
||||
R = DrumSound.MARCH_RIMSHOT
|
||||
C = DrumSound.MARCH_CLICK
|
||||
Q1 = DrumSound.QUAD_1
|
||||
Q2 = DrumSound.QUAD_2
|
||||
Q3 = DrumSound.QUAD_3
|
||||
Q4 = DrumSound.QUAD_4
|
||||
QS = DrumSound.QUAD_SPOCK
|
||||
B1 = DrumSound.BASS_1
|
||||
B2 = DrumSound.BASS_2
|
||||
B3 = DrumSound.BASS_3
|
||||
B4 = DrumSound.BASS_4
|
||||
B5 = DrumSound.BASS_5
|
||||
|
||||
# Snare line — 8 players
|
||||
p = score.part("snares", synth="sine", volume=0.9, reverb=0.2, ensemble=8)
|
||||
# Quad line — 4 players
|
||||
q = score.part("quads", synth="sine", volume=0.5, reverb=0.2, ensemble=4)
|
||||
# Bass line — 5 players
|
||||
b = score.part("basses", synth="sine", volume=0.55, reverb=0.2, ensemble=5)
|
||||
|
||||
_trip = 1.0 / 3
|
||||
|
||||
# Helper: bass split run (down or up)
|
||||
def bass_down(dur=Duration.SIXTEENTH):
|
||||
b.hit(B1, dur, velocity=95)
|
||||
b.hit(B2, dur, velocity=90)
|
||||
b.hit(B3, dur, velocity=85)
|
||||
b.hit(B4, dur, velocity=90)
|
||||
|
||||
def bass_up(dur=Duration.SIXTEENTH):
|
||||
b.hit(B4, dur, velocity=90)
|
||||
b.hit(B3, dur, velocity=85)
|
||||
b.hit(B2, dur, velocity=90)
|
||||
b.hit(B1, dur, velocity=95)
|
||||
|
||||
def bass_hit(dur=Duration.QUARTER):
|
||||
b.hit(B3, dur, velocity=100)
|
||||
|
||||
def quad_sweep_down():
|
||||
q.hit(Q1, Duration.SIXTEENTH, velocity=95)
|
||||
q.hit(Q2, Duration.SIXTEENTH, velocity=88)
|
||||
q.hit(Q3, Duration.SIXTEENTH, velocity=82)
|
||||
q.hit(Q4, Duration.SIXTEENTH, velocity=78)
|
||||
|
||||
def quad_sweep_up():
|
||||
q.hit(Q4, Duration.SIXTEENTH, velocity=78)
|
||||
q.hit(Q3, Duration.SIXTEENTH, velocity=82)
|
||||
q.hit(Q2, Duration.SIXTEENTH, velocity=88)
|
||||
q.hit(Q1, Duration.SIXTEENTH, velocity=95)
|
||||
|
||||
# ── Click count-off ──
|
||||
for _ in range(4):
|
||||
p.hit(C, Duration.QUARTER, velocity=95)
|
||||
q.rest(Duration.QUARTER)
|
||||
b.rest(Duration.QUARTER)
|
||||
|
||||
# ── Section 1: 16th groove — snares only (4 bars) ──
|
||||
for _ in range(16):
|
||||
q.rest(Duration.QUARTER)
|
||||
b.rest(Duration.QUARTER)
|
||||
|
||||
for _ in range(2):
|
||||
p.hit(R, Duration.SIXTEENTH, velocity=118)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=35)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(R, Duration.SIXTEENTH, velocity=115)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.hit(R, Duration.SIXTEENTH, velocity=118)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=35)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.hit(R, Duration.SIXTEENTH, velocity=120)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
|
||||
# Triplets mixed in
|
||||
for _ in range(2):
|
||||
p.hit(R, _trip, velocity=118)
|
||||
p.hit(S, _trip, velocity=32)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.hit(R, _trip, velocity=115)
|
||||
p.hit(S, _trip, velocity=28)
|
||||
p.hit(S, _trip, velocity=32)
|
||||
p.hit(R, Duration.SIXTEENTH, velocity=118)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
p.hit(R, _trip, velocity=118)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.hit(S, _trip, velocity=35)
|
||||
|
||||
# ── Section 2: Quads + bass enter (4 bars) ──
|
||||
for _ in range(2):
|
||||
p.flam(S, Duration.QUARTER, velocity=118)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.hit(R, _trip, velocity=118)
|
||||
p.hit(S, _trip, velocity=28)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.flam(S, Duration.QUARTER, velocity=118)
|
||||
|
||||
quad_sweep_down()
|
||||
q.hit(QS, Duration.QUARTER, velocity=100)
|
||||
quad_sweep_up()
|
||||
q.hit(QS, Duration.QUARTER, velocity=100)
|
||||
|
||||
bass_hit()
|
||||
b.hit(B5, Duration.QUARTER, velocity=95)
|
||||
bass_hit()
|
||||
b.hit(B1, Duration.QUARTER, velocity=95)
|
||||
|
||||
for _ in range(2):
|
||||
p.hit(S, _trip, velocity=35)
|
||||
p.flam(S, _trip * 2, velocity=118)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
p.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
p.flam(S, Duration.QUARTER, velocity=118)
|
||||
p.hit(S, _trip, velocity=28)
|
||||
p.hit(R, _trip, velocity=122)
|
||||
p.hit(S, _trip, velocity=35)
|
||||
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
q.hit(Q1, Duration.EIGHTH, velocity=95)
|
||||
q.hit(Q4, Duration.EIGHTH, velocity=85)
|
||||
q.hit(QS, Duration.QUARTER, velocity=100)
|
||||
|
||||
bass_down()
|
||||
bass_up()
|
||||
b.hit(B3, Duration.HALF, velocity=100)
|
||||
|
||||
# ── Section 3: Flams + diddles + full line (4 bars) ──
|
||||
for _ in range(2):
|
||||
p.flam(S, Duration.QUARTER, velocity=120)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=45)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.hit(S, _trip, velocity=32)
|
||||
p.hit(S, _trip, velocity=28)
|
||||
p.hit(R, Duration.EIGHTH, velocity=122)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=42)
|
||||
|
||||
q.hit(Q1, Duration.QUARTER, velocity=95)
|
||||
q.hit(Q3, Duration.EIGHTH, velocity=55)
|
||||
q.hit(Q2, _trip, velocity=55)
|
||||
q.hit(Q3, _trip, velocity=55)
|
||||
q.hit(Q4, _trip, velocity=55)
|
||||
q.hit(QS, Duration.EIGHTH, velocity=100)
|
||||
q.hit(Q1, Duration.EIGHTH, velocity=55)
|
||||
|
||||
bass_hit()
|
||||
b.hit(B1, Duration.EIGHTH, velocity=90)
|
||||
b.hit(B5, Duration.EIGHTH, velocity=95)
|
||||
bass_hit()
|
||||
b.hit(B5, Duration.EIGHTH, velocity=90)
|
||||
b.hit(B1, Duration.EIGHTH, velocity=95)
|
||||
|
||||
for _ in range(2):
|
||||
p.diddle(S, Duration.EIGHTH, velocity=45)
|
||||
p.hit(R, _trip, velocity=120)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.hit(S, _trip, velocity=32)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=48)
|
||||
p.hit(R, _trip, velocity=118)
|
||||
p.hit(S, _trip, velocity=28)
|
||||
p.hit(S, _trip, velocity=30)
|
||||
p.flam(S, Duration.EIGHTH, velocity=122)
|
||||
p.hit(S, Duration.EIGHTH, velocity=35)
|
||||
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
|
||||
bass_down()
|
||||
bass_up()
|
||||
bass_down()
|
||||
bass_up()
|
||||
|
||||
# ── Section 4: Cheese + 32nds (4 bars) ──
|
||||
for _ in range(2):
|
||||
p.cheese(S, Duration.QUARTER, velocity=120)
|
||||
p.hit(S, 0.0625, velocity=30)
|
||||
p.hit(S, 0.0625, velocity=32)
|
||||
p.hit(S, 0.0625, velocity=35)
|
||||
p.hit(S, 0.0625, velocity=30)
|
||||
p.cheese(S, Duration.QUARTER, velocity=118)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=48)
|
||||
p.hit(R, Duration.EIGHTH, velocity=125)
|
||||
|
||||
q.hit(QS, Duration.QUARTER, velocity=105)
|
||||
q.hit(Q1, Duration.SIXTEENTH, velocity=55)
|
||||
q.hit(Q2, Duration.SIXTEENTH, velocity=55)
|
||||
q.hit(Q3, Duration.SIXTEENTH, velocity=55)
|
||||
q.hit(Q4, Duration.SIXTEENTH, velocity=55)
|
||||
q.hit(QS, Duration.QUARTER, velocity=105)
|
||||
q.hit(Q4, Duration.EIGHTH, velocity=55)
|
||||
q.hit(Q1, Duration.EIGHTH, velocity=90)
|
||||
|
||||
bass_hit()
|
||||
b.hit(B1, Duration.EIGHTH, velocity=90)
|
||||
b.hit(B3, Duration.EIGHTH, velocity=85)
|
||||
b.hit(B5, Duration.EIGHTH, velocity=95)
|
||||
b.hit(B3, Duration.EIGHTH, velocity=85)
|
||||
bass_hit()
|
||||
b.rest(Duration.QUARTER)
|
||||
|
||||
# All cheese
|
||||
p.cheese(S, Duration.QUARTER, velocity=122)
|
||||
p.cheese(S, Duration.QUARTER, velocity=120)
|
||||
p.cheese(S, Duration.QUARTER, velocity=125)
|
||||
p.cheese(S, Duration.QUARTER, velocity=122)
|
||||
|
||||
q.hit(QS, Duration.QUARTER, velocity=105)
|
||||
q.hit(QS, Duration.QUARTER, velocity=105)
|
||||
q.hit(QS, Duration.QUARTER, velocity=108)
|
||||
q.hit(QS, Duration.QUARTER, velocity=105)
|
||||
|
||||
b.hit(B5, Duration.QUARTER, velocity=100)
|
||||
b.hit(B3, Duration.QUARTER, velocity=100)
|
||||
b.hit(B1, Duration.QUARTER, velocity=100)
|
||||
b.hit(B3, Duration.QUARTER, velocity=100)
|
||||
|
||||
p.flam(S, Duration.EIGHTH, velocity=120)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=50)
|
||||
p.flam(S, Duration.EIGHTH, velocity=122)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=52)
|
||||
p.flam(S, Duration.EIGHTH, velocity=125)
|
||||
p.diddle(S, Duration.EIGHTH, velocity=55)
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
p.hit(S, Duration.EIGHTH, velocity=38)
|
||||
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
|
||||
bass_down()
|
||||
bass_up()
|
||||
bass_down()
|
||||
bass_up()
|
||||
|
||||
# ── Section 5: 16ths + triplet 16ths + 32nds (4 bars) ──
|
||||
_trip16 = 1.0 / 6
|
||||
|
||||
for _ in range(2):
|
||||
for beat in range(4):
|
||||
p.hit(R, _trip, velocity=118)
|
||||
p.hit(S, _trip, velocity=35)
|
||||
p.hit(S, _trip, velocity=32)
|
||||
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
quad_sweep_down()
|
||||
quad_sweep_up()
|
||||
|
||||
bass_hit()
|
||||
b.hit(B5, Duration.QUARTER, velocity=95)
|
||||
bass_hit()
|
||||
b.hit(B1, Duration.QUARTER, velocity=95)
|
||||
|
||||
# 32nd run crescendo
|
||||
for i in range(32):
|
||||
p.hit(S, 0.0625, velocity=min(22 + i * 3, 92))
|
||||
p.hit(R, Duration.EIGHTH, velocity=125)
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
|
||||
for _ in range(4):
|
||||
q.hit(Q1, 0.0625, velocity=55)
|
||||
q.hit(Q2, 0.0625, velocity=55)
|
||||
q.hit(Q3, 0.0625, velocity=55)
|
||||
q.hit(Q4, 0.0625, velocity=55)
|
||||
q.hit(QS, Duration.QUARTER, velocity=108)
|
||||
|
||||
bass_down()
|
||||
bass_up()
|
||||
bass_down()
|
||||
b.hit(B5, Duration.QUARTER, velocity=100)
|
||||
b.hit(B1, Duration.QUARTER, velocity=100)
|
||||
|
||||
# Triplet 16ths — all sections
|
||||
for _ in range(2):
|
||||
for beat in range(4):
|
||||
p.hit(R, _trip16, velocity=115)
|
||||
p.hit(S, _trip16, velocity=30)
|
||||
p.hit(S, _trip16, velocity=32)
|
||||
p.hit(R, _trip16, velocity=112)
|
||||
p.hit(S, _trip16, velocity=28)
|
||||
p.hit(S, _trip16, velocity=30)
|
||||
|
||||
for beat in range(4):
|
||||
q.hit(Q1, _trip16, velocity=90)
|
||||
q.hit(Q2, _trip16, velocity=55)
|
||||
q.hit(Q3, _trip16, velocity=55)
|
||||
q.hit(Q4, _trip16, velocity=55)
|
||||
q.hit(Q3, _trip16, velocity=55)
|
||||
q.hit(Q2, _trip16, velocity=55)
|
||||
|
||||
bass_down()
|
||||
bass_up()
|
||||
bass_down()
|
||||
bass_up()
|
||||
|
||||
# ── Section 6: Buzz roll climax (2 bars) ──
|
||||
for i in range(64):
|
||||
p.hit(S, 0.0625, velocity=min(20 + i * 1.5, 100))
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
|
||||
for i in range(32):
|
||||
q.hit([Q1, Q2, Q3, Q4][i % 4], 0.0625, velocity=min(40 + i * 2, 95))
|
||||
q.hit(QS, Duration.QUARTER, velocity=110)
|
||||
|
||||
for i in range(16):
|
||||
b.hit([B1, B2, B3, B4, B5, B4, B3, B2,
|
||||
B1, B2, B3, B4, B5, B4, B3, B2][i], Duration.SIXTEENTH, velocity=90)
|
||||
b.hit(B3, Duration.HALF, velocity=100)
|
||||
b.hit(B3, Duration.HALF, velocity=100)
|
||||
|
||||
# ── Ending: big unison hits ──
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
q.hit(QS, Duration.EIGHTH, velocity=110)
|
||||
b.hit(B3, Duration.EIGHTH, velocity=100)
|
||||
|
||||
p.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
q.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
b.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
|
||||
p.hit(R, Duration.EIGHTH, velocity=127)
|
||||
q.hit(QS, Duration.EIGHTH, velocity=110)
|
||||
b.hit(B3, Duration.EIGHTH, velocity=100)
|
||||
|
||||
p.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
q.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
b.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
|
||||
# Flam into final CRACK — all sections
|
||||
p.flam(S, Duration.EIGHTH, velocity=127)
|
||||
q.hit(QS, Duration.EIGHTH, velocity=110)
|
||||
b.hit(B3, Duration.EIGHTH, velocity=100)
|
||||
|
||||
p.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
q.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
b.rest(Duration.QUARTER + Duration.EIGHTH)
|
||||
|
||||
p.hit(R, Duration.QUARTER, velocity=127)
|
||||
q.hit(QS, Duration.QUARTER, velocity=110)
|
||||
b.hit(B3, Duration.QUARTER, velocity=100)
|
||||
|
||||
p.rest(Duration.HALF)
|
||||
q.rest(Duration.HALF)
|
||||
b.rest(Duration.HALF)
|
||||
|
||||
play_song(score, "Snare Cadence — full drumline (8 snares, 4 quads, 5 basses)")
|
||||
|
||||
|
||||
def ensemble_showcase():
|
||||
"""Ensemble Showcase — acid bass, tabla solo, strings, snare line."""
|
||||
score = Score("4/4", bpm=128)
|
||||
|
||||
# ── Drums: house kit ──
|
||||
score.drums("house", repeats=24, fill="house", fill_every=8)
|
||||
score.set_drum_effects(volume=0.4, reverb=0.1)
|
||||
|
||||
# ── 303 Acid Bass: detuned, spread, LFO filter, ensemble=3 ──
|
||||
acid = score.part("acid", synth="saw", volume=0.55, ensemble=3,
|
||||
lowpass=400, lowpass_q=10.0, distortion=0.35,
|
||||
distortion_drive=4.0, legato=True, glide=0.03,
|
||||
detune=12, spread=0.4, sub_osc=0.15,
|
||||
sidechain=0.5, sidechain_release=0.08)
|
||||
|
||||
acid.lfo("lowpass", rate=0.5, min=400, max=5000, bars=16, shape="sine")
|
||||
for _ in range(8):
|
||||
for n in ["C2", "C3", "C2", "Eb2", "C2", "G2", "Bb2", "C2"]:
|
||||
acid.add(n, Duration.EIGHTH, velocity=90)
|
||||
|
||||
acid.ramp(over=Duration.WHOLE * 8, curve="ease_in", lowpass=6000)
|
||||
for _ in range(8):
|
||||
for n in ["C2", "Eb3", "C3", "G2", "Bb2", "C3", "G2", "C2"]:
|
||||
acid.add(n, Duration.EIGHTH, velocity=95)
|
||||
|
||||
acid.ramp(over=Duration.WHOLE * 8, curve="ease_out", lowpass=500)
|
||||
for _ in range(8):
|
||||
for n in ["C2", "C3", "C2", "Eb2", "C2", "G2", "Bb2", "C2"]:
|
||||
acid.add(n, Duration.EIGHTH, velocity=88)
|
||||
|
||||
# ── Strings: 16-player ensemble pad ──
|
||||
strings = score.part("strings", instrument="string_ensemble", volume=0.0,
|
||||
reverb=0.4, ensemble=16, detune=8, spread=0.5)
|
||||
|
||||
for _ in range(32):
|
||||
strings.rest(Duration.QUARTER)
|
||||
strings.ramp(over=Duration.WHOLE * 4, curve="ease_in", volume=0.18)
|
||||
for ch in ["Cm", "Ab", "Eb", "Bb"] * 4:
|
||||
strings.add(Chord.from_symbol(ch), Duration.WHOLE, velocity=55)
|
||||
strings.ramp(over=Duration.WHOLE * 4, curve="ease_out", volume=0.0)
|
||||
for ch in ["Cm", "Ab", "Eb", "Bb"]:
|
||||
strings.add(Chord.from_symbol(ch), Duration.WHOLE, velocity=45)
|
||||
for _ in range(16):
|
||||
strings.rest(Duration.QUARTER)
|
||||
|
||||
# ── Tabla: ensemble=3, enters bar 9 ──
|
||||
tabla = score.part("tabla", synth="sine", volume=0.0, reverb=0.15, ensemble=3)
|
||||
|
||||
for _ in range(32):
|
||||
tabla.rest(Duration.QUARTER)
|
||||
tabla.ramp(over=Duration.WHOLE * 2, volume=0.45)
|
||||
|
||||
# Keherwa groove — 8 bars
|
||||
for _ in range(8):
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=95, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=50)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=82)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=78)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=88, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=72)
|
||||
|
||||
# Tabla solo — getting busier
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=100, articulation="marcato")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=45)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=85)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=80)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=45)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=55)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=95, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.EIGHTH, velocity=82)
|
||||
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=105, articulation="marcato")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=55)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=45)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=48)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=88)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.SIXTEENTH, velocity=100)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=50)
|
||||
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.EIGHTH, velocity=85)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=108, articulation="accent")
|
||||
|
||||
# Tihai crescendo
|
||||
for vel in [85, 90, 95, 100, 105, 110]:
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=vel, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.SIXTEENTH, velocity=int(vel * 0.55))
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.SIXTEENTH, velocity=int(vel * 0.7))
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.QUARTER, velocity=125, articulation="fermata")
|
||||
tabla.hit(DrumSound.TABLA_GE_BEND, Duration.QUARTER, velocity=110)
|
||||
|
||||
# Groove out
|
||||
for _ in range(4):
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=88, articulation="accent")
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=45)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
|
||||
tabla.hit(DrumSound.TABLA_NA, Duration.EIGHTH, velocity=75)
|
||||
tabla.hit(DrumSound.TABLA_TIN, Duration.EIGHTH, velocity=70)
|
||||
tabla.hit(DrumSound.TABLA_TIT, Duration.EIGHTH, velocity=42)
|
||||
tabla.hit(DrumSound.TABLA_DHA, Duration.EIGHTH, velocity=80)
|
||||
tabla.hit(DrumSound.TABLA_GE, Duration.EIGHTH, velocity=65)
|
||||
|
||||
# ── Snare line: 8-player ensemble, enters bar 17 ──
|
||||
S = DrumSound.MARCH_SNARE
|
||||
R = DrumSound.MARCH_RIMSHOT
|
||||
|
||||
snares = score.part("snares", synth="sine", volume=0.0, reverb=0.15, ensemble=8)
|
||||
|
||||
for _ in range(64):
|
||||
snares.rest(Duration.QUARTER)
|
||||
snares.ramp(over=Duration.WHOLE * 2, volume=0.7)
|
||||
|
||||
for _ in range(4):
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=118)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=115)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=118)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=35)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=120)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
|
||||
# Buzz roll finale
|
||||
for i in range(64):
|
||||
snares.hit(S, 0.0625, velocity=min(20 + i * 1.5, 100))
|
||||
snares.hit(R, Duration.EIGHTH, velocity=127)
|
||||
snares.hit(R, Duration.EIGHTH, velocity=127)
|
||||
|
||||
snares.ramp(over=Duration.WHOLE * 2, curve="ease_out", volume=0.0)
|
||||
for _ in range(2):
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=110)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=108)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=105)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
snares.hit(R, Duration.SIXTEENTH, velocity=100)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=30)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=28)
|
||||
snares.hit(S, Duration.SIXTEENTH, velocity=32)
|
||||
|
||||
# ── Lead synth: 6-player ensemble, enters bar 5 ──
|
||||
lead = score.part("lead", synth="saw", envelope="pluck", volume=0.0,
|
||||
lowpass=3500, detune=6, chorus=0.1, reverb=0.2,
|
||||
delay=0.15, delay_time=0.33, delay_feedback=0.25,
|
||||
ensemble=6)
|
||||
|
||||
for _ in range(16):
|
||||
lead.rest(Duration.QUARTER)
|
||||
lead.ramp(over=Duration.WHOLE * 2, volume=0.3)
|
||||
|
||||
for _ in range(2):
|
||||
lead.add("Eb5", Duration.QUARTER, velocity=88)
|
||||
lead.add("G5", Duration.QUARTER, velocity=92)
|
||||
lead.add("Bb5", Duration.HALF, velocity=95, articulation="accent")
|
||||
lead.add("Ab5", Duration.QUARTER, velocity=88)
|
||||
lead.add("G5", Duration.QUARTER, velocity=85)
|
||||
lead.add("Eb5", Duration.QUARTER, velocity=82)
|
||||
lead.add("D5", Duration.QUARTER, velocity=80)
|
||||
|
||||
lead.swell(["Eb5", "G5", "Bb5", "C6", "Bb5", "G5", "Eb5", "D5"],
|
||||
Duration.QUARTER, low_vel=75, peak_vel=105)
|
||||
lead.decrescendo(["Eb5", "D5", "C5", "Bb4"], Duration.HALF,
|
||||
start_vel=90, end_vel=60)
|
||||
|
||||
for _ in range(16):
|
||||
lead.rest(Duration.QUARTER)
|
||||
|
||||
lead.ramp(over=Duration.WHOLE * 2, volume=0.35)
|
||||
lead.crescendo(["C5", "Eb5", "G5", "Bb5", "C6", "Eb6", "C6", "Bb5"],
|
||||
Duration.QUARTER, start_vel=80, end_vel=110)
|
||||
lead.add("G5", Duration.HALF, velocity=105, bend=1, bend_type="smooth")
|
||||
lead.add("Eb5", Duration.HALF, velocity=95)
|
||||
lead.decrescendo(["C5", "Bb4", "G4", "Eb4"], Duration.WHOLE,
|
||||
start_vel=85, end_vel=40)
|
||||
|
||||
play_song(score, "Ensemble Showcase — acid bass, tabla solo, 16-player strings, 8-player snare line")
|
||||
|
||||
|
||||
SONGS = {
|
||||
"1": ("Bossa Nova in A minor", bossa_nova_girl),
|
||||
"2": ("Bebop in Bb major", bebop_in_bb),
|
||||
@@ -2355,6 +3030,9 @@ SONGS = {
|
||||
"28": ("Descent (Generative — different every time)", descent),
|
||||
"29": ("Pop Rock (I-V-vi-IV)", pop_rock),
|
||||
"30": ("Sitar Drone (Bhairav, hold() polyphony)", sitar_drone),
|
||||
"31": ("Acid Tabla (303 + tabla, ramp, articulations)", acid_tabla),
|
||||
"32": ("Snare Cadence (marching snare, flams, diddles)", snare_cadence),
|
||||
"33": ("Ensemble Showcase (acid+tabla+strings+snare line)", ensemble_showcase),
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -2368,7 +3046,7 @@ if __name__ == "__main__":
|
||||
print(f" {key:>2}. {name}")
|
||||
|
||||
print()
|
||||
choice = input(" Pick a song (1-30, or 'all'): ").strip()
|
||||
choice = input(" Pick a song (1-33, or 'all'): ").strip()
|
||||
print()
|
||||
|
||||
if choice == "all":
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Sprunki Simon Phase 1 — melody reference.
|
||||
|
||||
Notes transcribed from MIDI. Use as a base for arrangements.
|
||||
|
||||
Usage:
|
||||
python examples/sprunki.py
|
||||
"""
|
||||
|
||||
import sounddevice as sd
|
||||
|
||||
from pytheory import Score, Duration
|
||||
from pytheory.play import render_score, SAMPLE_RATE
|
||||
|
||||
|
||||
def sprunki_simon():
|
||||
score = Score("4/4", bpm=200)
|
||||
|
||||
lead = score.part("lead", synth="square", envelope="pluck", volume=0.5,
|
||||
lowpass=4500, detune=3, reverb=0.1)
|
||||
|
||||
# Phrase A
|
||||
lead.add("E4", 1.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.rest(1.5)
|
||||
lead.add("A4", 0.5)
|
||||
lead.add("B4", 1.0)
|
||||
lead.add("A4", 1.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.add("D4", 1.0)
|
||||
|
||||
# Phrase B
|
||||
lead.add("E4", 1.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.rest(1.5)
|
||||
lead.add("A4", 0.5)
|
||||
lead.add("D4", 2.0)
|
||||
lead.add("B3", 1.0)
|
||||
lead.add("A3", 0.5)
|
||||
lead.add("D4", 0.5)
|
||||
|
||||
# Phrase C
|
||||
lead.add("E4", 1.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.rest(1.5)
|
||||
lead.add("A4", 0.5)
|
||||
lead.add("B4", 1.0)
|
||||
lead.add("A4", 1.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.add("B4", 1.0)
|
||||
|
||||
# Phrase D
|
||||
lead.add("A4", 2.0)
|
||||
lead.add("G4", 1.0)
|
||||
lead.add("E4", 1.0)
|
||||
lead.add("B3", 2.0)
|
||||
lead.add("D4", 2.0)
|
||||
|
||||
return score
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
score = sprunki_simon()
|
||||
print(" Sprunki Simon Phase 1")
|
||||
try:
|
||||
buf = render_score(score)
|
||||
sd.play(buf, SAMPLE_RATE)
|
||||
sd.wait()
|
||||
except KeyboardInterrupt:
|
||||
sd.stop()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Play a recorded MIDI file through pytheory's full renderer.
|
||||
|
||||
Takes a MIDI file captured by the live engine and plays it back
|
||||
through the complete synthesis pipeline — with ensemble, effects,
|
||||
reverb, and master compression.
|
||||
|
||||
Usage:
|
||||
python play_recording.py recording.mid
|
||||
python play_recording.py recording.mid --bpm 110
|
||||
"""
|
||||
|
||||
import sys
|
||||
import sounddevice as sd
|
||||
|
||||
from pytheory import Score
|
||||
from pytheory.play import render_score, SAMPLE_RATE
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(" Usage: python play_recording.py <file.mid> [--bpm N]")
|
||||
return
|
||||
|
||||
filename = sys.argv[1]
|
||||
bpm = None
|
||||
if "--bpm" in sys.argv:
|
||||
idx = sys.argv.index("--bpm")
|
||||
if idx + 1 < len(sys.argv):
|
||||
bpm = int(sys.argv[idx + 1])
|
||||
|
||||
print(f" Loading {filename}...")
|
||||
score = Score.from_midi(filename)
|
||||
|
||||
if bpm:
|
||||
score.bpm = bpm
|
||||
|
||||
print(f" {score}")
|
||||
print(f" Rendering...")
|
||||
|
||||
buf = render_score(score)
|
||||
duration = len(buf) / SAMPLE_RATE
|
||||
|
||||
print(f" Playing ({duration:.1f}s)...")
|
||||
try:
|
||||
sd.play(buf, SAMPLE_RATE)
|
||||
sd.wait()
|
||||
except KeyboardInterrupt:
|
||||
sd.stop()
|
||||
print("\n Stopped.")
|
||||
|
||||
print(" Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.38.1"
|
||||
version = "0.40.2"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@@ -21,9 +21,9 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
dependencies = [
|
||||
"numeral",
|
||||
"sounddevice",
|
||||
"scipy",
|
||||
"rich>=14.3.3",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.38.1"
|
||||
__version__ = "0.40.2"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
@@ -23,7 +23,7 @@ __all__ = [
|
||||
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
|
||||
"System", "SYSTEMS", "TET", "CHARTS", "charts_for_fretboard",
|
||||
"play", "save", "save_midi", "play_progression", "play_pattern",
|
||||
"play_score", "Synth", "Envelope",
|
||||
"play_score", "render_score", "Synth", "Envelope",
|
||||
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
|
||||
"DrumSound", "Pattern", "Section", "INSTRUMENTS",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,43 @@ import math
|
||||
|
||||
REFERENCE_A = 440
|
||||
|
||||
# ── Roman numeral helpers (replaces `numeral` package) ───────────────────
|
||||
|
||||
_ROMAN_MAP = [
|
||||
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
||||
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
||||
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
|
||||
]
|
||||
|
||||
_ROMAN_VALUES = {
|
||||
"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000,
|
||||
}
|
||||
|
||||
|
||||
def int2roman(n: int) -> str:
|
||||
"""Convert an integer to an uppercase Roman numeral string."""
|
||||
result = []
|
||||
for value, numeral in _ROMAN_MAP:
|
||||
while n >= value:
|
||||
result.append(numeral)
|
||||
n -= value
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def roman2int(s: str) -> int:
|
||||
"""Convert a Roman numeral string (case-insensitive) to an integer."""
|
||||
s = s.upper()
|
||||
total = 0
|
||||
prev = 0
|
||||
for ch in reversed(s):
|
||||
val = _ROMAN_VALUES.get(ch, 0)
|
||||
if val < prev:
|
||||
total -= val
|
||||
else:
|
||||
total += val
|
||||
prev = val
|
||||
return total
|
||||
|
||||
# Index of C in the Western tone list (A=0, A#=1, B=2, C=3, ...).
|
||||
# Scientific pitch notation changes octave at C, not A, so this offset
|
||||
# is needed for all octave arithmetic.
|
||||
|
||||
+2
-2
@@ -849,7 +849,7 @@ class Chord:
|
||||
>>> Chord([D4, F4, A4]).analyze("C")
|
||||
'ii'
|
||||
"""
|
||||
import numeral as numeral_mod
|
||||
from ._statics import int2roman
|
||||
from .scales import TonedScale
|
||||
from .systems import SYSTEMS
|
||||
from .tones import Tone
|
||||
@@ -874,7 +874,7 @@ class Chord:
|
||||
scale_names = [t.name for t in scale.tones[:-1]]
|
||||
|
||||
def _build_numeral(root, quality, degree_idx, prefix=""):
|
||||
numeral_str = numeral_mod.int2roman(degree_idx + 1, only_ascii=True)
|
||||
numeral_str = int2roman(degree_idx + 1)
|
||||
suffix = ""
|
||||
if "minor" in quality:
|
||||
numeral_str = numeral_str.lower()
|
||||
|
||||
@@ -0,0 +1,826 @@
|
||||
"""Real-time MIDI-driven synthesis engine.
|
||||
|
||||
Listens for MIDI input (e.g. from an OP-XY, keyboard, or DAW) and
|
||||
synthesizes audio in real-time through pytheory's synth engine.
|
||||
|
||||
Usage::
|
||||
|
||||
from pytheory.live import LiveEngine
|
||||
|
||||
engine = LiveEngine()
|
||||
engine.channel(1, instrument="electric_piano")
|
||||
engine.channel(2, instrument="bass_guitar", lowpass=800)
|
||||
engine.channel(10, drums=True)
|
||||
engine.start() # blocks until Ctrl-C
|
||||
|
||||
Note: sustained notes are pre-rendered to a 3-second wavetable.
|
||||
Instruments requiring longer sustain (pads, organ) will cut off
|
||||
after 3 seconds. This is a known limitation of the current
|
||||
wavetable approach.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import numpy
|
||||
import sounddevice as sd
|
||||
|
||||
try:
|
||||
import rtmidi
|
||||
except ImportError:
|
||||
rtmidi = None
|
||||
|
||||
from .play import (
|
||||
_SYNTH_FUNCTIONS, _resolve_synth, _resolve_envelope,
|
||||
_apply_envelope, _apply_lowpass, _render_drum_hit_cached,
|
||||
SAMPLE_RATE, SAMPLE_PEAK,
|
||||
)
|
||||
from .rhythm import INSTRUMENTS, DrumSound
|
||||
|
||||
|
||||
# ── Voice ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _Voice:
|
||||
"""A single sounding note - holds a pre-rendered wavetable and
|
||||
tracks playback position + envelope state."""
|
||||
__slots__ = ('active', 'note', 'pitch_ratio', 'pos', 'release_len',
|
||||
'release_pos', 'releasing', 'velocity', 'wave')
|
||||
|
||||
def __init__(self, wave, velocity, note):
|
||||
self.wave = wave # float32 array - one shot
|
||||
self.pos = 0.0 # current read position (float for pitch bend)
|
||||
self.velocity = velocity
|
||||
self.active = True
|
||||
self.releasing = False
|
||||
self.release_pos = 0
|
||||
self.release_len = int(SAMPLE_RATE * 0.05) # 50ms release
|
||||
self.note = note # MIDI note number
|
||||
self.pitch_ratio = 1.0 # 1.0 = normal, >1 = up, <1 = down
|
||||
|
||||
|
||||
# ── Channel ──────────────────────────────────────────────────────────────
|
||||
|
||||
class _Channel:
|
||||
"""One MIDI channel - has a synth, effects, and a voice pool."""
|
||||
|
||||
def __init__(self, synth_name="sine", envelope_name="piano",
|
||||
is_drums=False, max_voices=12, **kwargs):
|
||||
self.synth_fn = _resolve_synth(synth_name)
|
||||
self.synth_name = synth_name
|
||||
self.envelope_name = envelope_name
|
||||
self.env_tuple = _resolve_envelope(envelope_name)
|
||||
self.is_drums = is_drums
|
||||
self.max_voices = max_voices
|
||||
self.kwargs = kwargs
|
||||
self.lowpass = kwargs.get('lowpass', 0)
|
||||
self.lowpass_q = kwargs.get('lowpass_q', 0.707)
|
||||
self.reverb = kwargs.get('reverb', 0)
|
||||
self.volume = kwargs.get('volume', 0.5)
|
||||
self.pan = kwargs.get('pan', 0.0) # -1 left, 0 center, 1 right
|
||||
self.chorus = kwargs.get('chorus', 0)
|
||||
self.detune = kwargs.get('detune', 0)
|
||||
self.spread = kwargs.get('spread', 0)
|
||||
self.analog = kwargs.get('analog', 0)
|
||||
self.distortion = kwargs.get('distortion', 0)
|
||||
self.delay = kwargs.get('delay', 0)
|
||||
self.tremolo_depth = kwargs.get('tremolo_depth', 0)
|
||||
self.saturation = kwargs.get('saturation', 0)
|
||||
self.phaser = kwargs.get('phaser', 0)
|
||||
self.sub_osc = kwargs.get('sub_osc', 0)
|
||||
self.noise_mix = kwargs.get('noise_mix', 0)
|
||||
|
||||
self.voices = [] # active _Voice objects
|
||||
self._cache = {} # MIDI note -> pre-rendered wave
|
||||
self._lock = threading.Lock()
|
||||
self.level = 0.0 # current output level (for VU meter)
|
||||
|
||||
def _get_wave(self, midi_note, n_samples):
|
||||
"""Get or render a waveform for a MIDI note."""
|
||||
if self.is_drums:
|
||||
return _render_drum_hit_cached(midi_note, n_samples)
|
||||
|
||||
if midi_note in self._cache:
|
||||
cached = self._cache[midi_note]
|
||||
if len(cached) >= n_samples:
|
||||
return cached[:n_samples]
|
||||
|
||||
hz = 440.0 * (2 ** ((midi_note - 69) / 12.0))
|
||||
|
||||
# Synth kwargs
|
||||
skw = {}
|
||||
if self.synth_name in ("fm",):
|
||||
skw["mod_ratio"] = self.kwargs.get("fm_ratio", 2.0)
|
||||
skw["mod_index"] = self.kwargs.get("fm_index", 3.0)
|
||||
|
||||
wave = self.synth_fn(hz, n_samples=n_samples, **skw)
|
||||
wave_f = wave.astype(numpy.float32) / SAMPLE_PEAK
|
||||
|
||||
# Apply envelope
|
||||
a, d, s, r = self.env_tuple
|
||||
if a > 0 or d > 0 or s < 1.0 or r > 0:
|
||||
wave_f = _apply_envelope(wave_f, a, d, s, r)
|
||||
|
||||
# Apply lowpass
|
||||
if self.lowpass > 0:
|
||||
wave_f = _apply_lowpass(wave_f, self.lowpass, q=self.lowpass_q)
|
||||
|
||||
# Apply reverb - simple feedback delay for real-time
|
||||
if self.reverb > 0:
|
||||
wet = self.reverb
|
||||
delay_samples = int(SAMPLE_RATE * 0.03) # 30ms early reflection
|
||||
delay2 = int(SAMPLE_RATE * 0.047) # second tap
|
||||
delay3 = int(SAMPLE_RATE * 0.071) # third tap
|
||||
reverbed = wave_f.copy()
|
||||
for delay, gain in [(delay_samples, 0.4), (delay2, 0.3), (delay3, 0.2)]:
|
||||
if delay < len(reverbed):
|
||||
reverbed[delay:] += wave_f[:-delay] * gain
|
||||
# Feedback loop for tail
|
||||
fb_delay = int(SAMPLE_RATE * 0.05)
|
||||
feedback = 0.35
|
||||
for _ in range(6):
|
||||
if fb_delay < len(reverbed):
|
||||
reverbed[fb_delay:] += reverbed[:-fb_delay] * feedback
|
||||
feedback *= 0.7
|
||||
fb_delay = int(fb_delay * 1.5)
|
||||
wave_f = wave_f * (1.0 - wet) + reverbed * wet
|
||||
|
||||
# Apply distortion/saturation
|
||||
if self.distortion > 0:
|
||||
drive = 3.0
|
||||
wave_f = numpy.tanh(wave_f * drive * (1 + self.distortion * 3)) / drive
|
||||
if self.saturation > 0:
|
||||
wave_f = numpy.tanh(wave_f * (1 + self.saturation * 2))
|
||||
|
||||
# Apply tremolo
|
||||
if self.tremolo_depth > 0:
|
||||
t = numpy.arange(len(wave_f), dtype=numpy.float32) / SAMPLE_RATE
|
||||
trem = 1.0 - self.tremolo_depth * 0.5 * (1 + numpy.sin(2 * numpy.pi * 5.0 * t))
|
||||
wave_f *= trem
|
||||
|
||||
# Apply chorus (simple delay modulation)
|
||||
if self.chorus > 0:
|
||||
t = numpy.arange(len(wave_f), dtype=numpy.float32) / SAMPLE_RATE
|
||||
mod = (numpy.sin(2 * numpy.pi * 1.5 * t) * 0.002 * SAMPLE_RATE).astype(int)
|
||||
chorus_buf = numpy.zeros_like(wave_f)
|
||||
for i in range(len(wave_f)):
|
||||
idx = i - abs(mod[i]) - int(SAMPLE_RATE * 0.015)
|
||||
if 0 <= idx < len(wave_f):
|
||||
chorus_buf[i] = wave_f[idx]
|
||||
wave_f = wave_f * (1 - self.chorus * 0.5) + chorus_buf * self.chorus * 0.5
|
||||
|
||||
self._cache[midi_note] = wave_f
|
||||
return wave_f
|
||||
|
||||
def note_on(self, midi_note, velocity):
|
||||
"""Start a new voice."""
|
||||
vel_scale = velocity / 127.0
|
||||
# Render 3 seconds of audio (extra for reverb tail)
|
||||
n_samples = SAMPLE_RATE * 3
|
||||
wave = self._get_wave(midi_note, n_samples)
|
||||
|
||||
with self._lock:
|
||||
# Voice stealing - kill oldest if at max
|
||||
if len(self.voices) >= self.max_voices:
|
||||
self.voices.pop(0)
|
||||
self.voices.append(_Voice(wave, vel_scale, midi_note))
|
||||
|
||||
def note_off(self, midi_note):
|
||||
"""Trigger release on voices playing this note."""
|
||||
with self._lock:
|
||||
for v in self.voices:
|
||||
if v.note == midi_note and v.active and not v.releasing:
|
||||
v.releasing = True
|
||||
v.release_pos = 0
|
||||
|
||||
def render_stereo(self, n_frames):
|
||||
"""Mix all active voices into a stereo buffer (n_frames, 2)."""
|
||||
mono = numpy.zeros(n_frames, dtype=numpy.float32)
|
||||
dead = []
|
||||
|
||||
with self._lock:
|
||||
for i, v in enumerate(self.voices):
|
||||
if not v.active:
|
||||
dead.append(i)
|
||||
continue
|
||||
|
||||
remaining = len(v.wave) - int(v.pos)
|
||||
chunk = min(n_frames, remaining)
|
||||
|
||||
if chunk <= 0:
|
||||
v.active = False
|
||||
dead.append(i)
|
||||
continue
|
||||
|
||||
# Pitch bend: variable-rate read
|
||||
if abs(v.pitch_ratio - 1.0) > 0.001:
|
||||
read_positions = v.pos + numpy.arange(chunk) * v.pitch_ratio
|
||||
read_positions = numpy.clip(read_positions, 0, len(v.wave) - 2)
|
||||
idx = read_positions.astype(numpy.int64)
|
||||
frac = (read_positions - idx).astype(numpy.float32)
|
||||
samples = (v.wave[idx] * (1 - frac) +
|
||||
v.wave[numpy.minimum(idx + 1, len(v.wave) - 1)] * frac)
|
||||
samples *= v.velocity * self.volume
|
||||
else:
|
||||
int_pos = int(v.pos)
|
||||
samples = v.wave[int_pos:int_pos + chunk] * v.velocity * self.volume
|
||||
|
||||
# Release crossfade
|
||||
if v.releasing:
|
||||
fade_chunk = min(chunk, v.release_len - v.release_pos)
|
||||
if fade_chunk > 0:
|
||||
fade = numpy.linspace(
|
||||
1.0 - v.release_pos / v.release_len,
|
||||
1.0 - (v.release_pos + fade_chunk) / v.release_len,
|
||||
fade_chunk
|
||||
).astype(numpy.float32)
|
||||
samples[:fade_chunk] *= fade
|
||||
v.release_pos += fade_chunk
|
||||
if v.release_pos >= v.release_len:
|
||||
v.active = False
|
||||
samples[fade_chunk:] = 0
|
||||
|
||||
mono[:chunk] += samples
|
||||
v.pos += chunk * v.pitch_ratio
|
||||
|
||||
# Clean up dead voices
|
||||
for i in reversed(dead):
|
||||
if i < len(self.voices):
|
||||
self.voices.pop(i)
|
||||
|
||||
# VU meter
|
||||
peak = numpy.abs(mono).max() if len(mono) > 0 else 0
|
||||
self.level = self.level * 0.7 + peak * 0.3 # smooth
|
||||
|
||||
# Stereo pan (constant power)
|
||||
import math
|
||||
angle = (self.pan + 1) * math.pi / 4 # 0 to pi/2
|
||||
l_gain = math.cos(angle)
|
||||
r_gain = math.sin(angle)
|
||||
stereo = numpy.zeros((n_frames, 2), dtype=numpy.float32)
|
||||
stereo[:, 0] = mono * l_gain
|
||||
stereo[:, 1] = mono * r_gain
|
||||
|
||||
return stereo
|
||||
|
||||
|
||||
# ── LiveEngine ───────────────────────────────────────────────────────────
|
||||
|
||||
class LiveEngine:
|
||||
"""Real-time MIDI-to-audio engine.
|
||||
|
||||
Maps MIDI channels to pytheory instruments and synthesizes
|
||||
audio in real-time via sounddevice.
|
||||
|
||||
Example::
|
||||
|
||||
engine = LiveEngine()
|
||||
engine.channel(1, instrument="electric_piano")
|
||||
engine.channel(2, instrument="bass_guitar")
|
||||
engine.channel(10, drums=True)
|
||||
engine.start()
|
||||
"""
|
||||
|
||||
def __init__(self, buffer_size=512, sample_rate=SAMPLE_RATE):
|
||||
self.buffer_size = buffer_size
|
||||
self.sample_rate = sample_rate
|
||||
self.channels = {} # MIDI channel (1-16) -> _Channel
|
||||
self._cc_map = {} # (channel, cc_number) -> (param_name, min, max)
|
||||
self._midi_in = None
|
||||
self._stream = None
|
||||
self._stop_event = threading.Event()
|
||||
# Recording
|
||||
self._recording = False
|
||||
self._record_events = [] # (timestamp, ch, note, velocity, on/off)
|
||||
self._record_start = 0
|
||||
# Keyboard MIDI
|
||||
self._keyboard_channel = None
|
||||
self._keyboard_octave = 4
|
||||
# Clock sync
|
||||
self._clock_count = 0 # MIDI clock pulses (24 per quarter note)
|
||||
self._clock_times = [] # timestamps for BPM calculation
|
||||
self._bpm = 120.0
|
||||
self._playing = False
|
||||
# Drum pattern
|
||||
self._drum_pattern = None
|
||||
self._drum_channel = None
|
||||
|
||||
def channel(self, ch, *, instrument=None, synth=None, envelope=None,
|
||||
drums=False, **kwargs):
|
||||
"""Configure a MIDI channel.
|
||||
|
||||
Args:
|
||||
ch: MIDI channel number (1-16). Channel 10 = drums by convention.
|
||||
instrument: Instrument preset name (e.g. "electric_piano").
|
||||
synth: Synth waveform name (overrides instrument).
|
||||
envelope: Envelope name (overrides instrument).
|
||||
drums: If True, this channel triggers drum sounds by MIDI note.
|
||||
**kwargs: Any Part parameter (lowpass, reverb, volume, etc.)
|
||||
"""
|
||||
if not isinstance(ch, int) or not (1 <= ch <= 16):
|
||||
raise ValueError(f"MIDI channel must be an integer 1-16, got {ch!r}")
|
||||
|
||||
# Build params from instrument preset
|
||||
params = {}
|
||||
if instrument:
|
||||
preset = INSTRUMENTS.get(instrument)
|
||||
if preset:
|
||||
params.update(preset)
|
||||
if synth:
|
||||
params["synth"] = synth
|
||||
if envelope:
|
||||
params["envelope"] = envelope
|
||||
params.update(kwargs)
|
||||
|
||||
synth_name = params.pop("synth", "sine")
|
||||
env_name = params.pop("envelope", "piano")
|
||||
|
||||
self.channels[ch] = _Channel(
|
||||
synth_name=synth_name,
|
||||
envelope_name=env_name,
|
||||
is_drums=drums or ch == 10,
|
||||
**params,
|
||||
)
|
||||
return self
|
||||
|
||||
def drums(self, pattern_name, *, volume=0.5):
|
||||
"""Add a drum pattern that syncs to MIDI clock.
|
||||
|
||||
The pattern plays in sync with the OP-XY's transport -
|
||||
starts on Start, stops on Stop, tempo from MIDI clock.
|
||||
|
||||
Args:
|
||||
pattern_name: Drum pattern preset name (e.g. "rock", "house").
|
||||
volume: Drum volume (0.0-1.0).
|
||||
"""
|
||||
from .rhythm import Pattern
|
||||
self._drum_pattern = Pattern.preset(pattern_name)
|
||||
self._drum_channel = _Channel(synth_name="sine", is_drums=True,
|
||||
volume=volume)
|
||||
return self
|
||||
|
||||
def cc(self, cc_number, param, *, min_val=0.0, max_val=1.0, ch=None):
|
||||
"""Map a MIDI CC to a channel parameter.
|
||||
|
||||
Args:
|
||||
cc_number: MIDI CC number (0-127).
|
||||
param: Parameter name ("volume", "lowpass", "reverb", etc.)
|
||||
min_val: Value when CC = 0.
|
||||
max_val: Value when CC = 127.
|
||||
ch: MIDI channel (None = all channels).
|
||||
|
||||
Example::
|
||||
|
||||
>>> engine.cc(11, "lowpass", min_val=200, max_val=8000)
|
||||
>>> engine.cc(12, "volume", min_val=0.0, max_val=1.0)
|
||||
>>> engine.cc(13, "reverb", min_val=0.0, max_val=0.8)
|
||||
>>> engine.cc(14, "distortion", min_val=0.0, max_val=0.8)
|
||||
"""
|
||||
self._cc_map[(ch, cc_number)] = (param, min_val, max_val)
|
||||
return self
|
||||
|
||||
def _apply_cc(self, ch, cc_number, value):
|
||||
"""Apply a CC value to the matching channel parameter."""
|
||||
for key in [(ch, cc_number), (None, cc_number)]:
|
||||
if key in self._cc_map:
|
||||
param, min_val, max_val = self._cc_map[key]
|
||||
scaled = min_val + (max_val - min_val) * (value / 127.0)
|
||||
|
||||
target_chs = [ch] if key[0] is not None else list(self.channels.keys())
|
||||
for target_ch in target_chs:
|
||||
if target_ch in self.channels:
|
||||
channel = self.channels[target_ch]
|
||||
if param == "volume":
|
||||
channel.volume = scaled
|
||||
elif param == "lowpass":
|
||||
channel.lowpass = scaled
|
||||
channel._cache.clear()
|
||||
elif param == "reverb":
|
||||
channel.reverb = scaled
|
||||
channel._cache.clear()
|
||||
elif hasattr(channel, param):
|
||||
setattr(channel, param, scaled)
|
||||
channel._cache.clear()
|
||||
print(f" CC {cc_number}: {param}={scaled:.2f}")
|
||||
return
|
||||
|
||||
def _on_clock(self):
|
||||
"""Handle MIDI clock pulse (24 per quarter note)."""
|
||||
import time as _time
|
||||
|
||||
if not self._playing:
|
||||
return
|
||||
|
||||
now = _time.perf_counter()
|
||||
self._clock_times.append(now)
|
||||
if len(self._clock_times) > 240:
|
||||
self._clock_times = self._clock_times[-240:]
|
||||
# Only update BPM every 24 ticks to avoid jitter
|
||||
if self._clock_count % 24 == 0 and len(self._clock_times) >= 48:
|
||||
# Use as many ticks as we have for best accuracy
|
||||
n = min(len(self._clock_times), 240)
|
||||
total_time = self._clock_times[-1] - self._clock_times[-n]
|
||||
if total_time > 0:
|
||||
ticks = n - 1
|
||||
self._bpm = round(60.0 * ticks / (24.0 * total_time))
|
||||
|
||||
# Trigger drum hits at the right time
|
||||
if self._drum_pattern and self._drum_channel:
|
||||
pattern = self._drum_pattern
|
||||
beat_pos = self._clock_count / 24.0
|
||||
pattern_beat = beat_pos % pattern.beats
|
||||
beat_resolution = 1.0 / 24.0
|
||||
for hit in pattern.hits:
|
||||
if abs(hit.position - pattern_beat) < beat_resolution / 2:
|
||||
self._drum_channel.note_on(hit.sound.value, hit.velocity)
|
||||
|
||||
self._clock_count += 1
|
||||
|
||||
def _all_notes_off(self):
|
||||
"""Kill all sounding voices on all channels."""
|
||||
for channel in self.channels.values():
|
||||
with channel._lock:
|
||||
channel.voices.clear()
|
||||
if self._drum_channel:
|
||||
with self._drum_channel._lock:
|
||||
self._drum_channel.voices.clear()
|
||||
|
||||
def _midi_callback(self, event, data=None):
|
||||
"""Handle incoming MIDI messages."""
|
||||
msg, _ = event
|
||||
if len(msg) == 0:
|
||||
return
|
||||
|
||||
# System realtime messages (1 byte)
|
||||
if msg[0] == 0xF8: # Clock - 24 ppqn
|
||||
self._on_clock()
|
||||
return
|
||||
elif msg[0] == 0xFA: # Start
|
||||
print(" > Start")
|
||||
self._playing = True
|
||||
self._clock_count = 0
|
||||
return
|
||||
elif msg[0] == 0xFC: # Stop
|
||||
print(" [] Stop")
|
||||
self._playing = False
|
||||
self._all_notes_off()
|
||||
return
|
||||
elif msg[0] == 0xFB: # Continue
|
||||
print(" > Continue")
|
||||
self._playing = True
|
||||
return
|
||||
|
||||
if len(msg) < 3:
|
||||
return
|
||||
|
||||
status = msg[0]
|
||||
ch = (status & 0x0F) + 1
|
||||
msg_type = status & 0xF0
|
||||
|
||||
if ch not in self.channels:
|
||||
return
|
||||
|
||||
channel = self.channels[ch]
|
||||
note = msg[1]
|
||||
velocity = msg[2]
|
||||
|
||||
if msg_type == 0x90 and velocity > 0:
|
||||
channel.note_on(note, velocity)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start, ch, note, velocity, True))
|
||||
elif msg_type == 0x80 or (msg_type == 0x90 and velocity == 0):
|
||||
channel.note_off(note)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start, ch, note, 0, False))
|
||||
elif msg_type == 0xB0:
|
||||
self._apply_cc(ch, note, velocity)
|
||||
elif msg_type == 0xE0:
|
||||
bend_raw = (msg[2] << 7) | msg[1]
|
||||
bend_semitones = (bend_raw - 8192) / 8192.0 * 2.0
|
||||
ratio = 2.0 ** (bend_semitones / 12.0)
|
||||
with channel._lock:
|
||||
for v in channel.voices:
|
||||
if v.active:
|
||||
v.pitch_ratio = ratio
|
||||
|
||||
def _audio_callback(self, outdata, frames, time_info, status):
|
||||
"""sounddevice callback - mix all channels to stereo."""
|
||||
stereo = numpy.zeros((frames, 2), dtype=numpy.float32)
|
||||
for channel in self.channels.values():
|
||||
stereo += channel.render_stereo(frames)
|
||||
if self._drum_channel:
|
||||
stereo += self._drum_channel.render_stereo(frames)
|
||||
|
||||
# Soft clip per channel
|
||||
stereo[:, 0] = numpy.tanh(stereo[:, 0])
|
||||
stereo[:, 1] = numpy.tanh(stereo[:, 1])
|
||||
outdata[:] = stereo
|
||||
|
||||
def list_ports(self):
|
||||
"""List available MIDI input ports."""
|
||||
if rtmidi is None:
|
||||
raise ImportError("python-rtmidi required. Install with: pip install pytheory[live]")
|
||||
midi_in = rtmidi.MidiIn()
|
||||
ports = midi_in.get_ports()
|
||||
for i, name in enumerate(ports):
|
||||
print(f" {i}: {name}")
|
||||
midi_in.delete()
|
||||
return ports
|
||||
|
||||
def start(self, port=None):
|
||||
"""Start the engine - opens MIDI input and audio output.
|
||||
|
||||
Args:
|
||||
port: MIDI port index or name. None = first available.
|
||||
|
||||
Blocks until Ctrl-C or stop() is called.
|
||||
"""
|
||||
if rtmidi is None:
|
||||
raise ImportError(
|
||||
"python-rtmidi is required for live MIDI. "
|
||||
"Install it with: pip install pytheory[live]"
|
||||
)
|
||||
|
||||
if not self.channels:
|
||||
self.channel(1, instrument="electric_piano")
|
||||
|
||||
# Pre-compute wavetables
|
||||
print(" Pre-rendering wavetables...")
|
||||
n_samples = SAMPLE_RATE * 3
|
||||
for _, channel in self.channels.items():
|
||||
if channel.is_drums:
|
||||
continue
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, n_samples)
|
||||
print(f" Cached {sum(len(c._cache) for c in self.channels.values())} wavetables.")
|
||||
print()
|
||||
|
||||
# Open MIDI
|
||||
self._midi_in = rtmidi.MidiIn()
|
||||
ports = self._midi_in.get_ports()
|
||||
|
||||
if not ports:
|
||||
print(" No MIDI input ports found. (Keyboard mode still works)")
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
# Don't return — still start audio for keyboard mode
|
||||
|
||||
port_name = "none"
|
||||
if self._midi_in and ports:
|
||||
if port is None:
|
||||
port = 0
|
||||
elif isinstance(port, str):
|
||||
matched = False
|
||||
for i, name in enumerate(ports):
|
||||
if port.lower() in name.lower():
|
||||
port = i
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
print(f" MIDI port not found: {port!r}, continuing without MIDI")
|
||||
port = None
|
||||
|
||||
if self._midi_in and port is not None:
|
||||
try:
|
||||
self._midi_in.open_port(port)
|
||||
self._midi_in.ignore_types(sysex=True, timing=False, active_sense=True)
|
||||
self._midi_in.set_callback(self._midi_callback)
|
||||
port_name = ports[port]
|
||||
except Exception:
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
print(" Failed to open MIDI port, continuing without MIDI")
|
||||
|
||||
print(f" PyTheory Live Engine")
|
||||
print(f" MIDI: {port_name}")
|
||||
print(f" Buffer: {self.buffer_size} samples ({self.buffer_size/self.sample_rate*1000:.1f}ms)")
|
||||
print(f" Channels:")
|
||||
for ch, channel in sorted(self.channels.items()):
|
||||
kind = "drums" if channel.is_drums else channel.synth_name
|
||||
print(f" {ch:2d}: {kind} (vol={channel.volume})")
|
||||
if self._drum_pattern:
|
||||
print(f" Drums: {self._drum_pattern.name} (synced to MIDI clock)")
|
||||
print()
|
||||
print(" Playing... (Ctrl-C to stop)")
|
||||
print()
|
||||
|
||||
self._stream = sd.OutputStream(
|
||||
samplerate=self.sample_rate,
|
||||
blocksize=self.buffer_size,
|
||||
channels=2,
|
||||
dtype='float32',
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
|
||||
try:
|
||||
self._stream.start()
|
||||
self._stop_event.clear()
|
||||
self._stop_event.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("\n Stopped.")
|
||||
finally:
|
||||
if self._stream:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
self._stream = None
|
||||
if self._midi_in:
|
||||
self._midi_in.close_port()
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
|
||||
def keyboard_play(self, ch=1):
|
||||
"""Enable computer keyboard as MIDI input on a channel."""
|
||||
self._keyboard_channel = ch
|
||||
return self
|
||||
|
||||
def keyboard_note(self, key, on=True):
|
||||
"""Translate a keyboard key to a MIDI note and play it.
|
||||
|
||||
QWERTY layout: ZSXDCVGBHNJM = C through B (lower octave)
|
||||
Q2W3ER5T6Y7U = C through B (upper octave)
|
||||
"""
|
||||
# Chromatic layout across the full keyboard
|
||||
# Bottom two rows = lower octave range
|
||||
# Top two rows = upper octave range (+1 octave)
|
||||
# Black keys on the row above their white keys
|
||||
#
|
||||
# Row 3 (ZXCVBNM,./): white keys C D E F G A B C D E
|
||||
# Row 2 (ASDFGHJKL;'): black keys + extras
|
||||
# Row 1 (QWERTYUIOP[]): white keys C D E F G A B C D E F G
|
||||
# Row 0 (1234567890-=): black keys + extras
|
||||
lower = {
|
||||
# White keys: Z X C V B N M , . /
|
||||
'z': 0, 'x': 2, 'c': 4, 'v': 5, 'b': 7, 'n': 9, 'm': 11,
|
||||
',': 12, '.': 14, '/': 16,
|
||||
# Black keys: S D G H J L ;
|
||||
's': 1, 'd': 3, 'g': 6, 'h': 8, 'j': 10,
|
||||
'l': 13, ';': 15,
|
||||
# Extras
|
||||
'a': 0, 'f': 4, 'k': 11, "'": 17,
|
||||
}
|
||||
upper = {
|
||||
# White keys: Q W E R T Y U I O P [ ]
|
||||
'q': 0, 'w': 2, 'e': 4, 'r': 5, 't': 7, 'y': 9, 'u': 11,
|
||||
'i': 12, 'o': 14, 'p': 16, '[': 17, ']': 19,
|
||||
# Black keys: 2 3 5 6 7 9 0
|
||||
'2': 1, '3': 3, '5': 6, '6': 8, '7': 10,
|
||||
'9': 13, '0': 15,
|
||||
# Extras
|
||||
'1': 0, '4': 4, '8': 11, '-': 18, '=': 19,
|
||||
}
|
||||
|
||||
if self._keyboard_channel is None:
|
||||
return False
|
||||
|
||||
ch = self._keyboard_channel
|
||||
if ch not in self.channels:
|
||||
return False
|
||||
|
||||
midi_note = None
|
||||
if key in lower:
|
||||
midi_note = (self._keyboard_octave + 1) * 12 + lower[key]
|
||||
elif key in upper:
|
||||
midi_note = (self._keyboard_octave + 2) * 12 + upper[key]
|
||||
|
||||
if midi_note is not None:
|
||||
channel = self.channels[ch]
|
||||
if on:
|
||||
channel.note_on(midi_note, 100)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start,
|
||||
ch, midi_note, 100, True))
|
||||
else:
|
||||
channel.note_off(midi_note)
|
||||
if self._recording:
|
||||
import time as _t
|
||||
self._record_events.append(
|
||||
(_t.perf_counter() - self._record_start,
|
||||
ch, midi_note, 0, False))
|
||||
return True
|
||||
return False
|
||||
|
||||
def start_recording(self):
|
||||
"""Start recording MIDI events."""
|
||||
import time as _t
|
||||
self._record_events = []
|
||||
self._record_start = _t.perf_counter()
|
||||
self._recording = True
|
||||
|
||||
def stop_recording(self):
|
||||
"""Stop recording."""
|
||||
self._recording = False
|
||||
|
||||
def export_recording(self, filename="recording.mid", bpm=None):
|
||||
"""Export recorded events to a MIDI file.
|
||||
|
||||
Returns a pytheory Score if no filename given.
|
||||
"""
|
||||
if not self._record_events:
|
||||
return None
|
||||
|
||||
use_bpm = bpm or (self._bpm if self._bpm > 10 else 120)
|
||||
|
||||
from .rhythm import Score, Duration
|
||||
|
||||
score = Score("4/4", bpm=int(use_bpm))
|
||||
|
||||
# Group events by channel
|
||||
by_channel = {}
|
||||
for ts, ch, note, vel, is_on in self._record_events:
|
||||
if ch not in by_channel:
|
||||
by_channel[ch] = []
|
||||
by_channel[ch].append((ts, note, vel, is_on))
|
||||
|
||||
# Build parts
|
||||
for ch, events in sorted(by_channel.items()):
|
||||
inst = self.picks[ch - 1] if 1 <= ch <= 8 else "piano"
|
||||
part = score.part(f"ch{ch}", instrument=inst)
|
||||
|
||||
# Convert to note-on/off pairs
|
||||
active = {}
|
||||
notes = []
|
||||
for ts, note, vel, is_on in events:
|
||||
if is_on:
|
||||
active[note] = (ts, vel)
|
||||
elif note in active:
|
||||
start_ts, start_vel = active.pop(note)
|
||||
dur_sec = ts - start_ts
|
||||
dur_beats = dur_sec * use_bpm / 60.0
|
||||
notes.append((start_ts, note, max(0.125, dur_beats), start_vel))
|
||||
|
||||
notes.sort(key=lambda x: x[0])
|
||||
|
||||
beat_pos = 0.0
|
||||
for ts, midi_note, dur, vel in notes:
|
||||
note_beat = ts * use_bpm / 60.0
|
||||
if note_beat > beat_pos:
|
||||
part.rest(note_beat - beat_pos)
|
||||
# Convert MIDI note to name
|
||||
name = NOTE_NAMES[midi_note % 12]
|
||||
octave = midi_note // 12 - 1
|
||||
part.add(f"{name}{octave}", dur, velocity=vel)
|
||||
beat_pos = note_beat + dur
|
||||
|
||||
if filename:
|
||||
score.save_midi(filename)
|
||||
|
||||
return score
|
||||
|
||||
def save_config(self, filename):
|
||||
"""Save current configuration to JSON."""
|
||||
import json
|
||||
config = {
|
||||
"seed": self.seed if hasattr(self, 'seed') else None,
|
||||
"buffer_size": self.buffer_size,
|
||||
"drums": getattr(self, '_drum_pattern_name', None),
|
||||
"channels": {},
|
||||
}
|
||||
for ch, channel in self.channels.items():
|
||||
config["channels"][str(ch)] = {
|
||||
"synth": channel.synth_name,
|
||||
"envelope": channel.envelope_name,
|
||||
"volume": channel.volume,
|
||||
"pan": channel.pan,
|
||||
"reverb": channel.reverb,
|
||||
"lowpass": channel.lowpass,
|
||||
"chorus": channel.chorus,
|
||||
"distortion": channel.distortion,
|
||||
"is_drums": channel.is_drums,
|
||||
}
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
def load_config(self, filename):
|
||||
"""Load configuration from JSON."""
|
||||
import json
|
||||
with open(filename) as f:
|
||||
config = json.load(f)
|
||||
for ch_str, ch_cfg in config.get("channels", {}).items():
|
||||
ch = int(ch_str)
|
||||
self.channel(ch,
|
||||
synth=ch_cfg.get("synth"),
|
||||
envelope=ch_cfg.get("envelope"),
|
||||
drums=ch_cfg.get("is_drums", False),
|
||||
volume=ch_cfg.get("volume", 0.5),
|
||||
pan=ch_cfg.get("pan", 0.0),
|
||||
reverb=ch_cfg.get("reverb", 0),
|
||||
lowpass=ch_cfg.get("lowpass", 0),
|
||||
chorus=ch_cfg.get("chorus", 0),
|
||||
distortion=ch_cfg.get("distortion", 0))
|
||||
if config.get("drums"):
|
||||
self.drums(config["drums"])
|
||||
|
||||
def stop(self):
|
||||
"""Stop the engine."""
|
||||
self._stop_event.set()
|
||||
if self._stream:
|
||||
self._stream.stop()
|
||||
if self._midi_in:
|
||||
self._midi_in.close_port()
|
||||
self._midi_in.delete()
|
||||
self._midi_in = None
|
||||
@@ -0,0 +1,819 @@
|
||||
"""PyTheory Live — interactive MIDI synthesizer with TUI."""
|
||||
|
||||
import curses
|
||||
import random
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
|
||||
from pytheory.live import LiveEngine
|
||||
from pytheory.rhythm import INSTRUMENTS, Pattern
|
||||
|
||||
|
||||
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
||||
|
||||
|
||||
def note_name(midi):
|
||||
return f"{NOTE_NAMES[midi % 12]}{midi // 12 - 1}"
|
||||
|
||||
|
||||
class LiveTUI:
|
||||
def __init__(self, seed=None, port="OP-XY", n_channels=8,
|
||||
drum_pattern="rock", buffer_size=128):
|
||||
self.seed = seed or random.randint(0, 9999)
|
||||
self.port = port
|
||||
self.n_channels = n_channels
|
||||
self.buffer_size = buffer_size
|
||||
self.engine = None
|
||||
self.log_lines = []
|
||||
self.max_log = 500
|
||||
self.running = True
|
||||
self.instruments = sorted([k for k in INSTRUMENTS.keys()
|
||||
if k not in ("808_bass",)])
|
||||
self.drum_patterns = sorted(Pattern.list_presets())
|
||||
self.current_drum = drum_pattern
|
||||
self.picks = []
|
||||
self.bpm = "—"
|
||||
self.status = "Init"
|
||||
self.status_color = 3
|
||||
self._build_engine()
|
||||
|
||||
def _build_engine(self):
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, min(self.n_channels, len(self.instruments)))
|
||||
self.engine = LiveEngine(buffer_size=self.buffer_size)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
if self.current_drum and self.current_drum not in ("none", "-"):
|
||||
self.engine.drums(self.current_drum, volume=0.5)
|
||||
self.engine.cc(0, "lowpass", min_val=300, max_val=8000)
|
||||
|
||||
def log(self, msg, color=0):
|
||||
self.log_lines.append((time.time(), msg, color))
|
||||
if len(self.log_lines) > self.max_log:
|
||||
self.log_lines = self.log_lines[-self.max_log:]
|
||||
|
||||
def _patch_engine_logging(self):
|
||||
original_cb = self.engine._midi_callback
|
||||
|
||||
def logging_cb(event, data=None):
|
||||
msg, _ = event
|
||||
if len(msg) == 0:
|
||||
return
|
||||
if msg[0] == 0xF8:
|
||||
if self.engine._bpm > 10:
|
||||
self.bpm = f"{self.engine._bpm:.0f}"
|
||||
elif msg[0] == 0xFA:
|
||||
self.log("▶ Start", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif msg[0] == 0xFC:
|
||||
self.log("■ Stop", 4)
|
||||
self.status = "Stopped"
|
||||
self.status_color = 4
|
||||
elif msg[0] == 0xFB:
|
||||
self.log("▶ Continue", 5)
|
||||
self.status = "Playing"
|
||||
self.status_color = 1
|
||||
elif len(msg) >= 3:
|
||||
status = msg[0]
|
||||
ch = (status & 0x0F) + 1
|
||||
msg_type = status & 0xF0
|
||||
if msg_type == 0x90 and msg[2] > 0:
|
||||
inst = self.picks[ch - 1] if 1 <= ch <= 8 else "?"
|
||||
self.log(f"♪ {ch}:{inst} {note_name(msg[1])} v={msg[2]}", 1)
|
||||
elif msg_type == 0xB0:
|
||||
self.log(f"⚙ CC{msg[1]}={msg[2]}", 3)
|
||||
elif msg_type == 0xE0:
|
||||
bend = ((msg[2] << 7) | msg[1]) - 8192
|
||||
self.log(f"↕ Bend ch{ch} {bend:+d}", 3)
|
||||
original_cb(event, data)
|
||||
|
||||
self.engine._midi_callback = logging_cb
|
||||
|
||||
def run(self, stdscr):
|
||||
curses.curs_set(0)
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_CYAN, -1)
|
||||
curses.init_pair(3, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(4, curses.COLOR_RED, -1)
|
||||
curses.init_pair(5, curses.COLOR_MAGENTA, -1)
|
||||
curses.init_pair(6, curses.COLOR_BLACK, curses.COLOR_BLUE)
|
||||
curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
||||
curses.init_pair(8, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
||||
curses.init_pair(9, curses.COLOR_BLACK, curses.COLOR_RED)
|
||||
stdscr.nodelay(True)
|
||||
stdscr.timeout(60)
|
||||
|
||||
self._patch_engine_logging()
|
||||
|
||||
# Pre-render with progress
|
||||
self.status = "Rendering"
|
||||
self.status_color = 3
|
||||
stdscr.erase()
|
||||
stdscr.addstr(1, 2, "PyTheory Live", curses.A_BOLD)
|
||||
stdscr.addstr(2, 2, "Pre-rendering wavetables...", curses.color_pair(3))
|
||||
stdscr.refresh()
|
||||
|
||||
n_samples = 44100 * 3
|
||||
count = 0
|
||||
for _, channel in self.engine.channels.items():
|
||||
if channel.is_drums:
|
||||
continue
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, n_samples)
|
||||
count += 1
|
||||
if count % 50 == 0:
|
||||
stdscr.addstr(3, 2, f" {count} wavetables...", curses.color_pair(2))
|
||||
stdscr.refresh()
|
||||
|
||||
self.log(f"Cached {count} wavetables", 1)
|
||||
self.log("Starting engine...", 3)
|
||||
self.status = "Starting"
|
||||
self.status_color = 3
|
||||
|
||||
# Start engine
|
||||
def run_engine():
|
||||
import io
|
||||
capture = io.StringIO()
|
||||
old = sys.stdout
|
||||
sys.stdout = capture
|
||||
try:
|
||||
self.engine.start(port=self.port)
|
||||
except Exception as e:
|
||||
self.log(f"Engine error: {e}", 4)
|
||||
finally:
|
||||
sys.stdout = old
|
||||
output = capture.getvalue()
|
||||
if output.strip():
|
||||
for line in output.strip().split('\n'):
|
||||
self.log(line.strip(), 2)
|
||||
|
||||
engine_thread = threading.Thread(target=run_engine, daemon=True)
|
||||
engine_thread.start()
|
||||
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
cmd_history = []
|
||||
history_idx = -1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
tab_prefix = ""
|
||||
self.kbd_active = False
|
||||
self._kbd_held = {} # key → last_press_time
|
||||
self._picker = None # {"channel": int, "index": int, "scroll": int, "filter": str}
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
# Update status from engine state
|
||||
if (self.engine._stream and self.engine._stream.active
|
||||
and self.status == "Starting"):
|
||||
self.status = "Listening"
|
||||
self.status_color = 2
|
||||
self.log("Audio stream active", 1)
|
||||
|
||||
h, w = stdscr.getmaxyx()
|
||||
if h < 10 or w < 40:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
stdscr.erase()
|
||||
|
||||
div = max(30, w * 3 // 5)
|
||||
cfg_x = div + 2
|
||||
|
||||
# ═══ HEADER BAR ═══
|
||||
header = f" PyTheory Live "
|
||||
stdscr.addstr(0, 0, header, curses.color_pair(6) | curses.A_BOLD)
|
||||
|
||||
# Status badge
|
||||
badge_colors = {1: 7, 2: 6, 3: 8, 4: 9}
|
||||
badge_cp = badge_colors.get(self.status_color, 6)
|
||||
badge = f" {self.status} "
|
||||
x = len(header)
|
||||
stdscr.addstr(0, x, badge, curses.color_pair(badge_cp) | curses.A_BOLD)
|
||||
x += len(badge)
|
||||
|
||||
kbd_mode = ""
|
||||
if self.engine._keyboard_channel:
|
||||
kbd_mode = f" KBD:ch{self.engine._keyboard_channel}"
|
||||
rec_mode = " ●REC" if self.engine._recording else ""
|
||||
info = f" BPM:{self.bpm} drums:{self.current_drum} seed:{self.seed}{kbd_mode}{rec_mode}"
|
||||
try:
|
||||
stdscr.addstr(0, x, info[:w - x], curses.color_pair(6))
|
||||
# Fill rest of header
|
||||
remaining = w - x - len(info)
|
||||
if remaining > 0:
|
||||
stdscr.addstr(0, x + len(info), " " * remaining, curses.color_pair(6))
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ DIVIDER ═══
|
||||
for y in range(1, h - 2):
|
||||
try:
|
||||
stdscr.addch(y, div, '│', curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ LEFT: EVENTS ═══
|
||||
try:
|
||||
stdscr.addstr(1, 1, " Events ", curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
log_h = h - 5
|
||||
visible = self.log_lines[-log_h:] if log_h > 0 else []
|
||||
for i, (ts, msg, color) in enumerate(visible):
|
||||
ly = 2 + i
|
||||
if ly >= h - 3:
|
||||
break
|
||||
# Fade old messages
|
||||
age = time.time() - ts
|
||||
attr = curses.A_DIM if age > 8 else 0
|
||||
try:
|
||||
stdscr.addstr(ly, 1, msg[:div - 2],
|
||||
curses.color_pair(color) | attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ RIGHT: CONFIG ═══
|
||||
try:
|
||||
stdscr.addstr(1, cfg_x, " Config ",
|
||||
curses.color_pair(2) | curses.A_BOLD)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
y = 3
|
||||
rw = w - cfg_x - 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, ":", curses.A_DIM)
|
||||
stdscr.addstr(y, cfg_x + 2, inst[:rw - 2],
|
||||
curses.color_pair(1))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
# VU meters per channel
|
||||
y += 1
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Levels:", curses.A_BOLD | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
if i in self.engine.channels:
|
||||
lv = self.engine.channels[i].level
|
||||
bars = int(min(lv, 1.0) * 16)
|
||||
meter = "|" * bars + "-" * (16 - bars)
|
||||
color = 1 if bars < 15 else 3 if bars < 18 else 4
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{i}", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + 1, f" {meter}", curses.color_pair(color))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
rec_indicator = " ● REC " if self.engine._recording else ""
|
||||
kbd_indicator = f" kbd:ch{self.engine._keyboard_channel} oct{self.engine._keyboard_octave}" if self.engine._keyboard_channel else ""
|
||||
|
||||
pairs = [
|
||||
("Drums", self.current_drum, 5),
|
||||
("BPM", str(self.bpm), 0),
|
||||
("Latency", f"{self.engine.buffer_size / 44100 * 1000:.1f}ms", 0),
|
||||
("MIDI", self.port, 0),
|
||||
("Seed", str(self.seed), 2),
|
||||
]
|
||||
if rec_indicator:
|
||||
pairs.append(("", rec_indicator, 4))
|
||||
if kbd_indicator:
|
||||
pairs.append(("", kbd_indicator, 2))
|
||||
for label, val, cp in pairs:
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, f"{label}:", curses.A_BOLD)
|
||||
stdscr.addstr(y, cfg_x + len(label) + 1, f" {val}"[:rw],
|
||||
curses.color_pair(cp))
|
||||
except curses.error:
|
||||
pass
|
||||
y += 1
|
||||
|
||||
y += 1
|
||||
cmds = [
|
||||
"ch <n> [inst]",
|
||||
"fx <n> <param> <val>",
|
||||
"pan <n> <-1..1>",
|
||||
"drums [pattern|-]",
|
||||
"kbd [ch] [oct]",
|
||||
"rec / stop / export",
|
||||
"save/load <file>",
|
||||
"seed [n] / list",
|
||||
"exit",
|
||||
]
|
||||
try:
|
||||
stdscr.addstr(y, cfg_x, "Commands:", curses.color_pair(3))
|
||||
except curses.error:
|
||||
pass
|
||||
for i, c in enumerate(cmds):
|
||||
try:
|
||||
stdscr.addstr(y + 1 + i, cfg_x + 1, c[:rw],
|
||||
curses.color_pair(2) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ INPUT BAR ═══
|
||||
iy = h - 2
|
||||
try:
|
||||
stdscr.addstr(iy - 1, 0, "─" * (w - 1), curses.A_DIM)
|
||||
if self.kbd_active:
|
||||
stdscr.addstr(iy, 0, " KEYBOARD ",
|
||||
curses.color_pair(7) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 10, " Esc=exit Up/Down=octave ",
|
||||
curses.color_pair(3))
|
||||
else:
|
||||
stdscr.addstr(iy, 0, " $ ",
|
||||
curses.color_pair(1) | curses.A_BOLD)
|
||||
stdscr.addstr(iy, 3, cmd_buf[:w - 5])
|
||||
# Cursor at position
|
||||
cx = 3 + cursor_pos
|
||||
if cx < w - 1:
|
||||
# Show character under cursor inverted, or block at end
|
||||
if cursor_pos < len(cmd_buf):
|
||||
stdscr.addstr(iy, cx, cmd_buf[cursor_pos],
|
||||
curses.A_REVERSE)
|
||||
else:
|
||||
stdscr.addstr(iy, cx, " ", curses.A_REVERSE)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# ═══ PICKER OVERLAY ═══
|
||||
if self._picker is not None:
|
||||
filt = self._picker["filter"]
|
||||
items = [i for i in self.instruments if filt in i] if filt else self.instruments
|
||||
idx = self._picker["index"]
|
||||
pw = min(32, w - 4)
|
||||
ph = min(len(items) + 2, h - 4)
|
||||
px = (w - pw) // 2
|
||||
py = (h - ph) // 2
|
||||
# Border
|
||||
title = f" Ch {self._picker['channel']} "
|
||||
try:
|
||||
stdscr.addstr(py, px, "┌" + title + "─" * (pw - 2 - len(title)) + "┐", curses.color_pair(2) | curses.A_BOLD)
|
||||
for ri in range(1, ph - 1):
|
||||
stdscr.addstr(py + ri, px, "│" + " " * (pw - 2) + "│", curses.color_pair(2))
|
||||
stdscr.addstr(py + ph - 1, px, "└" + "─" * (pw - 2) + "┘", curses.color_pair(2))
|
||||
except curses.error:
|
||||
pass
|
||||
# Items
|
||||
vis_h = ph - 2
|
||||
scroll = self._picker["scroll"]
|
||||
if idx < scroll:
|
||||
scroll = idx
|
||||
elif idx >= scroll + vis_h:
|
||||
scroll = idx - vis_h + 1
|
||||
self._picker["scroll"] = scroll
|
||||
for ri in range(vis_h):
|
||||
li = scroll + ri
|
||||
if li >= len(items):
|
||||
break
|
||||
name = items[li][:pw - 4]
|
||||
attr = curses.A_REVERSE | curses.color_pair(1) if li == idx else curses.color_pair(0)
|
||||
try:
|
||||
padded = f" {name}" + " " * (pw - 3 - len(name))
|
||||
stdscr.addstr(py + 1 + ri, px + 1, padded, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
# Filter hint
|
||||
hint = f"/{filt}" if filt else "type to filter"
|
||||
try:
|
||||
stdscr.addstr(py + ph - 1, px + 2, hint[:pw - 4], curses.color_pair(3) | curses.A_DIM)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
# ═══ INPUT ═══
|
||||
ch = stdscr.getch()
|
||||
if ch == -1:
|
||||
continue
|
||||
|
||||
# KEYBOARD MODE: all keys go to MIDI
|
||||
if self.kbd_active:
|
||||
if ch == 27: # Escape exits keyboard mode
|
||||
# Release all held notes
|
||||
for k in list(self._kbd_held):
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
self._kbd_held.clear()
|
||||
self.kbd_active = False
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
elif ch == curses.KEY_UP:
|
||||
self.engine._keyboard_octave = min(8, self.engine._keyboard_octave + 1)
|
||||
self.log(f"Octave ↑ {self.engine._keyboard_octave}", 2)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
self.engine._keyboard_octave = max(0, self.engine._keyboard_octave - 1)
|
||||
self.log(f"Octave ↓ {self.engine._keyboard_octave}", 2)
|
||||
elif 32 <= ch < 127:
|
||||
key = chr(ch).lower()
|
||||
now = time.time()
|
||||
if key in self._kbd_held:
|
||||
# Key repeat — just refresh the timer
|
||||
self._kbd_held[key] = now
|
||||
else:
|
||||
# New key press
|
||||
played = self.engine.keyboard_note(key, on=True)
|
||||
if played:
|
||||
self._kbd_held[key] = now
|
||||
|
||||
# Release keys that haven't been pressed for 150ms
|
||||
now = time.time()
|
||||
expired = [k for k, t in self._kbd_held.items()
|
||||
if now - t > 0.15]
|
||||
for k in expired:
|
||||
self.engine.keyboard_note(k, on=False)
|
||||
del self._kbd_held[k]
|
||||
|
||||
continue
|
||||
|
||||
# PICKER MODE
|
||||
if self._picker is not None:
|
||||
filt = self._picker["filter"]
|
||||
items = [i for i in self.instruments if filt in i] if filt else self.instruments
|
||||
if ch == 27: # Escape cancels
|
||||
self._picker = None
|
||||
elif ch == curses.KEY_UP:
|
||||
self._picker["index"] = max(0, self._picker["index"] - 1)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
self._picker["index"] = min(len(items) - 1, self._picker["index"] + 1)
|
||||
elif ch == 10 or ch == 13: # Enter selects
|
||||
if items:
|
||||
inst = items[self._picker["index"]]
|
||||
n = self._picker["channel"]
|
||||
self.picks[n - 1] = inst
|
||||
self.engine.channel(n, instrument=inst, reverb=0.3)
|
||||
self.log(f"Ch {n} → {inst}", 1)
|
||||
self._picker = None
|
||||
elif ch == curses.KEY_BACKSPACE or ch == 127:
|
||||
if filt:
|
||||
self._picker["filter"] = filt[:-1]
|
||||
self._picker["index"] = 0
|
||||
self._picker["scroll"] = 0
|
||||
elif 32 <= ch < 127:
|
||||
self._picker["filter"] = filt + chr(ch)
|
||||
self._picker["index"] = 0
|
||||
self._picker["scroll"] = 0
|
||||
continue
|
||||
|
||||
if ch == 10 or ch == 13:
|
||||
if cmd_buf.strip():
|
||||
cmd_history.append(cmd_buf)
|
||||
self._handle_command(cmd_buf.strip())
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
history_idx = -1
|
||||
elif ch == 27:
|
||||
if self.engine._keyboard_channel and not cmd_buf:
|
||||
# Escape exits keyboard mode
|
||||
self.engine._keyboard_channel = None
|
||||
self.log("Keyboard off (Esc)", 3)
|
||||
else:
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_BACKSPACE or ch == 127:
|
||||
if cursor_pos > 0:
|
||||
cmd_buf = cmd_buf[:cursor_pos - 1] + cmd_buf[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
elif ch == curses.KEY_LEFT:
|
||||
cursor_pos = max(0, cursor_pos - 1)
|
||||
elif ch == curses.KEY_RIGHT:
|
||||
cursor_pos = min(len(cmd_buf), cursor_pos + 1)
|
||||
elif ch == curses.KEY_HOME or ch == 1: # Ctrl-A
|
||||
cursor_pos = 0
|
||||
elif ch == curses.KEY_END or ch == 5: # Ctrl-E
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_UP:
|
||||
if cmd_history and history_idx < len(cmd_history) - 1:
|
||||
history_idx += 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif ch == curses.KEY_DOWN:
|
||||
if history_idx > 0:
|
||||
history_idx -= 1
|
||||
cmd_buf = cmd_history[-(history_idx + 1)]
|
||||
cursor_pos = len(cmd_buf)
|
||||
else:
|
||||
history_idx = -1
|
||||
cmd_buf = ""
|
||||
cursor_pos = 0
|
||||
elif ch == 9: # Tab
|
||||
if tab_matches and tab_prefix == cmd_buf:
|
||||
tab_idx = (tab_idx + 1) % len(tab_matches)
|
||||
cmd_buf = tab_matches[tab_idx]
|
||||
else:
|
||||
tab_matches = self._complete(cmd_buf)
|
||||
tab_prefix = cmd_buf
|
||||
if len(tab_matches) == 1:
|
||||
cmd_buf = tab_matches[0]
|
||||
tab_matches = []
|
||||
elif tab_matches:
|
||||
tab_idx = 0
|
||||
cmd_buf = tab_matches[0]
|
||||
else:
|
||||
tab_matches = []
|
||||
cursor_pos = len(cmd_buf)
|
||||
elif 32 <= ch < 127:
|
||||
cmd_buf = cmd_buf[:cursor_pos] + chr(ch) + cmd_buf[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
tab_matches = []
|
||||
tab_idx = -1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.running = False
|
||||
|
||||
self.engine.stop()
|
||||
|
||||
def _complete(self, text):
|
||||
"""Return list of completions for current input."""
|
||||
parts = text.split()
|
||||
commands = ["ch", "fx", "pan", "drums", "kbd", "rec", "stop", "export",
|
||||
"save", "load", "seed", "list", "patterns", "octave", "help", "exit"]
|
||||
fx_params = ["volume", "pan", "lowpass", "lowpass_q", "reverb", "chorus",
|
||||
"detune", "spread", "analog", "distortion", "delay",
|
||||
"tremolo_depth", "saturation", "phaser", "sub_osc", "noise_mix"]
|
||||
|
||||
if not parts:
|
||||
return [c + " " for c in commands]
|
||||
|
||||
# Completing first word
|
||||
if len(parts) == 1 and not text.endswith(" "):
|
||||
prefix = parts[0].lower()
|
||||
return [c + " " for c in commands if c.startswith(prefix)]
|
||||
|
||||
verb = parts[0].lower()
|
||||
|
||||
# ch <n> <instrument>
|
||||
if verb == "ch" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments
|
||||
if i.startswith(prefix)]
|
||||
if verb == "ch" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"ch {parts[1]} {i} " for i in self.instruments]
|
||||
|
||||
# drums <pattern>
|
||||
if verb == "drums" and len(parts) == 2 and not text.endswith(" "):
|
||||
prefix = parts[1].lower()
|
||||
matches = [p for p in self.drum_patterns if p.startswith(prefix)]
|
||||
return [f"drums {m} " for m in matches]
|
||||
if verb == "drums" and len(parts) == 1 and text.endswith(" "):
|
||||
return [f"drums {p} " for p in self.drum_patterns]
|
||||
|
||||
# fx <n> <param> <val>
|
||||
if verb == "fx" and len(parts) == 3 and not text.endswith(" "):
|
||||
prefix = parts[2].lower()
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params
|
||||
if p.startswith(prefix)]
|
||||
if verb == "fx" and len(parts) == 2 and text.endswith(" "):
|
||||
return [f"fx {parts[1]} {p} " for p in fx_params]
|
||||
|
||||
return []
|
||||
|
||||
def _handle_command(self, cmd):
|
||||
parts = cmd.split()
|
||||
if not parts:
|
||||
return
|
||||
verb = parts[0].lower()
|
||||
|
||||
if verb in ("quit", "q", "exit"):
|
||||
self.running = False
|
||||
elif verb in ("help", "h"):
|
||||
self.log("ch <n> [inst] | fx <n> <param> <val> | drums [pat|-]", 2)
|
||||
self.log("seed [n] | list | patterns | exit", 2)
|
||||
elif verb == "ch" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if 1 <= n <= len(self.picks):
|
||||
# Open instrument picker with current instrument pre-selected
|
||||
current = self.picks[n - 1]
|
||||
idx = self.instruments.index(current) if current in self.instruments else 0
|
||||
self._picker = {"channel": n, "index": idx, "scroll": max(0, idx - 5), "filter": ""}
|
||||
else:
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
except ValueError:
|
||||
self.log("ch <1-8> [instrument]", 4)
|
||||
elif verb == "ch" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
inst = parts[2]
|
||||
if inst not in INSTRUMENTS:
|
||||
self.log(f"Unknown: {inst}", 4)
|
||||
return
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
self.picks[n - 1] = inst
|
||||
self.engine.channel(n, instrument=inst, reverb=0.3)
|
||||
self.log(f"Ch {n} → {inst}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("ch <1-8> <instrument>", 4)
|
||||
elif verb == "fx" and len(parts) >= 4:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
param = parts[2]
|
||||
val = float(parts[3])
|
||||
if not (1 <= n <= len(self.picks)):
|
||||
self.log(f"Channel 1-{len(self.picks)}", 4)
|
||||
return
|
||||
if n not in self.engine.channels:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
return
|
||||
channel = self.engine.channels[n]
|
||||
if param == "reverb":
|
||||
channel.reverb = val
|
||||
channel._cache.clear()
|
||||
elif param == "lowpass":
|
||||
channel.lowpass = val
|
||||
channel._cache.clear()
|
||||
elif param == "volume":
|
||||
channel.volume = val
|
||||
elif hasattr(channel, param):
|
||||
setattr(channel, param, val)
|
||||
channel._cache.clear()
|
||||
else:
|
||||
self.log(f"Unknown param: {param}", 4)
|
||||
return
|
||||
self.log(f"Ch {n} {param}={val}", 1)
|
||||
except (ValueError, IndexError):
|
||||
self.log("fx <1-8> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) == 2:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
if n in self.engine.channels:
|
||||
ch = self.engine.channels[n]
|
||||
self.log(f"Ch {n}: vol={ch.volume} lp={ch.lowpass} rev={ch.reverb}", 2)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("fx <ch> <param> <value>", 4)
|
||||
elif verb == "fx" and len(parts) <= 1:
|
||||
self.log("Volume/Mix:", 3)
|
||||
self.log(" volume pan reverb delay", 2)
|
||||
self.log("Filter:", 3)
|
||||
self.log(" lowpass lowpass_q", 2)
|
||||
self.log("Modulation:", 3)
|
||||
self.log(" chorus detune spread tremolo_depth", 2)
|
||||
self.log(" phaser analog", 2)
|
||||
self.log("Drive:", 3)
|
||||
self.log(" distortion saturation", 2)
|
||||
self.log("Synth:", 3)
|
||||
self.log(" sub_osc noise_mix", 2)
|
||||
self.log("", 0)
|
||||
self.log("fx <ch> <param> <value>", 2)
|
||||
elif verb == "drums" and len(parts) == 1:
|
||||
self.log(f"Current: {self.current_drum}", 5)
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "drums" and len(parts) >= 2:
|
||||
pat = " ".join(parts[1:])
|
||||
if pat in ("none", "off", "mute", "-"):
|
||||
self.current_drum = "none"
|
||||
self.engine._drum_pattern = None
|
||||
self.engine._drum_channel = None
|
||||
self.log("Drums off", 4)
|
||||
else:
|
||||
try:
|
||||
self.current_drum = pat
|
||||
self.engine.drums(pat, volume=0.5)
|
||||
self.log(f"Drums → {pat}", 5)
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "seed" and len(parts) == 1:
|
||||
self.log(f"Seed: {self.seed}", 2)
|
||||
elif verb == "seed" and len(parts) >= 2:
|
||||
try:
|
||||
self.seed = int(parts[1])
|
||||
rng = random.Random(self.seed)
|
||||
self.picks = rng.sample(self.instruments, 8)
|
||||
for i, inst in enumerate(self.picks, 1):
|
||||
self.engine.channel(i, instrument=inst, reverb=0.3)
|
||||
self.log(f"Seed → {self.seed}", 1)
|
||||
except ValueError:
|
||||
self.log("seed <number>", 4)
|
||||
elif verb == "list":
|
||||
for i in range(0, len(self.instruments), 3):
|
||||
row = " ".join(f"{x:22s}" for x in self.instruments[i:i+3])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "patterns":
|
||||
for i in range(0, len(self.drum_patterns), 4):
|
||||
row = " ".join(f"{x:17s}" for x in self.drum_patterns[i:i+4])
|
||||
self.log(f" {row}", 2)
|
||||
elif verb == "pan" and len(parts) >= 3:
|
||||
try:
|
||||
n = int(parts[1])
|
||||
val = float(parts[2])
|
||||
val = max(-1.0, min(1.0, val))
|
||||
if n in self.engine.channels:
|
||||
self.engine.channels[n].pan = val
|
||||
self.log(f"Ch {n} pan={val:+.1f}", 1)
|
||||
else:
|
||||
self.log(f"Channel {n} not active", 4)
|
||||
except ValueError:
|
||||
self.log("pan <1-8> <-1..1>", 4)
|
||||
elif verb == "kbd":
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
ch_num = int(parts[1])
|
||||
self.engine._keyboard_channel = ch_num
|
||||
if len(parts) >= 3:
|
||||
self.engine._keyboard_octave = int(parts[2])
|
||||
except ValueError:
|
||||
self.log("kbd <channel> [octave]", 4)
|
||||
return
|
||||
else:
|
||||
self.engine._keyboard_channel = self.engine._keyboard_channel or 1
|
||||
self.kbd_active = True
|
||||
# Make sure wavetables are cached for this channel
|
||||
ch_num = self.engine._keyboard_channel
|
||||
if ch_num in self.engine.channels:
|
||||
channel = self.engine.channels[ch_num]
|
||||
if not channel._cache:
|
||||
self.log("Rendering wavetables...", 3)
|
||||
for midi_note in range(36, 97):
|
||||
channel._get_wave(midi_note, 44100 * 3)
|
||||
self.log(f"♪ Keyboard ON ch{ch_num} oct{self.engine._keyboard_octave} (Esc=exit, ↑↓=octave)", 1)
|
||||
elif verb == "rec":
|
||||
self.engine.start_recording()
|
||||
self.log("● Recording...", 4)
|
||||
elif verb == "stop" and self.engine._recording:
|
||||
self.engine.stop_recording()
|
||||
n = len(self.engine._record_events)
|
||||
self.log(f"■ Stopped ({n} events)", 3)
|
||||
elif verb == "export":
|
||||
fname = parts[1] if len(parts) > 1 else "recording.mid"
|
||||
score = self.engine.export_recording(fname)
|
||||
if score:
|
||||
self.log(f"Exported → {fname}", 1)
|
||||
else:
|
||||
self.log("Nothing to export", 4)
|
||||
elif verb == "save" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
self.engine.seed = self.seed
|
||||
self.engine.save_config(fname)
|
||||
self.log(f"Saved → {fname}", 1)
|
||||
elif verb == "load" and len(parts) >= 2:
|
||||
fname = parts[1]
|
||||
if not fname.endswith(".json"):
|
||||
fname += ".json"
|
||||
try:
|
||||
self.engine.load_config(fname)
|
||||
self.log(f"Loaded ← {fname}", 1)
|
||||
# Update picks from channels
|
||||
for ch, channel in self.engine.channels.items():
|
||||
if 1 <= ch <= 8:
|
||||
self.picks[ch - 1] = channel.synth_name
|
||||
except Exception as e:
|
||||
self.log(f"Error: {e}", 4)
|
||||
elif verb == "octave" and len(parts) >= 2:
|
||||
try:
|
||||
self.engine._keyboard_octave = int(parts[1])
|
||||
self.log(f"Keyboard octave → {self.engine._keyboard_octave}", 2)
|
||||
except ValueError:
|
||||
self.log("octave <0-8>", 4)
|
||||
else:
|
||||
self.log(f"? {cmd}", 4)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="PyTheory Live — real-time MIDI synthesizer")
|
||||
parser.add_argument("seed", nargs="?", type=int, default=None, help="Random seed for instruments")
|
||||
parser.add_argument("--port", "-p", default="OP-XY", help="MIDI port name (default: OP-XY)")
|
||||
parser.add_argument("--channels", "-c", type=int, default=8, help="Number of channels (default: 8)")
|
||||
parser.add_argument("--drums", "-d", default="rock", help="Drum pattern (default: rock, 'none' to disable)")
|
||||
parser.add_argument("--buffer", "-b", type=int, default=128, help="Audio buffer size (default: 128)")
|
||||
args = parser.parse_args()
|
||||
|
||||
tui = LiveTUI(seed=args.seed, port=args.port, n_channels=args.channels,
|
||||
drum_pattern=args.drums, buffer_size=args.buffer)
|
||||
curses.wrapper(tui.run)
|
||||
|
||||
# Print resume command on exit
|
||||
cmd_parts = ["pytheory-live", str(tui.seed)]
|
||||
if tui.port != "OP-XY":
|
||||
cmd_parts += ["--port", tui.port]
|
||||
if tui.n_channels != 8:
|
||||
cmd_parts += ["--channels", str(tui.n_channels)]
|
||||
if tui.current_drum != "rock":
|
||||
cmd_parts += ["--drums", tui.current_drum]
|
||||
if tui.buffer_size != 128:
|
||||
cmd_parts += ["--buffer", str(tui.buffer_size)]
|
||||
print(f"\nResume this session with:\n {' '.join(cmd_parts)}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+991
-201
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user