Compare commits

...

12 Commits

Author SHA1 Message Date
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
8 changed files with 711 additions and 33 deletions
+74
View File
@@ -574,3 +574,77 @@ 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."
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)
+2 -1
View File
@@ -87,7 +87,8 @@ What's Inside
lowpass/highpass (with resonance), distortion, cabinet simulation,
saturation, chorus, phaser, tremolo, analog drift, sidechain compression,
automation, LFOs. Master bus compressor/limiter
- **Instruments**25 presets with fingering generation
- **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
+356 -2
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
@@ -1199,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),
@@ -1222,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__":
@@ -1235,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":
+111 -6
View File
@@ -295,8 +295,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 +384,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
+89 -13
View File
@@ -442,7 +442,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.0015 * 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
@@ -1925,6 +1925,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 +2113,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,31 +3283,44 @@ 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]
# Pitch bend: render with time-varying frequency
# 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_bend = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
t_norm = numpy.clip(t_bend / (dur_ms / 1000), 0.0, 1.0)
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':
# Log interpolation — perceptually linear pitch
freq_curve = hz * (hz_end / hz) ** t_norm
ratio = (hz_end / hz) ** t_norm
elif bend_type == 'linear':
freq_curve = hz + (hz_end - hz) * t_norm
ratio = 1.0 + (hz_end / hz - 1.0) * t_norm
elif bend_type == 'late':
# Hold pitch for 60%, bend in last 40%
late_t = numpy.clip((t_norm - 0.6) / 0.4, 0.0, 1.0)
freq_curve = hz * (hz_end / hz) ** late_t
ratio = (hz_end / hz) ** late_t
else:
freq_curve = hz * (hz_end / hz) ** t_norm
ratio = (hz_end / hz) ** t_norm
# Phase accumulation for smooth frequency change
phase = numpy.cumsum(2 * numpy.pi * freq_curve / SAMPLE_RATE)
bent = numpy.sin(phase).astype(numpy.float64)
# 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.)
@@ -3339,6 +3389,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:
@@ -3641,6 +3697,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,
@@ -3674,6 +3731,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:
@@ -3690,6 +3751,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)
+1
View File
@@ -428,6 +428,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)
+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,
+57 -6
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,9 +810,12 @@ 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)
# 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)
import sympy
pitch_scale = [period ** sympy.Rational(i, tones) for i in range(tones + 1)]
else: