Compare commits

...

17 Commits

Author SHA1 Message Date
kennethreitz 1d07b06968 Add Greensleeves (Renaissance lute, meantone A=415) to songs.py
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:33:50 -04:00
kennethreitz 9887b59cfb Add reference_pitch to Score and playback pipeline
Score(reference_pitch=415.0, temperament="meantone") renders an
entire piece at Baroque pitch with historical tuning. Flows through
to all .pitch() calls in both normal and legato renderers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:32:11 -04:00
kennethreitz 9850a8016e Bohlen-Pierce, just intonation, temperament in Score/playback
- Bohlen-Pierce (13-TET tritave): period=3.0 support in pitch(),
  System, and TET factory. 13 equal divisions of the 3:1 ratio.
- Just intonation temperament: 5-limit JI ratios (pure 3/2 fifths,
  5/4 thirds). Use temperament="just" anywhere.
- Score(temperament="just") flows through to playback — all .pitch()
  calls in the render pipeline use the Score's temperament.
- Carnatic 72-TET system with 10 melakartas.
- Fix c_index for Indian, Arabic, and Gamelan 12-TET systems.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:24:03 -04:00
kennethreitz 35f5f35dc5 Carnatic 72-TET, Score system param, 22 microtonal tests
- Carnatic (72-TET): 10 melakartas including shankarabharanam,
  kalyani, mayamalavagowla, kharaharapriya, etc.
- Score(system=) param passes tuning system to all parts, so
  Part.add("Sa") resolves through the correct system
- 22 new tests covering all microtonal systems: TET factory,
  19/31-TET, shruti, maqam, slendro, pelog, thai, makam,
  carnatic, circle of fifths, from_frequency, Score integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:18:09 -04:00
kennethreitz 47ca94111f Add gamelan slendro/pelog, Thai 7-TET, Turkish 53-TET makam
- Slendro (5-TET): true equal 5-tone gamelan tuning, 240 cents/step
- Pelog (9-TET): 7-of-9 gamelan tuning with pathet nem/lima/barang
- Thai classical (7-TET): 7 equal divisions (~171 cents each)
- Turkish makam (53-TET): Arel-Ezgi-Uzdilek system with 9 makams
  (rast, hicaz, ussak, nihavend, huseyni, kurdi, segah, saba, huzzam)
- Fix octave parser to only match trailing digits (not "Mib+3")
- Fix _index to use _name_to_index (avoid creating Tone objects)
- Fix _math to use per-system c_index

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:11:19 -04:00
kennethreitz 62cfbb2591 Add 22-shruti Indian and 24-TET Arabic maqam systems
- "shruti" system: 22 named shrutis with proper microtonal intervals
  for all 10 thaats (bilawal, bhairav, todi, etc.) and pentatonic
  scales (bhupali, malkauns, durga). Captures the 2-shruti vs 3-shruti
  distinctions that 12-TET approximations lose.

- "maqam" system: 24-TET with quarter-tone positions (↑/↓ notation).
  True maqam Rast with quarter-flat E and B. Bayati, Saba, Sikah,
  Hijaz, and 6 more maqamat with exact quarter-tone intervals.

- 12-TET "indian" and "arabic" systems preserved for backwards compat.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:04:27 -04:00
kennethreitz de855a3fe6 Add non-12-TET support: TET() factory, 19-TET, 31-TET
- TET(n) factory creates N-tone equal temperament systems
- Built-in named systems: "19-tet" and "31-tet" with proper note
  names and scale definitions (major, minor, harmonic minor, pentatonic)
- Per-system c_index replaces global C_INDEX constant
- Fix 6 hardcoded '12's in tones.py: from_frequency, from_midi,
  interval_to, midi property, circle_of_fifths/fourths
- Numbered pitch classes for custom EDOs: TET(17) uses "0"-"16"
- Octave parser skips numeric-only names (fixes "0" being eaten)

Refs #38

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:58:33 -04:00
kennethreitz dc9f7b3342 Update changelog for 0.32.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:50:52 -04:00
kennethreitz 60fdff6d36 Merge pull request #43 from kennethreitz/fix/enharmonics-and-double-accidentals
Support enharmonic spellings and double accidentals
2026-03-26 23:50:33 -04:00
kennethreitz f42d38d1fd Support Cb, Fb, E#, B#, double sharps/flats, unicode symbols
- Cb, Fb, E#, B# resolve to their enharmonic equivalents (fixes #40)
- C##, Dbb, etc. resolve via semitone arithmetic (fixes #41)
- Unicode symbols accepted: ♯ ♭ 𝄪 𝄫
- 'x'/'X' accepted as double sharp (Bach notation): Fx = F##
- resolve_name handles all accidentals dynamically

