mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9dc22db4b2 | |||
| f570e226cd | |||
| 0c5c3abedc | |||
| 35d07b984b | |||
| aec7723ee6 | |||
| b98a40297b | |||
| 9117568b74 | |||
| 11e4417c62 | |||
| 4edf1d983d | |||
| 74b07b1a8a | |||
| c9437209a7 | |||
| 92cb855a49 | |||
| f06c6f77d1 | |||
| 51bd63658f | |||
| 92ade3ee3d | |||
| 833867329e | |||
| 93b9fe9ced | |||
| 88a1171bbe | |||
| 3ca0842b7a | |||
| 00de5eb354 | |||
| d2b0c6f329 | |||
| 76612682f1 | |||
| ce480858e9 | |||
| 70efb0ad40 | |||
| bf6deaab64 | |||
| 7c792c0a2a | |||
| bf8d4b9a77 | |||
| d2d5115c8a | |||
| 3cdd98b158 | |||
| 751d5a49b8 | |||
| 6a836dd891 | |||
| 1f888e2b21 |
@@ -2,6 +2,55 @@
|
||||
|
||||
All notable changes to PyTheory are documented here.
|
||||
|
||||
## 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,
|
||||
fast decay with membrane resonance
|
||||
- **Mandolin synth** — paired steel strings (natural chorus from
|
||||
doubled courses), bright body resonance
|
||||
- **Ukulele synth** — nylon strings, small mid-heavy body, shorter
|
||||
sustain than guitar
|
||||
- **Cajón drums** — bass (woody box thump), slap (snare wire buzz),
|
||||
tap (ghost note). 3 patterns: cajon, cajon rumba, cajon folk
|
||||
- **Vocal/formant synth** — LF glottal model, 5 Peterson & Barney
|
||||
formant peaks, jitter/shimmer, consonant onsets, per-note lyrics.
|
||||
Presets: vocal, choir
|
||||
- **Granular synthesis** — grain cloud engine with scatter, pitch
|
||||
variation, Hanning windows. Presets: granular_pad, granular_texture
|
||||
- **Strum sweep** — subtle grace notes before chord hit for natural
|
||||
strum feel on all fretboard instruments
|
||||
- Mandola preset, 34 synth waveforms, 26 songs
|
||||
|
||||
## 0.35.0
|
||||
|
||||
- **8.5x faster import** — dropped pytuning/sympy, lazy-load scipy.
|
||||
|
||||
@@ -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
|
||||
|
||||
+52
-7
@@ -9,7 +9,7 @@ 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
|
||||
PyTheory includes a complete drum system -- 51 synthesized percussion
|
||||
sounds, 80+ pattern presets across dozens of genres, and 21 fill presets.
|
||||
Every sound is generated from waveforms; no samples needed.
|
||||
|
||||
@@ -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,
|
||||
@@ -330,14 +347,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)
|
||||
@@ -433,6 +461,23 @@ classic triplet-feel gallop rhythm).
|
||||
score = Score("4/4", bpm=200)
|
||||
score.drums("metal blast", repeats=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.
|
||||
|
||||
**2 sounds** -- slap (sharp, snare-like) and tap (bass-like).
|
||||
|
||||
**3 patterns:** cajon (basic groove), cajon rumba (flamenco-style rumba),
|
||||
and cajon folk (folk/acoustic pattern).
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
score = Score("4/4", bpm=100)
|
||||
score.drums("cajon", repeats=4)
|
||||
|
||||
MIDI Export
|
||||
-----------
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
------------------------------------------
|
||||
|
||||
@@ -667,8 +667,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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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** — 30 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
|
||||
|
||||
|
||||
+972
-193
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "pytheory"
|
||||
version = "0.35.1"
|
||||
version = "0.36.5"
|
||||
description = "Music Theory for Humans"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""PyTheory: Music Theory for Humans."""
|
||||
|
||||
__version__ = "0.35.1"
|
||||
__version__ = "0.36.5"
|
||||
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS, TET
|
||||
|
||||
+52
-1
@@ -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(" ♫")
|
||||
|
||||
|
||||
|
||||
+702
-9
@@ -909,6 +909,149 @@ def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
return (peak * wave).astype(numpy.int16)
|
||||
|
||||
|
||||
def vocal_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE, lyric="ah"):
|
||||
"""Vocal/formant synthesis — sings vowel sounds at a given pitch.
|
||||
|
||||
Models the human voice with:
|
||||
1. LF glottal model — asymmetric pulse with sharp closure (not just sines)
|
||||
2. 5 parallel resonant formant filters (real voice has 5 formant peaks)
|
||||
3. Jitter + shimmer (natural pitch/amplitude irregularity)
|
||||
4. Aspiration noise mixed with the glottal source
|
||||
5. Consonant onsets (plosives, sibilants, nasals, etc.)
|
||||
"""
|
||||
import scipy.signal as _sig
|
||||
|
||||
# 5-formant table: (F1, F2, F3, F4, F5) frequencies and bandwidths
|
||||
# Based on Peterson & Barney (1952) measurements, male voice
|
||||
FORMANTS = {
|
||||
'a': [(800, 130), (1200, 100), (2500, 140), (3300, 250), (3750, 300)],
|
||||
'e': [(530, 80), (1850, 100), (2500, 130), (3300, 250), (3750, 300)],
|
||||
'i': [(280, 60), (2250, 100), (2900, 120), (3350, 250), (3750, 300)],
|
||||
'o': [(500, 100), (1000, 80), (2500, 140), (3300, 250), (3750, 300)],
|
||||
'u': ((325, 70), (700, 60), (2530, 140), (3300, 250), (3750, 300)),
|
||||
}
|
||||
# Formant gains (relative amplitude per formant)
|
||||
FGAINS = [1.0, 0.8, 0.5, 0.25, 0.15]
|
||||
|
||||
rng = numpy.random.default_rng(int(hz * 100 + len(lyric) * 7) % 2**31)
|
||||
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
|
||||
|
||||
# Parse vowels from lyric
|
||||
vowels_in_lyric = [c.lower() for c in lyric if c.lower() in FORMANTS]
|
||||
if not vowels_in_lyric:
|
||||
vowels_in_lyric = ['a']
|
||||
|
||||
# ── Glottal source: LF model approximation ──
|
||||
# Asymmetric pulse: slow open phase, sharp closure, then closed phase.
|
||||
# Much more "voice-like" than a sine or sawtooth.
|
||||
# Jitter (pitch irregularity) + shimmer (amplitude irregularity)
|
||||
jitter = rng.normal(0, hz * 0.001, n_samples) # ~0.1% pitch jitter
|
||||
shimmer = 1.0 + rng.normal(0, 0.008, n_samples) # ~0.8% amp shimmer
|
||||
# Vibrato
|
||||
vib = hz * 0.001 * numpy.sin(2 * numpy.pi * 5.5 * t)
|
||||
inst_freq = hz + vib + jitter
|
||||
phase = numpy.cumsum(2 * numpy.pi * inst_freq / SAMPLE_RATE)
|
||||
# LF glottal shape: sharper falling edge via phase shaping
|
||||
saw = (phase / (2 * numpy.pi)) % 1.0 # 0 to 1 sawtooth
|
||||
# Asymmetric: slow rise (60%), fast fall (40%)
|
||||
glottal = numpy.where(saw < 0.6,
|
||||
numpy.sin(numpy.pi * saw / 0.6), # smooth rise
|
||||
-numpy.sin(numpy.pi * (saw - 0.6) / 0.4) * 0.8) # sharp fall
|
||||
glottal *= shimmer
|
||||
|
||||
# Aspiration noise (breathiness) — subtle
|
||||
breath = rng.normal(0, 0.04, n_samples)
|
||||
source = glottal * 0.92 + breath * 0.08
|
||||
|
||||
# ── Formant filtering ──
|
||||
n_vowels = len(vowels_in_lyric)
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
|
||||
if n_vowels == 1:
|
||||
# Single vowel — filter the whole thing
|
||||
formants = FORMANTS[vowels_in_lyric[0]]
|
||||
for (fc, bw), gain in zip(formants, FGAINS):
|
||||
lo = max(20, fc - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
out += _sig.lfilter(bp, ap, source).astype(numpy.float64) * gain
|
||||
else:
|
||||
# Multiple vowels — crossfade formants
|
||||
samples_per_vowel = n_samples // n_vowels
|
||||
for vi, vowel in enumerate(vowels_in_lyric):
|
||||
formants = FORMANTS[vowel]
|
||||
start = vi * samples_per_vowel
|
||||
end = n_samples if vi == n_vowels - 1 else start + samples_per_vowel
|
||||
seg = source[start:end].copy()
|
||||
seg_out = numpy.zeros_like(seg)
|
||||
for (fc, bw), gain in zip(formants, FGAINS):
|
||||
lo = max(20, fc - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, fc + bw)
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
seg_out += _sig.lfilter(bp, ap, seg).astype(numpy.float64) * gain
|
||||
# Crossfade
|
||||
fade = min(int(SAMPLE_RATE * 0.02), len(seg_out) // 4)
|
||||
if vi > 0 and fade > 0:
|
||||
seg_out[:fade] *= numpy.linspace(0, 1, fade)
|
||||
if vi < n_vowels - 1 and fade > 0:
|
||||
seg_out[-fade:] *= numpy.linspace(1, 0, fade)
|
||||
out[start:end] += seg_out[:end - start]
|
||||
|
||||
# ── Consonant onsets ──
|
||||
lyric_lower = lyric.lower()
|
||||
if lyric_lower and lyric_lower[0] not in 'aeiou':
|
||||
c = lyric_lower[0]
|
||||
cl = min(int(SAMPLE_RATE * 0.035), n_samples)
|
||||
if c in 'tdkpb':
|
||||
burst = rng.uniform(-0.5, 0.5, cl) * numpy.exp(-numpy.linspace(0, 18, cl))
|
||||
out[:cl] = burst + out[:cl] * 0.2
|
||||
elif c in 'sz':
|
||||
sib = rng.uniform(-0.4, 0.4, cl)
|
||||
if cl > 20:
|
||||
bl, al = _sig.butter(2, [3000, min(8000, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
|
||||
sib = _sig.lfilter(bl, al, numpy.pad(sib, (0, max(0, n_samples-cl))))[:cl]
|
||||
sib *= numpy.exp(-numpy.linspace(0, 10, cl))
|
||||
out[:cl] = sib * 0.6 + out[:cl] * 0.4
|
||||
elif c in 'mn':
|
||||
nl = min(int(SAMPLE_RATE * 0.06), n_samples)
|
||||
nasal = numpy.sin(2*numpy.pi*250*t[:nl]) * 0.4 * numpy.exp(-numpy.linspace(0, 4, nl))
|
||||
out[:nl] = nasal + out[:nl] * 0.4
|
||||
elif c in 'fv':
|
||||
fric = rng.uniform(-0.25, 0.25, cl) * numpy.exp(-numpy.linspace(0, 12, cl))
|
||||
out[:cl] = fric * 0.5 + out[:cl] * 0.5
|
||||
elif c in 'lr':
|
||||
gl = min(int(SAMPLE_RATE * 0.05), n_samples)
|
||||
ghz = hz * 0.7 + hz * 0.3 * numpy.linspace(0, 1, gl)
|
||||
glide = numpy.sin(numpy.cumsum(2*numpy.pi*ghz/SAMPLE_RATE)) * 0.35
|
||||
out[:gl] = glide + out[:gl] * 0.65
|
||||
elif c == 'h':
|
||||
hl = min(int(SAMPLE_RATE * 0.05), n_samples)
|
||||
asp = rng.uniform(-0.4, 0.4, hl) * numpy.exp(-numpy.linspace(0, 5, hl))
|
||||
out[:hl] = asp * 0.6 + out[:hl] * 0.4
|
||||
elif c == 'w':
|
||||
wl = min(int(SAMPLE_RATE * 0.06), n_samples)
|
||||
ws = numpy.sin(numpy.cumsum(2*numpy.pi*hz/SAMPLE_RATE*numpy.ones(wl)))
|
||||
if wl > 20:
|
||||
bp, ap = _sig.butter(2, [max(20,300), min(800, SAMPLE_RATE//2-1)], btype='band', fs=SAMPLE_RATE)
|
||||
ws = _sig.lfilter(bp, ap, ws)
|
||||
ws *= numpy.linspace(0.5, 0, wl)
|
||||
out[:wl] = ws * 0.4 + out[:wl] * 0.6
|
||||
|
||||
# Soft edges — prevent clicks at note boundaries
|
||||
fade_samples = min(int(SAMPLE_RATE * 0.01), n_samples // 4)
|
||||
if fade_samples > 0:
|
||||
out[:fade_samples] *= numpy.linspace(0, 1, fade_samples)
|
||||
out[-fade_samples:] *= numpy.linspace(1, 0, fade_samples)
|
||||
|
||||
mx = numpy.abs(out).max()
|
||||
if mx > 0:
|
||||
out /= mx
|
||||
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def granular_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE,
|
||||
grain_size=0.04, density=50, scatter=0.5,
|
||||
pitch_var=12, source="saw"):
|
||||
@@ -987,6 +1130,400 @@ 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.
|
||||
|
||||
The banjo's distinctive twang comes from the membrane head
|
||||
(like a drum skin) instead of a wooden soundboard. This gives
|
||||
a sharp attack, bright tone, and fast decay with a nasal,
|
||||
metallic quality. The 5th string drone adds shimmer.
|
||||
"""
|
||||
period = int(SAMPLE_RATE / hz)
|
||||
if period < 2:
|
||||
period = 2
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Steel string — bright, sharp attack
|
||||
buf = rng.uniform(-0.9, 0.9, period).astype(numpy.float64)
|
||||
# Minimal filtering — banjo keeps the brightness
|
||||
for k in range(period - 1):
|
||||
buf[k] = 0.7 * buf[k] + 0.3 * buf[k + 1]
|
||||
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for i in range(n_samples):
|
||||
out[i] = buf[i % period]
|
||||
next_idx = (i + 1) % period
|
||||
# Moderate decay — drum head rings but shorter than guitar
|
||||
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.9988
|
||||
|
||||
# Drum-head resonance — nasal, ringy, mid-frequency peaks
|
||||
# The membrane head rings more than wood — that's the twang
|
||||
import scipy.signal as _sig
|
||||
for center, bw, gain in [(600, 200, 0.5), (1500, 300, 0.4), (3000, 500, 0.25)]:
|
||||
lo = max(20, center - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
out += _sig.lfilter(bp, ap, out) * gain
|
||||
|
||||
mx = numpy.abs(out).max()
|
||||
if mx > 0:
|
||||
out /= mx
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def mandolin_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Mandolin — paired steel strings, bright and ringing.
|
||||
|
||||
The mandolin has 4 courses of paired strings, tuned in unison.
|
||||
The doubled strings create natural chorus. Bright attack from
|
||||
the plectrum, small body with high-frequency resonance.
|
||||
"""
|
||||
period = int(SAMPLE_RATE / hz)
|
||||
if period < 2:
|
||||
period = 2
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Two strings per course — slightly detuned for natural chorus
|
||||
buf1 = rng.uniform(-0.8, 0.8, period).astype(numpy.float64)
|
||||
period2 = max(2, period + rng.integers(-1, 2))
|
||||
buf2 = rng.uniform(-0.8, 0.8, period2).astype(numpy.float64)
|
||||
# Light filtering — steel is brighter than nylon
|
||||
for k in range(period - 1):
|
||||
buf1[k] = 0.65 * buf1[k] + 0.35 * buf1[k + 1]
|
||||
for k in range(period2 - 1):
|
||||
buf2[k] = 0.65 * buf2[k] + 0.35 * buf2[k + 1]
|
||||
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for i in range(n_samples):
|
||||
s1 = buf1[i % period]
|
||||
s2 = buf2[i % period2]
|
||||
out[i] = s1 * 0.55 + s2 * 0.45
|
||||
next1 = (i + 1) % period
|
||||
buf1[i % period] = 0.5 * (s1 + buf1[next1]) * 0.9988
|
||||
next2 = (i + 1) % period2
|
||||
buf2[i % period2] = 0.5 * (s2 + buf2[next2]) * 0.9988
|
||||
|
||||
# Small bright body — higher resonance than guitar
|
||||
import scipy.signal as _sig
|
||||
for center, bw, gain in [(500, 120, 0.3), (1000, 200, 0.25), (2000, 300, 0.15)]:
|
||||
lo = max(20, center - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
out += _sig.lfilter(bp, ap, out) * gain
|
||||
|
||||
mx = numpy.abs(out).max()
|
||||
if mx > 0:
|
||||
out /= mx
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def ukulele_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Ukulele — nylon strings on a small resonant body.
|
||||
|
||||
Brighter and thinner than guitar, shorter sustain. The small
|
||||
body gives a mid-heavy resonance (no deep bass). Nylon strings
|
||||
have a softer, warmer attack than steel.
|
||||
"""
|
||||
period = int(SAMPLE_RATE / hz)
|
||||
if period < 2:
|
||||
period = 2
|
||||
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
|
||||
|
||||
# Nylon string — soft noise
|
||||
buf = rng.uniform(-0.5, 0.5, period).astype(numpy.float64)
|
||||
for _ in range(5):
|
||||
for k in range(period - 1):
|
||||
buf[k] = 0.55 * buf[k] + 0.45 * buf[k + 1]
|
||||
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float64)
|
||||
for i in range(n_samples):
|
||||
out[i] = buf[i % period]
|
||||
next_idx = (i + 1) % period
|
||||
buf[i % period] = 0.5 * (buf[i % period] + buf[next_idx]) * 0.998
|
||||
|
||||
# Small body resonance — mid-heavy, no deep bass
|
||||
import scipy.signal as _sig
|
||||
for center, bw, gain in [(350, 100, 0.35), (700, 150, 0.25), (1200, 200, 0.15)]:
|
||||
lo = max(20, center - bw)
|
||||
hi = min(SAMPLE_RATE // 2 - 1, center + bw)
|
||||
if lo < hi:
|
||||
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
|
||||
out += _sig.lfilter(bp, ap, out) * gain
|
||||
|
||||
bl, al = _sig.butter(2, min(6000, hz * 12), btype='low', fs=SAMPLE_RATE)
|
||||
out = _sig.lfilter(bl, al, out)
|
||||
|
||||
mx = numpy.abs(out).max()
|
||||
if mx > 0:
|
||||
out /= mx
|
||||
return (peak * out).astype(numpy.int16)
|
||||
|
||||
|
||||
def acoustic_guitar_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
|
||||
"""Acoustic guitar — Karplus-Strong with wooden body resonance.
|
||||
|
||||
@@ -1245,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):
|
||||
@@ -1290,6 +1830,18 @@ class Synth(Enum):
|
||||
TIMPANI = "timpani_synth"
|
||||
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"
|
||||
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
|
||||
SITAR = "sitar_synth"
|
||||
ELECTRIC_GUITAR = "electric_guitar_synth"
|
||||
@@ -1312,7 +1864,13 @@ _SYNTH_FUNCTIONS = {
|
||||
"harpsichord_synth": harpsichord_wave, "cello_synth": cello_wave,
|
||||
"harp_synth": harp_wave, "upright_bass_synth": upright_bass_wave,
|
||||
"timpani_synth": timpani_wave, "saxophone_synth": saxophone_wave,
|
||||
"granular_synth": granular_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,
|
||||
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
|
||||
}
|
||||
@@ -2068,6 +2626,114 @@ 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.
|
||||
|
||||
Deep woody thump. The box resonates like a bass drum but with
|
||||
a warmer, more wooden character.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
|
||||
# Wooden box thump
|
||||
thump_len = min(int(SAMPLE_RATE * 0.06), n_samples)
|
||||
thump_raw = _noise(thump_len)
|
||||
import scipy.signal as _sig
|
||||
if thump_len > 20:
|
||||
bl, al = _sig.butter(2, [40, 200], btype='band', fs=SAMPLE_RATE)
|
||||
thump = _sig.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len].astype(numpy.float32)
|
||||
else:
|
||||
thump = thump_raw
|
||||
thump *= _exp_decay(thump_len, 18) * 0.8
|
||||
body = numpy.sin(2 * numpy.pi * 70 * t) * _exp_decay(n_samples, 7) * 0.8
|
||||
sub = _sine_f32(45, n_samples) * _exp_decay(n_samples, 9) * 0.4
|
||||
click_len = min(200, n_samples)
|
||||
click = _noise(click_len) * _exp_decay(click_len, 45) * 0.3
|
||||
result = body + sub
|
||||
result[:thump_len] += thump
|
||||
result[:click_len] += click
|
||||
return numpy.tanh(result * 1.3).astype(numpy.float32)
|
||||
|
||||
|
||||
def _synth_cajon_slap(n_samples):
|
||||
"""Cajón slap — fingers near the top edge, snare wires buzz.
|
||||
|
||||
Bright crack with a buzzy rattle from the internal snare wires.
|
||||
The signature cajón sound — like a snare but woodier.
|
||||
"""
|
||||
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
|
||||
# Snare wire buzz
|
||||
wire = _noise(n_samples) * _exp_decay(n_samples, 18) * 0.6
|
||||
import scipy.signal as _sig
|
||||
bl, al = _sig.butter(2, [1500, 6000], btype='band', fs=SAMPLE_RATE)
|
||||
wire = _sig.lfilter(bl, al, wire).astype(numpy.float32) * 1.2
|
||||
# Wood body
|
||||
body = numpy.sin(2 * numpy.pi * 200 * t) * _exp_decay(n_samples, 22) * 0.4
|
||||
# Sharp slap
|
||||
slap_len = min(int(SAMPLE_RATE * 0.008), n_samples)
|
||||
slap = _noise(slap_len) * _exp_decay(slap_len, 200) * 0.8
|
||||
result = body + wire
|
||||
result[:slap_len] += slap
|
||||
return numpy.tanh(result * 1.5).astype(numpy.float32)
|
||||
|
||||
|
||||
def _synth_cajon_tap(n_samples):
|
||||
"""Cajón tap — light fingertip on the face. Ghost note."""
|
||||
n = min(n_samples, int(SAMPLE_RATE * 0.04))
|
||||
t = numpy.arange(n, dtype=numpy.float32) / SAMPLE_RATE
|
||||
tap = numpy.sin(2 * numpy.pi * 300 * t) * _exp_decay(n, 35) * 0.3
|
||||
pop = _noise(min(50, n)) * _exp_decay(min(50, n), 250) * 0.5
|
||||
result = tap
|
||||
result[:min(50, n)] += pop
|
||||
out = numpy.zeros(n_samples, dtype=numpy.float32)
|
||||
out[:n] = numpy.tanh(result * 1.5)
|
||||
return out
|
||||
|
||||
|
||||
def _synth_metal_kick(n_samples):
|
||||
"""Metal kick — punchy with beater click. Double-bass ready.
|
||||
|
||||
@@ -2336,6 +3002,14 @@ 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),
|
||||
DrumSound.CAJON_TAP.value: lambda n: _synth_cajon_tap(n),
|
||||
# Metal kit
|
||||
DrumSound.METAL_KICK.value: lambda n: _synth_metal_kick(n),
|
||||
DrumSound.METAL_SNARE.value: lambda n: _synth_metal_snare(n),
|
||||
@@ -3528,8 +4202,13 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
|
||||
bent = src_f[idx] * (1 - frac) + src_f[numpy.minimum(idx + 1, src_len - 1)] * frac
|
||||
waves.append((bent * SAMPLE_PEAK).astype(numpy.int16))
|
||||
else:
|
||||
# Render oscillators (pass synth_kwargs for FM etc.)
|
||||
waves = [synth_fn(hz, n_samples=n_samples, **_skw)
|
||||
# Per-note kwargs (e.g. lyric for vocal synth)
|
||||
note_skw = dict(_skw)
|
||||
note_lyric = getattr(note, 'lyric', '')
|
||||
if note_lyric:
|
||||
note_skw['lyric'] = note_lyric
|
||||
# Render oscillators
|
||||
waves = [synth_fn(hz, n_samples=n_samples, **note_skw)
|
||||
for hz in pitches]
|
||||
# Sub-oscillator: octave-below sine
|
||||
if sub_osc > 0:
|
||||
@@ -3613,7 +4292,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,
|
||||
@@ -3655,7 +4336,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
|
||||
@@ -3920,6 +4602,14 @@ 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,
|
||||
DrumSound.CAJON_TAP.value: 0.1,
|
||||
# Metal kit
|
||||
DrumSound.METAL_KICK.value: 0.0,
|
||||
DrumSound.METAL_SNARE.value: 0.0,
|
||||
@@ -4046,8 +4736,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
@@ -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)]
|
||||
|
||||
+346
-3
@@ -195,6 +195,59 @@ 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,
|
||||
},
|
||||
"mandolin": {
|
||||
"synth": "mandolin_synth", "envelope": "none",
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"mandola": {
|
||||
"synth": "mandolin_synth", "envelope": "none",
|
||||
"lowpass": 3000,
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"ukulele": {
|
||||
"synth": "ukulele_synth", "envelope": "none",
|
||||
"humanize": 0.2,
|
||||
},
|
||||
"koto": {
|
||||
"synth": "pluck_synth", "envelope": "none",
|
||||
"lowpass": 4000,
|
||||
@@ -246,6 +299,16 @@ INSTRUMENTS = {
|
||||
"reverb": 0.4, "reverb_type": "cathedral",
|
||||
"analog": 0.3,
|
||||
},
|
||||
"vocal": {
|
||||
"synth": "vocal_synth", "envelope": "strings",
|
||||
"reverb": 0.3, "reverb_type": "hall",
|
||||
"humanize": 0.15,
|
||||
},
|
||||
"choir": {
|
||||
"synth": "vocal_synth", "envelope": "pad",
|
||||
"detune": 8, "spread": 0.4,
|
||||
"reverb": 0.45, "reverb_type": "cathedral",
|
||||
},
|
||||
"granular_texture": {
|
||||
"synth": "granular_synth", "envelope": "none",
|
||||
"reverb": 0.5, "reverb_type": "taj_mahal",
|
||||
@@ -325,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."""
|
||||
@@ -367,6 +448,8 @@ class Note:
|
||||
velocity: int = 100
|
||||
bend: float = 0.0
|
||||
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
|
||||
lyric: str = "" # syllable for vocal synth
|
||||
_hold: bool = False # if True, don't advance beat position
|
||||
|
||||
@property
|
||||
def beats(self) -> float:
|
||||
@@ -468,6 +551,14 @@ 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
|
||||
CAJON_TAP = 110 # light finger tap
|
||||
# Metal kit — tighter, punchier, more attack
|
||||
METAL_KICK = 105 # clicky, punchy, tight
|
||||
METAL_SNARE = 106 # crack, bright, cutting
|
||||
@@ -1499,6 +1590,105 @@ 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
|
||||
CT = DrumSound.CAJON_TAP
|
||||
|
||||
# Cajón flamenco — the classic acoustic percussion groove
|
||||
Pattern._PRESETS["cajon"] = dict(
|
||||
name="cajon",
|
||||
time_signature="4/4",
|
||||
beats=4.0,
|
||||
hits=[
|
||||
_h(CB, 0.0, 85), _h(CT, 0.5, 35), _h(CT, 0.75, 38),
|
||||
_h(CSL, 1.0, 80), _h(CT, 1.5, 32),
|
||||
_h(CB, 2.0, 82), _h(CT, 2.5, 35), _h(CT, 2.75, 40),
|
||||
_h(CSL, 3.0, 82), _h(CT, 3.25, 30), _h(CT, 3.5, 35),
|
||||
],
|
||||
)
|
||||
|
||||
# Cajón rumba — Latin-flavored
|
||||
Pattern._PRESETS["cajon rumba"] = dict(
|
||||
name="cajon rumba",
|
||||
time_signature="4/4",
|
||||
beats=4.0,
|
||||
hits=[
|
||||
_h(CB, 0.0, 88), _h(CT, 0.5, 38),
|
||||
_h(CSL, 1.0, 78), _h(CT, 1.25, 32), _h(CB, 1.5, 72),
|
||||
_h(CSL, 2.0, 82), _h(CT, 2.5, 35),
|
||||
_h(CB, 3.0, 75), _h(CSL, 3.5, 80), _h(CT, 3.75, 38),
|
||||
],
|
||||
)
|
||||
|
||||
# Cajón singer-songwriter — simple, supportive
|
||||
Pattern._PRESETS["cajon folk"] = dict(
|
||||
name="cajon folk",
|
||||
time_signature="4/4",
|
||||
beats=4.0,
|
||||
hits=[
|
||||
_h(CB, 0.0, 80),
|
||||
_h(CSL, 1.0, 72), _h(CT, 1.5, 30),
|
||||
_h(CB, 2.0, 78),
|
||||
_h(CSL, 3.0, 75),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Metal kit patterns ────────────────────────────────────────────────────
|
||||
MK = DrumSound.METAL_KICK
|
||||
MS = DrumSound.METAL_SNARE
|
||||
@@ -1985,6 +2175,123 @@ 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),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class Part:
|
||||
"""A named voice within a Score, with its own synth, envelope, and effects.
|
||||
@@ -2095,7 +2402,7 @@ 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") -> "Part":
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: 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).
|
||||
@@ -2113,7 +2420,36 @@ class Part:
|
||||
duration = _RawDuration(duration)
|
||||
self.notes.append(Note(tone=tone_or_string, duration=duration,
|
||||
velocity=velocity, bend=bend,
|
||||
bend_type=bend_type))
|
||||
bend_type=bend_type, lyric=lyric))
|
||||
return self
|
||||
|
||||
def hold(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
|
||||
bend: float = 0.0, bend_type: str = "smooth", lyric: 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, _hold=True))
|
||||
return self
|
||||
|
||||
def set(self, **params) -> "Part":
|
||||
@@ -2398,7 +2734,7 @@ class Part:
|
||||
|
||||
def strum(self, chord_name: str, duration=Duration.QUARTER, *,
|
||||
direction: str = "down", velocity: int = 100,
|
||||
strum_time: float = 0.08) -> "Part":
|
||||
strum_time: float = 0.05) -> "Part":
|
||||
"""Strum a chord using the part's fretboard fingering.
|
||||
|
||||
Looks up the chord on the fretboard, gets the fingering, and
|
||||
@@ -2466,6 +2802,13 @@ class Part:
|
||||
from .chords import Chord as ChordClass
|
||||
chord_obj = ChordClass(tones=strum_tones)
|
||||
|
||||
# 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 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
|
||||
|
||||
+254
-2
@@ -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
|
||||
@@ -6827,7 +6840,7 @@ def test_strum_direction():
|
||||
p = score.part("g", instrument="acoustic_guitar", fretboard=fb)
|
||||
p.strum("G", Duration.QUARTER, direction="down")
|
||||
p.strum("G", Duration.QUARTER, direction="up")
|
||||
assert len(p.notes) == 2
|
||||
assert len(p.notes) >= 2 # grace notes + chord per strum
|
||||
|
||||
|
||||
# ── World drums ──────────────────────────────────────────────────────────────
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user