Compare commits

...

29 Commits

Author SHA1 Message Date
kennethreitz 0e10359236 v0.38.2: Part.ramp() for smooth parameter automation
Smoothly sweep any parameter (lowpass, reverb, distortion, etc.)
from current value to target with linear, ease_in, ease_out, or
ease_in_out interpolation curves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:12:32 -04:00
kennethreitz df00c3436d Docs: articulations, dynamic curves, Part.hit(), Duration arithmetic
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:24:21 -04:00
kennethreitz 2f02df15b8 v0.38.1: Dynamic curves (crescendo, decrescendo, swell, dynamics)
Part.crescendo(), Part.decrescendo(), Part.swell(), and Part.dynamics()
for velocity ramps and custom curves across note sequences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:23:16 -04:00
kennethreitz a2740b8d57 v0.38.0: Articulations, Part.hit(), djembe expansion, cross-choke
Articulations (staccato, legato, marcato, tenuto, accent, fermata)
on Part.add() and Part.hold(). Part.hit() for placing individual
drum sounds with articulation support. 5 new djembe patterns,
3 djembe fills, cross-choke damping, improved djembe slap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:14:21 -04:00
kennethreitz 840bfcc36c v0.37.0: Djembe expansion and cross-choke drum damping
5 new djembe patterns (dununba, tiriba, yankadi, djansa, mendiani),
3 djembe fills, cross-choke damping across drum families, and
improved djembe slap synthesis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:32:14 -04:00
kennethreitz 938c1cc132 v0.36.6: Cajón and metal drum fills
Add 6 new drum fills: cajon flam, cajon rumble, cajon breakdown,
metal triplet, metal blast, metal cascade. 27 fills total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:25:52 -04:00
kennethreitz 9dc22db4b2 v0.36.5: Duration arithmetic support
Duration enum now supports multiplication, division, and addition
so expressions like `Duration.WHOLE * 2` work instead of raising TypeError.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:19:28 -04:00
kennethreitz f570e226cd v0.36.4: Harmonium, doumbek, tabla fills, Part.hold()
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:11:06 -04:00
kennethreitz 0c5c3abedc Harmonium synth, doumbek drums (3 sounds, 4 patterns, 2 fills)
- Harmonium: single free reed, nasal midrange, bellows swell.
  The sound of kirtan and qawwali.
- Doumbek (darbuka): dum (center bass), tek (edge sharp), ka (muted).
  4 patterns: maqsoum, baladi, saidi, ayoub.
  2 fills: doumbek roll, doumbek accent.
- 42 synth waveforms total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 12:05:46 -04:00
kennethreitz 35d07b984b Docs: add tabla fills to drums.rst
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:57:08 -04:00
kennethreitz aec7723ee6 5 tabla fills: tihai, chakkardar, tiri kita, bayan, tabla call
Tihai (3x crescendo landing on sam), chakkardar (32nd triplet
cascade), tiri kita (rapid 16th dayan burst), bayan (bass bends),
tabla call (dayan/bayan call-and-response).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:56:19 -04:00
kennethreitz b98a40297b v0.36.3: Part.hold() polyphony, strum fix, 30 songs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:46:45 -04:00
kennethreitz 9117568b74 Strum uses hold() — leading string plays simultaneously with chord
No more timing gaps. The leading string is held at 15% velocity
at the same beat position as the full chord via hold(), adding
strum texture without stealing time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:42:36 -04:00
kennethreitz 11e4417c62 Part.hold() — polyphonic overlap on a single part
hold() adds a note without advancing the beat position, so the
next note starts at the same time. Enables: piano sustain (bass
rings while melody plays), drone notes under melody, held chords
with moving lines.

Two lines in the renderer: skip beat_pos advance when _hold is set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:38:41 -04:00
kennethreitz 4edf1d983d Remove strum grace notes — clean chord hit only
Grace notes created audible gap before chord and sounded like
separate plucks. Pure chord hit sounds better.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:30:18 -04:00
kennethreitz 74b07b1a8a Song #29: Pop Rock (I-V-vi-IV) — the progression that launched 1000 hits
G-D-Em-C at 120 BPM. Picked intro, strummed verse, electric lead
melody, strings swell, rock drums. The most popular chord progression
in pop history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:22:08 -04:00
kennethreitz c9437209a7 Song #28: Descent (generative — different every time)
Random key, tempo, reverb space, instruments, and melodies.
Melodies walk the scale stepwise (not random jumps), arpeggios
follow chord tones in order, piano walks up/down. Tabla solo
always closes with random strokes. No seed — truly unique each play.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:18:19 -04:00
kennethreitz 92cb855a49 Song #27: Ascent (Deep → Sky → Theremin Solo → Tabla Solo)
Didgeridoo drone throughout, granular abyss, kalimba light,
cello surfacing, piano + quiet uke, pedal steel + theremin solo
(searching → building → soaring peak), strings/flute/harp/timpani
at the peak, 4-part tabla solo finale (whisper → ghosts → 9-tuplet
call-response → 32nd triplet cascade + grand tihai).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 23:09:34 -04:00
kennethreitz f06c6f77d1 Comprehensive docs sweep: all 9 guide pages updated
- index.rst: 16 systems, 60+ presets, 41 waveforms, full feature list
- synths.rst: 31 dedicated synths, 60+ presets, complete instrument list
- drums.rst: 51 drum sounds, cajón section, bayan pitch bend
- effects.rst: cabinet/analog_drift in automatable params
- playback.rst: temperament, reference_pitch, KeyboardInterrupt
- systems.rst: 16 systems, full microtonal section (shruti JI,
  maqam Zalzalian, slendro, pelog, thai, makam, carnatic, 19/31-TET,
  Bohlen-Pierce), TET factory, int tone names, System.tone()
- sequencing.rst: Score tuning params documented
- tones.rst: enharmonics (Cb/Fb/E#/B#, double sharps/flats, unicode),
  B#/Cb octave fix, tone validation
- chords.rst: enharmonic support cross-reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:41:08 -04:00
kennethreitz 51bd63658f Docs: update synths.rst — 41 waveforms, all 24 dedicated synths
Added: pedal steel, theremin, kalimba, steel drum, accordion,
didgeridoo, bagpipe, banjo, mandolin, ukulele. Updated counts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:31:31 -04:00
kennethreitz 92ade3ee3d v0.36.2: REPL updates, 862 tests, improved songs, Ctrl-C handling
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:28:49 -04:00
kennethreitz 833867329e REPL: new commands, all instruments, updated autocomplete
New commands: strum, roll, bend, temperament, reference, instruments
Updated autocomplete: 41 synths, 50+ instruments, bowed/mallet
envelopes, all drum patterns (tabla, dhol, djembe, cajón, metal)
Part command supports instrument= keyword
Status shows temperament and reference pitch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:27:41 -04:00
kennethreitz 93b9fe9ced 25 new tests: all new synths, vocal, cajón, bends, rolls, int tones
862 tests total. Covers: 11 new synth waveforms, vocal synth with
lyrics, all instrument presets, cajón drums/patterns, pitch bend
rendering (3 types), roll velocity ramp, int tone names + wrapping,
B#/Cb octave fix, note choking, Score system/temperament/ref_pitch,
synth enum count (41).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:27:06 -04:00
kennethreitz 88a1171bbe Fix Theremin Noir: granular pad → strings pad (less noisy)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:20:01 -04:00
kennethreitz 3ca0842b7a Improve songs 1-16, 19: humanize, reverb, velocity dynamics
- humanize=0.2 added to all melodic parts (leads, basses, bells)
- Subtle reverb (0.1-0.2) on bass parts that had none
- Per-note velocity dynamics on all leads (was static)
- Blues lead changed from trumpet to saxophone (more fitting)
- Songs 17-18, 20-26 left untouched (already well-crafted)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:19:22 -04:00
kennethreitz 00de5eb354 Catch KeyboardInterrupt in all playback functions
play(), play_score(), _play_for() now catch Ctrl-C and stop
cleanly instead of crashing with a traceback. CLI demo also
wrapped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:18:43 -04:00
kennethreitz d2b0c6f329 v0.36.1: 7 new synths, 9 new demo moods
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:15:22 -04:00
kennethreitz 76612682f1 9 new demo moods: theremin noir, caribbean, accordion waltz, kalimba
dreams, outback drone, highland, nashville tears, tabla fusion

All new synths represented in pytheory demo random rotation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:13:11 -04:00
kennethreitz ce480858e9 7 new synths: pedal steel, theremin, kalimba, steel drum, accordion, didgeridoo, bagpipe
- Pedal steel: singing harmonics, slow vibrato, spring reverb
- Theremin: pure sine with hand wobble, legato+glide preset
- Kalimba: inharmonic metal tine modes, wooden body, bell-like
- Steel drum: hammered metal partials, bright Caribbean ring
- Accordion: musette-tuned doubled reeds, bellows pressure swell
- Didgeridoo: deep cylindrical drone, shifting formant overtones
- Bagpipe: bright chanter reed, constant bag pressure
- 41 synth waveforms total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:11:32 -04:00
19 changed files with 2999 additions and 294 deletions
+71
View File
@@ -2,6 +2,77 @@
All notable changes to PyTheory are documented here.
## 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()`,
`Part.swell()`, and `Part.dynamics()` for velocity ramps and custom
curves across a sequence of notes
## 0.38.0
- **Articulations** — `staccato`, `legato`, `marcato`, `tenuto`, `accent`,
`fermata` via `articulation=` on `Part.add()` and `Part.hold()`
- **`Part.hit()`** — place individual drum sounds in a Part's note stream
with articulation, velocity, and effects support
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
- **3 new djembe fills** — djembe call, djembe roll, djembe break (30 fills total)
- **Cross-choke drum damping** — striking one sound fades out related sounds
(djembe, hi-hats, cajón, doumbek)
- **Improved djembe slap** — dry goatskin pop instead of snare-like noise
## 0.37.0
- **5 new djembe patterns** — dununba, tiriba, yankadi, djansa, mendiani
- **3 new djembe fills** — djembe call, djembe roll, djembe break (30 fills total)
- **Cross-choke drum damping** — striking one sound on a hand drum fades
out the ring of related sounds (djembe slap kills bass resonance, closed
hat chokes open hat, cajón slap dampens bass, doumbek tek dampens dum)
- **Improved djembe slap** — dry, high-pitched goatskin pop instead of
snare-like noise rattle
## 0.36.6
- **6 new drum fills** — 3 cajón (flam, rumble, breakdown) and 3 metal
(triplet, blast, cascade). 27 fills total.
- Updated drums documentation with fill lists and examples
## 0.36.5
- **Duration arithmetic** — `Duration.WHOLE * 2`, `Duration.HALF + Duration.QUARTER`,
division, and reverse multiply all work now (previously raised TypeError)
## 0.36.3
- **`Part.hold()`** — polyphonic overlap on a single part. Add notes
without advancing the beat position so they play simultaneously.
Enables: piano sustain, sitar drone under melody, guitar strum texture.
- **Strum uses hold()** — leading string plays simultaneously with chord,
no more timing gaps or choppiness
- **Improved songs** 1-16: humanize, velocity dynamics, reverb, saxophone
for blues
- **Ctrl-C handling** — clean stop on all playback functions
- **REPL updates** — strum, roll, bend, temperament, reference commands
- Song #28 Descent (generative), #29 Pop Rock, #30 Sitar Drone
- 862 tests
## 0.36.1
- **7 new instrument synths:** pedal steel guitar, theremin, kalimba/thumb
piano, steel drum/pan, accordion (musette reeds), didgeridoo (drone +
shifting formants), bagpipes (chanter reed)
- **9 new demo moods** in ``pytheory demo``: Theremin Noir, Caribbean,
Accordion Waltz, Kalimba Dreams, Outback Drone, Highland, Nashville
Tears, Tabla Fusion
- Improved existing songs with dedicated instrument synths
- 41 synth waveforms, 26+ songs, 21 demo moods
## 0.36.0
- **Banjo synth** — steel strings on drum-head body, nasal twang,
+8
View File
@@ -322,6 +322,14 @@ against 17 known chord types (triads, 7ths, 9ths, sus, power chords).
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
Enharmonic spellings are fully supported — Cb, Fb, E#, B#, double
sharps/flats, and unicode symbols (see :doc:`tones` for details):
.. code-block:: pycon
>>> Chord.from_tones("Cb", "Eb", "Gb").identify()
'B minor'
You can also access the root and quality separately:
.. code-block:: pycon
+79 -16
View File
@@ -9,8 +9,8 @@ in Atlanta. Over a dancehall pattern, you're in Kingston. The drums ARE
the genre -- they tell the listener's body how to move before a single
melodic note is played.
PyTheory includes a complete drum system -- 27 synthesized percussion
sounds, 80+ pattern presets across dozens of genres, and 21 fill presets.
PyTheory includes a complete drum system -- 51 synthesized percussion
sounds, 85+ pattern presets across dozens of genres, and 30 fill presets.
Every sound is generated from waveforms; no samples needed.
Drum Sounds
@@ -91,7 +91,7 @@ The ``DrumSound`` enum maps to General MIDI percussion note numbers:
>>> DrumSound.CLOSED_HAT.value
42
All 27 sounds, organized by type:
All 51 sounds, organized by type:
**Kicks:** KICK (36)
@@ -106,7 +106,24 @@ All 27 sounds, organized by type:
**Percussion:** COWBELL (56), CLAVE (75), SHAKER (70), TAMBOURINE (54),
CONGA_HIGH (63), CONGA_LOW (64), BONGO_HIGH (60), BONGO_LOW (61),
TIMBALE_HIGH (65), TIMBALE_LOW (66), AGOGO_HIGH (67), AGOGO_LOW (68),
GUIRO (73), MARACAS (70)
GUIRO (73)
**Tabla:** TABLA_NA (86), TABLA_TIN (87), TABLA_GE (88), TABLA_DHA (89),
TABLA_TIT (90), TABLA_KE (91), TABLA_GE_BEND (108 -- bayan with upward
pitch bend from palm pressing into the head)
**Dhol:** DHOL_DAGGA (92), DHOL_TILLI (93), DHOL_BOTH (94)
**Dholak:** DHOLAK_GE (95), DHOLAK_NA (96), DHOLAK_TIT (97)
**Mridangam:** MRIDANGAM_THAM (98), MRIDANGAM_NAM (99), MRIDANGAM_DIN (100),
MRIDANGAM_THA (101)
**Djembe:** DJEMBE_BASS (102), DJEMBE_TONE (103), DJEMBE_SLAP (104)
**Cajón:** CAJON_SLAP (109), CAJON_TAP (110)
**Metal Kit:** METAL_KICK (105), METAL_SNARE (106), METAL_HAT (107)
Drum Synthesis
--------------
@@ -200,8 +217,8 @@ everything to its essentials. The metal kit adds 3 dedicated sounds
(double kick, china cymbal, stack) and 4 patterns for extreme metal
subgenres.
**World Percussion:** tabla, dhol, dholak, mridangam, djembe -- Deep
traditions from across the globe, each with authentic sound sets and
**World Percussion:** tabla, dhol, dholak, mridangam, djembe, cajón --
Deep traditions from across the globe, each with authentic sound sets and
idiomatic patterns. See the World Percussion section below for details.
**Other:** funk, hip hop, bo diddley, second line, new orleans, waltz,
@@ -235,14 +252,17 @@ ending and a new one is about to begin. Without fills, a drum pattern
just loops. With them, it breathes and has structure.
``Pattern.fill()`` loads a 1-bar drum fill -- a short break that
transitions between sections. 21 fill presets are available:
transitions between sections. 30 fill presets are available:
.. code-block:: pycon
>>> Pattern.list_fills()
['afrobeat', 'blast', 'bossa nova', 'breakdown', 'buildup',
'cumbia', 'disco', 'funk', 'highlife', 'hip hop', 'house',
'jazz', 'jazz brush', 'metal', 'reggae', 'rock', 'rock crash',
'cajon breakdown', 'cajon flam', 'cajon rumble',
'cumbia', 'disco', 'djembe break', 'djembe call', 'djembe roll',
'funk', 'highlife', 'hip hop', 'house',
'jazz', 'jazz brush', 'metal', 'metal blast', 'metal cascade',
'metal triplet', 'reggae', 'rock', 'rock crash',
'salsa', 'samba', 'second line', 'trap']
>>> fill = Pattern.fill("rock")
@@ -330,14 +350,25 @@ most expressive percussion instruments ever created. A single tabla
player can produce an astonishing range of tones by varying finger
placement, pressure, and striking technique.
**6 sounds** -- covering the primary tabla strokes (na, tin, tun, ge,
ke, and ti-ra-ki-ta combinations).
**7 sounds** -- covering the primary tabla strokes (na, tin, tun, ge,
dha, ke, tit) plus a bayan pitch bend sound (TABLA_GE_BEND) that
models the technique of pressing the palm into the bayan head to bend
the pitch upward.
**7 patterns:** teental (16 beats, the most common taal), jhaptaal
(10 beats), rupak (7 beats), dadra (6 beats), keherwa (8 beats, folk
and light classical), tabla solo, and tiri kita (fast ornamental
pattern).
**5 fills:** tihai (3x crescendo landing on sam), chakkardar (32nd
triplet cascade into slam), tiri kita (rapid 16th-note dayan burst),
bayan (deep bass bends showcase), tabla call (dayan/bayan call-and-response).
.. code-block:: python
score.drums("teental", repeats=4, fill="tihai")
score.drums("keherwa", repeats=4, fill="chakkardar")
.. code-block:: python
score = Score("4/4", bpm=80)
@@ -405,14 +436,19 @@ central to the drum ensemble traditions of Mali, Guinea, and Senegal.
**3 sounds** -- bass (open center strike), tone (edge strike), and
slap (sharp edge strike).
**3 patterns:** djembe (a basic accompanying rhythm), kuku (a
traditional rhythm from Guinea associated with fishing), and soli (a
solo/celebration rhythm).
**8 patterns:** djembe (basic accompanying rhythm), kuku (Guinean harvest
dance), soli (powerful Mandinka rhythm), dununba (heavy bass-driven),
tiriba (joyful Susu rhythm), yankadi (gentle greeting/welcome), djansa
(fast Malinke dance), mendiani (women's celebratory dance).
**3 fills:** djembe call (bass-tone-slap conversation building to climax),
djembe roll (rapid slaps accelerating into bass), djembe break (syncopated
West African-style break).
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("djembe", repeats=4)
score.drums("djembe", repeats=8, fill="djembe call", fill_every=4)
Metal Kit
~~~~~~~~~
@@ -428,10 +464,37 @@ metal blast (blast beat with china cymbal accents), metal groove (a
half-time groove with double kick fills), and metal gallop (the
classic triplet-feel gallop rhythm).
**4 fills:** metal (double kick 16ths with descending toms), metal triplet
(double kick triplets with snare accents), metal blast (alternating
snare/kick 32nds into half-time crash), metal cascade (descending snare
roll → kick roll → alternating → crash ending).
.. code-block:: python
score = Score("4/4", bpm=200)
score.drums("metal blast", repeats=4)
score.drums("metal blast", repeats=8, fill="metal cascade", fill_every=4)
Cajón
~~~~~
The cajón is a box-shaped percussion instrument from Peru, now
ubiquitous in acoustic and unplugged settings worldwide. Players sit
on the box and strike the front face with their hands.
**3 sounds** -- bass (deep center thump), slap (sharp, snare-like edge
hit with wire buzz), and tap (light finger tap).
**3 patterns:** cajon (basic groove), cajon rumba (flamenco-style rumba),
and cajon folk (folk/acoustic pattern).
**3 fills:** cajon flam (slaps accelerating into bass hits), cajon rumble
(fast taps building to slap accents), cajon breakdown (syncopated
bass-slap groove).
.. code-block:: python
score = Score("4/4", bpm=100)
score.drums("cajon", repeats=8, fill="cajon flam", fill_every=4)
MIDI Export
-----------
+5 -3
View File
@@ -841,9 +841,11 @@ processes each section independently:
lead.arpeggio("Gm", bars=4, pattern="updown", octaves=2)
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``highpass``,
``reverb``, ``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
``distortion``, ``distortion_drive``, ``chorus``, ``phaser``, ``phaser_rate``,
``saturation``, ``tremolo_depth``, ``tremolo_rate``, ``volume``.
``reverb``, ``reverb_decay``, ``reverb_type``, ``delay``, ``delay_time``,
``delay_feedback``, ``distortion``, ``distortion_drive``, ``chorus``,
``phaser``, ``phaser_rate``, ``saturation``, ``tremolo_depth``,
``tremolo_rate``, ``cabinet``, ``cabinet_brightness``, ``analog_drift``,
``volume``.
LFO Automation
--------------
+12 -1
View File
@@ -66,6 +66,17 @@ the mix louder and punchier:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
play_score(score)
The render pipeline respects the Score's ``temperament`` and
``reference_pitch`` settings, so Baroque or microtonal scores play back
at the correct tuning:
.. code-block:: python
score = Score("4/4", bpm=80, temperament="meantone", reference_pitch=415.0)
Press **Ctrl+C** at any time during playback to stop — PyTheory catches
``KeyboardInterrupt`` and stops audio cleanly.
See :doc:`sequencing` for how to build scores and parts.
render_score() -- Headless Rendering
@@ -153,7 +164,7 @@ Play a drum pattern through the speakers:
play_pattern(Pattern.preset("rock"), repeats=4, bpm=120)
play_pattern(Pattern.preset("bossa nova"), repeats=4, bpm=140)
See :doc:`drums` for the full list of 58 presets and 21 fills.
See :doc:`drums` for the full list of 80+ presets and 21 fills.
play_progression() -- Quick Chord Playback
------------------------------------------
+162 -2
View File
@@ -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
---------------
@@ -399,6 +411,102 @@ 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
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])
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.
Swing and Groove
----------------
@@ -478,6 +586,50 @@ 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)
``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
--------
@@ -667,8 +819,16 @@ A Score can use any tuning system and temperament:
# Just intonation — pure intervals
score = Score("4/4", bpm=90, temperament="just")
Temperaments: ``"equal"`` (default), ``"pythagorean"``, ``"meantone"``,
``"just"``.
The Score constructor accepts these tuning parameters:
- ``system``: Musical system name (default ``"western"``). Any system
from :doc:`systems` works — ``"indian"``, ``"shruti"``, ``"maqam"``,
``"carnatic"``, etc. Note strings in ``Part.add()`` are parsed against
this system.
- ``temperament``: Tuning temperament — ``"equal"`` (default),
``"pythagorean"``, ``"meantone"``, ``"just"``.
- ``reference_pitch``: Concert pitch in Hz (default 440.0). Use 415.0
for Baroque tuning, 432.0 for "Verdi tuning", etc.
Custom equal temperaments via the ``TET()`` factory:
+119 -10
View File
@@ -1,7 +1,7 @@
Synthesizers
============
PyTheory includes 30 built-in waveforms and 10 ADSR envelope presets.
PyTheory includes 41 built-in waveforms and 10 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
@@ -390,11 +390,11 @@ Dedicated Instrument Synths
--------------------------
Beyond the classic and physical modeling waveforms, PyTheory includes
17 dedicated instrument synths. Each one uses tailored synthesis
31 dedicated instrument synths. Each one uses tailored synthesis
techniques -- additive harmonics, formant shaping, body resonance
modeling, and specialized envelopes -- to capture the character of a
specific acoustic instrument. These are the waveforms that bring the
total count to 30.
total count to 41.
Piano Synth
~~~~~~~~~~~
@@ -558,6 +558,107 @@ mids, reed buzz, and brass body warmth. Four presets: ``saxophone``,
sax = score.part("sax", instrument="tenor_sax")
Pedal Steel Synth
~~~~~~~~~~~~~~~~~
The Nashville crying sound — singing harmonics with slow vibrato
and long sustain. Pairs naturally with spring reverb.
.. code-block:: python
steel = score.part("steel", instrument="pedal_steel")
Theremin Synth
~~~~~~~~~~~~~~
Pure sine with natural hand wobble — the eerie sci-fi sound.
Best used with legato and glide for continuous pitch.
.. code-block:: python
theremin = score.part("theremin", instrument="theremin")
Kalimba Synth
~~~~~~~~~~~~~
Metal tines on a wooden body. Bright, bell-like attack with
inharmonic overtones (modes at 1x, 2.92x, 5.4x).
.. code-block:: python
kalimba = score.part("kalimba", instrument="kalimba")
Steel Drum Synth
~~~~~~~~~~~~~~~~
Hammered metal pan with bright, ringing, tropical character.
Inharmonic partials at 2.0x, 3.01x, 4.1x, 5.3x.
.. code-block:: python
pan = score.part("pan", instrument="steel_drum")
Accordion Synth
~~~~~~~~~~~~~~~
Musette-tuned doubled reeds — two slightly detuned reed sets
create natural beating. Bellows pressure swell modulates amplitude.
.. code-block:: python
acc = score.part("acc", instrument="accordion")
Didgeridoo Synth
~~~~~~~~~~~~~~~~
Deep cylindrical drone with shifting formant overtones. The
overtone singing effect sweeps a resonant peak between 500-1500Hz.
Best with cave reverb.
.. code-block:: python
didg = score.part("didg", instrument="didgeridoo")
Bagpipe Synth
~~~~~~~~~~~~~
Bright chanter reed with constant bag pressure. All harmonics
peaked around 3-7 (the piercing brightness). No dynamics — always ff.
.. code-block:: python
pipes = score.part("pipes", instrument="bagpipe")
Banjo Synth
~~~~~~~~~~~
Steel strings on a drum-head membrane body. The membrane gives
nasal, ringy resonance with faster decay than guitar.
.. code-block:: python
banjo = score.part("banjo", instrument="banjo")
Mandolin Synth
~~~~~~~~~~~~~~
Paired steel strings in 4 courses — natural chorus from the
doubled unison strings. Bright, ringing, fast attack.
.. code-block:: python
mando = score.part("mando", instrument="mandolin")
Ukulele Synth
~~~~~~~~~~~~~
Nylon strings on a small body. Mid-heavy resonance (no deep bass),
softer attack than guitar, shorter sustain.
.. code-block:: python
uke = score.part("uke", instrument="ukulele")
Granular Synth
~~~~~~~~~~~~~~
@@ -614,7 +715,7 @@ Instrument Presets
------------------
Instead of choosing synth + envelope + effects manually, use an
instrument preset — 40+ predefined combinations that approximate real
instrument preset — 60+ predefined combinations that approximate real
instruments:
.. code-block:: python
@@ -627,20 +728,28 @@ instruments:
Available instruments:
**Keys**: piano, electric_piano, organ, harpsichord, celesta, music_box
**Keys**: piano, electric_piano, organ, harpsichord, celesta, music_box,
accordion
**Strings**: violin, viola, cello, contrabass, string_ensemble
**Woodwinds**: flute, clarinet, oboe, bassoon
**Woodwinds**: flute, clarinet, oboe, bassoon, saxophone, alto_sax,
tenor_sax, bari_sax
**Brass**: trumpet, trombone, french_horn, tuba, brass_ensemble
**Plucked**: acoustic_guitar, electric_guitar, distorted_guitar,
bass_guitar, upright_bass, harp, sitar, koto
**Plucked**: acoustic_guitar, electric_guitar, clean_guitar, crunch_guitar,
distorted_guitar, orange_crunch, metal_guitar, bass_guitar, upright_bass,
harp, sitar, koto, banjo, mandolin, mandola, ukulele
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass
**World/Exotic**: pedal_steel, theremin, kalimba, steel_drum, didgeridoo,
bagpipe
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass,
granular_pad, granular_texture, vocal, choir
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells,
timpani
Explicit kwargs override preset defaults:
+118 -4
View File
@@ -1,10 +1,11 @@
Musical Systems
===============
PyTheory supports **six musical systems**, each with its own tone names,
scale patterns, and centuries of tradition behind them. Every system
maps onto the same 12-tone equal temperament backbone, so you can
compare scales across cultures and even combine them in your own music.
PyTheory supports **16 musical systems** — 6 core systems mapped onto
12-tone equal temperament, plus 10 microtonal systems with their own
native tunings. The core systems let you compare scales across cultures;
the microtonal systems go beyond 12-TET into genuinely different pitch
universes.
Western
-------
@@ -271,4 +272,117 @@ produce the same pitches:
>>> do4.frequency
261.6255653005986
Microtonal Systems
------------------
Beyond the six 12-TET core systems, PyTheory includes 10 microtonal
systems that use their own native tunings — more notes per octave,
just intonation ratios, or entirely alien pitch structures.
Shruti (22 tones per octave)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The Indian 22-shruti system divides the octave into 22 unequal steps
using just intonation ratios. These microtonal inflections are what
give classical Indian music its characteristic expressiveness — pitches
that fall "between the cracks" of the piano.
.. code-block:: python
score = Score("4/4", bpm=75, system="shruti")
Maqam (24 tones per octave)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The Arabic 24-tone system adds Zalzalian quarter-tone intervals
(derived from just intonation ratios of 11 and 13) to the standard
12 tones. These "neutral" intervals — halfway between major and minor —
are the soul of maqam music.
.. code-block:: python
score = Score("4/4", bpm=90, system="maqam")
Slendro (5-TET)
~~~~~~~~~~~~~~~~
The Javanese slendro scale — 5 equal divisions of the octave. Each
step is 240 cents, wider than any Western interval. Ethereal and
floating.
Pelog (9-TET)
~~~~~~~~~~~~~
Approximation of the Javanese pelog tuning as 9 equal divisions of
the octave.
Thai (7-TET)
~~~~~~~~~~~~~
Thai classical music divides the octave into 7 equal steps of ~171
cents each — every interval is the same size.
Makam (53-TET)
~~~~~~~~~~~~~~
Turkish makam music uses 53 equal divisions of the octave — fine
enough to approximate virtually any just interval. The system that
underlies Ottoman classical music.
Carnatic (72-TET)
~~~~~~~~~~~~~~~~~
South Indian Carnatic music theory describes 72 melakarta ragas.
The 72-TET system provides enough resolution to represent all the
microtonal inflections of Carnatic practice.
19-TET and 31-TET
~~~~~~~~~~~~~~~~~~
Extended equal temperaments that offer better approximations of
just intonation intervals than 12-TET. 19-TET has excellent major
thirds; 31-TET closely matches quarter-comma meantone.
.. code-block:: python
score = Score("4/4", bpm=100, system="19-tet")
Bohlen-Pierce (13 equal divisions of the tritave)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A genuinely alien tuning system — 13 equal divisions of the
**tritave** (3:1 ratio) instead of the octave (2:1). No octaves, no
fifths, built on 3:5:7 harmonics. Used by experimental composers.
.. code-block:: python
score = Score("4/4", bpm=100, system="bohlen-pierce")
The TET() Factory
~~~~~~~~~~~~~~~~~
Create any equal temperament on the fly with the ``TET()`` factory:
.. code-block:: python
from pytheory import TET
edo19 = TET(19) # 19-tone equal temperament
edo31 = TET(31) # 31-tone equal temperament
score = Score("4/4", bpm=100, system=edo19)
Tone names in custom TET systems are integers (0, 1, 2, ..., n-1).
System.tone() Method
~~~~~~~~~~~~~~~~~~~~
Any system can create a Tone directly:
.. code-block:: python
from pytheory import SYSTEMS
western = SYSTEMS["western"]
c4 = western.tone("C", octave=4)
Music is universal, but every culture hears it differently. These systems are different maps of the same territory -- explore one you've never played in before and see what you find.
+39
View File
@@ -357,6 +357,45 @@ every tone knows its enharmonic spelling:
>>> Tone.from_string("C4", system="western").enharmonic is None
True
Extended Enharmonics
~~~~~~~~~~~~~~~~~~~~
PyTheory supports the full range of enharmonic spellings used in real
music theory:
- **Cb** and **Fb** — musically valid flats (Cb = B, Fb = E)
- **E#** and **B#** — musically valid sharps (E# = F, B# = C)
- **Double sharps** (``##`` or ``x``) — e.g. F## = G
- **Double flats** (``bb``) — e.g. Dbb = C
- **Unicode symbols**```` (sharp), ```` (flat), ``𝄪`` (double sharp),
``𝄫`` (double flat) are all recognized and normalized to ASCII
.. code-block:: pycon
>>> Tone.from_string("Cb4") # resolves to B3 (octave boundary fix)
<Tone B3>
>>> Tone.from_string("B#4") # resolves to C5 (octave boundary fix)
<Tone C5>
>>> Tone.from_string("E#4") # resolves to F4
<Tone F4>
>>> Tone.from_string("Fb4") # resolves to E4
<Tone E4>
The octave boundary is correctly handled: B# crosses up to the next
octave (B#4 = C5), and Cb crosses down (Cb4 = B3), matching standard
scientific pitch notation where the octave number increments at C.
Tone Validation
~~~~~~~~~~~~~~~
Tones are validated on construction — if a tone name is not recognized
in its system, a ``ValueError`` is raised:
.. code-block:: pycon
>>> Tone.from_string("X4") # not a valid tone name
ValueError: ...
The Circle of Fifths
--------------------
+19 -15
View File
@@ -18,8 +18,8 @@ Theory
------
The theory layer works everywhere Python runs — no audio setup needed.
Tones, scales, chords, keys, intervals, harmony, 6 musical systems,
25 instruments:
Tones, scales, chords, keys, intervals, harmony, 16 musical systems,
60+ instruments:
.. code-block:: pycon
@@ -72,25 +72,29 @@ every time::
What's Inside
-------------
- **Theory** — tones, scales (40+ across 6 systems), chords (17 types),
- **Theory** — tones, scales (40+ across 16 systems), chords (17 types),
keys, Roman numeral analysis, figured bass, pitch class sets (Forte
numbers), scale recommendation, modulation, voice leading
numbers), scale recommendation, modulation, voice leading, enharmonic
support (Cb, Fb, E#, B#, double sharps/flats, unicode symbols)
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
swing, humanize, tempo changes, song sections with repeat
- **Synthesis** — 34 waveforms (including Karplus-Strong pluck, Hammond organ,
bowed string, and 14 dedicated instrument synths), 10 envelopes, 40+
instrument presets, configurable FM, sub-oscillator, noise layer, filter
envelope, velocity-to-brightness, analog oscillator drift, detune, stereo
pan/spread, strumming, 80+ drum patterns (stereo panned, including world
percussion), 21 fills
swing, humanize, tempo changes, song sections with repeat, strumming,
pitch bends (3 types), rolls, tuning systems (TET factory, 4
temperaments, reference_pitch)
- **Synthesis** — 41 waveforms (including Karplus-Strong pluck, Hammond organ,
bowed string, granular, vocal/formant, and 31 dedicated instrument synths),
10 envelopes, 60+ instrument presets, configurable FM, sub-oscillator,
noise layer, filter envelope, velocity-to-brightness, analog oscillator
drift, detune, stereo pan/spread, 80+ drum patterns (stereo panned,
including world percussion and cajón), 21 fills, 11 microtonal systems
- **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay,
lowpass/highpass (with resonance), distortion, cabinet simulation,
lowpass/highpass (with resonance), distortion, guitar cabinet simulation,
saturation, chorus, phaser, tremolo, analog drift, sidechain compression,
automation, LFOs. Master bus compressor/limiter
- **Instruments**49 presets with fingering generation, guitar strumming,
pitch bends
- **Instruments**60+ presets with fingering generation, guitar strumming,
pitch bends, note choking
- **Output** — stereo playback, WAV export, MIDI import/export
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``,
KeyboardInterrupt handling for clean stop
- **AI-friendly** — Claude Code can compose
and play music through PyTheory from natural language
+701 -185
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.36.0"
version = "0.38.2"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.36.0"
__version__ = "0.38.2"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+52 -1
View File
@@ -299,6 +299,54 @@ def cmd_demo(args):
"lead": ("trumpet_synth", "bowed", 0.3, 0.2),
"pad": ("piano_synth", "none", -0.2),
"bass_lp": 600, "reverb_type": "plate"},
{"name": "Theremin Noir", "key": ("A", "minor"), "drums": "hip hop",
"fill": "rock", "bpm": 85,
"prog": ("i", "iv", "V", "i"),
"lead": ("theremin_synth", "pad", 0.4, 0.0),
"pad": ("strings_synth", "pad", 0.0),
"bass_lp": 300, "reverb_type": "cave"},
{"name": "Caribbean", "key": ("C", "major"), "drums": "reggae",
"fill": "reggae", "bpm": 110,
"prog": ("I", "IV", "V", "IV"),
"lead": ("steel_drum_synth", "none", 0.25, 0.3),
"pad": ("acoustic_guitar_synth", "none", -0.3),
"bass_lp": 500, "reverb_type": "plate"},
{"name": "Accordion Waltz", "key": ("D", "minor"), "drums": "waltz",
"fill": "jazz", "bpm": 88,
"prog": ("i", "iv", "V", "i"),
"lead": ("accordion_synth", "organ", 0.2, 0.1),
"pad": ("strings_synth", "pad", -0.2),
"bass_lp": 500, "reverb_type": "plate"},
{"name": "Kalimba Dreams", "key": ("G", "major"), "drums": "cajon folk",
"fill": "bossa nova", "bpm": 95,
"prog": ("I", "vi", "IV", "V"),
"lead": ("kalimba_synth", "none", 0.35, 0.2),
"pad": ("piano_synth", "none", -0.2),
"bass_lp": 400, "reverb_type": "taj_mahal"},
{"name": "Outback Drone", "key": ("E", "minor"), "drums": "djembe",
"fill": "afrobeat", "bpm": 70,
"prog": ("i", "iv", "i", "V"),
"lead": ("didgeridoo_synth", "pad", 0.3, 0.0),
"pad": ("granular_synth", "pad", 0.0),
"bass_lp": 200, "reverb_type": "cave"},
{"name": "Highland", "key": ("A", "minor"), "drums": "flamenco",
"fill": "rock", "bpm": 95,
"prog": ("i", "iv", "V", "i"),
"lead": ("bagpipe_synth", "organ", 0.15, 0.0),
"pad": ("strings_synth", "pad", -0.2),
"bass_lp": 400, "reverb_type": "cathedral"},
{"name": "Nashville Tears", "key": ("G", "major"), "drums": "country",
"fill": "rock", "bpm": 85,
"prog": ("I", "IV", "V", "IV"),
"lead": ("pedal_steel_synth", "strings", 0.35, 0.2),
"pad": ("piano_synth", "none", -0.3),
"bass_lp": 500, "reverb_type": "spring"},
{"name": "Tabla Fusion", "key": ("E", "minor"), "drums": "teental",
"fill": "rock", "bpm": 120,
"prog": ("i", "iv", "V", "i"),
"lead": ("sitar_synth", "none", 0.3, 0.2),
"pad": ("vocal_synth", "pad", 0.0),
"bass_lp": 400, "reverb_type": "taj_mahal"},
]
mood = random.choice(moods)
@@ -375,7 +423,10 @@ def cmd_demo(args):
print(f" {mood['drums']} | {lead_synth} lead | {pad_synth} pad | {mood['reverb_type']} reverb")
print()
play_score(score)
try:
play_score(score)
except KeyboardInterrupt:
pass
print("")
+440 -30
View File
@@ -1130,6 +1130,269 @@ def granular_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
return (peak * out).astype(numpy.int16)
def pedal_steel_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Pedal steel guitar — the Nashville crying sound.
Sustained steel string with natural portamento character,
very smooth, lots of harmonics, and a singing quality from
the bar sliding on the strings.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Slow, singing vibrato — the bar wobbling on the strings
vib = hz * 0.002 * numpy.sin(2 * numpy.pi * 4.0 * t)
# Rich harmonics — steel bar gives a clear, singing tone
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = 1.0 / n * numpy.exp(-0.08 * n)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Long sustain envelope
wave *= numpy.exp(-0.8 * t)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def theremin_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Theremin — pure sine with natural wobble.
The theremin's sound is a nearly pure sine wave with slight
pitch instability from hand position. The eerie, sci-fi sound
comes from this purity combined with continuous pitch.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Natural hand wobble — slightly irregular vibrato
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
wobble = hz * 0.004 * numpy.sin(2 * numpy.pi * 5.8 * t)
wobble += hz * 0.001 * rng.normal(0, 1, n_samples)
wave = numpy.sin(2 * numpy.pi * (hz + wobble) * t)
# Slight 2nd harmonic — real theremins aren't perfectly pure
wave += 0.08 * numpy.sin(2 * numpy.pi * (hz * 2 + wobble * 2) * t)
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def kalimba_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Kalimba/thumb piano — metal tines on a wooden body.
Bright, bell-like attack with inharmonic overtones from the
metal tines. The wooden resonator gives warmth underneath.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Metal tine modes — slightly inharmonic like marimba
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 2.92 * t) * 0.25 * numpy.exp(-12 * t)
wave += numpy.sin(2 * numpy.pi * hz * 5.4 * t) * 0.1 * numpy.exp(-20 * t)
# Two-stage decay: bright attack dies fast, fundamental rings
decay = numpy.where(t < 0.1,
numpy.exp(-3 * t),
numpy.exp(-3 * 0.1) * numpy.exp(-1.5 * (t - 0.1)))
wave *= decay
# Wooden body resonance
import scipy.signal as _sig
for center, bw, gain in [(300, 100, 0.2), (600, 120, 0.15)]:
lo, hi = max(20, center - bw), min(SAMPLE_RATE // 2 - 1, center + bw)
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
wave += _sig.lfilter(bp, ap, wave) * gain
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def steel_drum_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Steel drum/pan — hammered metal with bright, ringing tone.
The steel pan has specific inharmonic partials from the
hand-hammered notes. Bright, tropical, bell-like.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Steel pan modes — distinctly metallic
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.7
wave += numpy.sin(2 * numpy.pi * hz * 2.0 * t) * 0.4 * numpy.exp(-5 * t)
wave += numpy.sin(2 * numpy.pi * hz * 3.01 * t) * 0.25 * numpy.exp(-8 * t)
wave += numpy.sin(2 * numpy.pi * hz * 4.1 * t) * 0.15 * numpy.exp(-12 * t)
wave += numpy.sin(2 * numpy.pi * hz * 5.3 * t) * 0.08 * numpy.exp(-18 * t)
# Mallet impact
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
hit_len = min(int(SAMPLE_RATE * 0.008), n_samples)
hit = rng.uniform(-0.2, 0.2, hit_len) * numpy.exp(-numpy.linspace(0, 12, hit_len))
wave[:hit_len] += hit
# Two-stage decay
decay = numpy.where(t < 0.15,
numpy.exp(-4 * t),
numpy.exp(-4 * 0.15) * numpy.exp(-1.2 * (t - 0.15)))
wave *= decay
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def harmonium_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Harmonium — Indian pump organ, single free reed per note.
Unlike accordion (doubled musette reeds), the harmonium has one
reed per note no beating, just a pure, nasal, reedy tone.
Constant bellows pressure, warm but slightly buzzy. The sound
of kirtan, qawwali, and devotional music.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Single reed — odd harmonics stronger (like clarinet but warmer)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 12):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
amp = (1.0 / n) * (1.0 if n % 2 == 1 else 0.5)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
# Bellows pressure — gentle swell, slower than accordion
bellows = 0.9 + 0.1 * numpy.sin(2 * numpy.pi * 0.5 * t)
wave *= bellows
# Nasal character — slight midrange boost
import scipy.signal as _sig
center = min(1200, hz * 3)
lo = max(20, int(center - 300))
hi = min(SAMPLE_RATE // 2 - 1, int(center + 300))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
nasal = _sig.lfilter(bp, ap, wave) * 0.2
wave += nasal
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def accordion_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Accordion — bellows-driven free reeds.
Two reeds per note slightly detuned (musette tuning) create
the characteristic beating/tremolo. Rich in harmonics from
the reed vibration.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Two reeds slightly detuned — musette beating
detune_cents = 8
hz2 = hz * (2 ** (detune_cents / 1200))
# Reed harmonics — rich, like a square-ish wave but warmer
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for reed_hz in [hz, hz2]:
for n in range(1, 10):
f_n = reed_hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Odd harmonics stronger (reed character)
amp = (1.0 / n) * (1.2 if n % 2 == 1 else 0.6)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
wave *= 0.5 # normalize for two reeds
# Bellows pressure variation — slow amplitude swell
bellows = 0.85 + 0.15 * numpy.sin(2 * numpy.pi * 0.8 * t)
wave *= bellows
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def didgeridoo_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Didgeridoo — circular breathing drone through a wooden tube.
Deep fundamental with strong odd harmonics from the cylindrical
bore. The overtone singing technique creates shifting formants.
Buzzy, droning, primal.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Lip buzz source — rich, raw
phase = numpy.cumsum(2 * numpy.pi * hz / SAMPLE_RATE * numpy.ones(n_samples))
buzz = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 15):
if hz * n >= SAMPLE_RATE / 2:
break
# Odd harmonics stronger (cylindrical bore, like clarinet)
amp = (1.0 / n) * (1.0 if n % 2 == 1 else 0.4)
buzz += amp * numpy.sin(phase * n + rng.uniform(0, 2 * numpy.pi))
# Shifting formant — the overtone singing effect
# Sweeps slowly between 500Hz and 1500Hz
formant_center = 800 + 400 * numpy.sin(2 * numpy.pi * 0.3 * t)
import scipy.signal as _sig
# Block-process the formant sweep
block = 2048
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(0, n_samples, block):
end = min(i + block, n_samples)
fc = formant_center[(i + end) // 2]
lo = max(20, int(fc - 300))
hi = min(SAMPLE_RATE // 2 - 1, int(fc + 300))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
seg = _sig.lfilter(bp, ap, buzz[i:end])
out[i:end] = buzz[i:end] * 0.5 + seg * 0.5
else:
out[i:end] = buzz[i:end]
# Breath noise
breath = rng.normal(0, 0.04, n_samples)
out += breath
mx = numpy.abs(out).max()
if mx > 0:
out /= mx
return (peak * out).astype(numpy.int16)
def bagpipe_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Bagpipes — chanter reed with constant drone pressure.
The chanter (melody pipe) uses a double reed like an oboe
but with more buzz and brightness. The constant air pressure
from the bag means no dynamics always ff.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Chanter — all harmonics, bright and reedy
wave = numpy.zeros(n_samples, dtype=numpy.float64)
for n in range(1, 18):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Peaked around harmonics 3-7 (the piercing brightness)
amp = (1.0 / n) * numpy.exp(-0.03 * (n - 5) ** 2)
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * f_n * t + phase)
# Reed buzz — more than oboe
reed = rng.normal(0, 0.08, n_samples)
import scipy.signal as _sig
lo = max(20, int(hz * 2))
hi = min(SAMPLE_RATE // 2 - 1, int(hz * 8))
if lo < hi:
br, ar = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 1.5
wave += reed
# Bag pressure wobble — very subtle
bag = 1.0 + 0.02 * numpy.sin(2 * numpy.pi * 1.5 * t)
wave *= bag
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def banjo_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Banjo — steel strings on a drum-head body.
@@ -1519,8 +1782,11 @@ def _play_for(sample_wave, ms):
"""Play the given NumPy sample array through the speakers."""
normalized_wave = sample_wave.astype(numpy.float32) / SAMPLE_PEAK
_sd = _get_sd()
_sd.play(normalized_wave, SAMPLE_RATE)
_sd.wait()
try:
_sd.play(normalized_wave, SAMPLE_RATE)
_sd.wait()
except KeyboardInterrupt:
_sd.stop()
class Synth(Enum):
@@ -1565,6 +1831,14 @@ class Synth(Enum):
SAXOPHONE = "saxophone_synth"
GRANULAR = "granular_synth"
VOCAL = "vocal_synth"
PEDAL_STEEL = "pedal_steel_synth"
THEREMIN = "theremin_synth"
KALIMBA = "kalimba_synth"
STEEL_DRUM = "steel_drum_synth"
HARMONIUM = "harmonium_synth"
ACCORDION = "accordion_synth"
DIDGERIDOO = "didgeridoo_synth"
BAGPIPE = "bagpipe_synth"
BANJO = "banjo_synth"
MANDOLIN = "mandolin_synth"
UKULELE = "ukulele_synth"
@@ -1591,6 +1865,10 @@ _SYNTH_FUNCTIONS = {
"harp_synth": harp_wave, "upright_bass_synth": upright_bass_wave,
"timpani_synth": timpani_wave, "saxophone_synth": saxophone_wave,
"granular_synth": granular_wave, "vocal_synth": vocal_wave,
"pedal_steel_synth": pedal_steel_wave, "theremin_synth": theremin_wave,
"kalimba_synth": kalimba_wave, "steel_drum_synth": steel_drum_wave,
"harmonium_synth": harmonium_wave, "accordion_synth": accordion_wave, "didgeridoo_synth": didgeridoo_wave,
"bagpipe_synth": bagpipe_wave,
"banjo_synth": banjo_wave, "mandolin_synth": mandolin_wave,
"ukulele_synth": ukulele_wave,
"acoustic_guitar_synth": acoustic_guitar_wave,
@@ -2348,6 +2626,52 @@ def _synth_mridangam_tha(n_samples):
return out
def _synth_doumbek_dum(n_samples):
"""Doumbek Dum — open center strike, deep and round."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
freq = 80 + 40 * numpy.exp(-25 * t)
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 8) * 0.8
thump_len = min(int(SAMPLE_RATE * 0.04), n_samples)
import scipy.signal as _sig
thump = _noise(thump_len)
if thump_len > 20:
bl, al = _sig.butter(2, [50, 250], btype='band', fs=SAMPLE_RATE)
thump = _sig.lfilter(bl, al, numpy.pad(thump, (0, max(0, n_samples - thump_len))))[:thump_len].astype(numpy.float32)
thump *= _exp_decay(thump_len, 22) * 0.7
body[:thump_len] += thump
return numpy.tanh(body * 1.3).astype(numpy.float32)
def _synth_doumbek_tek(n_samples):
"""Doumbek Tek — sharp edge strike, bright and cutting."""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
ring = numpy.sin(2 * numpy.pi * 400 * t) * _exp_decay(n_samples, 22) * 0.5
ring2 = numpy.sin(2 * numpy.pi * 900 * t) * 0.3 * _exp_decay(n_samples, 30)
click_len = min(int(SAMPLE_RATE * 0.005), n_samples)
click = _noise(click_len) * _exp_decay(click_len, 300) * 0.9
import scipy.signal as _sig
if click_len > 10:
bl, al = _sig.butter(2, [2000, min(8000, SAMPLE_RATE // 2 - 1)], btype='band', fs=SAMPLE_RATE)
click = _sig.lfilter(bl, al, numpy.pad(click, (0, max(0, n_samples - click_len))))[:click_len].astype(numpy.float32)
result = ring + ring2
result[:click_len] += click
return numpy.tanh(result * 1.8).astype(numpy.float32)
def _synth_doumbek_ka(n_samples):
"""Doumbek Ka — muted edge slap, short and dry."""
n = min(n_samples, int(SAMPLE_RATE * 0.04))
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
body = numpy.sin(2 * numpy.pi * 350 * t) * _exp_decay(n, 30) * 0.4
slap = _noise(min(80, n)) * _exp_decay(min(80, n), 200) * 0.7
result = body
result[:min(80, n)] += slap
out = numpy.zeros(n_samples, dtype=numpy.float32)
out[:n] = numpy.tanh(result * 1.5)
return out
def _synth_cajon_bass(n_samples):
"""Cajón bass — palm strike on center of the face.
@@ -2566,29 +2890,27 @@ def _synth_djembe_tone(n_samples):
def _synth_djembe_slap(n_samples):
"""Djembe slap — edge strike with fingers spread, sharp crack.
The highest, sharpest djembe sound. Fingers fan out on contact
creating a loud crack with minimal sustain.
The highest, sharpest djembe sound. A dry, high-pitched pop from
goatskin membrane NOT a snare. Tight attack, very short decay,
skin character rather than wire rattle.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Sharp crack — mostly noise
crack_len = min(int(SAMPLE_RATE * 0.02), n_samples)
crack = _noise(crack_len) * _exp_decay(crack_len, 100) * 1.0
# Brief high-pitched ring
ring = numpy.sin(2 * numpy.pi * 600 * t) * _exp_decay(n_samples, 25) * 0.4
ring2 = numpy.sin(2 * numpy.pi * 1200 * t) * 0.2 * _exp_decay(n_samples, 35)
# Brief membrane pop
thump_len = min(int(SAMPLE_RATE * 0.02), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [300, 2000], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
# High membrane pop — goatskin resonance, much higher than snare
pop = numpy.sin(2 * numpy.pi * 900 * t) * _exp_decay(n_samples, 50) * 0.5
pop2 = numpy.sin(2 * numpy.pi * 1600 * t) * _exp_decay(n_samples, 60) * 0.25
pop3 = numpy.sin(2 * numpy.pi * 2400 * t) * _exp_decay(n_samples, 80) * 0.12
# Very short filtered click — hand-on-skin transient, not noise rattle
click_len = min(int(SAMPLE_RATE * 0.008), n_samples)
click_raw = _noise(click_len)
if click_len > 20:
bl, al = scipy.signal.butter(2, 1800 / (SAMPLE_RATE / 2), btype='high')
click = scipy.signal.lfilter(bl, al, numpy.pad(click_raw, (0, max(0, n_samples - click_len))))[:click_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 80) * 0.8
result = ring + ring2
result[:crack_len] += crack
result[:thump_len] += thump
return numpy.tanh(result * 1.7)
click = click_raw
click *= _exp_decay(click_len, 150) * 0.6
result = pop + pop2 + pop3
result[:click_len] += click
return numpy.tanh(result * 1.5)
def _synth_guiro(n_samples):
@@ -2678,6 +3000,10 @@ def _render_drum_hit(sound_value, n_samples):
DrumSound.DJEMBE_BASS.value: lambda n: _synth_djembe_bass(n),
DrumSound.DJEMBE_TONE.value: lambda n: _synth_djembe_tone(n),
DrumSound.DJEMBE_SLAP.value: lambda n: _synth_djembe_slap(n),
# Doumbek
DrumSound.DOUMBEK_DUM.value: lambda n: _synth_doumbek_dum(n),
DrumSound.DOUMBEK_TEK.value: lambda n: _synth_doumbek_tek(n),
DrumSound.DOUMBEK_KA.value: lambda n: _synth_doumbek_ka(n),
# Cajon
DrumSound.CAJON_BASS.value: lambda n: _synth_cajon_bass(n),
DrumSound.CAJON_SLAP.value: lambda n: _synth_cajon_slap(n),
@@ -3819,10 +4145,48 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
start += _rnd.randint(-max_offset, max_offset)
start = max(0, start)
dur_ms = note.beats * 60_000 / bpm
# Articulation: adjust duration and velocity
art = getattr(note, 'articulation', '')
art_vel_mult = 1.0
art_attack_mult = 1.0 # multiplier for envelope attack
if art == 'staccato':
dur_ms *= 0.4 # short and bouncy
elif art == 'legato':
dur_ms *= 1.15 # slight overlap into next note
elif art == 'marcato':
art_vel_mult = 1.25 # heavier
art_attack_mult = 0.3 # sharper attack
elif art == 'tenuto':
art_attack_mult = 1.8 # softer attack, full duration
elif art == 'accent':
art_vel_mult = 1.2
elif art == 'fermata':
dur_ms *= 1.5 # held longer
n_samples = int(SAMPLE_RATE * dur_ms / 1000)
if start + n_samples > total_samples:
n_samples = total_samples - start
if n_samples > 0 and start >= 0:
# Drum hit via Part.hit() — use drum synth directly
from .rhythm import _DrumTone
if isinstance(note.tone, _DrumTone):
drum_wave = _render_drum_hit(note.tone.sound.value, n_samples)
mixed = drum_wave.astype(numpy.float32)
# Staccato fade-out for drums
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume * vel_scale
if not getattr(note, '_hold', False):
beat_pos += note.beats
continue
# Get pitches
if hasattr(note.tone, 'tones'):
pitches = [t.pitch(temperament=temperament, reference_pitch=reference_pitch) for t in note.tone.tones]
@@ -3923,11 +4287,18 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
if noise_mix > 0:
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
# Amplitude envelope
if a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, a, d, s, r)
# Per-note velocity
# Amplitude envelope (articulation may adjust attack)
art_a = a * art_attack_mult
if art_a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, art_a, d, s, r)
# Staccato: apply a quick fade-out at the end
if art == 'staccato':
fade_len = min(int(SAMPLE_RATE * 0.01), len(mixed))
if fade_len > 0:
mixed[-fade_len:] *= numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
# Per-note velocity (articulation may boost)
vel = getattr(note, 'velocity', 100)
vel = min(127, int(vel * art_vel_mult))
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
@@ -3964,7 +4335,9 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
# Right channel gets up-detuned, left gets down-detuned
stereo_buf[start:end, 1] += up_env * gain * spread_amt
stereo_buf[start:end, 0] += down_env * gain * spread_amt
beat_pos += note.beats
# hold() notes don't advance the beat position
if not getattr(note, '_hold', False):
beat_pos += note.beats
def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
@@ -4006,7 +4379,8 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
events.append((start, end, hz, vel))
else:
events.append((start, end, 0, vel)) # rest
beat_pos += note.beats
if not getattr(note, '_hold', False):
beat_pos += note.beats
if not events:
return
@@ -4271,6 +4645,10 @@ def render_score(score):
DrumSound.DJEMBE_BASS.value: 0.0,
DrumSound.DJEMBE_TONE.value: 0.1,
DrumSound.DJEMBE_SLAP.value: -0.1,
# Doumbek
DrumSound.DOUMBEK_DUM.value: 0.0,
DrumSound.DOUMBEK_TEK.value: 0.1,
DrumSound.DOUMBEK_KA.value: -0.1,
# Cajon — centered (single instrument)
DrumSound.CAJON_BASS.value: 0.0,
DrumSound.CAJON_SLAP.value: 0.0,
@@ -4326,6 +4704,35 @@ def render_score(score):
part_stereo[fade_start:start, ch] *= fade
_last_hit_start[sound_id] = start
# Cross-choke: a new hit on one sound dampens the ring of
# related sounds on the same instrument (e.g. djembe slap
# kills the bass resonance, closed hat kills open hat).
_CHOKE_GROUPS = {
# Djembe — any strike dampens the others
DrumSound.DJEMBE_BASS.value: (DrumSound.DJEMBE_TONE.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_TONE.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_SLAP.value),
DrumSound.DJEMBE_SLAP.value: (DrumSound.DJEMBE_BASS.value, DrumSound.DJEMBE_TONE.value),
# Hi-hats — closed chokes open
DrumSound.CLOSED_HAT.value: (DrumSound.OPEN_HAT.value,),
DrumSound.PEDAL_HAT.value: (DrumSound.OPEN_HAT.value,),
# Cajón — slap dampens bass ring
DrumSound.CAJON_SLAP.value: (DrumSound.CAJON_BASS.value,),
DrumSound.CAJON_TAP.value: (DrumSound.CAJON_BASS.value,),
# Doumbek — tek/ka dampen dum
DrumSound.DOUMBEK_TEK.value: (DrumSound.DOUMBEK_DUM.value,),
DrumSound.DOUMBEK_KA.value: (DrumSound.DOUMBEK_DUM.value,),
}
choke_targets = _CHOKE_GROUPS.get(sound_id, ())
for target_id in choke_targets:
if target_id in _last_hit_start:
prev_start = _last_hit_start[target_id]
fade_len = min(int(SAMPLE_RATE * 0.004), max(0, start - prev_start))
if fade_len > 0 and start > 0:
fade = numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
fade_start = max(0, start - fade_len)
for ch in range(2):
part_stereo[fade_start:start, ch] *= fade
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
@@ -4401,8 +4808,11 @@ def play_score(score):
"""
buf = render_score(score)
_sd = _get_sd()
_sd.play(buf, SAMPLE_RATE)
_sd.wait()
try:
_sd.play(buf, SAMPLE_RATE)
_sd.wait()
except KeyboardInterrupt:
_sd.stop()
# ── MIDI export ─────────────────────────────────────────────────────────────
+176 -7
View File
@@ -77,6 +77,7 @@ def cmd_help(session, args):
Parts:
part lead saw pluck score.part("lead", synth="saw", envelope="pluck")
part bass sine score.part("bass", synth="sine")
part lead instrument piano score.part("lead", instrument="piano")
part list all parts
Notes (on active part):
@@ -85,6 +86,12 @@ def cmd_help(session, args):
rest 2 part.rest(2.0)
arp Am updown 2 2 part.arpeggio("Am", pattern="updown", bars=2, octaves=2)
prog I V vi IV part adds key.progression(...)
strum Am 2 down part.strum("Am", 2, direction="down")
strum G 2 up 0.1 lazy strum (strum_time=0.1)
roll C3 4 part.roll("C3", 4) timpani/tremolo
roll C3 4 30 110 roll with velocity ramp
bend C5 1 2 part.add("C5", 1, bend=2) bend up 2 semitones
bend C5 1 -1 bend down a half step
Effects (on active part):
reverb 0.4 reverb=0.4
@@ -110,6 +117,12 @@ def cmd_help(session, args):
fingering Am guitar chord fingering
diagram [mode] [frets] scale diagram on guitar
Tuning:
temperament equal set temperament (equal/pythagorean/meantone/just)
temperament show current temperament
reference 432 set reference pitch (default 440)
instruments list all available instruments
Session:
show score info
status current state
@@ -197,12 +210,22 @@ def cmd_part(session, args):
return
name = args[0]
synth = args[1] if len(args) > 1 else "saw"
envelope = args[2] if len(args) > 2 else "pluck"
if name not in session.parts:
session.parts[name] = session.score.part(name, synth=synth, envelope=envelope)
print(f" score.part(\"{name}\", synth=\"{synth}\", envelope=\"{envelope}\")")
# Check if second arg is "instrument" keyword or an instrument name
if len(args) > 1 and args[1] == "instrument" and len(args) > 2:
instrument = args[2]
session.parts[name] = session.score.part(name, instrument=instrument)
print(f" score.part(\"{name}\", instrument=\"{instrument}\")")
elif len(args) > 1 and args[1] in _INSTRUMENT_NAMES:
instrument = args[1]
session.parts[name] = session.score.part(name, instrument=instrument)
print(f" score.part(\"{name}\", instrument=\"{instrument}\")")
else:
synth = args[1] if len(args) > 1 else "saw"
envelope = args[2] if len(args) > 2 else "pluck"
session.parts[name] = session.score.part(name, synth=synth, envelope=envelope)
print(f" score.part(\"{name}\", synth=\"{synth}\", envelope=\"{envelope}\")")
else:
print(f"{name}")
session.current_part = session.parts[name]
@@ -534,6 +557,97 @@ def cmd_identify(session, args):
print(f" error: {e}")
def cmd_strum(session, args):
"""Strum a chord on a fretboard-equipped part."""
if not args:
print(" usage: strum Am [beats] [down|up] [strum_time]")
return
part = _require_part(session)
chord_name = args[0]
beats = float(args[1]) if len(args) > 1 else 1.0
direction = args[2] if len(args) > 2 else "down"
strum_time = float(args[3]) if len(args) > 3 else 0.05
try:
part.strum(chord_name, beats, direction=direction, strum_time=strum_time)
print(f" .strum(\"{chord_name}\", {beats}, direction=\"{direction}\", "
f"strum_time={strum_time})")
except Exception as e:
print(f" error: {e}")
def cmd_roll(session, args):
"""Play a roll (rapid repeated notes with velocity ramp)."""
if not args:
print(" usage: roll C3 [beats] [vel_start] [vel_end]")
return
part = _require_part(session)
tone = args[0]
beats = float(args[1]) if len(args) > 1 else 4.0
vel_start = int(args[2]) if len(args) > 2 else 40
vel_end = int(args[3]) if len(args) > 3 else 100
try:
part.roll(tone, beats, velocity_start=vel_start, velocity_end=vel_end)
print(f" .roll(\"{tone}\", {beats}, velocity_start={vel_start}, "
f"velocity_end={vel_end})")
except Exception as e:
print(f" error: {e}")
def cmd_bend(session, args):
"""Add a note with pitch bend."""
if len(args) < 3:
print(" usage: bend C5 1 2 (note, beats, semitones)")
print(" bend C5 1 -1 (bend down)")
return
part = _require_part(session)
note = args[0]
beats = float(args[1])
bend = float(args[2])
bend_type = args[3] if len(args) > 3 else "smooth"
try:
part.add(note, beats, bend=bend, bend_type=bend_type)
print(f" .add(\"{note}\", {beats}, bend={bend}, bend_type=\"{bend_type}\")")
except Exception as e:
print(f" error: {e}")
def cmd_temperament(session, args):
"""Set or show the tuning temperament."""
if not args:
temp = getattr(session.score, 'temperament', 'equal')
ref = getattr(session.score, 'reference_pitch', 440.0)
print(f" temperament={temp} reference={ref} Hz")
print(f" available: equal, pythagorean, meantone, just")
return
temp = args[0]
valid = ["equal", "pythagorean", "meantone", "just"]
if temp not in valid:
print(f" unknown temperament: {temp}")
print(f" available: {', '.join(valid)}")
return
session.score.temperament = temp
print(f" temperament={temp}")
def cmd_reference(session, args):
"""Set the reference pitch (A4 frequency)."""
if not args:
ref = getattr(session.score, 'reference_pitch', 440.0)
print(f" reference={ref} Hz")
return
ref = float(args[0])
session.score.reference_pitch = ref
print(f" reference={ref} Hz")
def cmd_instruments(session, args):
"""List all available instruments."""
cols = 3
for i in range(0, len(_INSTRUMENT_NAMES), cols):
row = _INSTRUMENT_NAMES[i:i + cols]
print(" " + " ".join(f"{name:<22s}" for name in row))
def cmd_circle(session, args):
"""Show circle of fifths."""
tonic = args[0] if args else session.key.tonic_name
@@ -560,7 +674,10 @@ def cmd_clear(session, args):
def cmd_status(session, args):
parts = ", ".join(session.parts.keys()) if session.parts else "none"
active = session.current_part.name if session.current_part else "none"
temp = getattr(session.score, 'temperament', 'equal')
ref = getattr(session.score, 'reference_pitch', 440.0)
print(f" key={session.key} bpm={session.bpm} swing={session.swing}")
print(f" temperament={temp} reference={ref} Hz")
print(f" drums={session._drum_preset or 'none'} parts=[{parts}] active={active}")
@@ -607,6 +724,12 @@ COMMANDS = {
"interval": cmd_interval,
"identify": cmd_identify, "id": cmd_identify,
"circle": cmd_circle,
"strum": cmd_strum,
"roll": cmd_roll,
"bend": cmd_bend,
"temperament": cmd_temperament, "temp": cmd_temperament,
"reference": cmd_reference, "ref": cmd_reference,
"instruments": cmd_instruments,
"clear": cmd_clear,
"status": cmd_status,
}
@@ -653,9 +776,43 @@ def _prompt(session):
# ── Tab completion ─────────────────────────────────────────────────────────
_SYNTH_NAMES = ["sine", "saw", "triangle", "square", "pulse", "fm",
"noise", "supersaw", "pwm_slow", "pwm_fast"]
"noise", "supersaw", "pwm_slow", "pwm_fast",
"pedal_steel_synth", "theremin_synth", "kalimba_synth",
"steel_drum_synth", "accordion_synth", "didgeridoo_synth",
"bagpipe_synth", "banjo_synth", "mandolin_synth",
"ukulele_synth", "vocal_synth", "granular_synth",
"piano_synth", "organ_synth", "harpsichord_synth",
"strings_synth", "cello_synth", "flute_synth",
"clarinet_synth", "oboe_synth", "trumpet_synth",
"acoustic_guitar_synth", "electric_guitar_synth",
"bass_guitar_synth", "upright_bass_synth", "harp_synth",
"sitar_synth", "pluck_synth", "saxophone_synth",
"marimba_synth", "timpani_synth"]
_INSTRUMENT_NAMES = [
# Keys
"piano", "electric_piano", "organ", "harpsichord", "celesta", "music_box",
# Strings
"violin", "viola", "cello", "contrabass", "string_ensemble",
# Woodwinds
"flute", "clarinet", "oboe", "bassoon",
# Brass
"trumpet", "trombone", "french_horn", "tuba", "brass_ensemble",
# Plucked
"acoustic_guitar", "electric_guitar", "clean_guitar", "crunch_guitar",
"distorted_guitar", "orange_crunch", "metal_guitar", "bass_guitar",
"upright_bass", "harp", "sitar", "pedal_steel", "theremin", "kalimba",
"steel_drum", "accordion", "didgeridoo", "bagpipe", "banjo", "mandolin",
"mandola", "ukulele", "koto",
# Synth presets
"synth_lead", "synth_pad", "synth_bass", "acid_bass",
"granular_pad", "vocal", "choir", "granular_texture", "808_bass",
# Percussion / Mallet
"vibraphone", "marimba", "xylophone", "glockenspiel", "tubular_bells", "timpani",
# Woodwinds (continued)
"saxophone", "alto_sax", "tenor_sax", "bari_sax",
]
_ENVELOPE_NAMES = ["piano", "pluck", "pad", "organ", "bell", "strings",
"staccato", "none"]
"staccato", "bowed", "mallet", "none"]
_ARP_PATTERNS = ["up", "down", "updown", "downup", "random"]
_LFO_SHAPES = ["sine", "triangle", "saw", "square"]
_SYSTEMS = ["western", "indian", "arabic", "japanese", "blues", "gamelan"]
@@ -667,7 +824,7 @@ _CHORD_SUFFIXES = ["", "m", "7", "m7", "maj7", "dim", "aug", "sus2", "sus4",
# Context-aware completions for the second word
_ARG_COMPLETIONS = {
"drums": lambda: Pattern.list_presets(),
"part": lambda: _SYNTH_NAMES,
"part": lambda: _SYNTH_NAMES + _INSTRUMENT_NAMES,
"key": lambda: [f"{n}m" for n in _NOTE_NAMES[:12]] + _NOTE_NAMES[:12],
"arp": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
"add": lambda: [f"{n}{o}" for n in _NOTE_NAMES[:12] for o in ["3", "4", "5"]],
@@ -679,6 +836,12 @@ _ARG_COMPLETIONS = {
"lowpass_q", "reverb_decay", "delay_time", "delay_feedback",
"distortion_drive"],
"identify": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
"strum": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
"roll": lambda: [f"{n}{o}" for n in _NOTE_NAMES[:12] for o in ["2", "3", "4", "5"]],
"bend": lambda: [f"{n}{o}" for n in _NOTE_NAMES[:12] for o in ["3", "4", "5"]],
"temperament": lambda: ["equal", "pythagorean", "meantone", "just"],
"reference": lambda: ["440", "432", "415", "444"],
"instruments": lambda: _INSTRUMENT_NAMES,
}
@@ -705,6 +868,12 @@ def _completer(text, state):
elif cmd == "arp" and len(tokens) == 3:
# Pattern for arp
options = [p for p in _ARP_PATTERNS if p.startswith(text)]
elif cmd == "strum" and len(tokens) == 4:
# Direction for strum
options = [d for d in ["down", "up"] if d.startswith(text)]
elif cmd == "bend" and len(tokens) == 5:
# Bend type
options = [t for t in ["smooth", "linear", "late"] if t.startswith(text)]
elif cmd == "lfo" and len(tokens) >= 7:
# Shape for lfo
options = [s for s in _LFO_SHAPES if s.startswith(text)]
+742 -16
View File
@@ -195,6 +195,42 @@ INSTRUMENTS = {
"detune": 12, "lowpass": 3000, "lowpass_q": 1.5,
"humanize": 0.2,
},
"pedal_steel": {
"synth": "pedal_steel_synth", "envelope": "strings",
"reverb": 0.3, "reverb_type": "spring",
"humanize": 0.15,
},
"theremin": {
"synth": "theremin_synth", "envelope": "pad",
"legato": True, "glide": 0.05,
"reverb": 0.3, "reverb_type": "plate",
},
"kalimba": {
"synth": "kalimba_synth", "envelope": "none",
"reverb": 0.35, "reverb_type": "plate",
},
"steel_drum": {
"synth": "steel_drum_synth", "envelope": "none",
"reverb": 0.3, "reverb_type": "plate",
},
"harmonium": {
"synth": "harmonium_synth", "envelope": "organ",
"reverb": 0.2, "reverb_type": "taj_mahal",
"humanize": 0.15,
},
"accordion": {
"synth": "accordion_synth", "envelope": "organ",
"humanize": 0.15,
},
"didgeridoo": {
"synth": "didgeridoo_synth", "envelope": "pad",
"lowpass": 1500,
"reverb": 0.4, "reverb_type": "cave",
},
"bagpipe": {
"synth": "bagpipe_synth", "envelope": "organ",
"lowpass": 4000,
},
"banjo": {
"synth": "banjo_synth", "envelope": "none",
"humanize": 0.2,
@@ -352,6 +388,24 @@ class Duration(Enum):
DOTTED_QUARTER = 1.5
TRIPLET_QUARTER = 2 / 3
# Arithmetic — lets you write ``Duration.WHOLE * 2`` → 8.0 beats.
def __mul__(self, other):
return self.value * other
def __rmul__(self, other):
return self.value * other
def __truediv__(self, other):
return self.value / other
def __add__(self, other):
if isinstance(other, Duration):
return self.value + other.value
return self.value + other
def __radd__(self, other):
return other + self.value
class TimeSignature:
"""A musical time signature like 4/4 or 6/8."""
@@ -395,6 +449,8 @@ class Note:
bend: float = 0.0
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
lyric: str = "" # syllable for vocal synth
articulation: str = "" # "", "staccato", "legato", "marcato", "tenuto", "accent", "fermata"
_hold: bool = False # if True, don't advance beat position
@property
def beats(self) -> float:
@@ -496,6 +552,10 @@ class DrumSound(Enum):
DJEMBE_BASS = 102 # open bass (center of head)
DJEMBE_TONE = 103 # open tone (edge, fingers together)
DJEMBE_SLAP = 104 # slap (edge, fingers spread, sharp crack)
# Doumbek (darbuka) sounds
DOUMBEK_DUM = 112 # center of head, deep bass
DOUMBEK_TEK = 113 # edge of head, sharp high
DOUMBEK_KA = 114 # muted edge slap
# Cajon sounds
CAJON_BASS = 108 # center of face, deep thump
CAJON_SLAP = 109 # top edge, snare wires buzz
@@ -506,6 +566,17 @@ class DrumSound(Enum):
METAL_HAT = 107 # tight, short, precise
class _DrumTone:
"""Wrapper so a DrumSound can be placed in a Part's note list."""
__slots__ = ('sound',)
def __init__(self, sound: DrumSound):
self.sound = sound
def pitch(self, **kwargs):
return -self.sound.value
class _Hit:
"""A single drum hit at a specific position in a pattern."""
__slots__ = ("sound", "position", "velocity")
@@ -1531,6 +1602,61 @@ Pattern._PRESETS["tabla solo"] = dict(
],
)
# ── Doumbek patterns ──────────────────────────────────────────────────────
DKD = DrumSound.DOUMBEK_DUM
DKT = DrumSound.DOUMBEK_TEK
DKK = DrumSound.DOUMBEK_KA
# Maqsoum — the most common Arabic rhythm (4/4)
Pattern._PRESETS["maqsoum"] = dict(
name="maqsoum",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 85), _h(DKT, 0.5, 65),
_h(DKT, 1.0, 68), _h(DKD, 1.5, 80),
_h(DKT, 2.0, 65), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKT, 3.5, 62),
],
)
# Baladi — heavy, earthy, belly dance
Pattern._PRESETS["baladi"] = dict(
name="baladi",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 88), _h(DKD, 0.5, 78),
_h(DKT, 1.0, 70), _h(DKD, 1.5, 82),
_h(DKT, 2.0, 68), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKK, 3.25, 45), _h(DKT, 3.5, 65),
],
)
# Saidi — Upper Egyptian, strong and driving
Pattern._PRESETS["saidi"] = dict(
name="saidi",
time_signature="4/4",
beats=4.0,
hits=[
_h(DKD, 0.0, 88), _h(DKT, 0.5, 65),
_h(DKD, 1.0, 82), _h(DKD, 1.5, 78),
_h(DKT, 2.0, 70), _h(DKT, 2.5, 62),
_h(DKT, 3.0, 68), _h(DKT, 3.5, 62),
],
)
# Ayoub — simple 2/4, trance-like repetition
Pattern._PRESETS["ayoub"] = dict(
name="ayoub",
time_signature="2/4",
beats=2.0,
hits=[
_h(DKD, 0.0, 85), _h(DKK, 0.5, 45),
_h(DKT, 1.0, 70), _h(DKT, 1.5, 62),
],
)
# ── Cajón patterns ────────────────────────────────────────────────────────
CB = DrumSound.CAJON_BASS
CSL = DrumSound.CAJON_SLAP
@@ -1793,6 +1919,74 @@ Pattern._PRESETS["soli"] = dict(
],
)
# Dununba — heavy bass-driven rhythm (accompaniment djembe part)
Pattern._PRESETS["dununba"] = dict(
name="dununba",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 110), _h(JB, 0.5, 95),
_h(JT, 1.0, 75), _h(JB, 1.5, 100),
_h(JB, 2.0, 108), _h(JT, 2.5, 70),
_h(JB, 3.0, 105), _h(JB, 3.5, 90), _h(JT, 3.75, 65),
],
)
# Tiriba — joyful Susu rhythm from Guinea
Pattern._PRESETS["tiriba"] = dict(
name="tiriba",
time_signature="4/4",
beats=4.0,
hits=[
_h(JT, 0.0, 85), _h(JS, 0.25, 95), _h(JT, 0.5, 80),
_h(JB, 1.0, 100), _h(JT, 1.5, 75),
_h(JS, 2.0, 92), _h(JT, 2.25, 78), _h(JT, 2.5, 80),
_h(JB, 3.0, 105), _h(JS, 3.5, 88), _h(JT, 3.75, 72),
],
)
# Yankadi — gentle greeting/welcome rhythm from Guinea
Pattern._PRESETS["yankadi"] = dict(
name="yankadi",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 90), _h(JT, 0.5, 70),
_h(JT, 1.0, 72), _h(JS, 1.5, 85),
_h(JB, 2.0, 88), _h(JT, 2.5, 68),
_h(JS, 3.0, 82), _h(JT, 3.5, 65),
],
)
# Djansa — fast Malinke dance rhythm
Pattern._PRESETS["djansa"] = dict(
name="djansa",
time_signature="4/4",
beats=4.0,
hits=[
_h(JS, 0.0, 100), _h(JT, 0.25, 72), _h(JT, 0.5, 70),
_h(JB, 0.75, 95),
_h(JS, 1.0, 98), _h(JT, 1.25, 68), _h(JB, 1.5, 92),
_h(JS, 2.0, 102), _h(JT, 2.25, 75), _h(JT, 2.5, 72),
_h(JB, 2.75, 90),
_h(JS, 3.0, 105), _h(JT, 3.25, 70), _h(JB, 3.5, 95),
_h(JS, 3.75, 88),
],
)
# Mendiani — women's dance rhythm, celebratory
Pattern._PRESETS["mendiani"] = dict(
name="mendiani",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 100), _h(JT, 0.25, 65), _h(JS, 0.5, 90),
_h(JT, 1.0, 70), _h(JB, 1.5, 95), _h(JT, 1.75, 68),
_h(JS, 2.0, 92), _h(JT, 2.5, 72), _h(JS, 2.75, 85),
_h(JB, 3.0, 105), _h(JT, 3.25, 65), _h(JS, 3.5, 95),
],
)
# ── Fill presets ──────────────────────────────────────────────────────────
Pattern._FILLS["rock"] = dict(
@@ -2061,6 +2255,277 @@ Pattern._FILLS["second line"] = dict(
],
)
# ── Doumbek fills ────────────────────────────────────────────────────────
_DKD = DrumSound.DOUMBEK_DUM
_DKT = DrumSound.DOUMBEK_TEK
_DKK = DrumSound.DOUMBEK_KA
# Doumbek roll — rapid teks building to dum
Pattern._FILLS["doumbek roll"] = dict(
name="doumbek roll fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(_DKT, i * 0.125, 40 + i * 4) for i in range(16)],
_h(_DKD, 2.0, 100), _h(_DKT, 2.25, 65), _h(_DKT, 2.5, 68),
_h(_DKD, 3.0, 110), _h(_DKD, 3.25, 105),
_h(_DKD, 3.5, 115), _h(_DKT, 3.75, 80),
],
)
# Doumbek accent — syncopated dum-tek-ka pattern
Pattern._FILLS["doumbek accent"] = dict(
name="doumbek accent fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_DKD, 0.0, 95), _h(_DKT, 0.25, 65), _h(_DKK, 0.5, 50),
_h(_DKT, 0.75, 68), _h(_DKD, 1.0, 90),
_h(_DKT, 1.5, 72), _h(_DKK, 1.75, 52), _h(_DKD, 2.0, 100),
_h(_DKT, 2.25, 68), _h(_DKT, 2.5, 70), _h(_DKT, 2.75, 72),
_h(_DKD, 3.0, 110), _h(_DKD, 3.5, 115),
],
)
# ── Tabla fills ──────────────────────────────────────────────────────────
_TNA = DrumSound.TABLA_NA
_TDH = DrumSound.TABLA_DHA
_TTT = DrumSound.TABLA_TIT
_TKE = DrumSound.TABLA_KE
_TGB = DrumSound.TABLA_GE_BEND
_TGE = DrumSound.TABLA_GE
_TTI = DrumSound.TABLA_TIN
_T3 = 1.0 / 12.0
# Tihai — the classic 3x pattern landing on sam
Pattern._FILLS["tihai"] = dict(
name="tihai fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TDH, 0.0, 105), _h(_TNA, 0.25, 72), _h(_TTT, 0.5, 48),
_h(_TKE, 0.75, 52), _h(_TDH, 1.0, 100),
_h(_TDH, 1.25, 110), _h(_TNA, 1.5, 78), _h(_TTT, 1.75, 52),
_h(_TKE, 2.0, 55), _h(_TDH, 2.25, 105),
_h(_TDH, 2.5, 118), _h(_TNA, 2.75, 82), _h(_TTT, 3.0, 58),
_h(_TKE, 3.25, 60), _h(_TDH, 3.5, 127),
],
)
# Chakkardar — 32nd triplet cascade into slam
Pattern._FILLS["chakkardar"] = dict(
name="chakkardar fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(_TTT, i * _T3, 32 + i * 3) for i in range(12)],
_h(_TDH, 1.0, 115), _h(_TGB, 1.5, 108),
*[_h(_TTT, 2.0 + i * _T3, 35 + i * 3) for i in range(12)],
_h(_TDH, 3.0, 120), _h(_TDH, 3.25, 115),
_h(_TGB, 3.5, 120), _h(_TDH, 3.75, 127),
],
)
# Tiri kita fill — rapid 16th note dayan burst
Pattern._FILLS["tiri kita"] = dict(
name="tiri kita fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TTT, 0.0, 50), _h(_TTT, 0.125, 38), _h(_TKE, 0.25, 48),
_h(_TNA, 0.5, 72), _h(_TTT, 0.75, 42),
_h(_TDH, 1.0, 95), _h(_TTT, 1.25, 38), _h(_TTT, 1.5, 42),
_h(_TKE, 1.75, 48), _h(_TNA, 2.0, 75),
_h(_TTT, 2.25, 40), _h(_TTT, 2.5, 45), _h(_TKE, 2.75, 50),
_h(_TDH, 3.0, 100), _h(_TNA, 3.25, 70),
_h(_TDH, 3.5, 110), _h(_TGB, 3.75, 105),
],
)
# Bayan showcase — deep bass bends
Pattern._FILLS["bayan"] = dict(
name="bayan fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TGB, 0.0, 100), _h(_TNA, 0.5, 65),
_h(_TGE, 1.0, 85), _h(_TGB, 1.5, 105),
_h(_TNA, 2.0, 70), _h(_TKE, 2.25, 48),
_h(_TGB, 2.5, 110), _h(_TDH, 3.0, 115),
_h(_TGB, 3.5, 120),
],
)
# Call and response — dayan speaks, bayan answers
Pattern._FILLS["tabla call"] = dict(
name="tabla call fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(_TNA, 0.0, 105), _h(_TNA, 0.25, 55), _h(_TTT, 0.5, 38),
_h(_TNA, 0.75, 100),
_h(_TGE, 1.0, 95), _h(_TGE, 1.25, 48), _h(_TGB, 1.5, 90),
_h(_TNA, 2.0, 108), _h(_TTT, 2.125, 30), _h(_TTT, 2.25, 35),
_h(_TNA, 2.5, 100),
_h(_TGB, 3.0, 112), _h(_TKE, 3.25, 48),
_h(_TDH, 3.5, 120),
],
)
# ── Djembe fills ─────────────────────────────────────────────────────────
# Djembe call — bass-tone-slap conversation building to climax
Pattern._FILLS["djembe call"] = dict(
name="djembe call fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 100), _h(JT, 0.25, 70), _h(JT, 0.5, 72),
_h(JS, 0.75, 90),
_h(JB, 1.0, 95), _h(JT, 1.25, 68), _h(JS, 1.5, 88),
_h(JT, 1.75, 75),
_h(JS, 2.0, 100), _h(JS, 2.25, 95), _h(JT, 2.5, 78),
_h(JB, 2.75, 105),
_h(JS, 3.0, 110), _h(JT, 3.25, 80), _h(JS, 3.5, 112),
_h(JB, 3.75, 120),
],
)
# Djembe roll — rapid slaps accelerating into bass
Pattern._FILLS["djembe roll"] = dict(
name="djembe roll fill",
time_signature="4/4",
beats=4.0,
hits=[
# Accelerating slap roll
*[_h(JS, i * 0.125, 50 + i * 4) for i in range(16)],
# Bass accents punching through
_h(JB, 2.0, 105), _h(JB, 2.5, 108),
_h(JB, 3.0, 112), _h(JT, 3.25, 85),
_h(JB, 3.5, 115), _h(JS, 3.75, 100),
],
)
# Djembe break — syncopated West African-style break
Pattern._FILLS["djembe break"] = dict(
name="djembe break fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(JB, 0.0, 105), _h(JT, 0.25, 65), _h(JS, 0.5, 90),
_h(JT, 0.75, 70), _h(JB, 1.0, 100),
_h(JS, 1.25, 85), _h(JS, 1.5, 88),
_h(JB, 1.75, 95), _h(JT, 2.0, 72),
_h(JS, 2.25, 92), _h(JB, 2.5, 108),
_h(JT, 2.75, 68), _h(JS, 2.875, 55),
_h(JB, 3.0, 115), _h(JS, 3.25, 100),
_h(JB, 3.5, 118), _h(JB, 3.75, 120),
],
)
# ── Cajón fills ──────────────────────────────────────────────────────────
# Cajón flam run — slaps accelerating into bass hit
Pattern._FILLS["cajon flam"] = dict(
name="cajon flam fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(CSL, 0.0, 95), _h(CT, 0.125, 45), _h(CSL, 0.25, 90),
_h(CB, 0.5, 100), _h(CT, 0.75, 50),
_h(CSL, 1.0, 88), _h(CT, 1.125, 42), _h(CSL, 1.25, 92),
_h(CT, 1.5, 55), _h(CSL, 1.75, 85),
_h(CB, 2.0, 105), _h(CSL, 2.25, 75), _h(CT, 2.5, 48),
_h(CSL, 2.75, 80), _h(CT, 2.875, 40),
_h(CB, 3.0, 110), _h(CSL, 3.25, 90), _h(CSL, 3.5, 95),
_h(CB, 3.75, 120),
],
)
# Cajón rumble — fast taps building to slap accents
Pattern._FILLS["cajon rumble"] = dict(
name="cajon rumble fill",
time_signature="4/4",
beats=4.0,
hits=[
*[_h(CT, i * 0.125, 35 + i * 3) for i in range(16)],
_h(CSL, 2.0, 95), _h(CSL, 2.5, 100),
_h(CB, 3.0, 108), _h(CSL, 3.25, 88),
_h(CB, 3.5, 112), _h(CSL, 3.75, 95),
],
)
# Cajón breakdown — syncopated bass-slap groove
Pattern._FILLS["cajon breakdown"] = dict(
name="cajon breakdown fill",
time_signature="4/4",
beats=4.0,
hits=[
_h(CB, 0.0, 100), _h(CT, 0.25, 45), _h(CSL, 0.5, 85),
_h(CB, 1.0, 95), _h(CSL, 1.25, 78), _h(CT, 1.5, 50),
_h(CSL, 1.75, 82),
_h(CB, 2.0, 105), _h(CT, 2.125, 40), _h(CT, 2.25, 42),
_h(CSL, 2.5, 90), _h(CT, 2.75, 48),
_h(CB, 3.0, 115), _h(CSL, 3.25, 95),
_h(CB, 3.5, 110), _h(CSL, 3.75, 100),
],
)
# ── Metal fills (using metal kit) ────────────────────────────────────────
# Metal triplet — double kick triplets with snare accents
Pattern._FILLS["metal triplet"] = dict(
name="metal triplet fill",
time_signature="4/4",
beats=4.0,
hits=[
# Triplet kick pattern (12 kicks across 4 beats = triplet 8ths)
*[_h(MK, i * (1/3), 95 + (i % 3 == 0) * 15) for i in range(12)],
# Snare accents on downbeats
_h(MS, 0.0, 110), _h(MS, 1.0, 105),
_h(MS, 2.0, 110), _h(MS, 3.0, 115),
# Hat on upbeats
_h(MH, 0.5, 60), _h(MH, 1.5, 60),
_h(MH, 2.5, 65), _h(MH, 3.5, 70),
],
)
# Metal blastbeat variant — alternating snare/kick 32nds
Pattern._FILLS["metal blast"] = dict(
name="metal blast fill",
time_signature="4/4",
beats=4.0,
hits=[
# Alternating kick-snare at 32nd note speed for 2 beats
*[_h(MK if i % 2 == 0 else MS, i * 0.125, 100 + i) for i in range(16)],
# Then crash into half-time for 2 beats
_h(MK, 2.0, 120), _h(MS, 2.5, 115),
_h(MK, 3.0, 120), _h(MH, 3.25, 80),
_h(MS, 3.5, 120),
],
)
# Metal cascade — descending snare/kick rolls
Pattern._FILLS["metal cascade"] = dict(
name="metal cascade fill",
time_signature="4/4",
beats=4.0,
hits=[
# Fast snare roll beat 1
*[_h(MS, i * 0.125, 80 + i * 5) for i in range(8)],
# Double kick beat 2
*[_h(MK, 1.0 + i * 0.125, 90 + i * 3) for i in range(8)],
# Alternating beat 3
_h(MS, 2.0, 105), _h(MK, 2.125, 95),
_h(MS, 2.25, 108), _h(MK, 2.375, 98),
_h(MS, 2.5, 110), _h(MK, 2.625, 100),
_h(MS, 2.75, 112), _h(MK, 2.875, 102),
# Crash ending
_h(MK, 3.0, 120), _h(MS, 3.0, 120),
_h(MK, 3.5, 120), _h(MS, 3.5, 120),
],
)
class Part:
"""A named voice within a Score, with its own synth, envelope, and effects.
@@ -2171,7 +2636,8 @@ class Part:
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "") -> "Part":
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
articulation: str = "") -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Duration can be a ``Duration`` enum or a raw float (beats).
@@ -2179,6 +2645,10 @@ class Part:
Bend specifies a pitch bend in semitones over the note duration
(e.g. ``bend=2`` bends up a whole step, ``bend=-1`` bends down
a half step). Used for guitar bends, sitar meends, slides.
Articulation changes how the note is played: ``"staccato"`` (short,
~40% duration), ``"legato"`` (overlaps next note), ``"marcato"``
(heavy accent), ``"tenuto"`` (full duration, soft attack),
``"accent"`` (velocity bump), ``"fermata"`` (held ~50% longer).
Returns self for chaining.
"""
@@ -2189,9 +2659,199 @@ class Part:
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type, lyric=lyric))
bend_type=bend_type, lyric=lyric,
articulation=articulation))
return self
def hold(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth", lyric: str = "",
articulation: str = "") -> "Part":
"""Add a note without advancing the beat position.
The note plays at the current position but the next note
starts at the *same* time enabling polyphonic overlap
on a single part.
Use this for: piano sustain pedal (bass note rings while
melody plays above), guitar strumming with individual
string timing, held drone notes under a melody.
Example::
>>> piano = score.part("piano", instrument="piano")
>>> piano.hold("C3", Duration.WHOLE) # bass rings for 4 beats
>>> piano.add("E4", Duration.HALF) # starts at same time as C3
>>> piano.add("G4", Duration.HALF) # starts at beat 2
"""
if isinstance(tone_or_string, str):
from .tones import Tone
tone_or_string = Tone.from_string(tone_or_string, system=self._system)
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type, lyric=lyric,
articulation=articulation, _hold=True))
return self
def hit(self, sound, duration=Duration.EIGHTH, *, velocity: int = 100,
articulation: str = "") -> "Part":
"""Add a drum hit to this part.
Places a drum sound into the note stream so it goes through the
normal renderer meaning articulations, humanize, and effects
all work on individual hits.
Args:
sound: A :class:`DrumSound` enum member (e.g. ``DrumSound.KICK``).
duration: How long the hit occupies in the timeline (default 8th note).
velocity: Hit loudness 1-127.
articulation: ``"accent"``, ``"staccato"``, ``"marcato"``, etc.
Example::
>>> drums = score.part("kit", synth="sine")
>>> drums.hit(DrumSound.KICK, Duration.QUARTER, articulation="accent")
>>> drums.hit(DrumSound.CLOSED_HAT, Duration.EIGHTH)
"""
if isinstance(duration, (int, float)):
duration = _RawDuration(duration)
self.notes.append(Note(tone=_DrumTone(sound), duration=duration,
velocity=velocity, articulation=articulation))
return self
def crescendo(self, notes, duration=Duration.QUARTER, *,
start_vel: int = 40, end_vel: int = 110,
articulation: str = "") -> "Part":
"""Add notes with velocity ramping up (getting louder).
Args:
notes: List of note strings (e.g. ``["C4", "D4", "E4"]``).
duration: Duration for each note.
start_vel: Starting velocity (quiet).
end_vel: Ending velocity (loud).
articulation: Optional articulation for all notes.
Example::
>>> piano.crescendo(["C4","D4","E4","F4","G4"], Duration.QUARTER,
... start_vel=40, end_vel=110)
"""
return self.dynamics(notes, duration, velocities=(start_vel, end_vel),
articulation=articulation)
def decrescendo(self, notes, duration=Duration.QUARTER, *,
start_vel: int = 110, end_vel: int = 40,
articulation: str = "") -> "Part":
"""Add notes with velocity ramping down (getting quieter).
Args:
notes: List of note strings.
duration: Duration for each note.
start_vel: Starting velocity (loud).
end_vel: Ending velocity (quiet).
articulation: Optional articulation for all notes.
Example::
>>> piano.decrescendo(["G4","F4","E4","D4","C4"], Duration.QUARTER,
... start_vel=110, end_vel=40)
"""
return self.dynamics(notes, duration, velocities=(start_vel, end_vel),
articulation=articulation)
def dynamics(self, notes, duration=Duration.QUARTER, *,
velocities=None, articulation: str = "") -> "Part":
"""Add notes with a velocity curve.
Args:
notes: List of note strings or Tone/Chord objects.
duration: Duration for each note (or list of durations).
velocities: Velocity curve either a ``(start, end)`` tuple
for a linear ramp, or a list of ints (one per note).
articulation: Optional articulation for all notes (or list).
Example::
>>> # Linear ramp
>>> piano.dynamics(["C4","E4","G4","C5"], Duration.QUARTER,
... velocities=(50, 120))
>>> # Custom curve (swell and fade)
>>> piano.dynamics(["C4","D4","E4","F4","G4","F4","E4","D4"],
... Duration.EIGHTH,
... velocities=[50, 70, 90, 110, 110, 90, 70, 50])
"""
n = len(notes)
if n == 0:
return self
# Resolve velocities
if velocities is None:
vels = [100] * n
elif isinstance(velocities, (tuple, list)) and len(velocities) == 2 and isinstance(velocities[0], (int, float)):
# (start, end) tuple — linear ramp
start_v, end_v = velocities
if n == 1:
vels = [int(start_v)]
else:
vels = [int(start_v + (end_v - start_v) * i / (n - 1))
for i in range(n)]
else:
vels = list(velocities)
# Resolve durations
if isinstance(duration, (list, tuple)):
durs = list(duration)
else:
durs = [duration] * n
# Resolve articulations
if isinstance(articulation, (list, tuple)):
arts = list(articulation)
else:
arts = [articulation] * n
for note, vel, dur, art in zip(notes, vels, durs, arts):
vel = max(1, min(127, vel))
self.add(note, dur, velocity=vel, articulation=art)
return self
def swell(self, notes, duration=Duration.QUARTER, *,
low_vel: int = 40, peak_vel: int = 110,
articulation: str = "") -> "Part":
"""Add notes that swell up then fade back down (< > shape).
The velocity ramps up to the midpoint then back down,
creating the classic orchestral swell.
Args:
notes: List of note strings.
duration: Duration for each note.
low_vel: Velocity at start and end.
peak_vel: Velocity at the peak (midpoint).
articulation: Optional articulation.
Example::
>>> strings.swell(["C4","D4","E4","F4","G4","F4","E4","D4"],
... Duration.QUARTER, low_vel=40, peak_vel=110)
"""
n = len(notes)
if n <= 2:
return self.dynamics(notes, duration, velocities=[peak_vel] * n,
articulation=articulation)
mid = n // 2
vels = []
for i in range(n):
if i <= mid:
v = low_vel + (peak_vel - low_vel) * i / mid
else:
v = peak_vel - (peak_vel - low_vel) * (i - mid) / (n - 1 - mid)
vels.append(int(v))
return self.dynamics(notes, duration, velocities=vels,
articulation=articulation)
def set(self, **params) -> "Part":
"""Change effect parameters at the current beat position.
@@ -2275,6 +2935,79 @@ class Part:
points = sorted(set(beat for beat, _ in self._automation))
return points
def ramp(self, over: float = 4.0, resolution: float = 0.25,
curve: str = "linear", **params) -> "Part":
"""Smoothly ramp parameters from their current values to new targets.
Generates interpolated automation points like turning a knob
gradually instead of jumping to a new position. Works for any
parameter that ``.set()`` accepts.
Args:
over: Duration of the ramp in beats (default 4.0 = 1 bar).
Use ``Duration.WHOLE * 4`` for a 4-bar ramp, etc.
resolution: How often to insert points, in beats (default 0.25).
Lower = smoother but more points.
curve: Interpolation shape ``"linear"`` (default),
``"ease_in"`` (slow start, fast end),
``"ease_out"`` (fast start, slow end),
``"ease_in_out"`` (slow start and end).
**params: Target values for any parameter. The ramp starts
from the parameter's current value at this beat position.
Returns:
Self for chaining.
Example::
>>> lead = score.part("lead", synth="saw", lowpass=200)
>>> # Open the filter over 4 bars
>>> lead.ramp(over=Duration.WHOLE * 4, lowpass=8000)
>>> # Fade reverb in over 2 bars
>>> pad.ramp(over=Duration.WHOLE * 2, reverb=0.5)
>>> # Multiple params at once with easing
>>> lead.ramp(over=8.0, curve="ease_in", lowpass=6000, distortion=0.4)
"""
current_beat = sum(n.beats for n in self.notes)
# Map param names to internal names
param_map = {
"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix",
"phaser": "phaser_mix",
}
# Get current values for each param
current_params = self._get_params_at(current_beat)
ramps = {}
for param, target in params.items():
internal = param_map.get(param, param)
start = current_params.get(internal, getattr(self, internal, 0.0))
ramps[internal] = (float(start), float(target))
# Generate interpolated points
beat = 0.0
while beat <= over:
t = beat / over if over > 0 else 1.0
t = max(0.0, min(1.0, t))
# Apply curve
if curve == "ease_in":
t = t * t
elif curve == "ease_out":
t = 1.0 - (1.0 - t) ** 2
elif curve == "ease_in_out":
t = 3 * t * t - 2 * t * t * t
point = {}
for internal, (start, end) in ramps.items():
point[internal] = start + (end - start) * t
self._automation.append((current_beat + beat, point))
beat += resolution
return self
def lfo(self, param: str, *, rate: float = 0.5, min: float = 0.0,
max: float = 1.0, bars: float = 4, shape: str = "sine",
resolution: float = 0.25) -> "Part":
@@ -2542,21 +3275,14 @@ class Part:
from .chords import Chord as ChordClass
chord_obj = ChordClass(tones=strum_tones)
# Strum sweep: quick individual string hits before the chord.
# Only the first 2-3 strings get a tiny grace note, the rest
# ring together as the full chord. Gives the strum feel without
# sounding like separate plucks.
# Strum: hold a quiet leading string simultaneously with the
# full chord using hold(). No timing gap — both start at the
# same beat position. The leading string adds strum texture.
n_strings = len(strum_tones)
if strum_time > 0.02 and n_strings >= 3:
n_grace = min(2, n_strings - 1)
per_grace = strum_time / n_grace
grace_vel = max(1, int(velocity * 0.25))
for i in range(n_grace):
self.add(strum_tones[i], per_grace, velocity=grace_vel)
ring = max(0.1, total_beats - strum_time)
self.add(chord_obj, ring, velocity=velocity)
else:
self.add(chord_obj, total_beats, velocity=velocity)
if strum_time > 0 and n_strings >= 3:
grace_vel = max(1, int(velocity * 0.15))
self.hold(strum_tones[0], total_beats, velocity=grace_vel)
self.add(chord_obj, total_beats, velocity=velocity)
return self
+253 -1
View File
@@ -4869,6 +4869,19 @@ def test_duration_values():
assert abs(Duration.TRIPLET_QUARTER.value - 2 / 3) < 1e-9
def test_duration_arithmetic():
# Multiplication
assert Duration.WHOLE * 2 == 8.0
assert 2 * Duration.HALF == 4.0
assert Duration.QUARTER * 3 == 3.0
# Division
assert Duration.WHOLE / 2 == 2.0
# Addition
assert Duration.HALF + Duration.QUARTER == 3.0
assert Duration.HALF + 1.0 == 3.0
assert 1.0 + Duration.HALF == 3.0
def test_time_signature_from_string_4_4():
ts = TimeSignature.from_string("4/4")
assert ts.beats == 4
@@ -5320,7 +5333,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 30
assert len(Synth) == 42
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@@ -6912,3 +6925,242 @@ def test_clean_guitar_preset():
p = score.part("g", instrument="clean_guitar")
assert p.synth == "electric_guitar_synth"
assert p.cabinet > 0
# ── New instrument synths (v0.36+) ──────────────────────────────────────────
def test_new_synths_render():
"""All 7 new synths produce valid audio."""
from pytheory.play import (pedal_steel_wave, theremin_wave, kalimba_wave,
steel_drum_wave, accordion_wave,
didgeridoo_wave, bagpipe_wave,
banjo_wave, mandolin_wave, ukulele_wave,
vocal_wave, SAMPLE_RATE)
synths = [pedal_steel_wave, theremin_wave, kalimba_wave, steel_drum_wave,
accordion_wave, didgeridoo_wave, bagpipe_wave,
banjo_wave, mandolin_wave, ukulele_wave, vocal_wave]
for fn in synths:
wave = fn(440, n_samples=11025)
assert len(wave) == 11025
assert wave.dtype == numpy.int16
assert numpy.abs(wave).max() > 0
def test_vocal_synth_with_lyric():
"""Vocal synth accepts lyric parameter."""
from pytheory.play import vocal_wave
for lyric in ["ah", "ee", "oh", "oo", "hi", "la"]:
wave = vocal_wave(330, n_samples=11025, lyric=lyric)
assert len(wave) == 11025
assert numpy.abs(wave).max() > 0
def test_vocal_different_vowels_differ():
"""Different vowels should produce different waveforms."""
from pytheory.play import vocal_wave
ah = vocal_wave(330, n_samples=22050, lyric="ah")
ee = vocal_wave(330, n_samples=22050, lyric="ee")
# They should differ (different formant peaks)
assert not numpy.array_equal(ah, ee)
def test_all_instrument_presets_create():
"""Every instrument preset in INSTRUMENTS should create a valid Part."""
from pytheory import Score
from pytheory.rhythm import INSTRUMENTS
for name in INSTRUMENTS:
score = Score("4/4", bpm=120)
p = score.part("test", instrument=name)
assert p.synth is not None
def test_new_instrument_presets():
"""New instrument presets have correct synths."""
from pytheory import Score
presets = {
"pedal_steel": "pedal_steel_synth",
"theremin": "theremin_synth",
"kalimba": "kalimba_synth",
"steel_drum": "steel_drum_synth",
"accordion": "accordion_synth",
"didgeridoo": "didgeridoo_synth",
"bagpipe": "bagpipe_synth",
"banjo": "banjo_synth",
"mandolin": "mandolin_synth",
"ukulele": "ukulele_synth",
}
for name, expected_synth in presets.items():
score = Score("4/4", bpm=120)
p = score.part("t", instrument=name)
assert p.synth == expected_synth, f"{name} has {p.synth}, expected {expected_synth}"
# ── Cajón drums ─────────────────────────────────────────────────────────────
def test_cajon_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.CAJON_BASS, DrumSound.CAJON_SLAP, DrumSound.CAJON_TAP]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
assert wave.dtype == numpy.float32
def test_cajon_patterns():
from pytheory.rhythm import Pattern
for name in ["cajon", "cajon rumba", "cajon folk"]:
p = Pattern.preset(name)
assert p.beats > 0
# ── Pitch bends ─────────────────────────────────────────────────────────────
def test_pitch_bend_renders():
"""Pitch bend should produce valid audio without errors."""
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=120)
p = score.part("t", instrument="electric_guitar")
p.add("A4", Duration.HALF, bend=2, bend_type="smooth")
p.add("A4", Duration.HALF, bend=-1, bend_type="late")
p.add("A4", Duration.HALF, bend=3, bend_type="linear")
p.add("A4", Duration.HALF)
buf = render_score(score)
assert len(buf) > 0
def test_pitch_bend_types():
"""All three bend types should work."""
from pytheory.rhythm import Note, Duration
for bt in ["smooth", "linear", "late"]:
n = Note(tone=None, duration=Duration.QUARTER, bend=2, bend_type=bt)
assert n.bend_type == bt
# ── Roll method ─────────────────────────────────────────────────────────────
def test_roll_adds_notes():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", instrument="timpani")
p.roll("C3", Duration.WHOLE, velocity_start=30, velocity_end=100)
assert len(p.notes) > 4 # should be many 16th notes
def test_roll_velocity_ramp():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", instrument="timpani")
p.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=100)
velocities = [n.velocity for n in p.notes]
# First should be quieter than last
assert velocities[0] < velocities[-1]
def test_roll_custom_speed():
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("t", synth="sine")
p.roll("A4", Duration.WHOLE, speed=0.125) # 32nd notes
# 4 beats / 0.125 = 32 notes
assert len(p.notes) == 32
# ── Int tone names ──────────────────────────────────────────────────────────
def test_int_tone_name():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(0, octave=4, system=edo)
assert t.name == "0"
assert t.frequency == pytest.approx(440.0, rel=1e-3)
def test_int_tone_wrapping():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(22, octave=4, system=edo)
assert t.name == "0"
assert t.octave == 5
assert t.frequency == pytest.approx(880.0, rel=1e-3)
def test_int_tone_negative():
from pytheory import Tone, TET
edo = TET(22)
t = Tone(-1, octave=4, system=edo)
assert t.name == "21"
assert t.octave == 3
def test_system_tone_method():
from pytheory import TET
edo = TET(19)
t = edo.tone(5, octave=4)
assert t.name == "5"
assert t.octave == 4
# ── B#/Cb octave boundary ──────────────────────────────────────────────────
def test_b_sharp_octave():
t = Tone("B#4")
assert t.octave == 5
assert t.frequency == pytest.approx(Tone("C5").frequency, rel=1e-3)
def test_c_flat_octave():
t = Tone("Cb4")
assert t.octave == 3
assert t.frequency == pytest.approx(Tone("B3").frequency, rel=1e-3)
# ── Note choking ────────────────────────────────────────────────────────────
def test_note_choking_renders():
"""Fast repeated notes should render without errors (choking active)."""
from pytheory import Score, Duration
from pytheory.play import render_score
score = Score("4/4", bpm=200)
p = score.part("t", instrument="piano")
for _ in range(32):
p.add("C4", Duration.SIXTEENTH)
buf = render_score(score)
assert len(buf) > 0
# ── Score system/temperament ───────────────────────────────────────────────
def test_score_temperament():
from pytheory import Score
score = Score("4/4", bpm=120, temperament="just")
assert score.temperament == "just"
def test_score_reference_pitch():
from pytheory import Score
score = Score("4/4", bpm=120, reference_pitch=415.0)
assert score.reference_pitch == 415.0
def test_score_system_propagates():
from pytheory import Score, SYSTEMS
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=120, system=shruti)
p = score.part("t", synth="sine")
assert p._system is shruti
# ── Synth enum count ────────────────────────────────────────────────────────
def test_synth_enum_count():
from pytheory.play import Synth
assert len(Synth) == 42
def test_all_synths_render_and_enum_match():
"""Every Synth enum member should render valid audio."""
from pytheory.play import Synth
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
Generated
+1 -1
View File
@@ -698,7 +698,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.36.0"
version = "0.38.2"
source = { editable = "." }
dependencies = [
{ name = "numeral" },