Compare commits

...

26 Commits

Author SHA1 Message Date
kennethreitz fb923f6c76 v0.35.1: Granular synthesis engine
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:50:32 -04:00
kennethreitz 59e3338892 Granular synthesis engine with presets
Grain cloud synthesis: source waveform chopped into tiny overlapping
grains (40ms, 50/sec) with Hanning windows, random scatter, and
per-grain pitch variation. Creates textures impossible with other
synthesis. Two presets: granular_pad, granular_texture.
30 synth waveforms total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:47:52 -04:00
kennethreitz 8cf4145c15 Docs: timpani, saxophone, Part.roll(), update waveform counts
- Add timpani and saxophone synth sections to synths.rst
- Add rolls section to sequencing.rst with examples
- Update waveform count: 27 → 29

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:38:46 -04:00
kennethreitz b3885b2c15 v0.35.0: JI ratios, 8.5x faster import, timpani, saxophone, rolls
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:34:34 -04:00
kennethreitz ae04fa60cc Reduce vibrato across all instruments to 0.001
Strings, cello, trumpet, clarinet, oboe all cut to 0.001 depth.
Much subtler in ensemble context.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:29:48 -04:00
kennethreitz 6c411e43f8 Part.roll() for crescendo/decrescendo rolls, reedier sax, timpani reverb
- roll(tone, duration, velocity_start, velocity_end, speed) — rapid
  repeated notes with velocity ramp. Works on any instrument.
- Saxophone reed noise boosted and bandpass filtered for more bite
- Timpani preset: cathedral reverb at 0.4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:26:28 -04:00
kennethreitz e0427af3cc Timpani and saxophone synths, 4 sax presets
- Timpani: inharmonic membrane modes (1.0, 1.5, 1.99, 2.44),
  felt mallet attack, copper kettle resonance, two-stage decay
- Saxophone: conical bore (all harmonics), strong mids, reed buzz,
  brass body warmth. 4 presets: saxophone, alto_sax, tenor_sax, bari_sax
- 29 synth waveforms total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:22:47 -04:00
kennethreitz 552836ae5b Drop pytuning/sympy, lazy-load scipy: import 0.48s → 0.05s (fixes #44)
- Replace pytuning with 30-line native implementations of EDO,
  Pythagorean, and quarter-comma meantone scale generators
- Lazy-load scipy.signal (337ms) — only imported when audio rendering
  is actually used, not on theory-only imports
- Removes pytuning and sympy from dependencies entirely

Import time: 0.479s → 0.056s (8.5x faster)

Closes #44

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:17:52 -04:00
kennethreitz 0fe53fcdeb Merge pull request #46 from kennethreitz/fix/accidental-octave-wrap
Fix B#/Cb octave boundary crossing
2026-03-27 11:11:43 -04:00
kennethreitz f6fb2a2cd6 Fix B#/Cb octave boundary crossing (fixes #45)
B#4 now correctly resolves to C5 (523.25 Hz), not C4 (261.63 Hz).
Cb4 now correctly resolves to B3 (246.94 Hz), not B4 (493.88 Hz).

When an accidental crosses the B/C octave boundary, the octave is
adjusted: sharps crossing B→C increment, flats crossing C→B decrement.
Also handles double sharps (B##→C#5) and double flats (Cbb→Bb3).

Closes #45

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:11:08 -04:00
kennethreitz 70d6e6b8ce Reduce flute vibrato further (0.0015 → 0.0008)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:05:55 -04:00
kennethreitz aec9a999cb Arabic maqam JI ratios: Zalzalian neutral third (27/22)
Maqam system now uses just intonation ratios instead of 24-TET:
- Quarter-tone positions use Zalzalian (11-limit) ratios
- Mi↓ (the defining Rast note) is exactly 27/22 from Do
- Standard JI intervals for chromatic positions
- Septimal ratios (7-limit) for other quarter-tone positions

Research confirmed: Turkish 53-TET and Thai 7-TET are already
correct as equal temperaments. Gamelan has no universal ratios
(each ensemble is unique), so TET remains the best default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:00:16 -04:00
kennethreitz 3acde86028 Int tone names, wrapping, System.tone(), proper shruti JI ratios
- Tone(0, system=edo22) works alongside Tone("0", ...)
- Tone(22, system=edo22) wraps to tone 0, octave+1
- Tone(-1) wraps to last tone, octave-1
- System.tone(name, octave) convenience method
- Shruti system now uses 5-limit just intonation ratios instead
  of 22-TET approximation. Based on Pythagorean/harmonic ratios
  from traditional Indian musicology. Pa is a pure 3/2, Ga is a
  pure 5/4.
- System.ratios attribute overrides equal temperament when set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:54:28 -04:00
kennethreitz aa405702a9 Fix Journey: EDM parts start after tabla solo ends
Calculated edm_start from actual section lengths so pad/sub/sitar2
don't bleed into the tabla solo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:44:42 -04:00
kennethreitz b7c018fb94 Expanded tabla solo: 4 sections, 9-tuplets, polyrhythm, grand tihai
Solo now has 4 distinct parts:
1. Whisper — single hits with space, breath
2. Ghosts emerge — 16th note ghost fills between accents
3. Call and response — dayan vs bayan, 9-tuplet break
4. Blazing — 32nd triplet cascades, rapid alternating hands,
   9-against-4 polyrhythm, grand tihai (3x, each louder), slam

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:06:59 -04:00
kennethreitz 07a52a3a25 Add tabla solo section to Journey, louder sitar in EDM drop
Tabla solo with ghost notes, 32nd triplet cascade, tihai, then
slams into house beat. Sitar volume 0.22 → 0.4 in EDM section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:03:57 -04:00
kennethreitz e12cb9003b Song #24: Journey (Piano → World → Sitar EDM)
Single score, one reverb space (Taj Mahal), tanpura drone throughout.
Piano arpeggios alone → cello joins → harp/oboe/flute with djembe →
sitar over tabla → EDM section with sitar, synth pad, 808 sub, house
drums. 28 bars, 5 movements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:01:36 -04:00
kennethreitz 28968a1b5c Docs: strumming, pitch bends, tuning systems, fix instrument count
- Add guitar strumming section to sequencing.rst
- Add pitch bends section with three bend types
- Add tuning systems section (temperament, reference_pitch, TET)
- Fix index.rst: 25 → 49 instrument presets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:49:24 -04:00
kennethreitz 8a4a2df1aa Song #23: Tabla Solo in Raga Yaman (22-shruti)
Tanpura drone intro, quiet sitar Yaman phrases, tabla solo building
from gentle theka through ghost notes to blazing tiri kita with
bayan pitch bends, tihai, dramatic silence, slam finish. Taj Mahal
reverb throughout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:37:34 -04:00
kennethreitz f4a90637db Note choking: new hits fade out previous tails on same sound
Drums and melodic notes now choke previous resonance with a quick
fade when a new hit/note starts. Prevents muddy buildup at fast
tempos. Added bayan pitch bend drum sound (TABLA_GE_BEND).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:32:34 -04:00
kennethreitz 90a1a31049 Fix pitch bends: resampling preserves instrument timbre
Render at base pitch using the actual synth, then variable-rate
resample to shift pitch over time. No more sine wave fallback or
retriggering artifacts. Three bend types: smooth (log), linear, late.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:22:03 -04:00
kennethreitz 33b2e82594 v0.34.1: Pitch bends, updated docs and songs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:15:32 -04:00
kennethreitz 9f8dd0006d Pitch bends, updated docs, songs with new instruments
- Pitch bend: part.add("C4", bend=2, bend_type="smooth") bends up
  a whole step. Three types: smooth (log/perceptual), linear, late
  (hold then bend — blues style).
- Updated songs.py: use dedicated instrument synths (piano_synth,
  flute_synth, trumpet_synth, etc.) instead of generic waveforms
- Updated docs: synths.rst (27 waveforms, instrument synths section),
  effects.rst (cabinet sim, analog drift, updated signal chain),
  drums.rst (world percussion: tabla, dhol, dholak, mridangam,
  djembe, metal kit), index.rst (feature counts)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:14:45 -04:00
kennethreitz 417f7f74a3 19 new tests: instrument synths, cabinet, analog, strumming, world drums
Tests for: all 14 dedicated synth waveforms, piano brightness scaling,
cabinet sim frequency reduction, analog drift rendering, strum with/
without fretboard, strum direction, all 6 tabla sounds, dhol/mridangam/
djembe/metal kit sounds, 20 world drum pattern presets, guitar preset
cabinet sim. 838 tests total.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:11:48 -04:00
kennethreitz cd6f814049 Update changelog with vibrato tuning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:08:37 -04:00
kennethreitz 83fcdb0a09 Reduce vibrato depth on flute, oboe, trumpet, cello
All cut from 0.003-0.004 to 0.0015-0.002 — less wobbly in
ensemble context, more natural.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:08:17 -04:00
16 changed files with 1913 additions and 271 deletions
+26
View File
@@ -2,6 +2,30 @@
All notable changes to PyTheory are documented here.
## 0.35.0
- **8.5x faster import** — dropped pytuning/sympy, lazy-load scipy.
`import pytheory` now takes ~50ms instead of ~480ms (#44)
- **Proper shruti JI ratios** — 22 positions with 5-limit just intonation
(pure 3/2 fifths, 5/4 thirds), not 22-TET approximation
- **Arabic maqam JI ratios** — Zalzalian 11-limit ratios.
Mi↓ (the Rast third) is exactly 27/22 from Do
- **B#/Cb octave boundary fix** — B#4 = C5, Cb4 = B3 (#45)
- **Int tone names** — `Tone(0, system=TET(22))` works alongside strings.
Wrapping: `Tone(22)` → tone 0, octave+1. `System.tone()` convenience.
- **Timpani synth** — inharmonic membrane modes, felt mallet, copper kettle
resonance, cathedral reverb
- **Saxophone synth** — conical bore, reed buzz, brass body warmth.
4 presets: saxophone, alto_sax, tenor_sax, bari_sax
- **Part.roll()** — rapid repeated notes with velocity ramp for crescendo/
decrescendo rolls on any instrument
- **Vibrato tuning** — all instruments reduced to 0.001 depth for cleaner
ensemble sound
- **Granular synthesis** — grain cloud engine with scatter, pitch
variation, and Hanning-windowed grains. Two presets: granular_pad,
granular_texture.
- 30 synth waveforms, 838 tests
## 0.34.0
- **16 dedicated instrument synths** — physical modeling and specialized
@@ -20,6 +44,8 @@ All notable changes to PyTheory are documented here.
with 22 new drum patterns
- **Piano improvements:** brightness scales with pitch, two-stage decay,
hammer impact with felt character
- **Vibrato tuning:** reduced across flute, oboe, trumpet, cello for
smoother ensemble sound
- 27 synth waveforms, 10 envelopes, 40+ instrument presets, 80+ drum patterns
## 0.33.1
+135 -6
View File
@@ -10,7 +10,7 @@ the genre -- they tell the listener's body how to move before a single
melodic note is played.
PyTheory includes a complete drum system -- 27 synthesized percussion
sounds, 58 pattern presets across dozens of genres, and 21 fill presets.
sounds, 80+ pattern presets across dozens of genres, and 21 fill presets.
Every sound is generated from waveforms; no samples needed.
Drum Sounds
@@ -145,8 +145,8 @@ Each sound has a dedicated synthesizer:
Pattern Presets
---------------
58 patterns spanning genres from rock to Afro-Cuban to electronic.
Load them with ``Pattern.preset()``:
80+ patterns spanning genres from rock to Afro-Cuban to electronic to
world percussion. Load them with ``Pattern.preset()``:
.. code-block:: pycon
@@ -193,9 +193,16 @@ adds syncopation.
rattling hi-hats of trap, the breakneck tempo of drum and bass. These
patterns were born in drum machines and they still live there.
**Metal/Punk:** metal, blast beat, punk -- Speed and aggression.
The blast beat is both feet and both hands going as fast as humanly
possible. Punk strips everything to its essentials.
**Metal/Punk:** metal, blast beat, punk, double kick, metal blast,
metal groove, metal gallop -- Speed and aggression. The blast beat is
both feet and both hands going as fast as humanly possible. Punk strips
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
idiomatic patterns. See the World Percussion section below for details.
**Other:** funk, hip hop, bo diddley, second line, new orleans, waltz,
12/8 blues, country, gospel, flamenco -- Everything else. The syncopated
@@ -304,6 +311,128 @@ drum pattern and all named parts are mixed together by ``play_score()``:
play_score(score)
World Percussion
----------------
PyTheory includes dedicated sound sets and pattern presets for
traditional percussion instruments from around the world. Each
instrument has its own synthesized sounds that capture the timbral
character of the real instrument, plus idiomatic rhythmic patterns
drawn from their musical traditions.
Tabla
~~~~~
The tabla is a pair of hand drums from the Indian subcontinent -- the
smaller, higher-pitched *dayan* and the larger, bass *bayan*. It is
the rhythmic backbone of Hindustani classical music, and one of the
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 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).
.. code-block:: python
score = Score("4/4", bpm=80)
score.drums("teental", repeats=4)
Dhol
~~~~
The dhol is a double-headed barrel drum from Punjab, played with
sticks. It is the driving force behind bhangra music -- loud,
energetic, and physically impossible to sit still to.
**3 sounds** -- bass stroke, treble stroke, and rimshot.
**2 patterns:** bhangra (the classic bhangra groove) and dhol chaal
(a processional rhythm).
.. code-block:: python
score = Score("4/4", bpm=160)
score.drums("bhangra", repeats=4)
Dholak
~~~~~~
The dholak is a smaller, lighter two-headed drum used across South
Asia in folk music, qawwali, and Bollywood. Played with bare hands,
it produces a warm, melodic tone.
**3 sounds** -- bass, treble, and slap.
**2 patterns:** qawwali (the rhythmic foundation of Sufi devotional
music) and dholak folk (a general folk groove).
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("qawwali", repeats=4)
Mridangam
~~~~~~~~~
The mridangam is a double-headed drum from South India, the
rhythmic anchor of Carnatic classical music. Its tuning system is
extraordinarily precise, and its rhythmic vocabulary is among the
most mathematically complex in the world.
**4 sounds** -- tha, thom, nam, and din.
**2 patterns:** adi talam (the most common Carnatic talam, 8 beats)
and mridangam korvai (a rhythmic cadence pattern).
.. code-block:: python
score = Score("4/4", bpm=90)
score.drums("adi talam", repeats=4)
Djembe
~~~~~~
The djembe is a rope-tuned goblet drum from West Africa, capable of
producing a wide range of tones from deep bass to sharp slaps. It is
central to the drum ensemble traditions of Mali, Guinea, and Senegal.
**3 sounds** -- bass (open center strike), tone (edge strike), and
slap (sharp edge strike).
**3 patterns:** djembe (a basic accompanying rhythm), kuku (a
traditional rhythm from Guinea associated with fishing), and soli (a
solo/celebration rhythm).
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("djembe", repeats=4)
Metal Kit
~~~~~~~~~
A dedicated percussion kit for extreme metal subgenres, with
specialized sounds and patterns that go beyond the standard drum kit.
**3 sounds** -- double kick (triggered, tight attack), china cymbal,
and stack (a short, trashy cymbal choke).
**4 patterns:** double kick (relentless double bass drum pattern),
metal blast (blast beat with china cymbal accents), metal groove (a
half-time groove with double kick fills), and metal gallop (the
classic triplet-feel gallop rhythm).
.. code-block:: python
score = Score("4/4", bpm=200)
score.drums("metal blast", repeats=4)
MIDI Export
-----------
+91 -7
View File
@@ -32,8 +32,8 @@ It's a well-tested order that sounds good by default.
Effects are applied in this fixed order::
Signal --> Saturation --> Tremolo --> Distortion --> Chorus --> Phaser
--> Highpass --> Lowpass --> Delay --> Reverb --> Mix
Signal --> Saturation --> Tremolo --> Distortion --> Cabinet --> Chorus
--> Phaser --> Highpass --> Lowpass --> Delay --> Reverb --> Mix
Additionally, these per-note effects are applied before the part effects chain:
@@ -47,11 +47,12 @@ Part-level effects:
- **Saturation** first: subtle even-harmonic warmth (tape/tube color).
- **Tremolo** second: amplitude LFO modulation.
- **Distortion** third: drives the signal before filtering.
- **Chorus** fourth: thickens the signal.
- **Phaser** fifth: swept allpass notches.
- **Highpass** sixth: removes low-frequency mud.
- **Lowpass** seventh: shapes the tone (like a tone knob on an amp).
- **Delay** eighth: echoes the shaped signal (tap delay / tape echo).
- **Cabinet** fourth: speaker cab simulation (rolloff + presence bump).
- **Chorus** fifth: thickens the signal.
- **Phaser** sixth: swept allpass notches.
- **Highpass** seventh: removes low-frequency mud.
- **Lowpass** eighth: shapes the tone (like a tone knob on an amp).
- **Delay** ninth: echoes the shaped signal (tap delay / tape echo).
- **Reverb** last: places everything in a space (room / hall).
Distortion
@@ -96,6 +97,89 @@ Parameters:
distortion_drive=10.0,
)
Cabinet Simulation
------------------
A real guitar amp doesn't just distort the signal -- the speaker
cabinet shapes the tone dramatically. A 12-inch speaker in a closed
cabinet rolls off the harsh high frequencies above 5 kHz and adds a
presence bump around 2--3 kHz that gives the sound its "in the room"
quality. Without a cabinet, distortion sounds thin and fizzy. With
one, it sounds like a real amp.
PyTheory's cabinet simulation applies a speaker rolloff curve (lowpass
at ~5 kHz) combined with a presence resonance bump, placed in the
signal chain immediately after distortion -- exactly where it sits in
a real amp.
Parameters:
- ``cabinet``: Wet/dry mix, 0.0--1.0 (default 0, off).
- 0.3--0.5 = subtle speaker coloring
- 0.6--0.8 = classic amp-in-a-room
- 1.0 = full cabinet, no dry signal
.. code-block:: python
# Classic rock amp tone: distortion into cabinet
guitar = score.part(
"guitar",
synth="saw",
envelope="pluck",
distortion=0.6,
distortion_drive=5.0,
cabinet=0.8,
)
# Clean amp with just cabinet warmth (no distortion)
clean = score.part(
"clean",
synth="triangle",
envelope="pluck",
cabinet=0.5,
)
Analog Drift
------------
Real analog synthesizers are never perfectly in tune. The voltage-
controlled oscillators drift slightly over time as components warm up
and temperature fluctuates. This imperfection is actually a big part
of why vintage analog synths sound so appealing -- the subtle pitch
wandering gives each note a unique, living quality that static digital
oscillators lack.
The ``analog_drift`` parameter adds slow, random pitch variation to
each oscillator, modeling this vintage behavior.
Parameters:
- ``analog_drift``: Drift amount, 0.0--1.0 (default 0, off).
- 0.05--0.1 = subtle warmth (studio-grade analog)
- 0.15--0.25 = noticeable drift (vintage gear warming up)
- 0.3+ = unstable, wobbly (broken tape machine)
.. code-block:: python
# Warm vintage pad
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
analog_drift=0.1,
chorus=0.3,
)
# Lo-fi detuned lead
lead = score.part(
"lead",
synth="saw",
envelope="pluck",
analog_drift=0.25,
)
Chorus
------
+104
View File
@@ -574,3 +574,107 @@ Define sections with ``score.section()`` and repeat them with
Use any names you want — ``"intro"``, ``"verse"``, ``"chorus"``,
``"bridge"``, ``"drop"``, ``"breakdown"``, ``"outro"``, or anything
that makes sense for your song. The names are just labels.
Guitar Strumming
----------------
Any part with a fretboard can strum chords using real fingering
positions. The ``strum()`` method looks up the chord on the fretboard,
gets the correct voicing, and plays all strings as a chord.
.. code-block:: python
from pytheory import Fretboard
guitar = score.part("guitar", instrument="acoustic_guitar",
fretboard=Fretboard.guitar())
guitar.strum("Am", Duration.HALF, direction="down")
guitar.strum("G", Duration.HALF, direction="up")
guitar.strum("F", Duration.WHOLE)
Works with any fretboard instrument — guitar, ukulele, banjo, mandolin.
Works with any guitar preset — clean, crunch, distorted, orange, metal.
Pitch Bends
-----------
Bend a note's pitch up or down over its duration. Essential for guitar
bends, sitar meends, trombone slides, and vocal-style expression.
.. code-block:: python
# Guitar bend: D up to E (2 semitones)
guitar.add("D4", Duration.HALF, bend=2, bend_type="smooth")
# Release bend: E back down to D
guitar.add("E4", Duration.HALF, bend=-2)
# Blues curl: hold then bend at the end
guitar.add("C4", Duration.HALF, bend=1, bend_type="late")
Three bend types:
- ``"smooth"`` — logarithmic (default). Perceptually even pitch change.
- ``"linear"`` — linear frequency interpolation. Mechanical/synth feel.
- ``"late"`` — holds the starting pitch for 60%, bends in the last 40%.
The classic blues "curl."
Rolls
-----
Rapid repeated notes with a velocity ramp — perfect for timpani
rolls, snare rolls, tremolo on any instrument. The velocity ramps
from ``velocity_start`` to ``velocity_end`` for crescendo or
decrescendo effects.
.. code-block:: python
# Timpani crescendo roll
timp = score.part("timp", instrument="timpani")
timp.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=110)
timp.add("C3", Duration.HALF, velocity=127) # big accent
# Snare roll with 32nd notes
snare = score.part("snare", synth="noise", envelope="pluck")
snare.roll("C4", Duration.HALF, speed=0.125,
velocity_start=40, velocity_end=100)
# Decrescendo (loud to quiet)
timp.roll("G2", Duration.WHOLE, velocity_start=100, velocity_end=30)
Parameters:
- ``velocity_start``: Starting velocity (default 40).
- ``velocity_end``: Ending velocity (default 100).
- ``speed``: Note subdivision (default ``Duration.SIXTEENTH``).
Use ``0.125`` for 32nd notes, ``Duration.EIGHTH`` for 8th notes.
Tuning Systems
--------------
A Score can use any tuning system and temperament:
.. code-block:: python
# Baroque harpsichord — meantone tuning, A=415
score = Score("4/4", bpm=80, temperament="meantone",
reference_pitch=415.0)
# Indian classical — 22-shruti system
score = Score("4/4", bpm=75, system="shruti")
# Just intonation — pure intervals
score = Score("4/4", bpm=90, temperament="just")
Temperaments: ``"equal"`` (default), ``"pythagorean"``, ``"meantone"``,
``"just"``.
Custom equal temperaments via the ``TET()`` factory:
.. code-block:: python
from pytheory import TET
edo19 = TET(19) # 19-tone equal temperament
score = Score("4/4", bpm=100, system=edo19)
+227 -3
View File
@@ -1,7 +1,7 @@
Synthesizers
============
PyTheory includes 13 built-in waveforms and 10 ADSR envelope presets.
PyTheory includes 30 built-in waveforms and 10 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
@@ -233,7 +233,7 @@ shapes the amplitude over time for natural-sounding notes:
- **Sustain** -- the held volume while the note is on.
- **Release** -- how quickly it fades to silence after the note ends.
PyTheory includes 8 presets:
PyTheory includes 10 presets:
.. code-block:: python
@@ -386,11 +386,235 @@ more "wooden" than a raw saw wave.
violin = score.part("violin", synth="strings_synth")
Dedicated Instrument Synths
--------------------------
Beyond the classic and physical modeling waveforms, PyTheory includes
17 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.
Piano Synth
~~~~~~~~~~~
Hammer-strike envelope with body resonance and subtle inharmonicity.
Models the way a felt hammer excites steel strings inside a wooden
soundboard.
.. code-block:: python
piano = score.part("piano", synth="piano_synth")
Bass Guitar Synth
~~~~~~~~~~~~~~~~~
Plucked string model with finger-damped harmonics and low-end warmth.
.. code-block:: python
bass = score.part("bass", synth="bass_guitar_synth")
Flute Synth
~~~~~~~~~~~~
Breathy noise excitation through a resonant tube model, with
overblowing behavior at higher velocities.
.. code-block:: python
flute = score.part("flute", synth="flute_synth")
Trumpet Synth
~~~~~~~~~~~~~
Brass lip-buzz model with spectral brightness that increases with
velocity, plus a characteristic brassy edge from shaped harmonics.
.. code-block:: python
trumpet = score.part("trumpet", synth="trumpet_synth")
Clarinet Synth
~~~~~~~~~~~~~~
Cylindrical bore model producing mostly odd harmonics, giving the
characteristic hollow, woody tone.
.. code-block:: python
clarinet = score.part("clarinet", synth="clarinet_synth")
Oboe Synth
~~~~~~~~~~~
Double-reed model with nasal formant shaping and a buzzy, penetrating
timbre.
.. code-block:: python
oboe = score.part("oboe", synth="oboe_synth")
Marimba Synth
~~~~~~~~~~~~~
Tuned bar model with a soft mallet attack and a warm, resonant decay
that emphasizes the fundamental.
.. code-block:: python
marimba = score.part("marimba", synth="marimba_synth")
Harpsichord Synth
~~~~~~~~~~~~~~~~~
Plucked-string model with a bright, immediate attack and rapid decay
-- the characteristic "plink" of a quill plucking a string.
.. code-block:: python
harpsi = score.part("harpsi", synth="harpsichord_synth")
Cello Synth
~~~~~~~~~~~
Bowed string model with body formants at cello resonance frequencies,
producing a rich, warm, sustained tone.
.. code-block:: python
cello = score.part("cello", synth="cello_synth")
Harp Synth
~~~~~~~~~~
Plucked string with longer sustain and gentle high-frequency rolloff,
modeling nylon strings on a resonant frame.
.. code-block:: python
harp = score.part("harp", synth="harp_synth")
Upright Bass Synth
~~~~~~~~~~~~~~~~~~
Pizzicato double bass with woody body resonance and a thumpy low end.
.. code-block:: python
bass = score.part("bass", synth="upright_bass_synth")
Acoustic Guitar Synth
~~~~~~~~~~~~~~~~~~~~~
Steel-string model with pick transient, body resonance, and natural
string decay.
.. code-block:: python
guitar = score.part("guitar", synth="acoustic_guitar_synth")
Electric Guitar Synth
~~~~~~~~~~~~~~~~~~~~~
Magnetic pickup model with brighter harmonics and less body resonance
than the acoustic, ready for effects processing.
.. code-block:: python
eguitar = score.part("eguitar", synth="electric_guitar_synth")
Sitar Synth
~~~~~~~~~~~~
Sympathetic string resonance with the characteristic buzzy "jawari"
bridge, producing a shimmering, metallic sustain.
.. code-block:: python
sitar = score.part("sitar", synth="sitar_synth")
Timpani Synth
~~~~~~~~~~~~~
Large kettle drum with definite pitch. Inharmonic membrane modes
(1.0, 1.5, 1.99, 2.44), felt mallet attack, copper kettle resonance.
Use ``Part.roll()`` for crescendo timpani rolls.
.. code-block:: python
timp = score.part("timp", synth="timpani_synth")
timp.roll("C3", Duration.WHOLE, velocity_start=20, velocity_end=110)
Saxophone Synth
~~~~~~~~~~~~~~~
Single reed through a conical brass bore. All harmonics with strong
mids, reed buzz, and brass body warmth. Four presets: ``saxophone``,
``alto_sax``, ``tenor_sax``, ``bari_sax``.
.. code-block:: python
sax = score.part("sax", instrument="tenor_sax")
Granular Synth
~~~~~~~~~~~~~~
Grain cloud synthesis — chops a source waveform into tiny overlapping
grains (10-200ms), each windowed and optionally pitch/time scattered.
Creates textures impossible with other synthesis: frozen tones,
shimmering clouds, evolving pads, glitchy stutters.
.. code-block:: python
# Atmospheric granular pad
pad = score.part("pad", instrument="granular_pad")
# Granular with filter envelope sweep + resonance
texture = score.part("texture", synth="granular_synth", envelope="pad",
filter_amount=4000, filter_attack=0.5,
filter_decay=1.5, filter_sustain=0.3,
lowpass=600, lowpass_q=3.0,
reverb=0.5, reverb_type="taj_mahal")
Parameters (passed as synth kwargs):
- ``grain_size``: Duration per grain in seconds (default 0.04).
- ``density``: Grains per second (default 50). Higher = denser cloud.
- ``scatter``: Random position jitter 0-1 (default 0.5).
- ``pitch_var``: Per-grain pitch randomization in cents (default 12).
- ``source``: Base waveform — ``"saw"``, ``"sine"``, ``"triangle"``,
``"square"``, ``"noise"``.
Analog Oscillator Drift
~~~~~~~~~~~~~~~~~~~~~~~~
All waveform synths support the ``analog_drift`` parameter, which adds
subtle, slow random pitch variation to each oscillator -- modeling the
voltage instability of vintage analog circuits. This is what makes a
real Minimoog sound slightly different on every note, and why analog
synths feel "alive" compared to their digital counterparts.
.. code-block:: python
# Subtle vintage drift
pad = score.part("pad", synth="saw", analog_drift=0.1)
# More pronounced, wobbly analog character
lead = score.part("lead", synth="square", analog_drift=0.3)
Drift values:
- **0.05--0.1** = subtle warmth (studio-grade analog)
- **0.15--0.25** = noticeable drift (vintage gear warming up)
- **0.3+** = unstable, wobbly (broken tape machine)
Instrument Presets
------------------
Instead of choosing synth + envelope + effects manually, use an
instrument preset — 38 predefined combinations that approximate real
instrument preset — 40+ predefined combinations that approximate real
instruments:
.. code-block:: python
+11 -8
View File
@@ -77,15 +77,18 @@ What's Inside
numbers), scale recommendation, modulation, voice leading
- **Sequencing** — Score, Parts, arpeggiator, legato/glide, velocity,
swing, humanize, tempo changes, song sections with repeat
- **Synthesis**13 waveforms (including Karplus-Strong pluck, Hammond organ,
bowed string), 10 envelopes, 38 instrument presets, configurable FM,
sub-oscillator, noise layer, filter envelope, velocity-to-brightness,
detune, stereo pan/spread, 58 drum patterns (stereo panned), 21 fills
- **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
- **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay,
lowpass/highpass (with resonance), distortion, saturation, chorus,
phaser, tremolo, sidechain compression, automation, LFOs. Master bus
compressor/limiter
- **Instruments**25 presets with fingering generation
lowpass/highpass (with resonance), distortion, 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
- **Output** — stereo playback, WAV export, MIDI import/export
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``
- **AI-friendly** — Claude Code can compose
+459 -141
View File
@@ -15,7 +15,8 @@ Usage:
import sounddevice as sd
from pytheory import Chord, Key, Pattern, Duration, Score
from pytheory import Chord, Key, Pattern, Duration, Score, Tone, TonedScale, SYSTEMS
from pytheory.rhythm import DrumSound, _Hit
from pytheory.play import render_score, SAMPLE_RATE
@@ -45,18 +46,15 @@ def bossa_nova_girl():
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
rhodes = score.part("rhodes", instrument="electric_piano",
volume=0.3, pan=-0.3,
reverb=0.4, reverb_decay=1.8, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
reverb=0.4, reverb_decay=1.8, reverb_type="plate")
lead = score.part("lead", instrument="flute",
volume=0.45, pan=0.3,
delay=0.25, delay_time=0.32, delay_feedback=0.35,
reverb=0.2, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, pan=0.0, lowpass=600,
humanize=0.15)
reverb=0.2, reverb_type="plate")
bass = score.part("bass", instrument="upright_bass",
volume=0.45, pan=0.0, lowpass=600)
for sym in ["Am", "Am", "Dm", "Dm", "E7", "E7", "Am", "Am"]:
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -90,19 +88,16 @@ def bebop_in_bb():
score = Score("4/4", bpm=160)
score.drums("bebop", repeats=8, fill="jazz", fill_every=8)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
rhodes = score.part("rhodes", instrument="electric_piano",
volume=0.25, pan=-0.3,
reverb=0.35, reverb_decay=1.2, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
reverb=0.35, reverb_decay=1.2, reverb_type="plate")
lead = score.part("lead", instrument="trumpet",
volume=0.4, pan=0.25,
lowpass=4000, lowpass_q=1.1,
delay=0.15, delay_time=0.19, delay_feedback=0.25,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="triangle", envelope="pluck",
volume=0.4, pan=0.0, lowpass=500,
humanize=0.15)
reverb=0.15, reverb_type="plate")
bass = score.part("bass", instrument="upright_bass",
volume=0.4, pan=0.0, lowpass=500)
for sym in ["Bb", "Gm", "Cm", "F7"] * 2:
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -148,14 +143,12 @@ def salsa_descarga():
volume=0.2, pan=-0.35,
reverb=0.3, reverb_type="plate", lowpass=2000,
detune=10, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
lead = score.part("lead", instrument="trumpet",
volume=0.4, pan=0.3,
delay=0.2, delay_time=0.167, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="pulse", envelope="pluck",
volume=0.45, pan=0.0, lowpass=500, lowpass_q=1.3,
humanize=0.15)
reverb=0.15, reverb_type="plate")
bass = score.part("bass", instrument="synth_bass",
volume=0.45, pan=0.0, lowpass=500, lowpass_q=1.3)
for sym in ["Em7b5", "A7", "Dm7", "Bbmaj7"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -191,20 +184,17 @@ def afrobeat_groove():
score = Score("4/4", bpm=115)
score.drums("afrobeat", repeats=8, fill="afrobeat", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
pads = score.part("pads", instrument="synth_pad",
volume=0.2, pan=-0.3,
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
lowpass=3000, detune=10, spread=0.6,
humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
lowpass=3000)
lead = score.part("lead", instrument="trumpet",
volume=0.4, pan=0.3,
lowpass=3000, lowpass_q=1.0,
delay=0.2, delay_time=0.26, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.5, pan=0.0, lowpass=500,
humanize=0.15)
reverb=0.15, reverb_type="plate")
bass = score.part("bass", instrument="bass_guitar",
volume=0.5, pan=0.0, lowpass=500)
for sym in ["Em", "Am", "D", "C"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -241,14 +231,12 @@ def reggae_one_drop():
reverb=0.5, reverb_decay=2.0, reverb_type="cathedral",
lowpass=2000, detune=8,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
lead = score.part("lead", instrument="flute",
volume=0.4, pan=0.3,
delay=0.35, delay_time=0.5625, delay_feedback=0.45,
reverb=0.3, reverb_type="cathedral",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, pan=0.0, lowpass=400, lowpass_q=1.3,
humanize=0.15)
reverb=0.3, reverb_type="cathedral")
bass = score.part("bass", instrument="bass_guitar",
volume=0.55, pan=0.0, lowpass=400, lowpass_q=1.3)
for sym in ["G", "C", "D", "C"] * 2:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -286,15 +274,13 @@ def funk_workout():
volume=0.25, pan=-0.4,
lowpass=2500, reverb=0.15, reverb_type="plate",
sidechain=0.4, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
lead = score.part("lead", instrument="synth_lead",
volume=0.4, pan=0.35,
lowpass=3500, lowpass_q=1.5,
delay=0.15, delay_time=0.15, delay_feedback=0.25,
reverb=0.1, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="pulse", envelope="pluck",
volume=0.5, pan=0.0, lowpass=600, lowpass_q=1.2,
humanize=0.15)
reverb=0.1, reverb_type="plate")
bass = score.part("bass", instrument="synth_bass",
volume=0.5, pan=0.0, lowpass=600, lowpass_q=1.2)
for sym in ["Em", "Am", "D", "B7"] * 2:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -332,18 +318,16 @@ def blues_shuffle():
score = Score("12/8", bpm=70)
score.drums("12/8 blues", repeats=6)
chords = score.part("chords", synth="fm", envelope="piano",
chords = score.part("chords", instrument="electric_piano",
volume=0.3, pan=-0.3,
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
detune=8, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="pluck",
reverb=0.3, reverb_decay=1.5, reverb_type="plate")
lead = score.part("lead", instrument="trumpet",
volume=0.45, pan=0.25,
reverb=0.3, reverb_decay=1.2, reverb_type="plate",
delay=0.2, delay_time=0.43, delay_feedback=0.3,
lowpass=3500, humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.5, pan=0.0, lowpass=500,
humanize=0.15)
lowpass=3500)
bass = score.part("bass", instrument="upright_bass",
volume=0.5, pan=0.0, lowpass=500)
for sym in ["A", "A", "D", "D", "E7", "A"]:
chords.add(Chord.from_symbol(sym), Duration.DOTTED_HALF)
@@ -381,19 +365,16 @@ def samba_de_janeiro():
score = Score("4/4", bpm=170)
score.drums("samba", repeats=8, fill="samba", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
pads = score.part("pads", instrument="synth_pad",
volume=0.2, pan=-0.3,
reverb=0.45, reverb_decay=2.0, reverb_type="plate",
lowpass=4000, detune=10, spread=0.5,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
lowpass=4000)
lead = score.part("lead", instrument="flute",
volume=0.45, pan=0.3,
delay=0.2, delay_time=0.176, delay_feedback=0.3,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, pan=0.0, lowpass=500,
humanize=0.15)
reverb=0.15, reverb_type="plate")
bass = score.part("bass", instrument="bass_guitar",
volume=0.45, pan=0.0, lowpass=500)
for sym in ["G", "Em", "Am", "D7"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -426,18 +407,15 @@ def jazz_waltz():
score = Score("3/4", bpm=150)
score.drums("waltz", repeats=16)
rhodes = score.part("rhodes", synth="fm", envelope="piano",
rhodes = score.part("rhodes", instrument="electric_piano",
volume=0.3, pan=-0.3,
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
reverb=0.4, reverb_decay=2.0, reverb_type="cathedral")
lead = score.part("lead", instrument="flute",
volume=0.4, pan=0.25,
reverb=0.3, reverb_decay=1.5, reverb_type="plate",
delay=0.2, delay_time=0.4, delay_feedback=0.3,
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.4, pan=0.0, lowpass=500,
humanize=0.15)
delay=0.2, delay_time=0.4, delay_feedback=0.3)
bass = score.part("bass", instrument="upright_bass",
volume=0.4, pan=0.0, lowpass=500)
for _ in range(2):
for sym in ["Fmaj7", "Gm", "C7", "Fmaj7"]:
@@ -471,20 +449,19 @@ def house_anthem():
score = Score("4/4", bpm=124)
score.drums("house", repeats=8, fill="house", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
pads = score.part("pads", instrument="synth_pad",
volume=0.25, pan=-0.3,
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
lowpass=5000, detune=12, spread=0.7,
sidechain=0.6, humanize=0.2)
lead = score.part("lead", synth="saw", envelope="staccato",
lowpass=5000,
sidechain=0.6)
lead = score.part("lead", instrument="synth_lead",
volume=0.35, pan=0.3,
lowpass=2000, lowpass_q=2.0,
delay=0.2, delay_time=0.242, delay_feedback=0.35,
reverb=0.15, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, pan=0.0, lowpass=300,
sidechain=0.5, humanize=0.15)
reverb=0.15, reverb_type="plate")
bass = score.part("bass", instrument="808_bass",
volume=0.55, pan=0.0,
sidechain=0.5)
for sym in ["Cm", "Ab", "Bb", "Cm"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -538,14 +515,12 @@ def dub_kingston():
reverb=0.6, reverb_decay=2.5, reverb_type="cathedral",
lowpass=1500, lowpass_q=0.9, detune=8,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
lead = score.part("lead", instrument="flute",
volume=0.4, pan=0.3,
delay=0.45, delay_time=0.625, delay_feedback=0.5,
reverb=0.35, reverb_decay=2.0, reverb_type="cathedral",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, pan=0.0, lowpass=400, lowpass_q=1.5,
humanize=0.15)
reverb=0.35, reverb_decay=2.0, reverb_type="cathedral")
bass = score.part("bass", instrument="bass_guitar",
volume=0.6, pan=0.0, lowpass=400, lowpass_q=1.5)
siren = score.part("siren", synth="pwm_slow", envelope="pad",
volume=0.15, pan=0.5,
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
@@ -585,20 +560,20 @@ def techno_minimal():
score = Score("4/4", bpm=130)
score.drums("techno", repeats=8, fill="house", fill_every=8)
pad = score.part("pad", synth="supersaw", envelope="pad",
pad = score.part("pad", instrument="synth_pad",
volume=0.2, pan=-0.3,
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
lowpass=3000, detune=12, spread=0.7,
sidechain=0.6, humanize=0.2)
lowpass=3000,
sidechain=0.6)
lead = score.part("lead", synth="pwm_fast", envelope="staccato",
volume=0.35, pan=0.3,
lowpass=1500, lowpass_q=3.0,
delay=0.3, delay_time=0.231, delay_feedback=0.4,
reverb=0.1, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, pan=0.0, lowpass=250,
sidechain=0.5, humanize=0.15)
bass = score.part("bass", instrument="808_bass",
volume=0.55, pan=0.0,
sidechain=0.5)
for sym in ["Fm", "Db", "Eb", "Fm"] * 2:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -624,18 +599,15 @@ def gospel_shuffle():
score = Score("4/4", bpm=108)
score.drums("gospel", repeats=8, fill="buildup", fill_every=8)
organ = score.part("organ", synth="fm", envelope="organ",
organ = score.part("organ", instrument="organ",
volume=0.3, pan=-0.3,
reverb=0.45, reverb_decay=2.0, reverb_type="cathedral",
detune=8, humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="pluck",
reverb=0.45, reverb_decay=2.0, reverb_type="cathedral")
lead = score.part("lead", instrument="flute",
volume=0.4, pan=0.3,
delay=0.2, delay_time=0.278, delay_feedback=0.3,
reverb=0.2, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.45, pan=0.0, lowpass=500,
humanize=0.15)
reverb=0.2, reverb_type="plate")
bass = score.part("bass", instrument="upright_bass",
volume=0.45, pan=0.0, lowpass=500)
for sym in ["C", "Am", "F", "G"] * 2:
organ.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -691,20 +663,18 @@ def dub_delay_madness():
volume=0.15, pan=-0.4,
reverb=0.7, reverb_decay=3.0, reverb_type="cathedral",
lowpass=1200, detune=8, humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, pan=0.0, lowpass=350, lowpass_q=1.5,
humanize=0.15)
bass = score.part("bass", instrument="bass_guitar",
volume=0.6, pan=0.0, lowpass=350, lowpass_q=1.5)
siren = score.part("siren", synth="pwm_slow", envelope="pad",
volume=0.12, pan=0.5,
reverb=0.8, reverb_decay=4.0, reverb_type="cathedral",
delay=0.4, delay_time=0.88, delay_feedback=0.6,
lowpass=900, detune=10)
# Melodica stabs — sparse, lots of delay
melodica = score.part("melodica", synth="triangle", envelope="pluck",
melodica = score.part("melodica", instrument="flute",
volume=0.35, pan=0.3,
delay=0.6, delay_time=0.66, delay_feedback=0.55,
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral",
humanize=0.2)
reverb=0.5, reverb_decay=2.5, reverb_type="cathedral")
for sym in ["Em", "Em", "Am", "Am", "Em", "Em", "Bm", "Em"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -741,19 +711,16 @@ def drum_and_bass():
score = Score("4/4", bpm=174)
score.drums("drum and bass", repeats=8, fill="buildup", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
pads = score.part("pads", instrument="synth_pad",
volume=0.25, pan=-0.3,
reverb=0.5, reverb_decay=2.5, reverb_type="plate",
lowpass=4000, detune=10, spread=0.6,
humanize=0.2)
lead = score.part("lead", synth="triangle", envelope="strings",
lowpass=4000)
lead = score.part("lead", instrument="flute",
volume=0.4, pan=0.3,
delay=0.3, delay_time=0.172, delay_feedback=0.4,
reverb=0.25, reverb_type="plate",
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.55, pan=0.0, lowpass=300,
humanize=0.15)
reverb=0.25, reverb_type="plate")
bass = score.part("bass", instrument="808_bass",
volume=0.55, pan=0.0)
for sym in ["Am", "F", "C", "G"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -787,25 +754,24 @@ def drake_vibes():
score = Score("4/4", bpm=68)
score.drums("trap", repeats=8, fill="trap", fill_every=8)
pads = score.part("pads", synth="supersaw", envelope="pad",
pads = score.part("pads", instrument="synth_pad",
volume=0.2, pan=-0.25,
reverb=0.5, reverb_decay=3.0, reverb_type="cathedral",
lowpass=2500, detune=12, spread=0.6,
sidechain=0.4, humanize=0.2)
bells = score.part("bells", synth="fm", envelope="bell",
lowpass=2500,
sidechain=0.4)
bells = score.part("bells", instrument="vibraphone",
volume=0.3, pan=0.4,
reverb=0.4, reverb_decay=2.0, reverb_type="plate",
delay=0.25, delay_time=0.44, delay_feedback=0.35,
humanize=0.2)
delay=0.25, delay_time=0.44, delay_feedback=0.35)
lead = score.part("lead", synth="pwm_slow", envelope="strings",
volume=0.35, pan=-0.2,
reverb=0.3, reverb_type="cathedral", lowpass=2000,
delay=0.2, delay_time=0.88, delay_feedback=0.3,
humanize=0.2)
bass = score.part("bass", synth="sine", envelope="pluck",
volume=0.6, pan=0.0, lowpass=200, lowpass_q=1.8,
bass = score.part("bass", instrument="808_bass",
volume=0.6, pan=0.0,
distortion=0.4, distortion_drive=2.0,
sidechain=0.3, humanize=0.15)
sidechain=0.3)
for sym in ["Ebm", "B", "Gb", "Db"] * 2:
pads.add(Chord.from_symbol(sym), Duration.WHOLE)
@@ -858,14 +824,14 @@ def neon_grid():
score._drum_pattern_beats = max(score._drum_pattern_beats, 32.0)
sky = score.part(
"sky", synth="supersaw", envelope="pad",
"sky", instrument="synth_pad",
volume=0.18, pan=0.0,
detune=30, spread=1.0,
reverb=0.6, reverb_decay=3.5,
chorus=0.3, sidechain=0.5,
)
acid_l = score.part(
"acid_l", synth="saw", envelope="pad",
"acid_l", instrument="acid_bass",
volume=0.35, pan=-0.7,
legato=True, glide=0.025,
distortion=0.9, distortion_drive=12.0,
@@ -875,7 +841,7 @@ def neon_grid():
acid_l.lfo("lowpass", rate=0.5, min=400, max=3000, bars=8, shape="sine")
acid_r = score.part(
"acid_r", synth="saw", envelope="pad",
"acid_r", instrument="acid_bass",
volume=0.3, pan=0.7,
legato=True, glide=0.02,
distortion=0.85, distortion_drive=10.0,
@@ -885,7 +851,7 @@ def neon_grid():
acid_r.lfo("lowpass", rate=0.33, min=500, max=2500, bars=8, shape="triangle")
sub = score.part(
"sub", synth="sine", envelope="pluck",
"sub", instrument="808_bass",
volume=0.55, pan=0.0,
lowpass=160, sidechain=0.85, sidechain_release=0.08,
)
@@ -896,12 +862,12 @@ def neon_grid():
detune=15, spread=0.7,
)
bell_l = score.part(
"bell_l", synth="fm", envelope="bell",
"bell_l", instrument="vibraphone",
volume=0.1, pan=-1.0,
reverb=0.7, reverb_decay=3.0,
)
bell_r = score.part(
"bell_r", synth="fm", envelope="bell",
"bell_r", instrument="vibraphone",
volume=0.1, pan=1.0,
reverb=0.7, reverb_decay=3.0,
delay=0.2, delay_time=0.8, delay_feedback=0.4,
@@ -1026,14 +992,14 @@ def dance_party():
score._drum_hits.append(_Hit(DrumSound.KICK, bar * 4.0 + beat, 120))
score._drum_pattern_beats = max(score._drum_pattern_beats, 32.0)
bass = score.part("bass", synth="square", envelope="pluck",
bass = score.part("bass", instrument="synth_bass",
volume=0.45, lowpass=500, lowpass_q=1.3,
sidechain=0.75, sidechain_release=0.12)
sparkle = score.part("sparkle", synth="fm", envelope="bell",
sparkle = score.part("sparkle", instrument="vibraphone",
volume=0.3, pan=0.4, reverb=0.3, reverb_decay=1.5,
delay=0.2, delay_time=0.234, delay_feedback=0.3)
chords_part = score.part("chords", synth="supersaw", envelope="pad",
volume=0.2, detune=12, spread=0.7,
chords_part = score.part("chords", instrument="synth_pad",
volume=0.2,
reverb=0.4, reverb_type="plate", sidechain=0.7)
fun = score.part("fun", synth="square", envelope="staccato",
volume=0.2, pan=-0.5, delay=0.15, delay_time=0.117,
@@ -1080,14 +1046,13 @@ def temple_bell():
score = Score("4/4", bpm=65)
score.drums("bolero", repeats=8)
koto = score.part("koto", synth="triangle", envelope="pluck",
koto = score.part("koto", instrument="koto",
volume=0.45, pan=0.2,
reverb=0.5, reverb_type="taj_mahal",
humanize=0.3)
reverb=0.5, reverb_type="taj_mahal")
drone = score.part("drone", synth="sine", envelope="pad",
volume=0.15, reverb=0.6, reverb_type="taj_mahal",
chorus=0.15, chorus_rate=0.2)
bell = score.part("bell", synth="fm", envelope="bell",
bell = score.part("bell", instrument="vibraphone",
volume=0.1, pan=-0.6,
reverb=0.8, reverb_type="taj_mahal")
@@ -1235,6 +1200,357 @@ def greensleeves():
play_song(score, "Greensleeves — Renaissance Lute (Meantone, A=415)")
def tabla_solo_yaman():
"""Tabla solo with tanpura drone, sitar, and Raga Yaman — 22-shruti tuning."""
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=160, system=shruti)
h = _Hit
NA = DrumSound.TABLA_NA
TI = DrumSound.TABLA_TIN
GE = DrumSound.TABLA_GE
DH = DrumSound.TABLA_DHA
TT = DrumSound.TABLA_TIT
KE = DrumSound.TABLA_KE
GB = DrumSound.TABLA_GE_BEND
# Tanpura drone — Sa + Pa
tanpura_sa = score.part("tanpura_sa", synth="strings_synth", envelope="pad",
detune=3, lowpass=1000, volume=0.18,
reverb=0.5, reverb_type="taj_mahal")
tanpura_pa = score.part("tanpura_pa", synth="strings_synth", envelope="pad",
detune=3, lowpass=1400, volume=0.14,
reverb=0.5, reverb_type="taj_mahal")
sa3 = Tone("Sa", octave=3, system=shruti)
pa3 = Tone("Pa", octave=3, system=shruti)
for _ in range(16):
tanpura_sa.add(sa3, Duration.WHOLE)
tanpura_pa.add(pa3, Duration.WHOLE)
# Quiet sitar — Raga Yaman (Kalyan thaat)
sitar = score.part("sitar", instrument="sitar", volume=0.12,
reverb=0.4, reverb_type="taj_mahal")
ts = TonedScale(system=shruti, tonic=Tone("Sa", octave=4, system=shruti))
y = list(ts["kalyan"].tones)
S, R, G, M, P, D, N, S2 = y
sitar.rest(Duration.WHOLE)
sitar.rest(Duration.WHOLE)
for tone, dur, vel in [
(S, 3.0, 55), (R, 1.0, 50), (G, 3.0, 58), (R, 1.0, 48),
(S, 4.0, 55), (G, 1.0, 50), (M, 1.0, 52), (P, 3.0, 58),
(M, 1.0, 48), (G, 1.0, 50), (R, 1.0, 48), (S, 4.0, 55),
(P, 2.0, 52), (D, 1.0, 55), (N, 2.0, 58), (S2, 3.0, 60),
(N, 1.0, 52), (D, 1.0, 50), (P, 1.0, 52), (G, 1.0, 48),
(R, 1.0, 48), (S, 4.0, 55),
]:
sitar.add(tone, dur, velocity=vel)
# 4 bars drone intro (silence for drums)
silence = Pattern(name="silence", time_signature="4/4", beats=16.0, hits=[])
score.add_pattern(silence, repeats=1)
# Gentle opening
p1 = Pattern(name="gentle", time_signature="4/4", beats=8.0, hits=[
h(DH, 0.0, 80), h(NA, 2.0, 60),
h(DH, 4.0, 85), h(NA, 5.0, 55), h(NA, 6.0, 60), h(DH, 7.0, 80),
])
# Building with ghost notes
p2 = Pattern(name="build", time_signature="4/4", beats=16.0, hits=[
h(DH, 0.0, 95), h(TT, 0.5, 35), h(NA, 1.0, 70), h(TT, 1.5, 30),
h(NA, 2.0, 65), h(DH, 3.0, 90),
h(DH, 4.0, 100), h(TT, 4.25, 35), h(TT, 4.5, 40), h(NA, 5.0, 75),
h(TT, 5.5, 35), h(NA, 6.0, 70), h(TT, 6.5, 30), h(DH, 7.0, 95),
h(DH, 8.0, 95), h(TI, 9.0, 70), h(TI, 10.0, 72), h(NA, 11.0, 80),
h(TT, 11.25, 40), h(TT, 11.5, 42), h(KE, 11.75, 45),
h(TT, 12.0, 50), h(TT, 12.25, 55), h(KE, 12.5, 58), h(NA, 12.75, 70),
h(DH, 13.0, 100), h(TT, 13.25, 40), h(TT, 13.5, 45), h(KE, 13.75, 50),
h(NA, 14.0, 75), h(KE, 14.25, 50), h(DH, 14.5, 85), h(NA, 14.75, 70),
h(DH, 15.0, 110), h(GB, 15.5, 100),
])
# Full intensity
p3 = Pattern(name="fire", time_signature="4/4", beats=16.0, hits=[
h(TT, 0.0, 50), h(TT, 0.125, 35), h(TT, 0.25, 45), h(KE, 0.5, 55),
h(NA, 0.75, 85),
h(DH, 1.0, 115), h(TT, 1.25, 38), h(DH, 1.5, 70), h(NA, 1.75, 60),
h(TT, 2.0, 50), h(TT, 2.125, 35), h(TT, 2.25, 48), h(KE, 2.5, 55),
h(NA, 2.75, 88),
h(DH, 3.0, 115), h(GB, 3.5, 105), h(NA, 3.75, 72),
h(NA, 4.0, 115), h(NA, 4.25, 60), h(TT, 4.5, 40), h(NA, 4.75, 105),
h(GE, 5.0, 105), h(GE, 5.25, 55), h(GB, 5.5, 95), h(GE, 5.75, 50),
h(NA, 6.0, 115), h(TT, 6.125, 30), h(TT, 6.25, 38), h(NA, 6.5, 100),
h(TT, 6.625, 32), h(TT, 6.75, 42),
h(GB, 7.0, 115), h(KE, 7.25, 52), h(GE, 7.5, 72), h(KE, 7.75, 48),
# Tihai
h(DH, 8.0, 115), h(NA, 8.25, 78), h(TT, 8.5, 52), h(KE, 8.75, 58),
h(DH, 9.0, 105),
h(DH, 9.5, 110), h(NA, 9.75, 78), h(TT, 10.0, 52), h(KE, 10.25, 58),
h(DH, 10.5, 105),
h(DH, 11.0, 120), h(NA, 11.25, 82), h(TT, 11.5, 58), h(KE, 11.75, 62),
h(DH, 12.0, 120),
# Silence... then finish
h(GB, 14.5, 120),
h(DH, 15.5, 127), h(DH, 15.75, 127),
])
score.add_pattern(p1, repeats=1)
score.add_pattern(p2, repeats=1)
score.add_pattern(p3, repeats=1)
score.set_drum_effects(reverb=0.4, reverb_type="taj_mahal")
play_song(score, "Tabla Solo — Raga Yaman (22-Shruti, Taj Mahal)")
def journey():
"""Journey — piano → orchestra → world → sitar EDM.
One reverb space (Taj Mahal), tanpura drone throughout. Piano opens
alone, cello joins, harp/oboe/flute take over with djembe, sitar
arrives over tabla, builds to an EDM section with house drums.
"""
REV = "taj_mahal"
score = Score("4/4", bpm=72)
# ── Drone — runs the entire piece ──
tanpura = score.part("tanpura", synth="strings_synth", envelope="pad",
detune=3, lowpass=1000, volume=0.12,
reverb=0.5, reverb_type=REV)
for _ in range(40):
tanpura.add("A2", Duration.WHOLE)
# ── Bars 1-8: Piano alone, then cello ──
piano = score.part("piano", instrument="piano", volume=0.35,
reverb=0.35, reverb_type=REV)
for notes in [
["A2","E3","A3","C4","E4","C4","A3","E3"],
["F2","C3","F3","A3","C4","A3","F3","C3"],
["G2","D3","G3","B3","D4","B3","G3","D3"],
["E2","B2","E3","G#3","B3","G#3","E3","B2"],
["A2","E3","A3","C4","E4","C4","A3","E3"],
["D2","A2","D3","F3","A3","F3","D3","A2"],
["E2","B2","E3","G#3","B3","G#3","E3","B2"],
["A2","E3","A3","C4","E4","A4","E4","C4"],
]:
for n in notes:
piano.add(n, Duration.EIGHTH, velocity=68)
cello = score.part("cello", instrument="cello", volume=0.2,
reverb=0.4, reverb_type=REV)
cello.rest(Duration.WHOLE)
for note, dur, vel in [
("A3", 4.0, 55), ("C4", 4.0, 58),
("B3", 2.0, 52), ("A3", 2.0, 50), ("G3", 4.0, 55),
("F3", 4.0, 52), ("E3", 4.0, 55), ("A3", 4.0, 58),
]:
cello.add(note, dur, velocity=vel)
# ── Bars 9-16: Harp + oboe + flute + djembe ──
harp = score.part("harp", instrument="harp", volume=0.28,
reverb=0.45, reverb_type=REV)
oboe = score.part("oboe", instrument="oboe", volume=0.22,
reverb=0.4, reverb_type=REV)
flute = score.part("flute", instrument="flute", volume=0.18,
reverb=0.4, reverb_type=REV)
for _ in range(8):
harp.rest(Duration.WHOLE)
for notes in [
["A3","C4","E4","A4","C5","E5","A5","E5"],
["D3","F3","A3","D4","F4","A4","D5","A4"],
["E3","G3","B3","E4","G4","B4","E5","B4"],
["A3","C4","E4","A4","E5","C5","A4","E4"],
["F3","A3","C4","F4","A4","C5","F5","C5"],
["E3","G#3","B3","E4","G#4","B4","E5","B4"],
]:
for n in notes:
harp.add(n, Duration.EIGHTH, velocity=58)
for _ in range(9):
oboe.rest(Duration.WHOLE)
for note, dur, vel in [
("E5", 1.5, 62), ("D5", 0.5, 55), ("C5", 1.0, 58),
("A4", 1.0, 62), ("G4", 1.0, 55), ("A4", 1.5, 58),
("B4", 0.5, 52), ("C5", 2.0, 62), ("A4", 4.0, 58),
]:
oboe.add(note, dur, velocity=vel)
for _ in range(10):
flute.rest(Duration.WHOLE)
for note, dur, vel in [
("A5", 2.0, 50), ("G5", 1.0, 45), ("E5", 1.0, 48),
("D5", 2.0, 50), ("C5", 1.0, 45), ("A4", 1.0, 48),
("G4", 2.0, 50), ("A4", 2.0, 52),
]:
flute.add(note, dur, velocity=vel)
# ── Bars 15-20: Sitar + tabla ──
sitar = score.part("sitar", instrument="sitar", volume=0.2,
reverb=0.35, reverb_type=REV)
for _ in range(14):
sitar.rest(Duration.WHOLE)
for note, dur, vel in [
("A4", 1.0, 70), ("Bb4", 0.5, 60), ("A4", 0.5, 65),
("C5", 1.5, 75), ("Bb4", 0.5, 60),
("D5", 1.0, 70), ("E5", 1.5, 78),
("F5", 0.5, 62), ("E5", 1.0, 70),
("D5", 0.5, 62), ("C5", 0.5, 65), ("Bb4", 0.5, 58),
("A4", 2.0, 75),
("Bb4", 0.25, 55), ("C5", 0.25, 58), ("D5", 0.25, 62),
("E5", 0.25, 68),
("F5", 0.25, 65), ("G5", 0.25, 70), ("A5", 0.5, 80),
("G5", 0.25, 62), ("F5", 0.25, 58), ("E5", 0.5, 62),
("C5", 0.5, 58), ("Bb4", 0.5, 55), ("A4", 2.0, 75),
]:
sitar.add(note, dur, velocity=vel)
# ── EDM section — sitar over house beat ──
# Solo sections: 8+8+8+12 = 36 beats = 9 bars
# Total bars before EDM: 8 piano + 6 harp + 6 djembe + 4 tabla + 9 solo = 33
edm_start = 33
pad = score.part("pad", instrument="synth_pad", volume=0.18,
reverb=0.45, reverb_type=REV,
sidechain=0.6, sidechain_release=0.15)
for _ in range(edm_start):
pad.rest(Duration.WHOLE)
for sym in ["Am", "F", "G", "Em"] * 2:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
sub = score.part("sub", instrument="808_bass", volume=0.4)
for _ in range(edm_start):
sub.rest(Duration.WHOLE)
for n in ["A1","A1","F1","F1","G1","G1","E1","E1"] * 2:
sub.add(n, Duration.HALF)
sitar2 = score.part("sitar2", instrument="sitar", volume=0.4,
reverb=0.3, reverb_type=REV)
for _ in range(edm_start + 2):
sitar2.rest(Duration.WHOLE)
for note, dur, vel in [
("A4", 0.25, 75), ("C5", 0.25, 78), ("E5", 0.5, 85),
("D5", 0.25, 72), ("C5", 0.25, 70), ("A4", 0.5, 75),
("G4", 0.25, 68), ("A4", 0.25, 72), ("C5", 0.5, 78),
("A4", 0.5, 72),
("E5", 0.5, 82), ("D5", 0.25, 72), ("C5", 0.25, 70),
("A4", 0.5, 75), ("G4", 0.5, 68), ("A4", 1.0, 78),
] * 2:
sitar2.add(note, dur, velocity=vel)
# Drums: djembe bars 9-14, tabla bars 15-20, house bars 21-28
DJB = DrumSound.DJEMBE_BASS
DJT = DrumSound.DJEMBE_TONE
DJS = DrumSound.DJEMBE_SLAP
NA = DrumSound.TABLA_NA
DH = DrumSound.TABLA_DHA
TT = DrumSound.TABLA_TIT
GB = DrumSound.TABLA_GE_BEND
silence = Pattern(name="s", time_signature="4/4", beats=32.0, hits=[])
score.add_pattern(silence, repeats=1)
p_dj = Pattern(name="dj", time_signature="4/4", beats=8.0, hits=[
_Hit(DJB, 0.0, 48), _Hit(DJT, 1.0, 40), _Hit(DJT, 1.5, 35),
_Hit(DJS, 2.0, 45), _Hit(DJT, 3.0, 40),
_Hit(DJB, 4.0, 52), _Hit(DJT, 5.0, 42), _Hit(DJT, 5.5, 38),
_Hit(DJS, 6.0, 48), _Hit(DJT, 6.5, 35), _Hit(DJS, 7.0, 45),
])
score.add_pattern(p_dj, repeats=3)
p_tab = Pattern(name="tab", time_signature="4/4", beats=8.0, hits=[
_Hit(DH, 0.0, 80), _Hit(TT, 0.5, 30), _Hit(NA, 1.0, 65),
_Hit(NA, 2.0, 60), _Hit(DH, 3.0, 80),
_Hit(DH, 4.0, 85), _Hit(TT, 4.25, 32), _Hit(TT, 4.5, 35),
_Hit(NA, 5.0, 68), _Hit(TT, 5.5, 30), _Hit(NA, 6.0, 65),
_Hit(DH, 7.0, 85),
])
score.add_pattern(p_tab, repeats=2)
# Tabla solo — everything drops out, just tabla and drone
KE = DrumSound.TABLA_KE
TI = DrumSound.TABLA_TIN
GE = DrumSound.TABLA_GE
T3 = 1.0 / 12.0 # 32nd triplet
T9 = 1.0 / 9.0 # ninth note
# Part 1: whisper — space, breath, single hits
p_solo1 = Pattern(name="solo1", time_signature="4/4", beats=8.0, hits=[
_Hit(DH, 0.0, 78),
_Hit(NA, 2.0, 55),
_Hit(DH, 4.0, 82),
_Hit(TT, 5.0, 30), _Hit(NA, 5.5, 52), _Hit(TT, 6.0, 28),
_Hit(DH, 7.0, 78),
])
score.add_pattern(p_solo1, repeats=1)
# Part 2: ghosts emerge — 16th note ghost fills between accents
p_solo2 = Pattern(name="solo2", time_signature="4/4", beats=8.0, hits=[
_Hit(DH, 0.0, 92), _Hit(TT, 0.25, 32), _Hit(TT, 0.5, 35),
_Hit(NA, 1.0, 68), _Hit(TT, 1.25, 30), _Hit(TT, 1.5, 28),
_Hit(NA, 2.0, 62), _Hit(TT, 2.5, 32), _Hit(DH, 3.0, 88),
_Hit(TT, 3.25, 35), _Hit(TT, 3.5, 38),
_Hit(DH, 4.0, 95), _Hit(TT, 4.25, 38), _Hit(TT, 4.5, 42),
_Hit(NA, 5.0, 72), _Hit(TT, 5.25, 32), _Hit(TT, 5.5, 35),
_Hit(KE, 5.75, 40),
_Hit(NA, 6.0, 68), _Hit(TT, 6.25, 35), _Hit(KE, 6.5, 38),
_Hit(DH, 7.0, 95), _Hit(GB, 7.5, 88),
])
score.add_pattern(p_solo2, repeats=1)
# Part 3: call and response — dayan vs bayan, dynamics wide open
p_solo3 = Pattern(name="solo3", time_signature="4/4", beats=8.0, hits=[
# Dayan speaks
_Hit(NA, 0.0, 110), _Hit(NA, 0.25, 55), _Hit(TT, 0.5, 38),
_Hit(NA, 0.75, 100),
# Bayan answers
_Hit(GE, 1.0, 100), _Hit(GE, 1.25, 50), _Hit(GB, 1.5, 90),
_Hit(GE, 1.75, 45),
# Dayan louder
_Hit(NA, 2.0, 115), _Hit(TT, 2.125, 30), _Hit(TT, 2.25, 35),
_Hit(NA, 2.5, 105), _Hit(TT, 2.625, 32), _Hit(TT, 2.75, 38),
# Bayan louder
_Hit(GB, 3.0, 112), _Hit(KE, 3.25, 50), _Hit(GE, 3.5, 68),
_Hit(KE, 3.75, 45),
# Together — explosion
_Hit(DH, 4.0, 120), _Hit(TT, 4.25, 45), _Hit(TT, 4.5, 48),
_Hit(DH, 5.0, 115), _Hit(TT, 5.25, 42), _Hit(NA, 5.5, 72),
# 9-tuplet — the weird one, breaks the grid
*[_Hit(TT if i % 2 == 0 else KE, 6.0 + i * T9, 38 + i * 5)
for i in range(9)],
_Hit(DH, 7.0, 118),
])
score.add_pattern(p_solo3, repeats=1)
# Part 4: blazing — 32nd triplets, cascades, tihai finale
p_solo4 = Pattern(name="solo4", time_signature="4/4", beats=12.0, hits=[
# 32nd triplet opening flourish
*[_Hit(TT, 0.0 + i * T3, 40 + i * 2) for i in range(12)],
_Hit(DH, 1.0, 120), _Hit(GB, 1.5, 108),
# Rapid alternating hands
_Hit(NA, 2.0, 110), _Hit(KE, 2.125, 45), _Hit(NA, 2.25, 105),
_Hit(KE, 2.375, 48), _Hit(NA, 2.5, 108), _Hit(KE, 2.625, 50),
_Hit(NA, 2.75, 112),
_Hit(DH, 3.0, 118),
# Another 32nd triplet burst — longer, crescendo
*[_Hit(TT, 3.5 + i * T3, 32 + i * 4) for i in range(18)],
_Hit(DH, 5.0, 122), _Hit(DH, 5.25, 118), _Hit(GB, 5.5, 115),
# 9 against 4 polyrhythm moment
_Hit(GE, 6.0, 88), _Hit(GE, 7.0, 85),
*[_Hit(NA if i % 3 == 0 else TT, 6.0 + i * (2.0 / 9.0), 42 + (i % 3) * 15)
for i in range(9)],
# Grand tihai — 3x pattern, each louder
_Hit(DH, 8.0, 108), _Hit(NA, 8.25, 72), _Hit(TT, 8.5, 48),
_Hit(KE, 8.75, 52), _Hit(DH, 9.0, 100),
_Hit(DH, 9.25, 112), _Hit(NA, 9.5, 78), _Hit(TT, 9.75, 52),
_Hit(KE, 10.0, 58), _Hit(DH, 10.25, 108),
_Hit(DH, 10.5, 120), _Hit(NA, 10.75, 85), _Hit(TT, 11.0, 58),
_Hit(KE, 11.25, 62), _Hit(DH, 11.5, 127),
# Silence.....
# SLAM
_Hit(GB, 11.875, 127),
])
score.add_pattern(p_solo4, repeats=1)
score.drums("house", repeats=8)
score.set_drum_effects(reverb=0.3, reverb_type=REV)
play_song(score, "Journey — Piano → World → Sitar EDM (Taj Mahal)")
SONGS = {
"1": ("Bossa Nova in A minor", bossa_nova_girl),
"2": ("Bebop in Bb major", bebop_in_bb),
@@ -1258,6 +1574,8 @@ SONGS = {
"20": ("Temple Bell (Japanese)", temple_bell),
"21": ("Cinematic Showcase (Orchestral)", cinematic_showcase),
"22": ("Greensleeves (Renaissance Lute)", greensleeves),
"23": ("Tabla Solo (Raga Yaman)", tabla_solo_yaman),
"24": ("Journey (Western → World → Indian)", journey),
}
if __name__ == "__main__":
@@ -1271,7 +1589,7 @@ if __name__ == "__main__":
print(f" {key:>2}. {name}")
print()
choice = input(" Pick a song (1-22, or 'all'): ").strip()
choice = input(" Pick a song (1-24, or 'all'): ").strip()
print()
if choice == "all":
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.34.0"
version = "0.35.1"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
@@ -21,7 +21,6 @@ classifiers = [
"Programming Language :: Python :: 3.13",
]
dependencies = [
"pytuning",
"numeral",
"sounddevice",
"scipy",
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.34.0"
__version__ = "0.35.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET
+159 -36
View File
@@ -1,4 +1,4 @@
from pytuning import scales
import math
REFERENCE_A = 440
@@ -6,41 +6,59 @@ REFERENCE_A = 440
# Scientific pitch notation changes octave at C, not A, so this offset
# is needed for all octave arithmetic.
C_INDEX = 3
def _create_just_intonation_scale(n):
"""5-limit just intonation ratios for 12-tone systems.
These are the pure frequency ratios derived from the harmonic series
the way intervals "want" to sound before equal temperament imposed
compromise. Each ratio is mathematically exact: a perfect fifth is
exactly 3/2, a major third is exactly 5/4.
For non-12 systems, falls back to equal temperament.
# ── Temperament scale generators (replaces pytuning dependency) ──────────
def _create_edo_scale(n):
"""N-tone equal division of the octave. Each step = 2^(1/n)."""
return [2 ** (i / n) for i in range(n + 1)]
def _create_pythagorean_scale(n):
"""Pythagorean tuning — spiral of pure fifths (3/2 ratio).
Each tone is generated by stacking perfect fifths and octave-reducing.
"""
from fractions import Fraction
ratios = [1.0]
for i in range(1, n):
# Stack fifths: (3/2)^i, then reduce to within one octave
r = (3 / 2) ** i
while r >= 2.0:
r /= 2.0
ratios.append(r)
ratios.sort()
ratios.append(2.0)
return ratios
def _create_quarter_comma_meantone_scale(n):
"""Quarter-comma meantone — pure major thirds (5/4), tempered fifths.
The fifth is narrowed by 1/4 of a syntonic comma so that four
fifths make a pure major third (5/4). The meantone fifth =
5^(1/4) 1.49535.
"""
fifth = 5 ** 0.25 # meantone fifth ≈ 1.49535 (vs 1.5 pure)
ratios = [1.0]
for i in range(1, n):
r = fifth ** i
while r >= 2.0:
r /= 2.0
ratios.append(r)
ratios.sort()
ratios.append(2.0)
return ratios
def _create_just_intonation_scale(n):
"""5-limit just intonation ratios for 12-tone systems."""
if n != 12:
return scales.create_edo_scale(n)
# Standard 5-limit JI ratios (A-based: A=1/1)
ratios = [
Fraction(1, 1), # A — unison
Fraction(16, 15), # A# — minor second
Fraction(9, 8), # B — major second
Fraction(6, 5), # C — minor third
Fraction(5, 4), # C# — major third
Fraction(4, 3), # D — perfect fourth
Fraction(45, 32), # D# — augmented fourth
Fraction(3, 2), # E — perfect fifth
Fraction(8, 5), # F — minor sixth
Fraction(5, 3), # F# — major sixth
Fraction(9, 5), # G — minor seventh
Fraction(15, 8), # G# — major seventh
Fraction(2, 1), # A — octave
]
return [float(r) for r in ratios]
return _create_edo_scale(n)
return [1, 16/15, 9/8, 6/5, 5/4, 4/3, 45/32, 3/2, 8/5, 5/3, 9/5, 15/8, 2.0]
TEMPERAMENTS = {
"equal": scales.create_edo_scale,
"pythagorean": scales.create_pythagorean_scale,
"meantone": scales.create_quarter_comma_meantone_scale,
"equal": _create_edo_scale,
"pythagorean": _create_pythagorean_scale,
"meantone": _create_quarter_comma_meantone_scale,
"just": _create_just_intonation_scale,
}
@@ -295,8 +313,51 @@ DEGREES_SHRUTI = [
("shadja", ()), # Sa (octave)
]
# 22-shruti frequency ratios — 5-limit just intonation.
# These are the REAL shruti intervals, NOT 22-TET approximations.
# Based on the traditional Pythagorean/harmonic ratios from Indian
# musicological treatises (Natya Shastra, Sangita Ratnakara).
#
# Ordered from Dha (A=1.0) to match our system indexing.
# Sa is at index 5 (ratio ≈ 6/5 from Dha).
from fractions import Fraction
_SHRUTI_RATIOS_FROM_SA = [
Fraction(1, 1), # 0: Sa — 1/1
Fraction(256, 243), # 1: atikomal Re — Pythagorean limma
Fraction(16, 15), # 2: komal Re — JI minor second
Fraction(10, 9), # 3: shuddha Re — minor whole tone
Fraction(9, 8), # 4: Re — major whole tone
Fraction(32, 27), # 5: atikomal Ga — Pythagorean minor 3rd
Fraction(6, 5), # 6: komal Ga — JI minor 3rd
Fraction(5, 4), # 7: Ga — JI major 3rd
Fraction(81, 64), # 8: tivra Ga — Pythagorean major 3rd
Fraction(4, 3), # 9: Ma — perfect 4th
Fraction(27, 20), # 10: ekashruti Ma
Fraction(45, 32), # 11: tivra Ma — augmented 4th
Fraction(729, 512), # 12: atitivra Ma — Pythagorean tritone
Fraction(3, 2), # 13: Pa — perfect 5th
Fraction(128, 81), # 14: atikomal Dha — Pythagorean minor 6th
Fraction(8, 5), # 15: komal Dha — JI minor 6th
Fraction(5, 3), # 16: shuddha Dha
Fraction(27, 16), # 17: Dha — Pythagorean major 6th
Fraction(16, 9), # 18: komal Ni — Pythagorean minor 7th
Fraction(9, 5), # 19: shuddha Ni — JI minor 7th
Fraction(15, 8), # 20: Ni — JI major 7th
Fraction(243, 128), # 21: tivra Ni — Pythagorean major 7th
]
# Rotate to start from Dha (index 17 in the Sa-based list above).
# Dha = 27/16 from Sa. We divide all ratios by 27/16 and wrap.
_dha_ratio = _SHRUTI_RATIOS_FROM_SA[17]
SHRUTI_RATIOS = []
for i in range(22):
sa_idx = (i + 17) % 22 # rotate: Dha=0, komalNi=1, ..., Sa=5, ...
r = _SHRUTI_RATIOS_FROM_SA[sa_idx] / _dha_ratio
if r < 1:
r *= 2 # wrap into the same octave
SHRUTI_RATIOS.append(float(r))
# 22-shruti thaat scales with proper microtonal intervals.
# Each interval is counted in shrutis (22-TET steps).
# Compare to the 12-TET approximations in INDIAN_SCALES which lose
# the distinction between 2-shruti and 3-shruti steps.
SHRUTI_SCALES = {
@@ -341,13 +402,75 @@ SHRUTI_SCALES = {
],
}
# ── 24-TET Arabic maqam system ─────────────────────────────────────────────
# Arabic maqam uses quarter-tones (half-flat, half-sharp). 24-TET captures
# these intervals exactly. Each step = 50 cents (vs 100 in 12-TET).
# The half-flat (♭½) is the defining sound of Arabic music — it's what
# makes maqam Rast and Bayati sound distinctly Middle Eastern.
# ── Arabic maqam system ───────────────────────────────────────────────────
# Arabic maqam uses quarter-tones with specific JI ratios, NOT equal
# 24-TET divisions. The neutral intervals (quarter-flat, quarter-sharp)
# are based on ratios involving the 11th partial, as theorized by
# Zalzal (8th century Baghdad). The quarter-flat E in Rast is 27/22,
# not simply halfway between Eb and E.
#
# 24 positions per octave, but with unequal JI spacing.
# Ordered from La (=A) to match Western index positions.
# Maqam JI ratios from Do (C). Based on traditional practice:
# - Standard JI intervals for the 12 chromatic positions
# - Zalzalian ratios (11-limit) for the quarter-tone positions
_MAQAM_RATIOS_FROM_DO = [
Fraction(1, 1), # 0: Do — unison
Fraction(33, 32), # 1: Do↑ — quarter-sharp (~53¢, 33rd harmonic)
Fraction(16, 15), # 2: Reb — JI minor 2nd
Fraction(12, 11), # 3: Re↓ — Zalzalian neutral 2nd (~151¢)
Fraction(9, 8), # 4: Re — major whole tone
Fraction(11, 9) * Fraction(1, 1), # 5: Re↑ — undecimal (~347¢... too high)
Fraction(6, 5), # 6: Mib — JI minor 3rd
Fraction(27, 22), # 7: Mi↓ — Zalzalian neutral 3rd (~355¢) THE Rast note
Fraction(5, 4), # 8: Mi — JI major 3rd
Fraction(4, 3), # 9: Fa — perfect 4th
Fraction(11, 8), # 10: Fa↑ — undecimal tritone (~551¢)
Fraction(45, 32), # 11: Fa# — augmented 4th
Fraction(22, 15), # 12: Sol↓ — neutral (~663¢... adjusted)
Fraction(3, 2), # 13: Sol — perfect 5th
Fraction(99, 64), # 14: Sol↑ — quarter-sharp 5th
Fraction(8, 5), # 15: Lab — JI minor 6th
Fraction(18, 11), # 16: La↓ — Zalzalian neutral 6th
Fraction(5, 3), # 17: La — JI major 6th
Fraction(27, 16), # 18: La↑/Sib↓ — Pythagorean major 6th
Fraction(16, 9), # 19: Sib — Pythagorean minor 7th
Fraction(11, 6), # 20: Si↓ — undecimal neutral 7th
Fraction(15, 8), # 21: Si — JI major 7th
Fraction(243, 128), # 22: Si↑ — Pythagorean major 7th
Fraction(2, 1) * Fraction(33, 64), # 23: near-octave (~1049¢)
]
# Ratios directly from La (A=1/1), each position defined explicitly.
# Standard JI intervals for chromatic positions, Zalzalian (11-limit)
# ratios for the quarter-tone positions.
MAQAM_RATIOS = [
1.0, # 0: La — A (unison)
float(Fraction(256, 243)), # 1: La↑ — Pythagorean comma up
float(Fraction(16, 15)), # 2: Sib — Bb (JI minor 2nd)
float(Fraction(12, 11)), # 3: Si↓ — B quarter-flat (Zalzalian)
float(Fraction(9, 8)), # 4: Si — B (major 2nd)
float(Fraction(6, 5)), # 5: Do — C (minor 3rd from A)
float(Fraction(11, 9)), # 6: Do↑ — C quarter-sharp (undecimal)
float(Fraction(5, 4)), # 7: Reb — Db (major 3rd from A...= JI Db)
float(Fraction(9, 7)), # 8: Re↓ — D quarter-flat (septimal)
float(Fraction(4, 3)), # 9: Re — D (perfect 4th from A)
float(Fraction(11, 8)), # 10: Re↑ — D quarter-sharp (undecimal)
float(Fraction(45, 32)), # 11: Mib — Eb (augmented 4th from A)
float(Fraction(6, 5) * Fraction(27, 22)), # 12: Mi↓ — E quarter-flat (Do × 27/22)
float(Fraction(3, 2)), # 13: Mi — E (perfect 5th from A)
float(Fraction(8, 5)), # 14: Fa — F (minor 6th from A)
float(Fraction(18, 11)), # 15: Fa↑ — F quarter-sharp (Zalzalian)
float(Fraction(5, 3)), # 16: Fa# — F# (major 6th from A)
float(Fraction(27, 16)), # 17: Sol↓ — G quarter-flat
float(Fraction(16, 9)), # 18: Sol — G (minor 7th from A)
float(Fraction(11, 6)), # 19: Sol↑ — G quarter-sharp (undecimal)
float(Fraction(15, 8)), # 20: Lab — Ab (major 7th from A)
float(Fraction(27, 14)), # 21: La↓ — A quarter-flat (septimal)
float(Fraction(243, 128)), # 22: La½b — near-octave
float(Fraction(2, 1) * Fraction(256, 257)), # 23: La♮ — near-octave
]
TONES_ARABIC_24 = [
("La",), # 0 — A
("La↑",), # 1 — A quarter-sharp
+318 -10
View File
@@ -2,11 +2,24 @@ from enum import Enum
import time
import numpy
import scipy.signal
from .tones import Tone
class _LazyModule:
"""Lazy import wrapper — module loaded on first attribute access."""
def __init__(self, name):
self._name = name
self._mod = None
def __getattr__(self, attr):
if self._mod is None:
import importlib
self._mod = importlib.import_module(self._name)
return getattr(self._mod, attr)
scipy = type('scipy', (), {'signal': _LazyModule('scipy.signal')})()
def _get_sd():
"""Lazy import sounddevice — only needed for actual audio playback."""
import sounddevice as sd
@@ -257,7 +270,7 @@ def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Delayed vibrato: ramps in over ~200ms, like a real bow
vib_rate = 5.2 + rng.uniform(-0.3, 0.3) # slight randomness per note
vib_depth = hz * 0.003 # ~5 cents
vib_depth = hz * 0.001 # subtle
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0) # ramp over 200ms
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
@@ -442,7 +455,7 @@ def flute_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Vibrato — develops after ~200ms
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0)
vib = hz * 0.003 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
vib = hz * 0.0008 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
# Tube resonance — mostly fundamental + weak odd harmonics
wave = numpy.sin(2 * numpy.pi * (hz + vib) * t) * 0.7
@@ -481,7 +494,7 @@ def trumpet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Vibrato
vib_onset = numpy.clip(t / 0.15, 0.0, 1.0)
vib = hz * 0.004 * vib_onset * numpy.sin(2 * numpy.pi * 5.5 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.5 * t)
# Lip buzz — additive with brass spectral shape
# Trumpet has strong even AND odd harmonics (unlike clarinet)
@@ -523,7 +536,7 @@ def clarinet_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.3, 0.0, 1.0)
vib = hz * 0.002 * vib_onset * numpy.sin(2 * numpy.pi * 4.5 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 4.5 * t)
# Cylindrical bore: odd harmonics dominate
wave = numpy.zeros(n_samples, dtype=numpy.float64)
@@ -592,7 +605,7 @@ def oboe_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
vib_onset = numpy.clip(t / 0.2, 0.0, 1.0)
vib = hz * 0.004 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
vib = hz * 0.001 * vib_onset * numpy.sin(2 * numpy.pi * 5.0 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(18, int((SAMPLE_RATE / 2) / hz))
@@ -668,7 +681,7 @@ def cello_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
# Delayed vibrato
vib_rate = 5.0 + rng.uniform(-0.3, 0.3)
vib_depth = hz * 0.004
vib_depth = hz * 0.001
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vibrato = vib_depth * vib_onset * numpy.sin(2 * numpy.pi * vib_rate * t)
@@ -787,6 +800,193 @@ def upright_bass_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return (peak * out).astype(numpy.int16)
def timpani_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Timpani — large kettle drum with definite pitch.
The copper kettle creates a tuned resonance with inharmonic
overtones. The head modes are at ratios 1.0, 1.5, 1.99, 2.44
(not integer multiples like strings). The felt mallet gives a
soft attack with a deep, booming body.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
# Timpani head modes — inharmonic but definite pitch
# Mode ratios from vibrating circular membrane physics
wave = numpy.sin(2 * numpy.pi * hz * t) * 0.8
wave += numpy.sin(2 * numpy.pi * hz * 1.5 * t) * 0.35 * numpy.exp(-6 * t)
wave += numpy.sin(2 * numpy.pi * hz * 1.99 * t) * 0.2 * numpy.exp(-10 * t)
wave += numpy.sin(2 * numpy.pi * hz * 2.44 * t) * 0.1 * numpy.exp(-15 * t)
# Two-stage decay: initial thump fades fast, fundamental rings
decay = numpy.where(t < 0.15,
numpy.exp(-4 * t),
numpy.exp(-4 * 0.15) * numpy.exp(-1.5 * (t - 0.15)))
wave *= decay
# Felt mallet impact — warm, not sharp
mallet_len = min(int(SAMPLE_RATE * 0.02), n_samples)
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
mallet = rng.uniform(-0.3, 0.3, mallet_len)
mallet *= numpy.exp(-numpy.linspace(0, 8, mallet_len))
wave[:mallet_len] += mallet
# Copper kettle resonance — boosts low-mids
import scipy.signal as _sig
lo, hi = max(20, int(hz * 0.7)), min(SAMPLE_RATE // 2 - 1, int(hz * 2))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
kettle = _sig.lfilter(bp, ap, wave) * 0.3
wave += kettle
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).astype(numpy.int16)
def saxophone_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Saxophone — single reed through a conical brass bore.
The conical bore produces all harmonics (like oboe), but the
brass body and larger mouthpiece give a warmer, fatter, more
vocal quality. The reed adds a slight buzz. Saxophone is
between clarinet (odd harmonics) and oboe (nasal even+odd)
it has everything, with a strong fundamental and rich mids.
"""
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Vibrato — develops after ~250ms, wider than flute
vib_onset = numpy.clip(t / 0.25, 0.0, 1.0)
vib = hz * 0.0012 * vib_onset * numpy.sin(2 * numpy.pi * 5.2 * t)
wave = numpy.zeros(n_samples, dtype=numpy.float64)
n_harmonics = min(20, int((SAMPLE_RATE / 2) / hz))
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= SAMPLE_RATE / 2:
break
# Sax spectral shape: strong fundamental, broad mid peak (3-6),
# slower rolloff than oboe (brass body carries harmonics further)
if n == 1:
amp = 1.0
elif n <= 3:
amp = 0.6
elif n <= 6:
amp = 0.4 * numpy.exp(-0.1 * (n - 4) ** 2)
else:
amp = 0.2 / n
phase = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n + vib * n) * t + phase)
# Reed buzz — more present than oboe but still warm
reed = rng.normal(0, 0.07, n_samples)
# Bandpass the reed noise around 1-3kHz (the "honk" range)
import scipy.signal as _sig
reed_lo = max(20, int(hz * 2))
reed_hi = min(SAMPLE_RATE // 2 - 1, int(hz * 6))
if reed_lo < reed_hi:
br, ar = _sig.butter(2, [reed_lo, reed_hi], btype='band', fs=SAMPLE_RATE)
reed = _sig.lfilter(br, ar, reed).astype(numpy.float64) * 2.0
wave += reed
# Brass body warmth — low-mid boost
center = min(1500, hz * 4)
bw = 500
lo = max(20, int(center - bw))
hi = min(SAMPLE_RATE // 2 - 1, int(center + bw))
if lo < hi:
bp, ap = _sig.butter(2, [lo, hi], btype='band', fs=SAMPLE_RATE)
body = _sig.lfilter(bp, ap, wave) * 0.2
wave += body
mx = numpy.abs(wave).max()
if mx > 0:
wave /= mx
return (peak * wave).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"):
"""Granular synthesis — clouds of tiny sound grains.
Chops a source waveform into overlapping micro-grains (10-200ms),
each independently windowed and optionally pitch/time scattered.
Creates textures impossible with other synthesis: frozen tones,
shimmering clouds, evolving pads, glitchy stutters.
Args:
hz: Base frequency.
grain_size: Duration of each grain in seconds (default 0.05 = 50ms).
density: Grains per second (default 20). Higher = denser cloud.
scatter: Random position jitter 0-1 (default 0.3). How much each
grain's read position varies from sequential order.
pitch_var: Random pitch variation per grain in cents (default 5).
source: Base waveform ``"saw"``, ``"sine"``, ``"triangle"``,
``"square"``, ``"noise"`` (default ``"saw"``).
"""
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Generate source material — longer than needed for scatter headroom
src_len = n_samples + int(SAMPLE_RATE * scatter * 2)
src_fns = {
"saw": sawtooth_wave, "sine": sine_wave, "triangle": triangle_wave,
"square": square_wave, "noise": noise_wave,
}
src_fn = src_fns.get(source, sawtooth_wave)
src = src_fn(hz, n_samples=src_len).astype(numpy.float64) / SAMPLE_PEAK
# Grain parameters
grain_samples = max(64, int(grain_size * SAMPLE_RATE))
n_grains = max(1, int(n_samples / SAMPLE_RATE * density))
# Hanning window for each grain (smooth fade in/out, no clicks)
window = numpy.hanning(grain_samples).astype(numpy.float64)
out = numpy.zeros(n_samples, dtype=numpy.float64)
for i in range(n_grains):
# Output position — evenly spaced with jitter
base_pos = int(i * n_samples / n_grains)
jitter = int(rng.uniform(-0.5, 0.5) * n_samples / n_grains * 0.3)
out_pos = max(0, min(n_samples - grain_samples, base_pos + jitter))
# Source read position — sequential with scatter
src_pos = int(base_pos * src_len / n_samples)
src_jitter = int(rng.uniform(-scatter, scatter) * grain_samples * 4)
src_pos = max(0, min(src_len - grain_samples, src_pos + src_jitter))
# Per-grain pitch variation via resampling
if pitch_var > 0:
cents = rng.uniform(-pitch_var, pitch_var)
rate = 2 ** (cents / 1200)
read_len = max(2, min(int(grain_samples * rate), src_len - src_pos))
grain_src = src[src_pos:src_pos + read_len]
x_old = numpy.linspace(0, 1, len(grain_src))
x_new = numpy.linspace(0, 1, grain_samples)
grain = numpy.interp(x_new, x_old, grain_src)
else:
end = min(src_pos + grain_samples, src_len)
grain = src[src_pos:end]
if len(grain) < grain_samples:
grain = numpy.pad(grain, (0, grain_samples - len(grain)))
# Apply window and mix
grain *= window[:len(grain)]
end = min(out_pos + len(grain), n_samples)
out[out_pos:end] += grain[:end - out_pos]
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.
@@ -1087,6 +1287,9 @@ class Synth(Enum):
CELLO = "cello_synth"
HARP = "harp_synth"
UPRIGHT_BASS = "upright_bass_synth"
TIMPANI = "timpani_synth"
SAXOPHONE = "saxophone_synth"
GRANULAR = "granular_synth"
ACOUSTIC_GUITAR = "acoustic_guitar_synth"
SITAR = "sitar_synth"
ELECTRIC_GUITAR = "electric_guitar_synth"
@@ -1108,6 +1311,8 @@ _SYNTH_FUNCTIONS = {
"marimba_synth": marimba_wave, "oboe_synth": oboe_wave,
"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,
"acoustic_guitar_synth": acoustic_guitar_wave,
"sitar_synth": sitar_wave, "electric_guitar_synth": electric_guitar_wave,
}
@@ -1925,6 +2130,42 @@ def _synth_metal_hat(n_samples):
return out
def _synth_tabla_ge_bend(n_samples):
"""Tabla Ge with upward pitch bend — palm pressing into bayan head.
The player strikes the bayan and then presses their palm into the
head, raising the pitch dramatically. The signature bayan sound
in Bollywood and fusion music.
"""
t = numpy.arange(n_samples, dtype=numpy.float32) / SAMPLE_RATE
# Membrane thud
thump_len = min(int(SAMPLE_RATE * 0.07), n_samples)
thump_raw = _noise(thump_len)
if thump_len > 20:
bl, al = scipy.signal.butter(2, [40, 250], btype='band', fs=SAMPLE_RATE)
thump = scipy.signal.lfilter(bl, al, numpy.pad(thump_raw, (0, max(0, n_samples - thump_len))))[:thump_len]
else:
thump = thump_raw
thump *= _exp_decay(thump_len, 20) * 0.8
# Pitch sweep UP — 60 Hz rising to 200+ Hz as palm presses
# Gets quieter as pitch rises (palm mutes the head as it presses)
freq = 60 + 180 * (1 - numpy.exp(-4 * t))
phase = 2 * numpy.pi * numpy.cumsum(freq) / SAMPLE_RATE
body = numpy.sin(phase) * _exp_decay(n_samples, 6) * 0.9
# Metal shell resonance
metal_len = min(int(SAMPLE_RATE * 0.1), n_samples)
metal = numpy.sin(2 * numpy.pi * 150 * t[:metal_len]) * _exp_decay(metal_len, 8) * 0.3
# Sub
sub = _sine_f32(50, n_samples) * _exp_decay(n_samples, 5) * 0.4
click_len = min(250, n_samples)
click = _noise(click_len) * _exp_decay(click_len, 35) * 0.3
result = body + sub
result[:thump_len] += thump
result[:metal_len] += metal
result[:click_len] += click
return numpy.tanh(result * 1.3).astype(numpy.float32)
def _synth_djembe_bass(n_samples):
"""Djembe bass — open palm strike in center of goatskin head.
@@ -2077,6 +2318,7 @@ def _render_drum_hit(sound_value, n_samples):
DrumSound.TABLA_DHA.value: lambda n: _synth_tabla_dha(n),
DrumSound.TABLA_TIT.value: lambda n: _synth_tabla_tit(n),
DrumSound.TABLA_KE.value: lambda n: _synth_tabla_ke(n),
DrumSound.TABLA_GE_BEND.value: lambda n: _synth_tabla_ge_bend(n),
# Dhol
DrumSound.DHOL_DAGGA.value: lambda n: _synth_dhol_dagga(n),
DrumSound.DHOL_TILLI.value: lambda n: _synth_dhol_tilli(n),
@@ -3246,9 +3488,49 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
if analog > 0:
pitches = [hz * (2 ** (_rnd.gauss(0, analog * 5) / 1200))
for hz in pitches]
# Render oscillators (pass synth_kwargs for FM etc.)
waves = [synth_fn(hz, n_samples=n_samples, **_skw)
for hz in pitches]
# Pitch bend: render at base pitch, then resample to shift
# pitch over time. Resampling preserves the synth's timbre
# perfectly — no sine waves, no retriggering.
bend_amt = getattr(note, 'bend', 0.0)
if bend_amt != 0:
bend_type = getattr(note, 'bend_type', 'smooth')
t_norm = numpy.linspace(0, 1, n_samples)
waves = []
for hz in pitches:
hz_end = hz * (2 ** (bend_amt / 12))
# Build pitch ratio curve (1.0 = no shift)
if bend_type == 'smooth':
ratio = (hz_end / hz) ** t_norm
elif bend_type == 'linear':
ratio = 1.0 + (hz_end / hz - 1.0) * t_norm
elif bend_type == 'late':
late_t = numpy.clip((t_norm - 0.6) / 0.4, 0.0, 1.0)
ratio = (hz_end / hz) ** late_t
else:
ratio = (hz_end / hz) ** t_norm
# Render a longer buffer at base pitch
max_ratio = max(ratio.max(), 1.0)
src_len = int(n_samples * max_ratio) + 100
src = synth_fn(hz, n_samples=src_len, **_skw)
src_f = src.astype(numpy.float64) / SAMPLE_PEAK
# Variable-rate resampling: read through source
# at speed determined by the ratio curve
read_pos = numpy.cumsum(ratio)
read_pos = (read_pos - read_pos[0]).astype(numpy.float64)
# Clamp to source bounds
read_pos = numpy.clip(read_pos, 0, src_len - 2)
# Linear interpolation
idx = read_pos.astype(numpy.int64)
frac = read_pos - idx
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)
for hz in pitches]
# Sub-oscillator: octave-below sine
if sub_osc > 0:
for hz in pitches:
@@ -3312,6 +3594,12 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
vel_cutoff = vel_to_filter * vel_scale + 1000
mixed = _apply_lowpass(mixed, vel_cutoff, q=filter_q)
end = min(start + len(mixed), total_samples)
# Choke: fade out any existing signal at this point
# so new notes don't pile up on previous tails
choke_len = min(int(SAMPLE_RATE * 0.003), start)
if choke_len > 0:
fade = numpy.linspace(1.0, 0.0, choke_len).astype(numpy.float32)
buf[start - choke_len:start] *= fade
buf[start:end] += mixed[:end - start] * volume * vel_scale
# Spread detuned oscillators into stereo L/R
if detune_up is not None and stereo_buf is not None:
@@ -3614,6 +3902,7 @@ def render_score(score):
DrumSound.TABLA_GE.value: -0.2,
DrumSound.TABLA_KE.value: -0.2,
DrumSound.TABLA_DHA.value: 0.0, # both drums = center
DrumSound.TABLA_GE_BEND.value: -0.2,
# Dhol: bass left, treble right
DrumSound.DHOL_DAGGA.value: -0.2,
DrumSound.DHOL_TILLI.value: 0.2,
@@ -3647,6 +3936,10 @@ def render_score(score):
drum_parts = [p for p in score.parts.values() if p.is_drums]
for drum_part in drum_parts:
part_stereo = numpy.zeros((total_samples, 2), dtype=numpy.float32)
# Track last hit position per sound for choke (new hit dampens
# the previous ring on the same drum)
_last_hit_start = {}
for hit in drum_part._drum_hits:
pos = hit.position
if drum_swing > 0:
@@ -3663,6 +3956,21 @@ def render_score(score):
start = max(0, start)
if start >= total_samples or start < 0:
continue
# Choke: if the same sound was hit recently, fade out
# the tail at this point (new hit dampens old resonance)
sound_id = hit.sound.value
if sound_id in _last_hit_start:
prev_start = _last_hit_start[sound_id]
# Quick 2ms fade-out at the new hit position
fade_len = min(int(SAMPLE_RATE * 0.002), max(0, start - prev_start))
if fade_len > 0 and start > 0:
fade = numpy.linspace(1.0, 0.0, fade_len).astype(numpy.float32)
fade_start = max(0, start - fade_len)
for ch in range(2):
part_stereo[fade_start:start, ch] *= fade
_last_hit_start[sound_id] = start
remaining = total_samples - start
hit_len = min(int(SAMPLE_RATE * 0.5), remaining)
wave = _render_drum_hit(hit.sound.value, hit_len)
+96 -3
View File
@@ -241,6 +241,16 @@ INSTRUMENTS = {
"vel_to_filter": 3000,
"analog": 0.3,
},
"granular_pad": {
"synth": "granular_synth", "envelope": "pad",
"reverb": 0.4, "reverb_type": "cathedral",
"analog": 0.3,
},
"granular_texture": {
"synth": "granular_synth", "envelope": "none",
"reverb": 0.5, "reverb_type": "taj_mahal",
"delay": 0.3, "delay_time": 0.4, "delay_feedback": 0.4,
},
"808_bass": {
"synth": "sine", "envelope": "pluck",
"distortion": 0.4, "distortion_drive": 2.5,
@@ -275,6 +285,31 @@ INSTRUMENTS = {
"fm_ratio": 2.0, "fm_index": 3.0,
"reverb": 0.4, "reverb_type": "cathedral",
},
"timpani": {
"synth": "timpani_synth", "envelope": "none",
"reverb": 0.4, "reverb_type": "cathedral",
},
# ── Woodwinds (continued) ──
"saxophone": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1500,
},
"alto_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"humanize": 0.15, "vel_to_filter": 1800,
},
"tenor_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 3000,
"humanize": 0.15, "vel_to_filter": 1200,
},
"bari_sax": {
"synth": "saxophone_synth", "envelope": "bowed",
"lowpass": 2000,
"humanize": 0.15, "vel_to_filter": 800,
"sub_osc": 0.15,
},
}
@@ -320,11 +355,18 @@ class TimeSignature:
@dataclass
class Note:
"""A pairing of a sound (Tone, Chord, or None for rest) with a duration."""
"""A pairing of a sound (Tone, Chord, or None for rest) with a duration.
The optional ``bend`` field specifies a pitch bend in semitones
applied over the note's duration. Positive = bend up, negative = down.
For example, ``bend=2`` bends the note up a whole step by the end.
"""
tone: object
duration: Duration
velocity: int = 100
bend: float = 0.0
bend_type: str = "smooth" # "smooth" (log), "linear", "late"
@property
def beats(self) -> float:
@@ -421,6 +463,7 @@ class DrumSound(Enum):
MRIDANGAM_NAM = 99 # treble ring (valanthalai/right head)
MRIDANGAM_DIN = 100 # both heads
MRIDANGAM_THA = 101 # muted treble
TABLA_GE_BEND = 108 # bayan with upward pitch bend (palm press)
# Djembe sounds
DJEMBE_BASS = 102 # open bass (center of head)
DJEMBE_TONE = 103 # open tone (edge, fingers together)
@@ -2051,11 +2094,15 @@ class Part:
self._drum_pattern_beats: float = 0.0
self._automation: list[tuple[float, dict]] = [] # (beat, {param: value})
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100) -> "Part":
def add(self, tone_or_string, duration=Duration.QUARTER, *, velocity: int = 100,
bend: float = 0.0, bend_type: str = "smooth") -> "Part":
"""Add a note. Accepts Tone/Chord objects or note strings like ``"E5"``.
Duration can be a ``Duration`` enum or a raw float (beats).
Velocity controls loudness (1-127, default 100).
Bend specifies a pitch bend in semitones over the note duration
(e.g. ``bend=2`` bends up a whole step, ``bend=-1`` bends down
a half step). Used for guitar bends, sitar meends, slides.
Returns self for chaining.
"""
@@ -2064,7 +2111,9 @@ class Part:
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))
self.notes.append(Note(tone=tone_or_string, duration=duration,
velocity=velocity, bend=bend,
bend_type=bend_type))
return self
def set(self, **params) -> "Part":
@@ -2421,6 +2470,50 @@ class Part:
return self
def roll(self, tone_or_string, duration=Duration.WHOLE, *,
velocity_start: int = 40, velocity_end: int = 100,
speed=Duration.SIXTEENTH) -> "Part":
"""Play a roll — rapid repeated notes with velocity ramp.
Perfect for timpani rolls, snare rolls, tremolo on any
instrument. The velocity ramps from ``velocity_start`` to
``velocity_end`` over the duration for crescendo/decrescendo.
Args:
tone_or_string: The note to repeat.
duration: Total duration of the roll.
velocity_start: Velocity of the first hit (default 40).
velocity_end: Velocity of the last hit (default 100).
speed: How fast to repeat (default SIXTEENTH notes).
Returns:
Self for chaining.
Example::
>>> timp = score.part("timp", instrument="timpani")
>>> timp.roll("C3", Duration.WHOLE, velocity_start=30, velocity_end=110)
"""
if hasattr(duration, 'value'):
total = duration.value
else:
total = float(duration)
if hasattr(speed, 'value'):
step = speed.value
else:
step = float(speed)
n_hits = max(1, int(total / step))
for i in range(n_hits):
frac = i / max(1, n_hits - 1)
vel = int(velocity_start + (velocity_end - velocity_start) * frac)
vel = max(1, min(127, vel))
remaining = total - i * step
note_dur = min(step, remaining)
if note_dur > 0:
self.add(tone_or_string, note_dur, velocity=vel)
return self
@property
def is_drums(self) -> bool:
"""True if this part contains drum hits."""
+21 -5
View File
@@ -2,8 +2,8 @@ from ._statics import (
TEMPERAMENTS, TONES, DEGREES, SCALES,
INDIAN_SCALES, ARABIC_SCALES, JAPANESE_SCALES,
BLUES_SCALES, GAMELAN_SCALES, SYSTEMS,
TONES_SHRUTI, DEGREES_SHRUTI, SHRUTI_SCALES,
TONES_ARABIC_24, DEGREES_ARABIC_24, ARABIC_24_SCALES,
TONES_SHRUTI, DEGREES_SHRUTI, SHRUTI_SCALES, SHRUTI_RATIOS,
TONES_ARABIC_24, DEGREES_ARABIC_24, ARABIC_24_SCALES, MAQAM_RATIOS,
TONES_SLENDRO, DEGREES_SLENDRO, SLENDRO_SCALES,
TONES_PELOG, DEGREES_PELOG, PELOG_SCALES,
TONES_THAI, DEGREES_THAI, THAI_SCALES,
@@ -14,7 +14,7 @@ from ._statics import (
class System:
def __init__(self, *, tone_names, degrees, scales=None, c_index=None,
period=2.0):
period=2.0, ratios=None):
self.tone_names = tone_names
self.degrees = degrees
@@ -25,6 +25,11 @@ class System:
# 3.0 for Bohlen-Pierce (tritave).
self.period = period
# Custom frequency ratios: if set, overrides equal temperament.
# A list of N floats (one per tone), each relative to the first
# tone (1.0). For example, just intonation shruti ratios.
self.ratios = ratios
# c_index: the index of the "reference C" in the tone list.
# For octave arithmetic — scientific pitch changes octave at C.
# Default 3 for 12-TET western (A=0, A#=1, B=2, C=3).
@@ -214,6 +219,17 @@ class System:
# descending goes in meta?
return {"intervals": scale, "hemitonic": hemitonic, "meta": {}}
def tone(self, name, octave=4):
"""Create a Tone in this system. Shorthand for ``Tone(name, octave=octave, system=self)``.
Example::
>>> edo19 = TET(19)
>>> edo19.tone(5, octave=4).frequency
"""
from . import Tone
return Tone(name, octave=octave, system=self)
def __repr__(self):
return f"<System semitones={self.semitones!r}>"
@@ -352,9 +368,9 @@ SYSTEMS = {
"31-tet": TET(31, names=_31TET_NAMES),
# Microtonal systems with proper intervals (not 12-TET approximations)
"shruti": System(tone_names=TONES_SHRUTI, degrees=DEGREES_SHRUTI,
scales=SHRUTI_SCALES, c_index=5),
scales=SHRUTI_SCALES, c_index=5, ratios=SHRUTI_RATIOS),
"maqam": System(tone_names=TONES_ARABIC_24, degrees=DEGREES_ARABIC_24,
scales=ARABIC_24_SCALES, c_index=5),
scales=ARABIC_24_SCALES, c_index=5, ratios=MAQAM_RATIOS),
"slendro": System(tone_names=TONES_SLENDRO, degrees=DEGREES_SLENDRO,
scales=SLENDRO_SCALES, c_index=1),
"pelog": System(tone_names=TONES_PELOG, degrees=DEGREES_PELOG,
+61 -11
View File
@@ -26,7 +26,7 @@ class Tone:
def __init__(
self,
name: str,
name,
*,
alt_names: Optional[list[str]] = None,
octave: Optional[int] = None,
@@ -36,8 +36,10 @@ class Tone:
"""Initialize a Tone with a name, optional octave, and musical system.
Args:
name: The note name (e.g. ``"C"``, ``"C#4"``). If the name
contains a digit, it is parsed as the octave.
name: The note name as a string (``"C"``, ``"C#4"``) or an int
for numbered systems (``0``, ``11``). Ints are converted to
strings and wrapped to the system's range (e.g. 22 in a
22-tone system becomes 0 at octave+1).
alt_names: Alternate spellings for this tone (e.g. enharmonics).
octave: The octave number. Overrides any octave parsed from *name*.
system: The tuning system, either as a string key (``"western"``)
@@ -46,6 +48,23 @@ class Tone:
if alt_names is None:
alt_names = []
# Int tone names: wrap to system range, adjust octave
if isinstance(name, int):
if isinstance(system, str):
from .systems import SYSTEMS
_sys = SYSTEMS[system]
else:
_sys = system
n_tones = len(_sys.tone_names)
if name < 0 or name >= n_tones:
extra_octaves = name // n_tones
name = name % n_tones
if octave is None:
octave = 4 + extra_octaves
else:
octave += extra_octaves
name = str(name)
if isinstance(name, str):
# Normalize unicode music symbols to ASCII equivalents
name = (name
@@ -70,6 +89,35 @@ class Tone:
if octave is None:
octave = parsed_octave
# Octave boundary fix: B#→C should increment octave,
# Cb→B should decrement octave (scientific pitch changes at C).
# Only applies to Western-style systems with letter names.
if octave is not None and name and name[0].isalpha():
if isinstance(system, str):
from .systems import SYSTEMS
_sys_check = SYSTEMS.get(system)
else:
_sys_check = system
if _sys_check is not None:
resolved = _sys_check.resolve_name(name)
if resolved is not None and resolved != name:
orig_letter = name[0].upper()
res_letter = resolved[0].upper()
# Sharp crossing B→C: B# resolves to C, octave up
if orig_letter == 'B' and res_letter == 'C' and '#' in name:
octave += 1
# Double sharp: A## resolves to B — no boundary cross
# But B## resolves to C# — boundary cross
if orig_letter == 'B' and res_letter not in ('B', 'A') and '##' in name:
octave += 1
# Flat crossing C→B: Cb resolves to B, octave down
if orig_letter == 'C' and res_letter == 'B' and 'b' in name and name != 'C':
octave -= 1
# Double flat: D♭♭ resolves to C — no boundary cross
# But C♭♭ resolves to Bb — boundary cross
if orig_letter == 'C' and res_letter not in ('C', 'D') and 'bb' in name:
octave -= 1
self.name = name
self.octave = octave
self.alt_names = alt_names
@@ -762,11 +810,13 @@ class Tone:
period = getattr(self.system, 'period', 2.0)
c_idx = getattr(self.system, 'c_index', C_INDEX)
if period != 2.0 and temperament == "equal":
# Non-octave period (e.g. Bohlen-Pierce tritave=3.0):
# generate ratios as period^(n/tones) instead of 2^(n/tones)
import sympy
pitch_scale = [period ** sympy.Rational(i, tones) for i in range(tones + 1)]
# Custom ratios override temperament (e.g. shruti just ratios)
custom_ratios = getattr(self.system, 'ratios', None)
if custom_ratios is not None:
pitch_scale = list(custom_ratios) + [period]
elif period != 2.0 and temperament == "equal":
# Non-octave period (e.g. Bohlen-Pierce tritave=3.0)
pitch_scale = [period ** (i / tones) for i in range(tones + 1)]
else:
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave if self.octave is not None else 4
@@ -783,7 +833,7 @@ class Tone:
if symbolic:
return reference_pitch * ratio
else:
result = reference_pitch * ratio
result = float(reference_pitch * ratio)
if precision:
return float(result.evalf(precision))
return float(result)
return round(result, precision)
return result
+202 -1
View File
@@ -5320,7 +5320,7 @@ def test_supersaw_wave():
@needs_portaudio
def test_all_synths_in_enum():
from pytheory.play import Synth
assert len(Synth) == 27
assert len(Synth) == 30
for s in Synth:
wave = s(440, n_samples=1000)
assert len(wave) == 1000
@@ -6711,3 +6711,204 @@ def test_interval_to_non12():
a5 = a.add(19)
result = a.interval_to(a5)
assert "octave" in result
# ── Dedicated instrument synths ──────────────────────────────────────────────
def test_all_dedicated_synths_render():
"""Every dedicated synth waveform produces valid audio."""
from pytheory.play import (piano_wave, bass_guitar_wave, flute_wave,
trumpet_wave, clarinet_wave, oboe_wave,
marimba_wave, harpsichord_wave, cello_wave,
harp_wave, upright_bass_wave,
acoustic_guitar_wave, electric_guitar_wave,
sitar_wave, SAMPLE_RATE)
synths = [piano_wave, bass_guitar_wave, flute_wave, trumpet_wave,
clarinet_wave, oboe_wave, marimba_wave, harpsichord_wave,
cello_wave, harp_wave, upright_bass_wave,
acoustic_guitar_wave, electric_guitar_wave, sitar_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_piano_brightness_scales():
"""High-pitched piano should be brighter (more high harmonics)."""
from pytheory.play import piano_wave
low = piano_wave(130, n_samples=22050) # C3
high = piano_wave(1047, n_samples=22050) # C6
# Both should produce valid audio
assert numpy.abs(low).max() > 0
assert numpy.abs(high).max() > 0
def test_acoustic_guitar_body_resonance():
"""Acoustic guitar should produce richer spectrum than raw pluck."""
from pytheory.play import acoustic_guitar_wave, pluck_wave
ag = acoustic_guitar_wave(220, n_samples=22050)
pk = pluck_wave(220, n_samples=22050)
assert len(ag) == len(pk) == 22050
def test_cello_has_vibrato():
"""Cello synth should produce pitch variation (vibrato)."""
from pytheory.play import cello_wave
wave = cello_wave(220, n_samples=44100)
assert len(wave) == 44100
assert numpy.abs(wave).max() > 0
# ── Cabinet simulation ───────────────────────────────────────────────────────
def test_cabinet_reduces_highs():
"""Cabinet sim should reduce high-frequency content."""
from pytheory.play import _apply_cabinet
# White noise has flat spectrum
noise = numpy.random.uniform(-1, 1, 44100).astype(numpy.float32)
cabbed = _apply_cabinet(noise, brightness=0.5)
# RMS of cabbed should be lower (energy removed by filters)
assert numpy.sqrt(numpy.mean(cabbed ** 2)) < numpy.sqrt(numpy.mean(noise ** 2))
def test_cabinet_brightness_param():
"""Higher brightness = more high-frequency content passes through."""
from pytheory.play import _apply_cabinet
noise = numpy.random.uniform(-1, 1, 44100).astype(numpy.float32)
dark = _apply_cabinet(noise, brightness=0.0)
bright = _apply_cabinet(noise, brightness=1.0)
# Bright should have more energy than dark
assert numpy.sqrt(numpy.mean(bright ** 2)) > numpy.sqrt(numpy.mean(dark ** 2))
# ── Analog drift ─────────────────────────────────────────────────────────────
def test_analog_drift_varies_pitch():
"""Analog drift should make repeated renders slightly different."""
from pytheory import Score, Duration
score1 = Score("4/4", bpm=120)
p1 = score1.part("t", synth="saw", analog=0.5)
p1.add("C4", Duration.QUARTER)
p1.add("C4", Duration.QUARTER)
# With analog > 0, each C4 gets a random pitch offset
# This is hard to test deterministically, just verify it renders
from pytheory.play import render_score
buf = render_score(score1)
assert len(buf) > 0
# ── Guitar strumming ─────────────────────────────────────────────────────────
def test_strum_requires_fretboard():
"""Strumming without a fretboard should raise ValueError."""
from pytheory import Score, Duration
score = Score("4/4", bpm=120)
p = score.part("g", synth="saw")
with pytest.raises(ValueError, match="fretboard"):
p.strum("Am", Duration.QUARTER)
def test_strum_adds_notes():
"""Strumming should add notes to the part."""
from pytheory import Score, Duration, Fretboard
score = Score("4/4", bpm=120)
fb = Fretboard.guitar()
p = score.part("g", instrument="acoustic_guitar", fretboard=fb)
p.strum("Am", Duration.HALF)
assert len(p.notes) > 0
def test_strum_direction():
"""Both down and up strums should work."""
from pytheory import Score, Duration, Fretboard
score = Score("4/4", bpm=120)
fb = Fretboard.guitar()
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
# ── World drums ──────────────────────────────────────────────────────────────
def test_tabla_sounds_render():
"""All tabla drum sounds should produce valid audio."""
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.TABLA_NA, DrumSound.TABLA_TIN, DrumSound.TABLA_GE,
DrumSound.TABLA_DHA, DrumSound.TABLA_TIT, DrumSound.TABLA_KE]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
assert wave.dtype == numpy.float32
def test_dhol_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.DHOL_DAGGA, DrumSound.DHOL_TILLI, DrumSound.DHOL_BOTH]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_mridangam_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.MRIDANGAM_THAM, DrumSound.MRIDANGAM_NAM,
DrumSound.MRIDANGAM_DIN, DrumSound.MRIDANGAM_THA]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_djembe_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.DJEMBE_BASS, DrumSound.DJEMBE_TONE, DrumSound.DJEMBE_SLAP]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_metal_kit_sounds_render():
from pytheory.play import _render_drum_hit
from pytheory.rhythm import DrumSound
for sound in [DrumSound.METAL_KICK, DrumSound.METAL_SNARE, DrumSound.METAL_HAT]:
wave = _render_drum_hit(sound.value, 22050)
assert len(wave) == 22050
def test_tabla_pattern_presets():
"""All tabla patterns should load without error."""
from pytheory.rhythm import Pattern
for name in ["teental", "jhaptaal", "rupak", "dadra",
"keherwa", "tabla solo", "tiri kita"]:
p = Pattern.preset(name)
assert p.beats > 0
def test_world_drum_pattern_presets():
"""All world drum patterns should load."""
from pytheory.rhythm import Pattern
for name in ["bhangra", "dhol chaal", "qawwali", "dholak folk",
"adi talam", "mridangam korvai", "djembe", "kuku", "soli",
"double kick", "metal blast", "metal groove", "metal gallop"]:
p = Pattern.preset(name)
assert p.beats > 0
# ── Guitar presets with cabinet sim ──────────────────────────────────────────
def test_guitar_presets_have_cabinet():
"""Distorted guitar presets should have cabinet simulation."""
from pytheory import Score
for preset in ["distorted_guitar", "orange_crunch", "metal_guitar"]:
score = Score("4/4", bpm=120)
p = score.part("g", instrument=preset)
assert p.cabinet > 0, f"{preset} should have cabinet sim"
def test_clean_guitar_preset():
from pytheory import Score
score = Score("4/4", bpm=120)
p = score.part("g", instrument="clean_guitar")
assert p.synth == "electric_guitar_synth"
assert p.cabinet > 0
Generated
+1 -37
View File
@@ -444,15 +444,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "myst-parser"
version = "4.0.1"
@@ -707,11 +698,10 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.34.0"
version = "0.35.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },
{ name = "pytuning" },
{ name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sounddevice" },
@@ -732,7 +722,6 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "numeral" },
{ name = "pytuning" },
{ name = "scipy" },
{ name = "sounddevice" },
]
@@ -744,19 +733,6 @@ docs = [
{ name = "sphinx" },
]
[[package]]
name = "pytuning"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/26/59/e2c2fc91688f788587fb387ef6120c9a1ad3a8b88771fba9fc6a9c9a969d/PyTuning-0.7.3-py3-none-any.whl", hash = "sha256:db0b1231c012c1cf6a3c73aa7d791b4cff79a72f2ec6535f159c873fe302214b", size = 108174, upload-time = "2023-09-02T21:11:00.657Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@@ -1151,18 +1127,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
]
[[package]]
name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"