Closes #40, closes #41

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:48:42 -04:00
kennethreitz 5a4122d61f Merge pull request #42 from kennethreitz/fix/tone-validate-early
Validate tone name at construction time
2026-03-26 23:43:41 -04:00
kennethreitz 3e4ba54a32 Validate tone name at construction time (fixes #39)
Tone("X") now raises ValueError immediately instead of silently
storing an invalid name and only failing on .frequency access.

Closes #39

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 23:43:00 -04:00
kennethreitz 5dd1c5e15d v0.32.0: 8 new synth features, highpass filter, preset overhaul
Filter envelope, velocity→brightness, sub-oscillator, tremolo,
saturation, noise layer, phaser, configurable FM. Highpass filter.
Bowed and mallet envelopes. Improved strings_synth with additive
synthesis. All 38 instrument presets sanity-checked and enhanced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:00:49 -04:00
kennethreitz e46732fb5a Improved strings_synth, highpass filter, bowed envelope
- Rewrite strings_wave with additive synthesis: natural 1/n harmonic
  rolloff shaped by body resonance curve, per-harmonic phase
  randomization, delayed vibrato onset, bow pressure variation
- Add highpass filter (12dB/oct biquad) to signal chain and Part API
- Add BOWED envelope (40ms attack with bite) for string instruments
- Update string presets to use strings_synth + bowed envelope

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:38:15 -04:00
kennethreitz 833ab56857 Fix solo string instruments: clean triangle, no detune
Solo violin/viola/cello/contrabass now use triangle + strings envelope
(clean, clear). String ensemble keeps strings_synth + detune for
thick ensemble textures. Solo instruments need clarity, not width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:26:35 -04:00
kennethreitz 6b2b1e201e Update index.rst with 13 synths, 38 instrument presets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:20:46 -04:00
kennethreitz f9c81fe05f v0.31.0: 3 new synths, 38 instrument presets
- Karplus-Strong pluck (physical modeling for guitar/harp/koto)
- Hammond organ (additive drawbar synthesis)
- String ensemble (filtered saw with body resonance formants)
- 38 instrument presets: score.part("lead", instrument="violin")
- Demo updated with pluck_synth, organ_synth, strings_synth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:18:27 -04:00
15 changed files with 2057 additions and 192 deletions
+37
View File
@@ -2,6 +2,43 @@
All notable changes to PyTheory are documented here.
## 0.32.1
- `Tone("X")` now raises `ValueError` immediately instead of silently accepting invalid names (#39)
- Support enharmonic spellings: `Cb`, `Fb`, `E#`, `B#` resolve correctly (#40)
- Support double sharps (`C##`, `Fx`) and double flats (`Dbb`) via semitone arithmetic (#41)
- Accept unicode music symbols: `♯` `♭` `𝄪` `𝄫`
## 0.32.0
- **8 new synth engine features:**
- Filter envelope: per-note lowpass sweep (`filter_amount`, `filter_attack`, `filter_decay`, `filter_sustain`)
- Velocity → brightness: harder notes = brighter filter (`vel_to_filter`)
- Sub-oscillator: octave-below sine for bass weight (`sub_osc`)
- Tremolo: amplitude LFO modulation (`tremolo_depth`, `tremolo_rate`)
- Saturation: even-harmonic tape/tube warmth (`saturation`)
- Noise layer: per-note breath/air texture (`noise_mix`)
- Phaser: swept allpass filter chain (`phaser`, `phaser_rate`)
- Configurable FM: `fm_ratio` and `fm_index` params
- **Highpass filter** (12 dB/oct biquad) on any part
- **2 new envelopes:** `bowed` (bow attack with sustain), `mallet` (strike with ringing sustain)
- **Improved `strings_synth`:** additive synthesis with body resonance curve, per-harmonic phase randomization, delayed vibrato onset, bow pressure variation
- **Instrument preset overhaul:** every preset sanity-checked against real instrument behavior
- Mallet instruments (vibraphone, celesta, music box, glockenspiel, tubular bells) now ring properly
- Trumpet uses sustaining envelope instead of pluck
- Woodwinds have breath noise, brass has velocity brightness
- Bass instruments have sub-oscillators, synth presets have filter envelopes
- Piano has velocity-to-brightness and subtle hammer noise
- Signal chain: saturation → tremolo → distortion → chorus → phaser → highpass → lowpass → delay → reverb
- Song #21: Cinematic Showcase (Orchestral)
## 0.31.0
- 3 new synth engines: Karplus-Strong pluck, Hammond organ, string ensemble with body formants
- 38 instrument presets: `score.part("lead", instrument="violin")`
- Keys, strings, woodwinds, brass, plucked, synth, and mallet categories
- 13 total synth waveforms
## 0.30.0
- Drums are a real Part — same effects pipeline as any voice
+238 -9
View File
@@ -32,13 +32,26 @@ It's a well-tested order that sounds good by default.
Effects are applied in this fixed order::
Signal --> Distortion --> Chorus --> Lowpass Filter --> Delay --> Reverb --> Mix
Signal --> Saturation --> Tremolo --> Distortion --> Chorus --> Phaser
--> Highpass --> Lowpass --> Delay --> Reverb --> Mix
- **Distortion** first: drives the raw signal before filtering (like
plugging a guitar into a fuzz pedal before the amp).
- **Chorus** second: thickens the distorted signal.
- **Lowpass** third: shapes the tone (like a tone knob on an amp).
- **Delay** fourth: echoes the shaped signal (tap delay / tape echo).
Additionally, these per-note effects are applied before the part effects chain:
- **Sub-oscillator**: octave-below sine mixed in at the oscillator stage
- **Noise layer**: filtered noise mixed per-note for breath/transients
- **Filter envelope**: per-note lowpass sweep (attack/decay/sustain)
- **Velocity → brightness**: harder velocity = brighter filter cutoff
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).
- **Reverb** last: places everything in a space (room / hall).
Distortion
@@ -498,6 +511,221 @@ whole mix will gasp for air:
delay=0.2,
)
Saturation
----------
Saturation is the warm, subtle harmonic enhancement of analog tape
machines and tube preamps. Unlike distortion (which uses ``tanh`` and
adds harsh odd harmonics), saturation uses a polynomial waveshaper
that adds even harmonics -- 2nd and 4th -- which the ear perceives as
warmth and fullness. It's why records mixed through a Neve console
sound "bigger" than the same mix done in the box.
Parameters:
- ``saturation``: Amount, 0.0--1.0 (default 0, off).
- 0.05--0.15 = subtle analog warmth (tape machine)
- 0.2--0.4 = noticeable color (tube preamp)
- 0.5+ = heavy coloring
.. code-block:: python
# Warm up a bass
bass = score.part("bass", synth="saw", saturation=0.2)
# Glue a string ensemble
strings = score.part("strings", instrument="string_ensemble",
saturation=0.1)
Tremolo
-------
Amplitude modulation by a sine LFO. The classic vibrating-amp sound.
Essential for vibraphone (the rotating discs in the resonator tubes),
Rhodes electric piano, and surf guitar. Not to be confused with
vibrato (pitch modulation).
Parameters:
- ``tremolo_depth``: Modulation depth, 0.0--1.0 (default 0, off).
- ``tremolo_rate``: LFO speed in Hz (default 5.0).
- 3--5 Hz = classic tremolo
- 5--7 Hz = vibraphone motor speed
- 8+ Hz = ring-mod territory
.. code-block:: python
# Classic Fender amp tremolo
guitar = score.part("guitar", synth="saw", envelope="pluck",
tremolo_depth=0.3, tremolo_rate=4.0)
# Vibraphone with motor
vib = score.part("vib", instrument="vibraphone") # built in
Phaser
------
A chain of allpass filters whose center frequencies are swept by an
LFO, creating moving notches in the spectrum. The classic "jet
engine" or "underwater" effect. Think Small Stone, MXR Phase 90, or
the intro to "Eruption." Different from chorus -- chorus adds a
detuned copy, phaser cancels specific frequencies.
Parameters:
- ``phaser``: Wet/dry mix, 0.0--1.0 (default 0, off).
- ``phaser_rate``: LFO sweep speed in Hz (default 0.5).
- 0.1--0.3 = slow, lush sweep
- 0.5--1.0 = classic phaser
- 2.0+ = fast, Leslie-like
.. code-block:: python
# Slow sweep on a pad
pad = score.part("pad", synth="supersaw", envelope="pad",
phaser=0.4, phaser_rate=0.2)
# Leslie sim on organ (built in)
organ = score.part("organ", instrument="organ")
Highpass Filter
---------------
The opposite of lowpass -- removes low-frequency content below the
cutoff. Useful for cleaning up mud from pads, keeping multiple bass
parts from masking each other, or thinning out a sound to sit better
in a mix.
Parameters:
- ``highpass``: Cutoff frequency in Hz (0 = off).
- 80--150 Hz = clean up sub rumble
- 200--400 Hz = thin out a pad
- 500+ Hz = telephone / radio effect
- ``highpass_q``: Resonance / Q factor (default 0.707).
.. code-block:: python
# Clean up sub rumble from a pad
pad = score.part("pad", synth="supersaw", highpass=120)
# Thin out rhythm guitar to leave room for bass
rhythm = score.part("rhythm", synth="saw", highpass=250)
Filter Envelope
---------------
A per-note lowpass filter whose cutoff sweeps over time. This is the
core of subtractive synthesis -- the reason a Moog bass goes "bwow"
instead of "boop." The filter opens on the attack and closes during
decay, giving each note a distinctive timbral shape.
Parameters:
- ``filter_amount``: Sweep range in Hz (0 = off). How far the filter
opens above the base cutoff.
- ``filter_attack``: Time to reach peak cutoff, in seconds (default 0.01).
- ``filter_decay``: Time to fall to sustain level (default 0.3).
- ``filter_sustain``: Sustain level as fraction of amount, 0.0--1.0
(default 0.0 = filter closes completely after decay).
.. code-block:: python
# Classic synth bass "bwow"
bass = score.part("bass", instrument="synth_bass") # built in
# Acid squelch
acid = score.part("acid", instrument="acid_bass") # built in
# Custom filter sweep on a lead
lead = score.part("lead", synth="saw",
filter_amount=4000, filter_attack=0.01,
filter_decay=0.4, filter_sustain=0.1)
Velocity to Brightness
~~~~~~~~~~~~~~~~~~~~~~
Real instruments get brighter when played harder. ``vel_to_filter``
maps note velocity to filter cutoff boost, so louder notes have more
high-frequency content.
- ``vel_to_filter``: Cutoff boost in Hz at max velocity (default 0, off).
.. code-block:: python
# Piano: soft = mellow, loud = bright
piano = score.part("piano", instrument="piano") # built in
# Manual: custom velocity mapping on a lead
lead = score.part("lead", synth="saw", vel_to_filter=3000)
Sub-Oscillator
--------------
An octave-below sine wave mixed in with the main oscillator. Adds
low-end weight without muddiness -- the sub fills in the fundamental
while the main oscillator provides harmonic character above.
- ``sub_osc``: Mix level, 0.0--1.0 (default 0, off).
- 0.1--0.2 = subtle weight (tuba, bass guitar)
- 0.3--0.5 = heavy sub (808, synth bass)
.. code-block:: python
# Fat 808 kick-bass
bass = score.part("bass", instrument="808_bass") # built in
# Add weight to any part
lead = score.part("lead", synth="saw", sub_osc=0.3)
Noise Layer
-----------
White noise mixed into each note, following the same amplitude
envelope. Adds breath for woodwinds, hammer/felt noise for piano,
bow rosin for strings, and attack transients for percussion.
- ``noise_mix``: Mix level, 0.0--1.0 (default 0, off).
- 0.02--0.04 = subtle texture (strings, piano)
- 0.05--0.08 = noticeable breath (woodwinds)
- 0.1+ = heavy air/texture
.. code-block:: python
# Breathy flute
flute = score.part("flute", instrument="flute") # noise_mix=0.08
# Add air to any synth
pad = score.part("pad", synth="supersaw", noise_mix=0.05)
Configurable FM
---------------
The FM synth now accepts ``fm_ratio`` and ``fm_index`` parameters,
letting you dial in specific FM timbres instead of using the defaults.
- ``fm_ratio``: Modulator frequency as multiple of carrier (default 2.0).
Integer ratios = harmonic timbres; non-integer = metallic/inharmonic.
- ``fm_index``: Modulation depth (default 3.0). Higher = more harmonics.
.. code-block:: python
# Warm electric piano (low ratio, low index)
ep = score.part("ep", synth="fm", fm_ratio=1.0, fm_index=1.5)
# Bright metallic bell (high ratio, high index)
bell = score.part("bell", synth="fm", fm_ratio=3.5, fm_index=5.0)
# Glockenspiel
glock = score.part("glock", instrument="glockenspiel") # built in
Automation
----------
@@ -528,9 +756,10 @@ processes each section independently:
lead.set(lowpass=4000, distortion=0.7, reverb=0.3)
lead.arpeggio("Gm", bars=4, pattern="updown", octaves=2)
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``reverb``,
``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
``distortion``, ``distortion_drive``, ``chorus``, ``volume``.
Any parameter can be automated: ``lowpass``, ``lowpass_q``, ``highpass``,
``reverb``, ``reverb_decay``, ``delay``, ``delay_time``, ``delay_feedback``,
``distortion``, ``distortion_drive``, ``chorus``, ``phaser``, ``phaser_rate``,
``saturation``, ``tremolo_depth``, ``tremolo_rate``, ``volume``.
LFO Automation
--------------
+89 -2
View File
@@ -1,7 +1,7 @@
Synthesizers
============
PyTheory includes 10 built-in waveforms and 8 ADSR envelope presets.
PyTheory includes 13 built-in waveforms and 10 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
@@ -247,6 +247,8 @@ PyTheory includes 8 presets:
play(tone, envelope=Envelope.ORGAN) # Instant on/off, no shaping
play(tone, envelope=Envelope.BELL) # Instant attack, long ring
play(tone, envelope=Envelope.STRINGS) # Gradual bow attack
play(tone, envelope=Envelope.BOWED) # Bow bite into sustain
play(tone, envelope=Envelope.MALLET) # Strike with ringing sustain
play(tone, envelope=Envelope.STACCATO) # Short and punchy
play(tone, envelope=Envelope.NONE) # Raw waveform, no shaping
@@ -260,8 +262,10 @@ Name Character
``"pluck"`` Sharp attack, fast decay -- guitar pick, harp
``"pad"`` Slow fade in, lush sustain -- strings, synth pads
``"organ"`` Instant on/off -- Hammond organ, no shaping
``"bell"`` Instant attack, long ring -- vibraphone, tubular
``"bell"`` Instant attack, no sustain -- short metallic ring
``"strings"`` Gradual bow attack -- orchestral strings, slow
``"bowed"`` Bow bite into sustain -- solo strings, brass
``"mallet"`` Strike with ringing sustain -- vibraphone, celesta
``"staccato"`` Short and punchy -- funk stabs, percussive hits
``"none"`` Raw waveform, no amplitude shaping at all
=============== ================================================
@@ -341,6 +345,89 @@ Reverb is also stereo — the left and right channels get different
early reflection patterns, so the reverb tail occupies real space
in the stereo field rather than sitting dead center.
Physical Modeling
-----------------
Three synths go beyond traditional waveform synthesis into physical
modeling territory — they simulate how real instruments produce sound.
Karplus-Strong Pluck
~~~~~~~~~~~~~~~~~~~~
A burst of noise fed into a short delay line. The delay length sets
the pitch, the feedback filter models the string decaying. This is
how every physical modeling synth since 1983 does plucked strings.
It sounds genuinely like a real guitar, harp, or koto.
.. code-block:: python
guitar = score.part("guitar", synth="pluck_synth")
harp = score.part("harp", instrument="harp") # uses pluck_synth
Hammond Organ
~~~~~~~~~~~~~
Additive synthesis with drawbar harmonics — sine waves at the
fundamental plus 2nd, 3rd, 4th, 5th, 6th, and 8th harmonics mixed
at musical levels. Warm, round, unmistakably organ.
.. code-block:: python
organ = score.part("organ", synth="organ_synth")
String Ensemble
~~~~~~~~~~~~~~~
Filtered sawtooth with body resonance formants at ~500 Hz and ~1500 Hz,
modeling the way a violin or cello body shapes the sound. Warmer and
more "wooden" than a raw saw wave.
.. code-block:: python
violin = score.part("violin", synth="strings_synth")
Instrument Presets
------------------
Instead of choosing synth + envelope + effects manually, use an
instrument preset — 38 predefined combinations that approximate real
instruments:
.. code-block:: python
piano = score.part("piano", instrument="piano")
violin = score.part("violin", instrument="violin")
guitar = score.part("guitar", instrument="acoustic_guitar")
organ = score.part("organ", instrument="organ")
bass = score.part("bass", instrument="upright_bass")
Available instruments:
**Keys**: piano, electric_piano, organ, harpsichord, celesta, music_box
**Strings**: violin, viola, cello, contrabass, string_ensemble
**Woodwinds**: flute, clarinet, oboe, bassoon
**Brass**: trumpet, trombone, french_horn, tuba, brass_ensemble
**Plucked**: acoustic_guitar, electric_guitar, distorted_guitar,
bass_guitar, upright_bass, harp, sitar, koto
**Synth**: synth_lead, synth_pad, synth_bass, acid_bass, 808_bass
**Percussion**: vibraphone, marimba, xylophone, glockenspiel, tubular_bells
Explicit kwargs override preset defaults:
.. code-block:: python
# Piano with extra reverb
piano = score.part("piano", instrument="piano", reverb=0.5)
# Violin panned left
violin = score.part("v", instrument="violin", pan=-0.4)
Choosing Synth and Envelope Combos
----------------------------------
+7 -4
View File
@@ -77,11 +77,14 @@ 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** — 10 waveforms, 8 envelopes, detune, stereo pan/spread,
58 drum patterns (stereo panned), 21 fills
- **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
- **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay,
lowpass (with resonance), distortion, chorus, sidechain compression,
automation, LFOs. Master bus compressor/limiter
lowpass/highpass (with resonance), distortion, saturation, chorus,
phaser, tremolo, sidechain compression, automation, LFOs. Master bus
compressor/limiter
- **Instruments** — 25 presets with fingering generation
- **Output** — stereo playback, WAV export, MIDI import/export
- **Interface** — REPL with tab completion, CLI (15 commands), ``pytheory demo``
+122 -1
View File
@@ -1116,6 +1116,125 @@ def temple_bell():
play_song(score)
def cinematic_showcase():
"""Cinematic orchestral showcase — tubular bells, strings, organ, harp, acid bass."""
score = Score("4/4", bpm=100)
# Tubular bells — dramatic intro
bells = score.part("bells", instrument="tubular_bells",
reverb=0.5, reverb_type="cathedral")
bells.add("A3", Duration.WHOLE)
for _ in range(7):
bells.rest(Duration.WHOLE)
# String ensemble — lush wide pad
strings = score.part("strings", instrument="string_ensemble",
reverb=0.4, reverb_type="hall")
strings.rest(Duration.WHOLE)
for sym in ["Am", "F", "C", "G", "Dm", "Am", "E"]:
strings.add(Chord.from_symbol(sym), Duration.WHOLE)
# Cello — deep foundation
cello = score.part("cello", instrument="cello",
reverb=0.3, reverb_type="hall")
cello.rest(Duration.WHOLE)
for n in ["A2", "F2", "C3", "G2", "D3", "A2", "E2"]:
cello.add(n, Duration.WHOLE)
# Violin — legato melody enters bar 3
violin = score.part("violin", instrument="violin",
reverb=0.25, reverb_type="hall", legato=True)
violin.rest(Duration.WHOLE)
violin.rest(Duration.WHOLE)
for note, dur in [
("E5", Duration.HALF), ("C5", Duration.HALF),
("D5", Duration.QUARTER), ("E5", Duration.QUARTER), ("G5", Duration.HALF),
("A5", Duration.HALF), ("G5", Duration.QUARTER), ("E5", Duration.QUARTER),
("F5", Duration.WHOLE),
("E5", Duration.HALF), ("D5", Duration.HALF),
("C5", Duration.HALF), ("B4", Duration.HALF),
("A4", Duration.WHOLE),
]:
violin.add(note, dur)
# Organ — enters halfway, cathedral weight
organ = score.part("organ", instrument="organ",
reverb=0.3, reverb_type="cathedral")
for _ in range(4):
organ.rest(Duration.WHOLE)
for sym in ["Dm", "Am", "E", "Am"]:
organ.add(Chord.from_symbol(sym), Duration.WHOLE)
# Harp — arpeggiated flourishes bars 3-4
harp = score.part("harp", instrument="harp")
harp.rest(Duration.WHOLE)
harp.rest(Duration.WHOLE)
for n in ["A3", "C4", "E4", "A4", "C5", "E5", "A5", "E5",
"G3", "B3", "D4", "G4", "B4", "D5", "G5", "D5"]:
harp.add(n, Duration.EIGHTH)
for _ in range(4):
harp.rest(Duration.WHOLE)
# Vibraphone — shimmer in last bars with delay
vib = score.part("vib", instrument="vibraphone",
delay=0.25, delay_time=0.375, delay_feedback=0.35)
for _ in range(5):
vib.rest(Duration.WHOLE)
for note, dur in [
("E5", Duration.QUARTER), ("D5", Duration.QUARTER),
("C5", Duration.QUARTER), ("A4", Duration.QUARTER),
("B4", Duration.HALF), ("E5", Duration.HALF),
("A5", Duration.WHOLE),
]:
vib.add(note, dur)
# Acid bass — gritty texture bars 4-5
acid = score.part("acid", instrument="acid_bass")
for _ in range(3):
acid.rest(Duration.WHOLE)
for n in ["C2", "C2", "E2", "G2", "G2", "G2", "A2", "E2",
"D2", "D2", "F2", "A2", "A2", "A2", "E2", "E2"]:
acid.add(n, Duration.EIGHTH)
for _ in range(2):
acid.rest(Duration.WHOLE)
# Half time drums
score.drums("half time", repeats=8)
play_song(score, "Cinematic Showcase — A minor")
def greensleeves():
"""Greensleeves — Renaissance lute, meantone tuning, A=415 Hz."""
score = Score("3/4", bpm=120, temperament="meantone", reference_pitch=415.0)
lute = score.part("lute", instrument="acoustic_guitar",
reverb=0.3, reverb_type="taj_mahal")
melody = [
("A4", 1.0, 80),
("C5", 2.0, 85), ("D5", 1.0, 80),
("E5", 3.0, 90),
("F5", 1.0, 75), ("E5", 2.0, 85),
("D5", 1.0, 80),
("B4", 3.0, 85),
("G4", 1.0, 70), ("B4", 2.0, 80),
("C5", 1.0, 75),
("A4", 3.0, 85),
("A4", 1.0, 70), ("A4", 2.0, 75),
("G#4", 1.0, 70),
("A4", 2.0, 80), ("B4", 1.0, 75),
("G4", 3.0, 85),
("E4", 1.0, 70),
("A4", 3.0, 90),
]
for note, dur, vel in melody:
lute.add(note, dur, velocity=vel)
play_song(score, "Greensleeves — Renaissance Lute (Meantone, A=415)")
SONGS = {
"1": ("Bossa Nova in A minor", bossa_nova_girl),
"2": ("Bebop in Bb major", bebop_in_bb),
@@ -1137,6 +1256,8 @@ SONGS = {
"18": ("Glass and Silk (Sine+Triangle)", glass_and_silk),
"19": ("Dance Party at the Reitz House", dance_party),
"20": ("Temple Bell (Japanese)", temple_bell),
"21": ("Cinematic Showcase (Orchestral)", cinematic_showcase),
"22": ("Greensleeves (Renaissance Lute)", greensleeves),
}
if __name__ == "__main__":
@@ -1150,7 +1271,7 @@ if __name__ == "__main__":
print(f" {key:>2}. {name}")
print()
choice = input(" Pick a song (1-20, or 'all'): ").strip()
choice = input(" Pick a song (1-22, or 'all'): ").strip()
print()
if choice == "all":
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.30.0"
version = "0.32.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+3 -3
View File
@@ -1,9 +1,9 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.30.0"
__version__ = "0.32.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .systems import System, SYSTEMS, TET
from .scales import TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
@@ -21,7 +21,7 @@ Scale = TonedScale
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"System", "SYSTEMS", "TET", "CHARTS", "charts_for_fretboard",
"play", "save", "save_midi", "play_progression", "play_pattern",
"play_score", "Synth", "Envelope",
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
+468
View File
@@ -6,10 +6,42 @@ 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.
"""
from fractions import Fraction
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]
TEMPERAMENTS = {
"equal": scales.create_edo_scale,
"pythagorean": scales.create_pythagorean_scale,
"meantone": scales.create_quarter_comma_meantone_scale,
"just": _create_just_intonation_scale,
}
TONES = {
@@ -220,6 +252,442 @@ INDIAN_SCALES = {
}
}
# ── 22-shruti Indian system ──────────────────────────────────────────────────
# The shruti system divides the octave into 22 microtonal steps, capturing
# the melodic nuances that 12-TET cannot represent. Each of the 7 swaras
# has multiple shruti positions (e.g. komal Re at shruti 2, shuddha Re at
# shruti 4). 22-TET is the standard equal-tempered approximation.
#
# Ordered from Dha (=A) to match Western index positions (Sa at index 5 ≈ C).
TONES_SHRUTI = [
("Dha",), # 0 — A — shuddha dhaivat (reference = 440 Hz)
("atikomal Ni",), # 1 — shruti between Dha and komal Ni
("komal Ni",), # 2 — Bb — komal nishad
("shuddha Ni",), # 3 — between komal Ni and Ni
("Ni",), # 4 — B — shuddha (kakali) nishad
("Sa",), # 5 — C — shadja (tonic)
("atikomal Re",), # 6 — shruti between Sa and komal Re
("komal Re",), # 7 — Db — komal rishabh
("shuddha Re",), # 8 — between komal Re and Re
("Re",), # 9 — D — chatushruti rishabh
("atikomal Ga",), # 10 — shruti between Re and komal Ga
("komal Ga",), # 11 — Eb — komal gandhar
("Ga",), # 12 — E — antara gandhar
("tivra Ga",), # 13 — shruti between Ga and Ma
("Ma",), # 14 — F — shuddha madhyam
("ekashruti Ma",), # 15 — shruti between Ma and tivra Ma
("tivra Ma",), # 16 — F# — tivra madhyam
("atitivra Ma",), # 17 — shruti between tivra Ma and Pa
("Pa",), # 18 — G — pancham
("atikomal Dha",), # 19 — shruti between Pa and komal Dha
("komal Dha",), # 20 — Ab — komal dhaivat
("shuddha Dha",), # 21 — shruti between komal Dha and Dha
]
DEGREES_SHRUTI = [
("shadja", ("bilawal",)), # Sa — tonic
("rishabh", ("marwa",)), # Re
("gandhar", ("bhairavi",)), # Ga
("madhyam", ("kalyan",)), # Ma
("pancham", ("kafi",)), # Pa
("dhaivat", ("asavari",)), # Dha
("nishad", ("khamaj",)), # Ni
("shadja", ()), # Sa (octave)
]
# 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 = {
"chromatic": (22, {}),
"thaat": [
7,
{
# Bilawal (≈ Ionian) — Sa Re Ga Ma Pa Dha Ni
"bilawal": {"intervals": (4, 3, 2, 4, 4, 3, 2)},
# Khamaj (≈ Mixolydian) — Sa Re Ga Ma Pa Dha komal-Ni
"khamaj": {"intervals": (4, 3, 2, 4, 4, 1, 4)},
# Kafi (≈ Dorian) — Sa Re komal-Ga Ma Pa Dha komal-Ni
"kafi": {"intervals": (4, 2, 3, 4, 4, 1, 4)},
# Asavari (≈ Aeolian) — Sa Re komal-Ga Ma Pa komal-Dha komal-Ni
"asavari": {"intervals": (4, 2, 3, 4, 2, 3, 4)},
# Bhairavi (≈ Phrygian) — Sa komal-Re komal-Ga Ma Pa komal-Dha komal-Ni
"bhairavi": {"intervals": (2, 4, 3, 4, 2, 3, 4)},
# Bhairav — Sa komal-Re Ga Ma Pa komal-Dha Ni (unique to Indian music)
"bhairav": {"intervals": (2, 5, 2, 4, 2, 5, 2)},
# Kalyan (≈ Lydian) — Sa Re Ga tivra-Ma Pa Dha Ni
"kalyan": {"intervals": (4, 3, 4, 2, 4, 3, 2)},
# Marwa — Sa komal-Re Ga tivra-Ma Pa Dha Ni (unique)
"marwa": {"intervals": (2, 5, 4, 2, 4, 3, 2)},
# Poorvi — Sa komal-Re Ga tivra-Ma Pa komal-Dha Ni (unique)
"poorvi": {"intervals": (2, 5, 4, 2, 2, 5, 2)},
# Todi — Sa komal-Re komal-Ga tivra-Ma Pa komal-Dha Ni (unique)
"todi": {"intervals": (2, 4, 5, 2, 2, 5, 2)},
},
],
"pentatonic": [
5,
{
# Bhupali (≈ major pentatonic) — Sa Re Ga Pa Dha
"bhupali": {"intervals": (4, 3, 6, 4, 5)},
# Malkauns — Sa komal-Ga Ma komal-Dha komal-Ni
"malkauns": {"intervals": (6, 3, 4, 5, 4)},
# Durga — Sa Re Ma Pa Dha
"durga": {"intervals": (4, 5, 4, 4, 5)},
# Bhairavi pentatonic — Sa komal-Re Ma Pa komal-Ni
"bhairavi pentatonic": {"intervals": (2, 7, 4, 2, 7)},
},
],
}
# ── 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.
#
# Ordered from La (=A) to match Western index positions.
TONES_ARABIC_24 = [
("La",), # 0 — A
("La↑",), # 1 — A quarter-sharp
("Sib",), # 2 — Bb
("Si↓",), # 3 — B quarter-flat
("Si",), # 4 — B
("Do",), # 5 — C
("Do↑",), # 6 — C quarter-sharp
("Reb",), # 7 — Db
("Re↓",), # 8 — D quarter-flat
("Re",), # 9 — D
("Re↑",), # 10 — D quarter-sharp
("Mib",), # 11 — Eb
("Mi↓",), # 12 — E quarter-flat
("Mi",), # 13 — E
("Fa",), # 14 — F
("Fa↑",), # 15 — F quarter-sharp
("Fa#",), # 16 — F#
("Sol↓",), # 17 — G quarter-flat
("Sol",), # 18 — G
("Sol↑",), # 19 — G quarter-sharp
("Lab",), # 20 — Ab
("La↓",), # 21 — A quarter-flat
("La½b",), # 22 — between Ab and A (rarely used)
("La♮",), # 23 — enharmonic A (rarely used)
]
DEGREES_ARABIC_24 = [
("tonic", ()),
("second", ()),
("third", ()),
("fourth", ()),
("fifth", ()),
("sixth", ()),
("seventh", ()),
("octave", ()),
]
# 24-TET maqam scales with true quarter-tone intervals.
# Each step = 1 quarter-tone (50 cents). A 12-TET semitone = 2 steps.
ARABIC_24_SCALES = {
"chromatic": (24, {}),
"maqam": [
7,
{
# Rast — the foundational maqam. E and B are quarter-flat.
# Do Re Mi↓ Fa Sol La Si↓ Do
"rast": {"intervals": (4, 3, 3, 4, 4, 3, 3)},
# Bayati — starts on D with quarter-flat 2nd.
# Re Mi↓ Fa Sol La Sib Do Re
"bayati": {"intervals": (3, 3, 4, 4, 2, 4, 4)},
# Saba — similar to Bayati with flattened 4th
"saba": {"intervals": (3, 3, 2, 6, 2, 4, 4)},
# Sikah — starts on E quarter-flat
"sikah": {"intervals": (3, 4, 3, 4, 3, 4, 3)},
# Hijaz — augmented 2nd (6 quarter-tones) between 2nd and 3rd
"hijaz": {"intervals": (2, 6, 2, 4, 2, 4, 4)},
# Nahawand (≈ harmonic minor)
"nahawand": {"intervals": (4, 2, 4, 4, 2, 6, 2)},
# Ajam (≈ major)
"ajam": {"intervals": (4, 4, 2, 4, 4, 4, 2)},
# Kurd (≈ Phrygian)
"kurd": {"intervals": (2, 4, 4, 4, 2, 4, 4)},
# Nikriz — augmented 2nd between 3rd and 4th
"nikriz": {"intervals": (4, 2, 6, 2, 4, 2, 4)},
# Jiharkah — like Rast but with natural B
"jiharkah": {"intervals": (4, 4, 2, 4, 4, 3, 3)},
},
],
}
# ── 5-TET Gamelan Slendro ────────────────────────────────────────────────────
# Slendro is a 5-tone equal temperament — each step is 240 cents.
# The actual tuning varies between gamelans (each set is unique), but
# 5-TET is the theoretical ideal that all slendro tunings approximate.
# Ordered from nem (≈A) to loosely match Western indexing.
TONES_SLENDRO = [
("nem",), # 0 — 6 (≈A)
("ji",), # 1 — 1 (≈C)
("ro",), # 2 — 2 (≈D)
("lu",), # 3 — 3 (≈F)
("mo",), # 4 — 5 (≈G)
]
DEGREES_SLENDRO = [
("nem", ()), ("ji", ()), ("ro", ()), ("lu", ()), ("mo", ()),
]
SLENDRO_SCALES = {
"chromatic": (5, {}),
"pentatonic": [5, {
# The full slendro IS the pentatonic — all 5 tones
"slendro": {"intervals": (1, 1, 1, 1, 1)},
}],
}
# ── 9-TET Gamelan Pelog ─────────────────────────────────────────────────────
# Pelog uses 7 tones from a roughly 9-step division of the octave.
# 9-TET (133 cents/step) approximates the unequal pelog intervals.
# The 3 pathet (modes) select 5 tones from the 7.
TONES_PELOG = [
("nem",), # 0 — 6
("pi",), # 1 — 7
("ji",), # 2 — 1
("ro",), # 3 — 2
("lu",), # 4 — 3
("pat",), # 5 — 4
("barang",), # 6 — complementary
("mo",), # 7 — 5
("nem+",), # 8 — auxiliary
]
DEGREES_PELOG = [
("nem", ()), ("pi", ()), ("ji", ()), ("ro", ()),
("lu", ()), ("pat", ()), ("barang", ()), ("mo", ()), ("nem+", ()),
]
PELOG_SCALES = {
"chromatic": (9, {}),
"heptatonic": [7, {
# Full pelog — 7 tones from 9 steps
"pelog": {"intervals": (1, 2, 1, 1, 2, 1, 1)},
}],
"pentatonic": [5, {
# Pathet nem — the most common mode
"pelog nem": {"intervals": (1, 2, 2, 2, 2)},
# Pathet lima
"pelog lima": {"intervals": (1, 2, 2, 1, 3)},
# Pathet barang
"pelog barang": {"intervals": (2, 1, 2, 2, 2)},
}],
}
# ── 7-TET Thai classical ────────────────────────────────────────────────────
# Thai classical music divides the octave into 7 exactly equal steps
# (~171 cents each). This is unique — no Western equivalent exists.
# The 7 tones are numbered 1-7 in Thai theory.
TONES_THAI = [
("do",), # 0 — 1st degree
("re",), # 1 — 2nd
("mi",), # 2 — 3rd
("fa",), # 3 — 4th
("sol",), # 4 — 5th
("la",), # 5 — 6th
("si",), # 6 — 7th
]
DEGREES_THAI = [
("thang 1", ()), ("thang 2", ()), ("thang 3", ()),
("thang 4", ()), ("thang 5", ()), ("thang 6", ()), ("thang 7", ()),
]
THAI_SCALES = {
"chromatic": (7, {}),
"pentatonic": [5, {
# The standard Thai pentatonic — 5 of 7 equal steps
"thai pentatonic": {"intervals": (1, 1, 2, 1, 2)},
# Alternate selection
"thai pentatonic 2": {"intervals": (2, 1, 1, 2, 1)},
}],
"heptatonic": [7, {
# The full 7-TET scale
"thai": {"intervals": (1, 1, 1, 1, 1, 1, 1)},
}],
}
# ── 53-TET Turkish makam (Arel-Ezgi-Uzdilek) ───────────────────────────────
# The gold standard for Turkish music theory. 53-TET has nearly perfect
# fifths (31 steps = 701.89 cents vs 701.96 just) and excellent thirds.
# A comma (1 step) = 22.6 cents. The basic intervals:
# Bakiye (B) = 4 commas ≈ 90 cents (like a limma)
# Küçük mücenneb (S) = 5 commas ≈ 113 cents
# Büyük mücenneb (K) = 8 commas ≈ 181 cents
# Tanini (T) = 9 commas ≈ 204 cents (like a whole tone)
TONES_TURKISH = [
("La",), # 0 — A (Dügah reference)
("La+1",), # 1
("La+2",), # 2
("La+3",), # 3
("Sib",), # 4 — Bb (4 commas from A)
("Sib+1",), # 5
("Sib+2",), # 6
("Sib+3",), # 7
("Sib+4",), # 8
("Si",), # 9 — B
("Si+1",), # 10
("Si+2",), # 11
("Si+3",), # 12
("Do",), # 13 — C (Rast)
("Do+1",), # 14
("Do+2",), # 15
("Do+3",), # 16
("Do+4",), # 17
("Reb",), # 18 — Db
("Reb+1",), # 19
("Reb+2",), # 20
("Reb+3",), # 21
("Re",), # 22 — D (Dügah)
("Re+1",), # 23
("Re+2",), # 24
("Re+3",), # 25
("Re+4",), # 26
("Mib",), # 27 — Eb
("Mib+1",), # 28
("Mib+2",), # 29
("Mib+3",), # 30
("Mi",), # 31 — E (Segah)
("Mi+1",), # 32
("Mi+2",), # 33
("Mi+3",), # 34
("Mi+4",), # 35
("Fa",), # 36 — F
("Fa+1",), # 37
("Fa+2",), # 38
("Fa+3",), # 39
("Fa#",), # 40 — F#
("Fa#+1",), # 41
("Fa#+2",), # 42
("Fa#+3",), # 43
("Sol",), # 44 — G (Neva)
("Sol+1",), # 45
("Sol+2",), # 46
("Sol+3",), # 47
("Lab",), # 48 — Ab
("Lab+1",), # 49
("Lab+2",), # 50
("Lab+3",), # 51
("Lab+4",), # 52
]
DEGREES_TURKISH = [(f"perde {i+1}", ()) for i in range(53)]
# Turkish makam scales in 53-TET commas.
# T=9 commas (whole tone), S=5 (small), K=8 (large), B=4 (limma)
TURKISH_SCALES = {
"chromatic": (53, {}),
"makam": [
7,
{
# Rast — the foundational makam. Uses segah (≈ neutral 3rd)
# T + T + S + T + T + T + S = 9+9+5+9+9+9+4 = 53...
# Actually: 9+8+5+9+9+8+5 = 53
"rast": {"intervals": (9, 8, 5, 9, 9, 8, 5)},
# Nihavend (≈ harmonic minor)
"nihavend": {"intervals": (9, 4, 9, 9, 4, 13, 5)},
# Hicaz — the augmented 2nd makam
"hicaz": {"intervals": (5, 12, 5, 9, 4, 9, 9)},
# Ussak — one of the most common makams
"ussak": {"intervals": (8, 5, 9, 9, 8, 5, 9)},
# Huseyni
"huseyni": {"intervals": (8, 5, 9, 9, 5, 8, 9)},
# Kurdi (≈ Phrygian)
"kurdi": {"intervals": (4, 9, 9, 9, 4, 9, 9)},
# Segah — starts on the neutral 3rd
"segah": {"intervals": (5, 9, 9, 8, 5, 9, 8)},
# Saba — descending differs from ascending
"saba": {"intervals": (8, 5, 4, 14, 4, 9, 9)},
# Hüzzam
"huzzam": {"intervals": (5, 9, 8, 5, 9, 8, 9)},
},
],
}
# ── 72-TET Carnatic (South Indian) ───────────────────────────────────────────
# The 72 melakarta system classifies all possible 7-note scales with
# fixed Sa and Pa. 72-TET (16.67 cents/step) captures the srutis used
# in Carnatic music with high precision. Each 12-TET semitone = 6 steps.
#
# Tone names: 12 swaras × 6 microtonal variants each.
# Main swaras at positions: Sa=0, Ri1=6, Ri2=12, Ga1=12, Ga2=18,
# Ma1=30, Ma2=36, Pa=42, Da1=48, Da2=54, Ni1=60, Ni2=66
TONES_CARNATIC = []
_SWARA_NAMES = [
"Sa", "atikomal Ri", "komal Ri", "shuddha Ri",
"Ri", "tivra Ri", "komal Ga", "atikomal Ga",
"Ga", "shuddha Ga", "tivra Ga", "antara Ga",
"komal Ma", "shuddha Ma", "Ma", "tivra shuddha Ma",
"ekashruti Ma", "chatushruti Ma", "tivra Ma", "atitivra Ma",
"prati Ma", "tivratara Ma", "atikomal Pa-", "komal Pa-",
"shuddha Pa-", "Pa-", "Pa-+1", "Pa-+2",
"Pa-+3", "Pa-+4", "Pa", "Pa+1",
"Pa+2", "Pa+3", "Pa+4", "Pa+5",
"komal Da", "atikomal Da", "Da-", "shuddha Da-",
"Da", "shuddha Da", "tivra Da", "atitivra Da",
"komal Ni", "atikomal Ni", "Ni-", "shuddha Ni-",
"Ni", "shuddha Ni", "tivra Ni", "chatushruti Ni",
"kakali Ni", "atikakali Ni",
]
# Generate 72 tone names: use standard names for the 12 main positions,
# numbered variants for the intermediates
for i in range(72):
main_pos = i // 6 # which semitone group (0-11)
micro = i % 6 # microtonal position within group
_base_names = ["Sa", "komal Ri", "Ri", "komal Ga", "Ga", "Ma",
"tivra Ma", "Pa", "komal Da", "Da", "komal Ni", "Ni"]
if micro == 0:
TONES_CARNATIC.append((_base_names[main_pos],))
else:
TONES_CARNATIC.append((f"{_base_names[main_pos]}+{micro}",))
DEGREES_CARNATIC = [(f"swara {i+1}", ()) for i in range(72)]
# A selection of important melakartas in 72-TET intervals.
# Each step = 1/72 of an octave ≈ 16.67 cents.
CARNATIC_SCALES = {
"chromatic": (72, {}),
"melakarta": [
7,
{
# Kanakangi (melakarta 1) — Sa Ri1 Ga1 Ma1 Pa Da1 Ni1
"kanakangi": {"intervals": (6, 6, 18, 12, 6, 6, 18)},
# Shankarabharanam (melakarta 29) — Sa Ri2 Ga3 Ma1 Pa Da2 Ni3
# The Carnatic equivalent of the major scale
"shankarabharanam": {"intervals": (12, 12, 6, 12, 12, 12, 6)},
# Kalyani (melakarta 65) — Sa Ri2 Ga3 Ma2 Pa Da2 Ni3
# Carnatic Lydian equivalent
"kalyani": {"intervals": (12, 12, 12, 6, 12, 12, 6)},
# Kharaharapriya (melakarta 22) — Sa Ri2 Ga2 Ma1 Pa Da2 Ni2
# Carnatic Dorian equivalent
"kharaharapriya": {"intervals": (12, 6, 12, 12, 12, 6, 12)},
# Hanumathodi (melakarta 8) — Sa Ri1 Ga2 Ma1 Pa Da1 Ni2
# Carnatic Phrygian equivalent
"hanumathodi": {"intervals": (6, 12, 12, 12, 6, 12, 12)},
# Natabhairavi (melakarta 20) — Sa Ri2 Ga2 Ma1 Pa Da1 Ni2
# Natural minor equivalent
"natabhairavi": {"intervals": (12, 6, 12, 12, 6, 12, 12)},
# Mayamalavagowla (melakarta 15) — Sa Ri1 Ga3 Ma1 Pa Da1 Ni3
# The "lesson scale" — first raga taught to students
"mayamalavagowla": {"intervals": (6, 18, 6, 12, 6, 18, 6)},
# Simhendramadhyamam (melakarta 57) — Sa Ri2 Ga3 Ma2 Pa Da1 Ni3
"simhendramadhyamam": {"intervals": (12, 12, 12, 6, 6, 18, 6)},
# Charukesi (melakarta 26) — Sa Ri2 Ga3 Ma1 Pa Da1 Ni2
"charukesi": {"intervals": (12, 12, 6, 12, 6, 12, 12)},
# Harikambhoji (melakarta 28) — Sa Ri2 Ga3 Ma1 Pa Da2 Ni2
# Mixolydian equivalent
"harikambhoji": {"intervals": (12, 12, 6, 12, 12, 6, 12)},
},
],
}
# Arabic maqam scales (12-TET approximations).
# True maqam uses quarter-tones; these are the closest 12-tone equivalents.
ARABIC_SCALES = {
+5 -5
View File
@@ -230,7 +230,7 @@ def cmd_demo(args):
{"name": "Bossa Nova", "key": ("A", "minor"), "drums": "bossa nova",
"fill": "bossa nova", "bpm": 140,
"prog": ("i", "iv", "V", "i"),
"lead": ("triangle", "strings", 0.2, -0.1),
"lead": ("pluck_synth", "none", 0.2, -0.1),
"pad": ("fm", "pad", -0.2),
"bass_lp": 600, "reverb_type": "plate"},
{"name": "Jazz Club", "key": ("Bb", "major"), "drums": "jazz",
@@ -254,8 +254,8 @@ def cmd_demo(args):
{"name": "Reggae", "key": ("G", "major"), "drums": "reggae",
"fill": "reggae", "bpm": 80,
"prog": ("I", "IV", "V", "IV"),
"lead": ("triangle", "strings", 0.25, 0.15),
"pad": ("pwm_slow", "pad", -0.3),
"lead": ("pluck_synth", "none", 0.25, 0.15),
"pad": ("organ_synth", "organ", -0.3),
"bass_lp": 400, "reverb_type": "cathedral"},
{"name": "Funk", "key": ("E", "minor"), "drums": "funk",
"fill": "funk", "bpm": 100,
@@ -272,8 +272,8 @@ def cmd_demo(args):
{"name": "Temple", "key": ("E", "minor"), "drums": "bolero",
"fill": "bossa nova", "bpm": 65,
"prog": ("i", "iv", "V", "i"),
"lead": ("triangle", "pluck", 0.3, 0.2),
"pad": ("sine", "pad", 0.0),
"lead": ("pluck_synth", "none", 0.3, 0.2),
"pad": ("strings_synth", "pad", 0.0),
"bass_lp": 200, "reverb_type": "taj_mahal"},
]
+370 -50
View File
@@ -243,42 +243,71 @@ def organ_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
def strings_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""String ensemble — filtered saw with body resonance formants.
"""Bowed string — additive synthesis with natural harmonic rolloff.
Goes beyond raw sawtooth by modeling the resonant body of a
stringed instrument. Two formant peaks (at ~500 Hz and ~1500 Hz)
shape the spectrum the way a violin or cello body does — boosting
certain frequencies and cutting others.
The result is warmer and more "wooden" than a raw saw wave,
with the characteristic nasal quality of bowed strings.
Models bowed string physics:
- Additive harmonics with 1/n rolloff shaped by body resonance
- Delayed vibrato (develops ~200ms in, like a real player)
- Subtle bow pressure variation (amplitude modulation)
- Per-harmonic phase randomization for natural timbre
- Gentle spectral tilt to avoid synthetic brightness
"""
# Base: sawtooth (all harmonics, like a bowed string)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=1)
wave = numpy.resize(onecycle, (n_samples,)).astype(numpy.float64)
t = numpy.arange(n_samples, dtype=numpy.float64) / SAMPLE_RATE
rng = numpy.random.default_rng(int(hz * 100) % 2**31)
# Body resonance formants — two bandpass peaks
# Formant 1: ~500 Hz (body resonance)
f1 = 500
bw1 = 200
b1, a1 = scipy.signal.butter(2, [max(20, f1 - bw1), f1 + bw1],
btype='band', fs=SAMPLE_RATE)
formant1 = scipy.signal.lfilter(b1, a1, wave)
# 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_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)
# Formant 2: ~1500 Hz (bridge/top plate)
f2 = 1500
bw2 = 400
b2, a2 = scipy.signal.butter(2, [f2 - bw2, f2 + bw2],
btype='band', fs=SAMPLE_RATE)
formant2 = scipy.signal.lfilter(b2, a2, wave)
# Additive synthesis — build harmonics with natural rolloff
nyquist = SAMPLE_RATE / 2.0
n_harmonics = min(40, int(nyquist / hz))
wave = numpy.zeros(n_samples, dtype=numpy.float64)
# Mix: original (attenuated) + formants
mixed = wave * 0.3 + formant1 * 0.4 + formant2 * 0.3
# Body resonance curve — emphasizes certain harmonic regions
# Modeled after violin/cello response: peaks around 300Hz, 1kHz, 2.5kHz
def body_response(f):
"""Approximate string instrument body resonance."""
r = 1.0
# Main air resonance (~280 Hz for violin, scales with pitch)
air_f = max(200, min(400, hz * 1.5))
r += 0.6 * numpy.exp(-((f - air_f) / 100) ** 2)
# Wood resonance (~1 kHz)
r += 0.4 * numpy.exp(-((f - 1000) / 300) ** 2)
# Bridge resonance (~2.5 kHz) — the "presence" peak
r += 0.3 * numpy.exp(-((f - 2500) / 500) ** 2)
return r
return (peak * mixed).astype(numpy.int16)
for n in range(1, n_harmonics + 1):
f_n = hz * n
if f_n >= nyquist:
break
# Amplitude: 1/n rolloff (sawtooth-like) shaped by body
amp = (1.0 / n) * body_response(f_n)
# Even harmonics slightly weaker (bowing point ~1/8 from bridge)
if n % 2 == 0:
amp *= 0.85
# Random phase per harmonic — prevents the "buzzy" coherent-phase sound
phi = rng.uniform(0, 2 * numpy.pi)
wave += amp * numpy.sin(2 * numpy.pi * (f_n * t + vibrato * n / hz) + phi)
# Normalize
max_val = numpy.abs(wave).max()
if max_val > 0:
wave /= max_val
# Subtle bow pressure variation — slow amplitude wobble
bow_pressure = 1.0 + 0.03 * numpy.sin(2 * numpy.pi * 3.7 * t)
wave *= bow_pressure
# Gentle lowpass — real instruments don't have infinite bandwidth
cutoff = min(10000, hz * 10)
bl, al = scipy.signal.butter(2, cutoff, btype='low', fs=SAMPLE_RATE)
wave = scipy.signal.lfilter(bl, al, wave)
return (peak * wave).astype(numpy.int16)
def _apply_envelope(samples, attack, decay, sustain, release, sample_rate=SAMPLE_RATE):
@@ -346,7 +375,9 @@ class Envelope(Enum):
PLUCK = (0.002, 0.15, 0.0, 0.1)
PAD = (0.4, 0.2, 0.7, 0.5)
STRINGS = (0.15, 0.1, 0.8, 0.3)
BOWED = (0.04, 0.08, 0.75, 0.25)
BELL = (0.001, 0.3, 0.0, 0.5)
MALLET = (0.002, 0.05, 0.6, 0.8)
STACCATO = (0.005, 0.05, 0.0, 0.02)
@@ -1458,6 +1489,41 @@ def _apply_lowpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_highpass(samples, cutoff, q=0.707, sample_rate=SAMPLE_RATE):
"""Apply a 2nd-order Butterworth highpass filter (12 dB/octave).
Removes low-frequency content below the cutoff. Useful for cleaning
up mud from pads, keeping bass parts from masking each other, or
thinning out a sound.
Args:
samples: Float32 numpy array.
cutoff: Cutoff frequency in Hz.
q: Resonance / Q factor (default 0.707 = Butterworth flat).
sample_rate: Sample rate in Hz.
Returns:
Float32 array with filter applied.
"""
if cutoff <= 0 or cutoff >= sample_rate / 2:
return samples
w0 = 2 * numpy.pi * cutoff / sample_rate
alpha = numpy.sin(w0) / (2 * q)
b0 = (1 + numpy.cos(w0)) / 2
b1 = -(1 + numpy.cos(w0))
b2 = (1 + numpy.cos(w0)) / 2
a0 = 1 + alpha
a1 = -2 * numpy.cos(w0)
a2 = 1 - alpha
b = numpy.array([b0/a0, b1/a0, b2/a0])
a = numpy.array([1.0, a1/a0, a2/a0])
return scipy.signal.lfilter(b, a, samples).astype(numpy.float32)
def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
sample_rate=SAMPLE_RATE):
"""Apply a chorus effect — slightly detuned delayed copy mixed in.
@@ -1503,6 +1569,171 @@ def _apply_chorus(samples, mix=0.5, rate=1.5, depth=0.003,
return samples * (1 - mix * 0.5) + wet * mix * 0.5
def _apply_filter_envelope(samples, base_cutoff, amount, f_attack, f_decay,
f_sustain, q=0.707, vel_cutoff_boost=0.0,
sample_rate=SAMPLE_RATE):
"""Apply a per-note filter envelope — cutoff sweeps over time.
This is the core of subtractive synthesis: the filter opens on the
attack and closes during decay, giving notes a characteristic
"bwow" or "wah" shape.
Uses block-based processing (64-sample blocks) with biquad coefficient
interpolation for efficiency and smooth sweeps.
"""
n = len(samples)
if n == 0 or amount <= 0:
return samples
block_size = 64
out = numpy.empty_like(samples)
# Build the filter cutoff envelope
a_samps = int(f_attack * sample_rate)
d_samps = int(f_decay * sample_rate)
sustain_level = f_sustain * amount
cutoff_env = numpy.full(n, sustain_level + base_cutoff + vel_cutoff_boost,
dtype=numpy.float64)
# Attack ramp: 0 → amount
if a_samps > 0:
a_end = min(a_samps, n)
cutoff_env[:a_end] = (base_cutoff + vel_cutoff_boost +
numpy.linspace(0, amount, a_end))
# Decay ramp: amount → sustain_level
if d_samps > 0:
d_start = min(a_samps, n)
d_end = min(a_samps + d_samps, n)
if d_end > d_start:
cutoff_env[d_start:d_end] = (base_cutoff + vel_cutoff_boost +
numpy.linspace(amount, sustain_level,
d_end - d_start))
# Clamp cutoff to valid range
cutoff_env = numpy.clip(cutoff_env, 20.0, sample_rate / 2 - 1)
# Block-based biquad processing with varying cutoff
# State variables for the filter
x1 = x2 = y1 = y2 = 0.0
pos = 0
while pos < n:
end = min(pos + block_size, n)
block = samples[pos:end]
# Use cutoff at block midpoint
mid = (pos + end) // 2
fc = cutoff_env[mid]
# Compute biquad coefficients
w0 = 2 * numpy.pi * fc / sample_rate
sin_w0 = numpy.sin(w0)
cos_w0 = numpy.cos(w0)
alpha = sin_w0 / (2 * q)
b0 = (1 - cos_w0) / 2
b1 = 1 - cos_w0
b2 = (1 - cos_w0) / 2
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
# Normalize
b0 /= a0; b1 /= a0; b2 /= a0
a1 /= a0; a2 /= a0
# Process block sample by sample (maintaining state)
out_block = numpy.empty(len(block), dtype=numpy.float32)
for i in range(len(block)):
x0 = float(block[i])
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
out_block[i] = y0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
out[pos:end] = out_block
pos = end
return out
def _apply_saturation(samples, amount=0.5):
"""Apply tape/tube saturation — subtle even-harmonic warmth.
Unlike distortion (tanh, odd harmonics), saturation uses a
polynomial waveshaper that adds 2nd and 4th harmonics — the
warm, pleasing character of analog tape and tube preamps.
"""
if amount <= 0:
return samples
# Asymmetric polynomial: x + k*x^2 adds even harmonics
driven = samples + amount * samples * samples
# Normalize to prevent gain buildup
driven = driven / (1.0 + amount)
# Soft clip any overs
return numpy.clip(driven, -1.0, 1.0).astype(numpy.float32)
def _apply_tremolo(samples, depth=0.5, rate=5.0, sample_rate=SAMPLE_RATE):
"""Apply tremolo — amplitude modulation by a sine LFO.
The classic vibrating amp sound. Essential for vibraphone,
electric guitar, and organ Leslie speaker simulation.
"""
if depth <= 0:
return samples
t = numpy.arange(len(samples), dtype=numpy.float64) / sample_rate
lfo = 1.0 - depth * 0.5 * (1.0 + numpy.sin(2 * numpy.pi * rate * t))
return (samples * lfo).astype(numpy.float32)
def _apply_phaser(samples, mix=0.5, rate=0.5, stages=4,
sample_rate=SAMPLE_RATE):
"""Apply phaser — swept allpass filter chain.
Creates moving notches in the frequency spectrum by passing
the signal through a chain of allpass filters whose center
frequencies are modulated by an LFO. Classic effect for
electric piano, pads, and guitar.
"""
if mix <= 0:
return samples
n = len(samples)
block_size = 64
t = numpy.arange(n, dtype=numpy.float64) / sample_rate
# LFO sweeps center frequency between 200Hz and 4000Hz (log scale)
lfo = 0.5 + 0.5 * numpy.sin(2 * numpy.pi * rate * t)
center_freqs = 200.0 * (20.0 ** lfo) # 200Hz to 4000Hz
# Process through allpass stages
wet = samples.copy().astype(numpy.float64)
for _stage in range(stages):
out = numpy.empty(n, dtype=numpy.float64)
# Allpass state
x1 = x2 = y1 = y2 = 0.0
pos = 0
while pos < n:
end = min(pos + block_size, n)
mid = (pos + end) // 2
fc = center_freqs[mid]
# Allpass biquad coefficients
w0 = 2 * numpy.pi * fc / sample_rate
alpha = numpy.sin(w0) / 2.0 # Q=0.5 for wide sweep
cos_w0 = numpy.cos(w0)
b0 = 1 - alpha
b1 = -2 * cos_w0
b2 = 1 + alpha
a0 = 1 + alpha
a1 = -2 * cos_w0
a2 = 1 - alpha
b0 /= a0; b1 /= a0; b2 /= a0
a1 /= a0; a2 /= a0
for i in range(pos, end):
x0 = wet[i]
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
out[i] = y0
x2 = x1; x1 = x0
y2 = y1; y1 = y0
pos = end
wet = out
return (samples * (1 - mix) + wet.astype(numpy.float32) * mix).astype(numpy.float32)
def _apply_distortion(samples, drive=1.0, mix=1.0):
"""Apply soft-clip distortion (tanh waveshaping).
@@ -1534,7 +1765,13 @@ def _apply_distortion(samples, drive=1.0, mix=1.0):
def _apply_effects_with_params(samples, params, skip_reverb=False):
"""Apply effects using a params dict. Used for both static and automated rendering."""
# Signal chain: distortion → chorus → lowpass → delay → reverb
# Signal chain: saturation → tremolo → distortion → chorus → phaser
# → highpass → lowpass → delay → reverb
if params.get("saturation", 0) > 0:
samples = _apply_saturation(samples, amount=params["saturation"])
if params.get("tremolo_depth", 0) > 0:
samples = _apply_tremolo(samples, depth=params["tremolo_depth"],
rate=params.get("tremolo_rate", 5.0))
if params.get("distortion_mix", 0) > 0:
samples = _apply_distortion(samples,
drive=params.get("distortion_drive", 3.0),
@@ -1544,6 +1781,12 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
mix=params["chorus_mix"],
rate=params.get("chorus_rate", 1.5),
depth=params.get("chorus_depth", 0.003))
if params.get("phaser_mix", 0) > 0:
samples = _apply_phaser(samples, mix=params["phaser_mix"],
rate=params.get("phaser_rate", 0.5))
if params.get("highpass", 0) > 0:
samples = _apply_highpass(samples, params["highpass"],
params.get("highpass_q", 0.707))
if params.get("lowpass", 0) > 0:
samples = _apply_lowpass(samples, params["lowpass"],
params.get("lowpass_q", 0.707))
@@ -1568,11 +1811,18 @@ def _apply_effects_with_params(samples, params, skip_reverb=False):
def _apply_part_effects(samples, part):
"""Apply all effects configured on a Part to a float32 buffer."""
params = {
"saturation": part.saturation,
"tremolo_depth": part.tremolo_depth,
"tremolo_rate": part.tremolo_rate,
"distortion_mix": part.distortion_mix,
"distortion_drive": part.distortion_drive,
"chorus_mix": part.chorus_mix,
"chorus_rate": part.chorus_rate,
"chorus_depth": part.chorus_depth,
"phaser_mix": part.phaser_mix,
"phaser_rate": part.phaser_rate,
"highpass": part.highpass,
"highpass_q": part.highpass_q,
"lowpass": part.lowpass,
"lowpass_q": part.lowpass_q,
"delay_mix": part.delay_mix,
@@ -1716,11 +1966,18 @@ def _total_samples_from_tempo_map(total_beats, tempo_map):
def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
synth_fn, envelope_tuple, volume, bpm,
swing=0.0, tempo_map=None, humanize=0.0,
detune=0.0, spread=0.0, stereo_buf=None):
detune=0.0, spread=0.0, stereo_buf=None,
sub_osc=0.0, noise_mix=0.0,
filter_attack=0.01, filter_decay=0.3,
filter_sustain=0.0, filter_amount=0.0,
vel_to_filter=0.0, filter_q=0.707,
synth_kwargs=None, temperament="equal",
reference_pitch=440.0):
"""Render a list of Notes into an existing buffer at the correct positions."""
import random as _rnd
a, d, s, r = envelope_tuple
_skw = synth_kwargs or {}
beat_pos = 0.0
for note_index, note in enumerate(notes):
if note.tone is not None:
@@ -1744,11 +2001,17 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
if n_samples > 0 and start >= 0:
# Get pitches
if hasattr(note.tone, 'tones'):
pitches = [t.pitch() for t in note.tone.tones]
pitches = [t.pitch(temperament=temperament, reference_pitch=reference_pitch) for t in note.tone.tones]
else:
pitches = [note.tone.pitch()]
# Render oscillators
waves = [synth_fn(hz, n_samples=n_samples) for hz in pitches]
pitches = [note.tone.pitch(temperament=temperament, reference_pitch=reference_pitch)]
# 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:
sub = sine_wave(hz / 2, n_samples=n_samples)
waves.append(sub)
# Detune: add oscillators shifted by ±cents
detune_up = None
detune_down = None
@@ -1758,8 +2021,8 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
for hz in pitches:
hz_up = hz * (2 ** (detune / 1200))
hz_down = hz * (2 ** (-detune / 1200))
up_waves.append(synth_fn(hz_up, n_samples=n_samples))
down_waves.append(synth_fn(hz_down, n_samples=n_samples))
up_waves.append(synth_fn(hz_up, n_samples=n_samples, **_skw))
down_waves.append(synth_fn(hz_down, n_samples=n_samples, **_skw))
if spread > 0 and stereo_buf is not None:
# Spread: detuned oscillators go to opposite channels
detune_up = sum(w.astype(numpy.float32) for w in up_waves) / SAMPLE_PEAK
@@ -1768,14 +2031,44 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
waves.extend(up_waves + down_waves)
n_osc = len(waves)
mixed = sum(w.astype(numpy.float32) for w in waves) / (SAMPLE_PEAK * max(1, n_osc))
# Mix sub-oscillator with appropriate gain
if sub_osc > 0:
# Sub was already included in waves; scale the mix
# Boost the sub contribution relative to main
sub_count = len(pitches)
main_count = n_osc - sub_count
if main_count > 0:
# Re-render: main only + sub at controlled level
main_waves = waves[:n_osc - sub_count]
sub_waves = waves[n_osc - sub_count:]
main_mix = sum(w.astype(numpy.float32) for w in main_waves) / (SAMPLE_PEAK * max(1, len(main_waves)))
sub_mix = sum(w.astype(numpy.float32) for w in sub_waves) / (SAMPLE_PEAK * max(1, len(sub_waves)))
mixed = main_mix * (1.0 - sub_osc * 0.3) + sub_mix * sub_osc * 0.3
# Noise layer: add noise following the note
if noise_mix > 0:
noise = numpy.random.uniform(-1, 1, n_samples).astype(numpy.float32)
mixed = mixed * (1.0 - noise_mix * 0.5) + noise * noise_mix * 0.5
# Amplitude envelope
if a > 0 or d > 0 or s < 1.0 or r > 0:
mixed = _apply_envelope(mixed, a, d, s, r)
# Apply per-note velocity scaling + humanize velocity
# Per-note velocity
vel = getattr(note, 'velocity', 100)
if humanize > 0.0:
vel_jitter = int(humanize * 15)
vel = max(1, min(127, vel + _rnd.randint(-vel_jitter, vel_jitter)))
vel_scale = vel / 127.0
# Filter envelope (per-note subtractive filter sweep)
if filter_amount > 0:
base_cut = 200.0 # base cutoff before envelope opens it
vel_boost = vel_to_filter * vel_scale if vel_to_filter > 0 else 0.0
mixed = _apply_filter_envelope(
mixed, base_cut, filter_amount,
filter_attack, filter_decay, filter_sustain,
q=filter_q, vel_cutoff_boost=vel_boost)
elif vel_to_filter > 0:
# Velocity brightness without filter envelope
vel_cutoff = vel_to_filter * vel_scale + 1000
mixed = _apply_lowpass(mixed, vel_cutoff, q=filter_q)
end = min(start + len(mixed), total_samples)
buf[start:end] += mixed[:end - start] * volume * vel_scale
# Spread detuned oscillators into stereo L/R
@@ -1795,7 +2088,8 @@ def _render_notes_to_buf(notes, buf, samples_per_beat, total_samples,
def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
synth_fn, envelope_tuple, volume, bpm,
glide_time=0.0, swing=0.0, tempo_map=None):
glide_time=0.0, swing=0.0, tempo_map=None,
temperament="equal", reference_pitch=440.0):
"""Render notes as one continuous waveform with pitch glide.
Instead of rendering each note separately with its own envelope,
@@ -1825,9 +2119,9 @@ def _render_legato_to_buf(notes, buf, samples_per_beat, total_samples,
vel = getattr(note, 'velocity', 100)
if note.tone is not None:
if hasattr(note.tone, 'tones'):
hz = note.tone.tones[0].pitch() # use root for chords
hz = note.tone.tones[0].pitch(temperament=temperament, reference_pitch=reference_pitch)
else:
hz = note.tone.pitch()
hz = note.tone.pitch(temperament=temperament, reference_pitch=reference_pitch)
events.append((start, end, hz, vel))
else:
events.append((start, end, 0, vel)) # rest
@@ -1940,12 +2234,20 @@ def render_score(score):
env_tuple = _resolve_envelope(part.envelope)
# Use part swing if set, otherwise score swing
effective_swing = part.swing if part.swing is not None else score.swing
# Build synth-specific kwargs (e.g. FM ratio/index)
synth_kwargs = {}
if part.synth in ("fm",):
synth_kwargs["mod_ratio"] = part.fm_ratio
synth_kwargs["mod_index"] = part.fm_index
_temperament = getattr(score, 'temperament', 'equal')
_ref_pitch = getattr(score, 'reference_pitch', 440.0)
if part.legato:
_render_legato_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
synth_fn, env_tuple, part.volume, score.bpm,
glide_time=part.glide, swing=effective_swing,
tempo_map=tempo_map if has_tempo_changes else None)
tempo_map=tempo_map if has_tempo_changes else None,
temperament=_temperament, reference_pitch=_ref_pitch)
else:
_render_notes_to_buf(
part.notes, part_buf, samples_per_beat, total_samples,
@@ -1955,7 +2257,18 @@ def render_score(score):
humanize=part.humanize,
detune=part.detune,
spread=part.spread,
stereo_buf=stereo_buf)
stereo_buf=stereo_buf,
sub_osc=part.sub_osc,
noise_mix=part.noise_mix,
filter_attack=part.filter_attack,
filter_decay=part.filter_decay,
filter_sustain=part.filter_sustain,
filter_amount=part.filter_amount,
vel_to_filter=part.vel_to_filter,
filter_q=part.lowpass_q,
synth_kwargs=synth_kwargs,
temperament=_temperament,
reference_pitch=_ref_pitch)
# Apply effects — segmented if automation exists
auto_points = part._get_automation_points()
@@ -1973,8 +2286,10 @@ def render_score(score):
params = part._get_params_at(seg_start_beat)
segment = part_buf[seg_start:seg_end].copy()
has_fx = any(params.get(k, 0) > 0 for k in
["distortion_mix", "chorus_mix", "lowpass",
"delay_mix", "reverb_mix"])
["saturation", "tremolo_depth",
"distortion_mix", "chorus_mix", "phaser_mix",
"highpass", "lowpass", "delay_mix",
"reverb_mix"])
if has_fx:
segment = _apply_effects_with_params(segment, params)
# Apply volume automation
@@ -1983,9 +2298,11 @@ def render_score(score):
segment = segment * (seg_vol / part.volume) if part.volume > 0 else segment
part_buf[seg_start:seg_end] = segment
else:
has_fx = (part.lowpass > 0 or part.delay_mix > 0
or part.reverb_mix > 0 or part.distortion_mix > 0
or part.chorus_mix > 0)
has_fx = (part.saturation > 0 or part.tremolo_depth > 0
or part.distortion_mix > 0 or part.chorus_mix > 0
or part.phaser_mix > 0 or part.highpass > 0
or part.lowpass > 0 or part.delay_mix > 0
or part.reverb_mix > 0)
if has_fx:
part_buf = _apply_part_effects(part_buf, part)
# Apply sidechain compression if enabled
@@ -2092,7 +2409,10 @@ def render_score(score):
part_stereo[start:start + hit_len] += panned
# Apply this drum Part's effects
has_drum_fx = (drum_part.lowpass > 0 or drum_part.delay_mix > 0
has_drum_fx = (drum_part.saturation > 0 or drum_part.tremolo_depth > 0
or drum_part.phaser_mix > 0
or drum_part.highpass > 0 or drum_part.lowpass > 0
or drum_part.delay_mix > 0
or drum_part.reverb_mix > 0 or drum_part.distortion_mix > 0
or drum_part.chorus_mix > 0)
if has_drum_fx:
+143 -38
View File
@@ -15,105 +15,124 @@ INSTRUMENTS = {
# ── Keys ──
"piano": {
"synth": "fm", "envelope": "piano",
"fm_ratio": 1.0, "fm_index": 1.5,
"detune": 5, "chorus": 0.1, "chorus_rate": 0.3,
"lowpass": 6000,
"lowpass": 6000, "saturation": 0.1,
"vel_to_filter": 3000, "noise_mix": 0.02,
},
"electric_piano": { # Rhodes/Wurlitzer
"synth": "fm", "envelope": "piano",
"fm_ratio": 1.0, "fm_index": 2.0,
"detune": 6, "chorus": 0.2, "chorus_rate": 1.0,
"lowpass": 4000,
"lowpass": 4000, "saturation": 0.15,
"tremolo_depth": 0.15, "tremolo_rate": 4.5,
},
"organ": {
"synth": "organ_synth", "envelope": "organ",
"chorus": 0.2, "chorus_rate": 5.5,
"lowpass": 5000,
"phaser": 0.15, "phaser_rate": 0.4,
},
"harpsichord": {
"synth": "pluck_synth", "envelope": "none",
"lowpass": 3500,
},
"celesta": {
"synth": "fm", "envelope": "bell",
"synth": "fm", "envelope": "mallet",
"fm_ratio": 3.0, "fm_index": 5.0,
"lowpass": 8000,
"reverb": 0.3, "reverb_type": "plate",
},
"music_box": {
"synth": "sine", "envelope": "bell",
"synth": "sine", "envelope": "mallet",
"lowpass": 6000,
"reverb": 0.25, "reverb_type": "plate",
},
# ── Strings ──
"violin": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 5000,
"humanize": 0.15,
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 5000,
"humanize": 0.15, "vel_to_filter": 1500,
"noise_mix": 0.03,
},
"viola": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 3500,
"humanize": 0.15,
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 3500,
"humanize": 0.15, "vel_to_filter": 1200,
"noise_mix": 0.03,
},
"cello": {
"synth": "strings_synth", "envelope": "strings",
"detune": 4, "lowpass": 2500,
"humanize": 0.15,
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 2500,
"humanize": 0.15, "vel_to_filter": 1000,
"noise_mix": 0.02,
},
"contrabass": {
"synth": "strings_synth", "envelope": "strings",
"detune": 3, "lowpass": 1500,
"humanize": 0.1,
"synth": "strings_synth", "envelope": "bowed",
"detune": 2, "lowpass": 1500,
"humanize": 0.1, "vel_to_filter": 800,
"sub_osc": 0.15,
},
"string_ensemble": {
"synth": "strings_synth", "envelope": "pad",
"detune": 12, "spread": 0.6,
"detune": 10, "spread": 0.5,
"chorus": 0.2, "chorus_rate": 0.5,
"lowpass": 4000,
"noise_mix": 0.02, "saturation": 0.05,
},
# ── Woodwinds ──
"flute": {
"synth": "sine", "envelope": "strings",
"lowpass": 4000,
"humanize": 0.2,
"humanize": 0.2, "noise_mix": 0.08,
"vel_to_filter": 2000,
},
"clarinet": {
"synth": "square", "envelope": "strings",
"lowpass": 3000,
"humanize": 0.15,
"humanize": 0.15, "noise_mix": 0.05,
"vel_to_filter": 1500,
},
"oboe": {
"synth": "saw", "envelope": "strings",
"lowpass": 3500, "lowpass_q": 1.2,
"humanize": 0.15,
"humanize": 0.15, "noise_mix": 0.04,
"vel_to_filter": 1000,
},
"bassoon": {
"synth": "saw", "envelope": "strings",
"lowpass": 2000,
"humanize": 0.15,
"humanize": 0.15, "noise_mix": 0.04,
"vel_to_filter": 800,
},
# ── Brass ──
"trumpet": {
"synth": "saw", "envelope": "pluck",
"synth": "saw", "envelope": "bowed",
"detune": 3, "lowpass": 4000, "lowpass_q": 1.1,
"humanize": 0.15,
"humanize": 0.15, "vel_to_filter": 2000,
"saturation": 0.1,
},
"trombone": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 2500,
"humanize": 0.15,
"humanize": 0.15, "vel_to_filter": 1500,
"saturation": 0.1,
},
"french_horn": {
"synth": "saw", "envelope": "strings",
"detune": 4, "lowpass": 2000,
"chorus": 0.1,
"humanize": 0.15,
"humanize": 0.15, "vel_to_filter": 1200,
"saturation": 0.1,
},
"tuba": {
"synth": "saw", "envelope": "strings",
"detune": 3, "lowpass": 1200,
"humanize": 0.1,
"humanize": 0.1, "vel_to_filter": 600,
"sub_osc": 0.2,
},
"brass_ensemble": {
"synth": "saw", "envelope": "strings",
@@ -136,18 +155,18 @@ INSTRUMENTS = {
"distorted_guitar": {
"synth": "saw", "envelope": "pluck",
"detune": 8, "distortion": 0.6, "distortion_drive": 5.0,
"lowpass": 3000,
"lowpass": 3000, "saturation": 0.3,
"humanize": 0.15,
},
"bass_guitar": {
"synth": "triangle", "envelope": "pluck",
"lowpass": 1000,
"humanize": 0.1,
"humanize": 0.1, "sub_osc": 0.2,
},
"upright_bass": {
"synth": "sine", "envelope": "pluck",
"synth": "triangle", "envelope": "pluck",
"lowpass": 800,
"humanize": 0.15,
"humanize": 0.15, "saturation": 0.1,
},
"harp": {
"synth": "pluck_synth", "envelope": "none",
@@ -170,49 +189,65 @@ INSTRUMENTS = {
"synth": "saw", "envelope": "pluck",
"detune": 8, "lowpass": 3000,
"delay": 0.2, "delay_time": 0.25, "delay_feedback": 0.3,
"filter_attack": 0.01, "filter_decay": 0.3,
"filter_sustain": 0.2, "filter_amount": 3000,
},
"synth_pad": {
"synth": "supersaw", "envelope": "pad",
"detune": 12, "spread": 0.6,
"chorus": 0.2,
"phaser": 0.3, "phaser_rate": 0.3,
"sub_osc": 0.2,
},
"synth_bass": {
"synth": "saw", "envelope": "pluck",
"lowpass": 800, "lowpass_q": 1.3,
"filter_attack": 0.005, "filter_decay": 0.2,
"filter_sustain": 0.0, "filter_amount": 2000,
"sub_osc": 0.4,
},
"acid_bass": {
"synth": "saw", "envelope": "pad",
"legato": True, "glide": 0.03,
"distortion": 0.7, "distortion_drive": 8.0,
"lowpass": 800, "lowpass_q": 5.0,
"filter_attack": 0.005, "filter_decay": 0.15,
"filter_sustain": 0.0, "filter_amount": 4000,
"vel_to_filter": 3000,
},
"808_bass": {
"synth": "sine", "envelope": "pluck",
"distortion": 0.4, "distortion_drive": 2.5,
"lowpass": 200, "lowpass_q": 1.5,
"sub_osc": 0.5, "saturation": 0.2,
},
# ── Percussion / Mallet ──
"vibraphone": {
"synth": "fm", "envelope": "bell",
"synth": "fm", "envelope": "mallet",
"fm_ratio": 1.0, "fm_index": 1.0,
"lowpass": 5000,
"tremolo_depth": 0.3, "tremolo_rate": 5.5,
"reverb": 0.3, "reverb_type": "plate",
},
"marimba": {
"synth": "sine", "envelope": "pluck",
"synth": "sine", "envelope": "mallet",
"lowpass": 3000,
},
"xylophone": {
"synth": "fm", "envelope": "pluck",
"fm_ratio": 3.0, "fm_index": 5.0,
"lowpass": 6000,
},
"glockenspiel": {
"synth": "fm", "envelope": "bell",
"synth": "fm", "envelope": "mallet",
"fm_ratio": 4.0, "fm_index": 6.0,
"lowpass": 8000,
"reverb": 0.2,
},
"tubular_bells": {
"synth": "fm", "envelope": "bell",
"synth": "fm", "envelope": "mallet",
"fm_ratio": 2.0, "fm_index": 3.0,
"reverb": 0.4, "reverb_type": "cathedral",
},
}
@@ -1571,6 +1606,7 @@ class Part:
reverb_type: str = "algorithmic",
delay: float = 0.0, delay_time: float = 0.375,
delay_feedback: float = 0.4,
highpass: float = 0.0, highpass_q: float = 0.707,
lowpass: float = 0.0, lowpass_q: float = 0.707,
distortion: float = 0.0, distortion_drive: float = 3.0,
legato: bool = False, glide: float = 0.0,
@@ -1582,7 +1618,22 @@ class Part:
sidechain_release: float = 0.1,
detune: float = 0.0,
pan: float = 0.0,
spread: float = 0.0):
spread: float = 0.0,
# ── New synth engine params ──
sub_osc: float = 0.0,
noise_mix: float = 0.0,
filter_attack: float = 0.01,
filter_decay: float = 0.3,
filter_sustain: float = 0.0,
filter_amount: float = 0.0,
vel_to_filter: float = 0.0,
saturation: float = 0.0,
tremolo_depth: float = 0.0,
tremolo_rate: float = 5.0,
phaser: float = 0.0,
phaser_rate: float = 0.5,
fm_ratio: float = 2.0,
fm_index: float = 3.0):
self.name = name
self.synth = synth
self.envelope = envelope
@@ -1597,6 +1648,8 @@ class Part:
self.delay_mix = delay
self.delay_time = delay_time
self.delay_feedback = delay_feedback
self.highpass = highpass
self.highpass_q = highpass_q
self.lowpass = lowpass
self.lowpass_q = lowpass_q
self.distortion_mix = distortion
@@ -1609,6 +1662,22 @@ class Part:
self.detune = detune
self.pan = pan
self.spread = spread
# New synth engine params
self.sub_osc = sub_osc
self.noise_mix = noise_mix
self.filter_attack = filter_attack
self.filter_decay = filter_decay
self.filter_sustain = filter_sustain
self.filter_amount = filter_amount
self.vel_to_filter = vel_to_filter
self.saturation = saturation
self.tremolo_depth = tremolo_depth
self.tremolo_rate = tremolo_rate
self.phaser_mix = phaser
self.phaser_rate = phaser_rate
self.fm_ratio = fm_ratio
self.fm_index = fm_index
self._system = "western" # default, overridden by Score.part()
self.notes: list[Note] = []
self._drum_hits: list[_Hit] = []
self._drum_pattern_beats: float = 0.0
@@ -1624,7 +1693,7 @@ class Part:
"""
if isinstance(tone_or_string, str):
from .tones import Tone
tone_or_string = Tone.from_string(tone_or_string, system="western")
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))
@@ -1673,6 +1742,8 @@ class Part:
mapped["distortion_mix"] = v
elif k == "chorus":
mapped["chorus_mix"] = v
elif k == "phaser":
mapped["phaser_mix"] = v
else:
mapped[k] = v
self._automation.append((beat_pos, mapped))
@@ -1683,10 +1754,14 @@ class Part:
# Start with initial values
params = {
"volume": self.volume,
"saturation": self.saturation,
"tremolo_depth": self.tremolo_depth, "tremolo_rate": self.tremolo_rate,
"reverb_mix": self.reverb_mix, "reverb_decay": self.reverb_decay,
"reverb_type": self.reverb_type,
"delay_mix": self.delay_mix, "delay_time": self.delay_time,
"delay_feedback": self.delay_feedback,
"phaser_mix": self.phaser_mix, "phaser_rate": self.phaser_rate,
"highpass": self.highpass, "highpass_q": self.highpass_q,
"lowpass": self.lowpass, "lowpass_q": self.lowpass_q,
"distortion_mix": self.distortion_mix,
"distortion_drive": self.distortion_drive,
@@ -1998,13 +2073,17 @@ class Score:
"""
def __init__(self, time_signature="4/4", bpm=120, swing: float = 0.0,
drum_humanize: float = 0.15):
drum_humanize: float = 0.15, system: str = "western",
temperament: str = "equal", reference_pitch: float = 440.0):
if isinstance(time_signature, str):
self.time_signature = TimeSignature.from_string(time_signature)
else:
self.time_signature = time_signature
self.bpm = bpm
self.swing = swing
self.system = system
self.temperament = temperament
self.reference_pitch = reference_pitch
self._drum_humanize = drum_humanize
self.notes: list[Note] = []
self.parts: dict[str, Part] = {}
@@ -2075,6 +2154,7 @@ class Score:
reverb_type: str = None,
delay: float = None, delay_time: float = None,
delay_feedback: float = None,
highpass: float = None, highpass_q: float = None,
lowpass: float = None, lowpass_q: float = None,
distortion: float = None, distortion_drive: float = None,
legato: bool = None, glide: float = None,
@@ -2086,7 +2166,22 @@ class Score:
sidechain_release: float = None,
detune: float = None,
pan: float = None,
spread: float = None) -> Part:
spread: float = None,
# New synth engine params
sub_osc: float = None,
noise_mix: float = None,
filter_attack: float = None,
filter_decay: float = None,
filter_sustain: float = None,
filter_amount: float = None,
vel_to_filter: float = None,
saturation: float = None,
tremolo_depth: float = None,
tremolo_rate: float = None,
phaser: float = None,
phaser_rate: float = None,
fm_ratio: float = None,
fm_index: float = None) -> Part:
"""Create a named part with its own synth voice and effects.
Args:
@@ -2179,6 +2274,7 @@ class Score:
"reverb_type": reverb_type,
"delay": delay, "delay_time": delay_time,
"delay_feedback": delay_feedback,
"highpass": highpass, "highpass_q": highpass_q,
"lowpass": lowpass, "lowpass_q": lowpass_q,
"distortion": distortion, "distortion_drive": distortion_drive,
"legato": legato, "glide": glide,
@@ -2187,6 +2283,14 @@ class Score:
"swing": swing, "humanize": humanize,
"sidechain": sidechain, "sidechain_release": sidechain_release,
"detune": detune, "pan": pan, "spread": spread,
"sub_osc": sub_osc, "noise_mix": noise_mix,
"filter_attack": filter_attack, "filter_decay": filter_decay,
"filter_sustain": filter_sustain, "filter_amount": filter_amount,
"vel_to_filter": vel_to_filter,
"saturation": saturation,
"tremolo_depth": tremolo_depth, "tremolo_rate": tremolo_rate,
"phaser": phaser, "phaser_rate": phaser_rate,
"fm_ratio": fm_ratio, "fm_index": fm_index,
}
for k, v in _locals.items():
if v is not None:
@@ -2195,6 +2299,7 @@ class Score:
merged = {**_defaults, **explicit}
p = Part(name, **merged)
p._system = self.system
self.parts[name] = p
return p
+232 -6
View File
@@ -2,18 +2,53 @@ 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_SLENDRO, DEGREES_SLENDRO, SLENDRO_SCALES,
TONES_PELOG, DEGREES_PELOG, PELOG_SCALES,
TONES_THAI, DEGREES_THAI, THAI_SCALES,
TONES_TURKISH, DEGREES_TURKISH, TURKISH_SCALES,
TONES_CARNATIC, DEGREES_CARNATIC, CARNATIC_SCALES,
)
class System:
def __init__(self, *, tone_names, degrees, scales=None):
def __init__(self, *, tone_names, degrees, scales=None, c_index=None,
period=2.0):
self.tone_names = tone_names
self.degrees = degrees
self._scales = scales
# Period: the frequency ratio of one "octave" in this system.
# 2.0 for standard octave-based systems.
# 3.0 for Bohlen-Pierce (tritave).
self.period = period
# 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).
# For non-12-TET systems, this is the index of the tone nearest C,
# or 0 if no C equivalent exists.
if c_index is not None:
self.c_index = c_index
else:
# Try to find C in the tone names, fall back to 0
self.c_index = 0
for i, names in enumerate(tone_names):
if "C" in names:
self.c_index = i
break
if scales is None:
self._scales = SCALES[self.semitones]
n = self.semitones
if n in SCALES:
self._scales = SCALES[n]
else:
# Generate chromatic scale for unknown sizes
self._scales = {
"chromatic": (n, {}),
}
@property
def semitones(self):
@@ -25,13 +60,56 @@ class System:
return tuple([Tone.from_tuple(tone) for tone in self.tone_names])
def resolve_name(self, name: str) -> str | None:
"""Resolve a note name (including flats) to the canonical name.
"""Resolve a note name (including flats, double sharps/flats) to the canonical name.
Handles enharmonic equivalents:
- Standard names and their alternates (e.g. Bb, C#)
- Double sharps (C## = D, F## = G)
- Double flats (Dbb = C, Ebb = D)
Returns the primary name if found, or None if not recognized.
"""
# Direct lookup first
for names in self.tone_names:
if name in names:
return names[0]
# Handle double sharps (e.g. C## → D, F## → G)
if name.endswith('##') and len(name) >= 3:
base = name[:-2]
base_idx = self._name_to_index(base)
if base_idx is not None:
resolved_idx = (base_idx + 2) % len(self.tone_names)
return self.tone_names[resolved_idx][0]
# Handle double flats (e.g. Dbb → C, Ebb → D)
if name.endswith('bb') and len(name) >= 3 and name[0] != 'b':
base = name[:-2]
base_idx = self._name_to_index(base)
if base_idx is not None:
resolved_idx = (base_idx - 2) % len(self.tone_names)
return self.tone_names[resolved_idx][0]
# Handle single sharps/flats on natural notes (e.g. Cb → B, E# → F)
if len(name) == 2:
base = name[0]
modifier = name[1]
base_idx = self._name_to_index(base)
if base_idx is not None:
if modifier == '#':
resolved_idx = (base_idx + 1) % len(self.tone_names)
return self.tone_names[resolved_idx][0]
elif modifier == 'b':
resolved_idx = (base_idx - 1) % len(self.tone_names)
return self.tone_names[resolved_idx][0]
return None
def _name_to_index(self, name: str) -> int | None:
"""Return the index of a tone name, or None if not found."""
for i, names in enumerate(self.tone_names):
if name in names:
return i
return None
@@ -139,11 +217,159 @@ class System:
def __repr__(self):
return f"<System semitones={self.semitones!r}>"
def TET(n, *, names=None, reference_index=0, period=2.0):
"""Create an N-tone equal temperament system.
Each step divides the period into *n* equal parts. The frequency
ratio between adjacent tones is ``period^(1/n)``.
For standard tunings the period is 2.0 (octave). For exotic systems
like Bohlen-Pierce, set ``period=3.0`` (tritave).
Args:
n: Number of equal divisions of the octave (e.g. 19, 24, 31, 53).
names: Optional list of *n* tone name strings. If omitted,
tones are numbered ``"0"`` through ``"n-1"``.
reference_index: Index of the tone that corresponds to A440
(default 0, meaning tone "0" = A4 = 440 Hz).
Returns:
A :class:`System` instance.
Example::
>>> edo19 = TET(19)
>>> from pytheory import Tone
>>> t = Tone("0", octave=4, system=edo19)
>>> t.frequency # 440.0 Hz (tone 0 = A4)
440.0
>>> edo31 = TET(31)
>>> t = Tone("18", octave=4, system=edo31)
>>> t.frequency # 18 steps above A in 31-TET
"""
if names is not None:
if len(names) != n:
raise ValueError(f"Expected {n} names, got {len(names)}")
tone_names = [(name,) for name in names]
else:
tone_names = [(str(i),) for i in range(n)]
# Degrees: numbered, with no modal names
degrees = [(f"degree {i+1}", ()) for i in range(n)]
# Scales: chromatic (all steps = 1) plus MOS scales for common EDOs
scale_data = {
"chromatic": (n, {}),
}
# Add well-known scales for specific EDOs
if n == 19:
# 19-TET: major and minor have different step sizes
# Major: 3 3 2 3 3 3 2 (sums to 19)
# Minor: 3 2 3 3 2 3 3
scale_data["heptatonic"] = [7, {
"major": {"intervals": (3, 3, 2, 3, 3, 3, 2)},
"minor": {"intervals": (3, 2, 3, 3, 2, 3, 3)},
"harmonic minor": {"intervals": (3, 2, 3, 3, 2, 4, 2)},
}]
scale_data["pentatonic"] = [5, {
"major pentatonic": {"intervals": (3, 3, 5, 3, 5)},
"minor pentatonic": {"intervals": (5, 3, 3, 5, 3)},
}]
elif n == 24:
# 24-TET (quarter-tone): standard 12-TET scales with doubled steps
scale_data["heptatonic"] = [7, {
"major": {"intervals": (4, 4, 2, 4, 4, 4, 2)},
"minor": {"intervals": (4, 2, 4, 4, 2, 4, 4)},
}]
elif n == 31:
# 31-TET: excellent approximation of quarter-comma meantone
# Major: 5 5 3 5 5 5 3 (sums to 31)
# Minor: 5 3 5 5 3 5 5
scale_data["heptatonic"] = [7, {
"major": {"intervals": (5, 5, 3, 5, 5, 5, 3)},
"minor": {"intervals": (5, 3, 5, 5, 3, 5, 5)},
"harmonic minor": {"intervals": (5, 3, 5, 5, 3, 7, 3)},
}]
scale_data["pentatonic"] = [5, {
"major pentatonic": {"intervals": (5, 5, 8, 5, 8)},
"minor pentatonic": {"intervals": (8, 5, 5, 8, 5)},
}]
elif n == 53:
# 53-TET: nearly perfect fifths and thirds
# Major: 9 9 4 9 9 9 4 (sums to 53)
scale_data["heptatonic"] = [7, {
"major": {"intervals": (9, 9, 4, 9, 9, 9, 4)},
"minor": {"intervals": (9, 4, 9, 9, 4, 9, 9)},
}]
# Find C equivalent for c_index (reference_index is A, C is 3 steps in 12-TET)
# Proportionally: C is 3/12 of the way around from A
c_idx = round(n * 3 / 12) if n != 12 else 3
return System(
tone_names=tone_names,
degrees=degrees,
scales=scale_data,
c_index=c_idx,
period=period,
)
# ── 19-TET named system ──
# Traditional note names for 19-TET: all 12 western notes plus
# 7 quarter-tone positions (enharmonic splits)
_19TET_NAMES = [
"A", "A#", "Bb", "B", "B#",
"C", "C#", "Db", "D", "D#",
"Eb", "E", "E#", "F", "F#",
"Gb", "G", "G#", "Ab",
]
# ── 31-TET named system ──
# Adriaan Fokker's naming: sharps and flats are distinct pitches
_31TET_NAMES = [
"A", "A↑", "A#", "Bb", "B↓",
"B", "B↑", "C", "C↑", "C#",
"Db", "D↓", "D", "D↑", "D#",
"Eb", "E↓", "E", "E↑", "E#",
"F", "F↑", "F#", "Gb", "G↓",
"G", "G↑", "G#", "Ab", "A↓",
"A♮", # enharmonic return (distinct from "A" by a diesis)
]
SYSTEMS = {
"western": System(tone_names=TONES["western"], degrees=DEGREES["western"]),
"indian": System(tone_names=TONES["indian"], degrees=DEGREES["indian"], scales=INDIAN_SCALES[12]),
"arabic": System(tone_names=TONES["arabic"], degrees=DEGREES["arabic"], scales=ARABIC_SCALES[12]),
"indian": System(tone_names=TONES["indian"], degrees=DEGREES["indian"], scales=INDIAN_SCALES[12], c_index=3),
"arabic": System(tone_names=TONES["arabic"], degrees=DEGREES["arabic"], scales=ARABIC_SCALES[12], c_index=3),
"japanese": System(tone_names=TONES["japanese"], degrees=DEGREES["japanese"], scales=JAPANESE_SCALES[12]),
"blues": System(tone_names=TONES["blues"], degrees=DEGREES["blues"], scales=BLUES_SCALES[12]),
"gamelan": System(tone_names=TONES["gamelan"], degrees=DEGREES["gamelan"], scales=GAMELAN_SCALES[12]),
"gamelan": System(tone_names=TONES["gamelan"], degrees=DEGREES["gamelan"], scales=GAMELAN_SCALES[12], c_index=3),
"19-tet": TET(19, names=_19TET_NAMES),
"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),
"maqam": System(tone_names=TONES_ARABIC_24, degrees=DEGREES_ARABIC_24,
scales=ARABIC_24_SCALES, c_index=5),
"slendro": System(tone_names=TONES_SLENDRO, degrees=DEGREES_SLENDRO,
scales=SLENDRO_SCALES, c_index=1),
"pelog": System(tone_names=TONES_PELOG, degrees=DEGREES_PELOG,
scales=PELOG_SCALES, c_index=2),
"thai": System(tone_names=TONES_THAI, degrees=DEGREES_THAI,
scales=THAI_SCALES, c_index=0),
"makam": System(tone_names=TONES_TURKISH, degrees=DEGREES_TURKISH,
scales=TURKISH_SCALES, c_index=13),
"carnatic": System(tone_names=TONES_CARNATIC, degrees=DEGREES_CARNATIC,
scales=CARNATIC_SCALES, c_index=18), # Sa ≈ C, 18 steps from A
# Bohlen-Pierce: 13 equal divisions of the tritave (3:1).
# Genuinely alien — no octaves, no fifths, built on 3:5:7 harmonics.
# Used by composers like Heinz Bohlen, Kees van Prooijen, Georg Hajdu.
"bohlen-pierce": TET(13, period=3.0, names=[
"A", "B", "C", "D", "E", "F", "G",
"H", "J", "K", "L", "M", "N",
]),
}
+140 -63
View File
@@ -31,6 +31,7 @@ class Tone:
alt_names: Optional[list[str]] = None,
octave: Optional[int] = None,
system: Union[str, object] = "western",
_validate: bool = True,
) -> None:
"""Initialize a Tone with a name, optional octave, and musical system.
@@ -46,15 +47,28 @@ class Tone:
alt_names = []
if isinstance(name, str):
try:
parsed_octave = int("".join([c for c in filter(str.isdigit, name)]))
except ValueError:
parsed_octave = None
# Normalize unicode music symbols to ASCII equivalents
name = (name
.replace('\u266f', '#') # ♯ → #
.replace('\u266d', 'b') # ♭ → b
.replace('\U0001d12a', '##') # 𝄪 → ##
.replace('\U0001d12b', 'bb') # 𝄫 → bb
)
# Normalize 'x' / 'X' as double sharp (only after letter name)
if len(name) >= 2 and name[1] in ('x', 'X') and name[0].isalpha():
name = name[0] + '##' + name[2:]
if parsed_octave is not None:
name = name.replace(str(parsed_octave), "")
if octave is None:
octave = parsed_octave
# Only parse trailing digits as octave (e.g. "C4" → "C", octave=4).
# Digits embedded in the name (e.g. "Mib+1") are NOT octaves.
# Numeric pitch class names ("0", "11") are also left alone.
if name and name[0].isalpha():
import re as _re
m = _re.search(r'(\d+)$', name)
if m:
parsed_octave = int(m.group(1))
name = name[:m.start()]
if octave is None:
octave = parsed_octave
self.name = name
self.octave = octave
@@ -68,6 +82,13 @@ class Tone:
self.system_name = None
self._system = system
# Validate tone name against the system early (fixes #39).
if _validate and self.system.resolve_name(name) is None:
raise ValueError(
f"Unknown tone name: {name!r}. "
f"Not found in the {system!r} system."
)
@property
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
@@ -335,17 +356,20 @@ class Tone:
Returns:
A new ``Tone`` instance.
"""
try:
octave = int("".join([c for c in filter(str.isdigit, s)]))
except ValueError:
octave = None
tone = s.replace(str(octave), "") if octave else s
import re as _re
octave = None
tone = s
# Only parse trailing digits as octave
if s and s[0].isalpha():
m = _re.search(r'(\d+)$', s)
if m:
octave = int(m.group(1))
tone = s[:m.start()]
if system:
return klass(name=tone, octave=octave, system=system)
else:
return klass(name=tone, octave=octave)
return klass(name=tone, octave=octave, _validate=False)
@classmethod
def from_tuple(klass, t: tuple[str, ...]) -> Tone:
@@ -381,19 +405,20 @@ class Tone:
import math
if hz <= 0:
raise ValueError("Frequency must be positive")
# Semitones from A4
semitones_from_a4 = 12 * math.log2(hz / REFERENCE_A)
semitones = round(semitones_from_a4)
# A4 is index 0 in the Western system, octave 4
# Convert to absolute position from C0
a4_from_c0 = ((0 - C_INDEX) % 12) + (4 * 12) # = 57
abs_pos = a4_from_c0 + semitones
octave = abs_pos // 12
relative = abs_pos % 12
index = (relative + C_INDEX) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
n = len(system.tone_names)
c_idx = getattr(system, 'c_index', C_INDEX)
# Steps from A4 in this EDO
steps_from_a4 = n * math.log2(hz / REFERENCE_A)
steps = round(steps_from_a4)
# A4 is index 0, octave 4. Convert to absolute position from C0.
a4_from_c0 = ((0 - c_idx) % n) + (4 * n)
abs_pos = a4_from_c0 + steps
octave = abs_pos // n
relative = abs_pos % n
index = (relative + c_idx) % n
return klass.from_index(index, octave=octave, system=system)
@classmethod
@@ -409,13 +434,19 @@ class Tone:
>>> Tone.from_midi(69)
<Tone A4>
"""
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
# MIDI is a 12-TET standard. Convert to Hz and use from_frequency
# for non-12 systems.
n = len(system.tone_names)
if n != 12:
hz = REFERENCE_A * (2 ** ((note_number - 69) / 12))
return klass.from_frequency(hz, system=system)
adjusted = note_number - 12 # MIDI C0=12
octave = adjusted // 12
relative = adjusted % 12
index = (relative + C_INDEX) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
return klass.from_index(index, octave=octave, system=system)
@classmethod
@@ -434,10 +465,27 @@ class Tone:
"""
tone_names = system.tone_names[i]
if prefer_flats and len(tone_names) > 1:
tone = tone_names[1] # flat spelling (e.g. "Bb")
# Find the first flat spelling (contains 'b' but isn't just 'B')
tone = tone_names[0] # fallback to primary
for tn in tone_names[1:]:
if 'b' in tn and tn != 'B':
tone = tn
break
else:
tone = tone_names[0] # sharp spelling (e.g. "A#")
return klass(name=tone, octave=octave, system=system)
tone = tone_names[0] # primary spelling
# Bypass parsing and validation — name comes from a known system index
obj = klass.__new__(klass)
obj.name = tone
obj.octave = octave
obj.alt_names = list(tone_names[1:]) if len(tone_names) > 1 else []
obj._frequency = None
if isinstance(system, str):
obj.system_name = system
obj._system = None
else:
obj.system_name = None
obj._system = system
return obj
@property
def _index(self) -> int:
@@ -453,7 +501,15 @@ class Tone:
canonical = self.system.resolve_name(self.name)
if canonical is None:
raise ValueError(f"Tone {self.name!r} not found in system")
return self.system.tones.index(canonical)
# Use _name_to_index for direct lookup (avoids creating Tone objects)
idx = self.system._name_to_index(canonical)
if idx is not None:
return idx
# Fallback: linear search through tone_names
for i, names in enumerate(self.system.tone_names):
if canonical in names:
return i
raise ValueError(f"Tone {self.name!r} not found in system")
except AttributeError:
raise ValueError("Tone index cannot be referenced without a system!")
@@ -467,19 +523,21 @@ class Tone:
octave = self.octave or 0
try:
mod = len(self.system.tones)
mod = len(self.system.tone_names)
except AttributeError:
raise ValueError(
"Tone math can only be computed with an associated system!"
)
# Convert to absolute semitones from C0
note_from_c0 = ((self._index - C_INDEX) % mod) + (octave * mod)
c_idx = getattr(self.system, 'c_index', C_INDEX)
# Convert to absolute steps from C0
note_from_c0 = ((self._index - c_idx) % mod) + (octave * mod)
note_from_c0 += interval
new_octave = note_from_c0 // mod
relative = note_from_c0 % mod
new_index = (relative + C_INDEX) % mod
new_index = (relative + c_idx) % mod
return (new_index, new_octave)
@@ -530,9 +588,10 @@ class Tone:
'octave'
"""
semitones = abs(self - other)
octaves = semitones // 12
remainder = semitones % 12
name = self._INTERVAL_NAMES.get(remainder, f"{remainder} semitones")
n = len(self.system.tones)
octaves = semitones // n
remainder = semitones % n
name = self._INTERVAL_NAMES.get(remainder, f"{remainder} steps")
if octaves == 0:
return name
if remainder == 0:
@@ -555,6 +614,12 @@ class Tone:
"""
if self.octave is None:
return None
n = len(self.system.tones)
if n != 12:
# Non-12-TET: approximate MIDI via frequency
import math
hz = self.pitch()
return round(69 + 12 * math.log2(hz / REFERENCE_A))
semitones_from_c0 = ((self._index - C_INDEX) % 12) + (self.octave * 12)
return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0)
@@ -596,42 +661,43 @@ class Tone:
return 1200 * math.log2(f2 / f1)
def circle_of_fifths(self) -> list[Tone]:
"""The 12 tones of the circle of fifths starting from this tone.
"""The circle of fifths starting from this tone.
Each step ascends by a perfect fifth (7 semitones). After 12
steps you return to the starting tone. The circle of fifths
is the backbone of Western harmony it determines key
signatures, chord relationships, and modulation paths.
Clockwise = add sharps: C G D A E B F# → ...
Counter-clockwise = add flats (see ``circle_of_fourths``).
Each step ascends by a perfect fifth (7 semitones in 12-TET).
After N steps (where N = number of tones in the system) you
return to the starting tone. The circle of fifths is the
backbone of Western harmony it determines key signatures,
chord relationships, and modulation paths.
Returns:
A list of 12 Tones.
A list of Tones (12 for Western, N for other systems).
"""
n = len(self.system.tones)
# Perfect fifth: the closest approximation to 3:2 ratio
fifth = round(n * 7 / 12) # 7 in 12-TET, 11 in 19-TET, 18 in 31-TET
tones: list[Tone] = []
t = self
for _ in range(12):
for _ in range(n):
tones.append(t)
t = t.add(7)
t = t.add(fifth)
return tones
def circle_of_fourths(self) -> list[Tone]:
"""The 12 tones of the circle of fourths starting from this tone.
"""The circle of fourths starting from this tone.
Each step ascends by a perfect fourth (5 semitones) the
reverse direction of the circle of fifths.
Clockwise = add flats: C F Bb Eb Ab ...
Each step ascends by a perfect fourth the reverse direction
of the circle of fifths.
Returns:
A list of 12 Tones.
A list of Tones (12 for Western, N for other systems).
"""
n = len(self.system.tones)
fourth = round(n * 5 / 12) # 5 in 12-TET, 8 in 19-TET, 13 in 31-TET
tones: list[Tone] = []
t = self
for _ in range(12):
for _ in range(n):
tones.append(t)
t = t.add(5)
t = t.add(fourth)
return tones
@property
@@ -687,21 +753,32 @@ class Tone:
precision: Optional[int] = None,
) -> float:
try:
tones = len(self.system.tones)
tones = len(self.system.tone_names)
except AttributeError:
raise ValueError("Pitches can only be computed with an associated system!")
pitch_scale = TEMPERAMENTS[temperament](tones)
# Period ratio: 2.0 for standard octave-based systems,
# 3.0 for Bohlen-Pierce (tritave), configurable per system.
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)]
else:
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave if self.octave is not None else 4
note_from_c0 = ((self._index - C_INDEX) % tones) + (octave * tones)
a4_from_c0 = ((0 - C_INDEX) % tones) + (4 * tones) # A4
note_from_c0 = ((self._index - c_idx) % tones) + (octave * tones)
a4_from_c0 = ((0 - c_idx) % tones) + (4 * tones) # A4
diff = note_from_c0 - a4_from_c0
octave_shift = diff // tones
within_octave = diff % tones
ratio = pitch_scale[within_octave] * (2 ** octave_shift)
ratio = pitch_scale[within_octave] * (period ** octave_shift)
if symbolic:
return reference_pitch * ratio
+201 -9
View File
@@ -68,9 +68,16 @@ def test_tone_system():
def test_tone_exists():
c4 = Tone(name="C", octave=4, system="western")
invalid_tone = Tone(name="H", octave=4, system="western")
assert c4.exists is True
assert invalid_tone.exists is False
def test_tone_invalid_raises():
"""Invalid tone names raise ValueError at construction time (fixes #39)."""
import pytest
with pytest.raises(ValueError, match="Unknown tone name"):
Tone(name="H", octave=4, system="western")
with pytest.raises(ValueError, match="Unknown tone name"):
Tone("X")
def test_tone_names_method():
@@ -4248,7 +4255,7 @@ def test_parallel_modes_g_major():
@needs_portaudio
def test_envelope_enum_presets():
from pytheory.play import Envelope
assert len(Envelope) == 8
assert len(Envelope) == 10
for e in Envelope:
a, d, s, r = e.value
assert a >= 0
@@ -4839,10 +4846,11 @@ def test_solfege_no_octave():
assert t.solfege == "Do"
def test_solfege_unknown_returns_name():
"""A non-standard name should be returned unchanged."""
t = Tone(name="X", system="western")
assert t.solfege == "X"
def test_solfege_unknown_raises():
"""A non-standard name should raise ValueError at construction (fixes #39)."""
import pytest
with pytest.raises(ValueError, match="Unknown tone name"):
Tone(name="X", system="western")
# ── Rhythm / Duration system ────────────────────────────────────────────────
@@ -6471,9 +6479,10 @@ def test_instrument_violin():
score = Score("4/4", bpm=120)
p = score.part("v", instrument="violin")
assert p.synth == "strings_synth"
assert p.envelope == "strings"
assert p.envelope == "bowed"
assert p.humanize == 0.15
assert p.lowpass == 5000
assert p.detune == 2
def test_instrument_override():
@@ -6512,7 +6521,7 @@ def test_instrument_effects():
assert p.reverb_mix == 0.3
assert p.reverb_type == "plate"
assert p.synth == "fm"
assert p.envelope == "bell"
assert p.envelope == "mallet"
def test_instrument_808_bass():
@@ -6525,3 +6534,186 @@ def test_instrument_808_bass():
assert p.lowpass_q == 1.5
assert p.synth == "sine"
assert p.envelope == "pluck"
# ── Non-12-TET / Microtonal systems ─────────────────────────────────────────
from pytheory import TET
def test_tet_factory_creates_system():
edo17 = TET(17)
assert len(edo17.tone_names) == 17
assert edo17.semitones == 17
def test_tet_factory_numbered_tones():
edo17 = TET(17)
t = Tone("0", octave=4, system=edo17)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
# One octave up
t_up = t.add(17)
assert t_up.frequency == pytest.approx(880.0, rel=1e-3)
def test_tet_factory_custom_names():
names = ["A", "B", "C", "D", "E"]
edo5 = TET(5, names=names)
assert len(edo5.tone_names) == 5
t = Tone("A", octave=4, system=edo5)
assert t.frequency == pytest.approx(440.0, rel=1e-3)
def test_tet_factory_wrong_name_count():
with pytest.raises(ValueError):
TET(5, names=["A", "B", "C"])
def test_19tet_system():
sys19 = SYSTEMS["19-tet"]
assert sys19.semitones == 19
a = Tone("A", octave=4, system=sys19)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
# Octave should double
a5 = a.add(19)
assert a5.frequency == pytest.approx(880.0, rel=1e-3)
def test_19tet_scale():
sys19 = SYSTEMS["19-tet"]
ts = TonedScale(system=sys19, tonic=Tone("C", octave=4, system=sys19))
major = ts["major"]
assert len(major.tones) == 8 # 7 + octave
def test_31tet_system():
sys31 = SYSTEMS["31-tet"]
assert sys31.semitones == 31
a = Tone("A", octave=4, system=sys31)
assert a.frequency == pytest.approx(440.0, rel=1e-3)
def test_shruti_system():
shruti = SYSTEMS["shruti"]
assert shruti.semitones == 22
sa = Tone("Sa", octave=4, system=shruti)
# Sa should be near C4 (261.63 Hz) — not exact due to 22-TET
assert 250 < sa.frequency < 270
def test_shruti_octave():
shruti = SYSTEMS["shruti"]
sa4 = Tone("Sa", octave=4, system=shruti)
sa5 = sa4.add(22)
assert sa5.frequency == pytest.approx(sa4.frequency * 2, rel=1e-3)
def test_shruti_bhairav_scale():
shruti = SYSTEMS["shruti"]
ts = TonedScale(system=shruti, tonic=Tone("Sa", octave=4, system=shruti))
bhairav = ts["bhairav"]
names = [t.name for t in bhairav.tones]
assert names[0] == "Sa"
assert "komal Re" in names # the microtonal komal Re
assert len(bhairav.tones) == 8
def test_maqam_system():
maqam = SYSTEMS["maqam"]
assert maqam.semitones == 24
do = Tone("Do", octave=4, system=maqam)
assert 250 < do.frequency < 270
def test_maqam_rast_has_quarter_tones():
maqam = SYSTEMS["maqam"]
ts = TonedScale(system=maqam, tonic=Tone("Do", octave=4, system=maqam))
rast = ts["rast"]
names = [t.name for t in rast.tones]
# Rast should contain quarter-tone positions
assert any("" in n or "" in n for n in names)
def test_slendro_system():
slendro = SYSTEMS["slendro"]
assert slendro.semitones == 5
ji = Tone("ji", octave=4, system=slendro)
# 5 steps = octave
ji_up = ji.add(5)
assert ji_up.frequency == pytest.approx(ji.frequency * 2, rel=1e-3)
def test_pelog_system():
pelog = SYSTEMS["pelog"]
assert pelog.semitones == 9
ts = TonedScale(system=pelog, tonic=Tone("ji", octave=4, system=pelog))
full_pelog = ts["pelog"]
assert len(full_pelog.tones) == 8
def test_thai_system():
thai = SYSTEMS["thai"]
assert thai.semitones == 7
do = Tone("do", octave=4, system=thai)
# 7 steps = octave
do_up = do.add(7)
assert do_up.frequency == pytest.approx(do.frequency * 2, rel=1e-3)
def test_turkish_makam_system():
makam = SYSTEMS["makam"]
assert makam.semitones == 53
ts = TonedScale(system=makam, tonic=Tone("Do", octave=4, system=makam))
rast = ts["rast"]
assert len(rast.tones) == 8
def test_carnatic_system():
carnatic = SYSTEMS["carnatic"]
assert carnatic.semitones == 72
ts = TonedScale(system=carnatic, tonic=Tone("Sa", octave=4, system=carnatic))
shankarabharanam = ts["shankarabharanam"]
assert len(shankarabharanam.tones) == 8
def test_circle_of_fifths_19tet():
sys19 = SYSTEMS["19-tet"]
c = Tone("C", octave=4, system=sys19)
cof = c.circle_of_fifths()
assert len(cof) == 19 # should cycle through all 19 tones
def test_circle_of_fifths_western_unchanged():
"""Existing 12-TET circle of fifths should not be affected."""
c = Tone("C", octave=4, system="western")
cof = c.circle_of_fifths()
assert len(cof) == 12
assert cof[0].name == "C"
assert cof[1].name == "G"
def test_from_frequency_non12():
sys19 = SYSTEMS["19-tet"]
t = Tone.from_frequency(440.0, system=sys19)
assert t.name == "A"
assert t.octave == 4
def test_score_system_param():
"""Score passes system to parts for string→Tone resolution."""
from pytheory import Score, Duration
shruti = SYSTEMS["shruti"]
score = Score("4/4", bpm=120, system=shruti)
p = score.part("test", synth="sine")
assert p._system is shruti
# String "Sa" should resolve via shruti system, not western
p.add(Tone("Sa", octave=4, system=shruti), Duration.QUARTER)
assert len(p.notes) == 1
def test_interval_to_non12():
sys19 = SYSTEMS["19-tet"]
a = Tone("A", octave=4, system=sys19)
a5 = a.add(19)
result = a.interval_to(a5)
assert "octave" in result
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.30.0"
version = "0.32.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },