Compare commits

...

8 Commits

Author SHA1 Message Date
kennethreitz 02df87af09 v0.6.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:39:48 -04:00
kennethreitz b3110c6e0e Fix TODOs, add WAV export, CLI tests, and play module tests
Play module:
- Add save() for WAV file export
- Add _render() to separate rendering from playback
- Expand play() docstring with args and examples
- Add comments to SAMPLE_RATE/SAMPLE_PEAK constants
- Remove orphaned comment at EOF

Code cleanup:
- Remove 3 TODO comments (_statics.py, systems.py, scales.py)
- Remove commented-out incomplete scale definitions
- Fix silent return in TonedScale.get() to explicit return None

Tests (15 new):
- CLI: tone, scale, chord, key, fingering, progression, detect commands
- Play: _render for tones and chords, all synth engines, WAV save

README:
- Add CLI section with usage examples
- Mention WAV export in audio and features sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:39:12 -04:00
kennethreitz fd82dccbfd Fix sawtooth and triangle wave generation
Both were using incorrect frequency scaling (magic numbers instead of
deriving cycle length from sample rate / hz). Now they match the sine
wave approach: compute one cycle at the correct frequency, then resize.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:31:36 -04:00
kennethreitz 6f7f9008b0 Use sine wave for chord progression playback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:30:55 -04:00
kennethreitz acb92171a1 Speed up songs (BPM 120→180) and catch KeyboardInterrupt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:30:04 -04:00
kennethreitz c006f5b3da Rewrite song.py with 7 playable songs and chord progressions
Melodies: Twinkle Twinkle, Ode to Joy, Happy Birthday, Fur Elise
Progressions: Pop I-V-vi-IV, 12-bar blues in A, Jazz ii-V-I turnaround
Interactive menu for picking songs. Clean helper functions for
melody and chord progression playback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:28:31 -04:00
kennethreitz 9da3ac8b28 Add 12 example scripts showcasing pytheory features
- circle_of_fifths.py — visualize keys around the circle
- chord_identifier.py — identify chords from notes and fingerings
- key_explorer.py — explore keys, signatures, progressions, borrowed chords
- temperament_comparison.py — compare equal, Pythagorean, and meantone
- chord_tension.py — analyze tension, consonance, and voice leading
- world_scales.py — scales from 6 musical traditions
- fretboard_explorer.py — instruments, tunings, capo transposition
- midi_converter.py — MIDI ↔ note ↔ frequency reference
- progression_writer.py — famous progressions, Nashville numbers, random generation
- interval_trainer.py — interval names, songs, and consonance ranking
- overtone_series.py — harmonics and why chords sound good
- key_detection.py — detect keys from melodies and chord progressions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:27:18 -04:00
kennethreitz e94ef5dcfd Expand documentation with undocumented features and CLI guide
Tones: add from_frequency, from_midi, letter, midi, exists properties;
  interval naming with interval_to(); transpose(); MIDI section
Scales: add Key.signature, relative/parallel keys, borrowed chords,
  secondary dominants, random progressions, all_keys, scale transpose
Chords: add transpose, add_tone/remove_tone, root/quality properties;
  simplify identification examples with from_tones()
CLI: new guide covering all 8 commands (tone, scale, chord, key,
  fingering, progression, play, detect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:11:01 -04:00
26 changed files with 1509 additions and 151 deletions
+17 -1
View File
@@ -146,6 +146,22 @@ Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
>>> tone = Tone.from_string("A4", system="western")
>>> play(tone, t=1_000) # sine wave, 1 second
>>> play(tone, synth=Synth.SAW, t=1_000) # sawtooth wave
>>> from pytheory import save, Chord
>>> save(Chord.from_name("Am7"), "am7.wav", t=2_000) # save to WAV
```
## Command-Line Interface
```
$ pytheory tone A4 # frequency, MIDI, overtones
$ pytheory chord C E G # identify chord from notes
$ pytheory key G major # explore a key
$ pytheory scale C dorian # show a scale
$ pytheory fingering Am --capo 2 # guitar fingering
$ pytheory progression C major I V vi IV # build a progression
$ pytheory detect C E G A D # detect key from notes
$ pytheory play Am7 --synth triangle # play a chord
```
## Features
@@ -157,7 +173,7 @@ Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
- **25 instrument presets**: guitar (8 tunings), 12-string, bass, mandolin family, violin family, banjo, harp, oud, sitar, shamisen, erhu, charango, pipa, balalaika, lute, pedal steel, keyboard
- **Pitch tools**: frequency ↔ tone conversion, MIDI ↔ tone, interval naming, circle of fifths, overtone series, transposition
- **3 temperaments**: equal, Pythagorean, quarter-comma meantone
- **Audio synthesis**: sine, sawtooth, and triangle wave playback
- **Audio synthesis**: sine, sawtooth, and triangle wave playback + WAV export
## Documentation
+49 -14
View File
@@ -258,6 +258,39 @@ you hear a pulsing at the **beat frequency**: ``|f1 - f2|`` Hz.
# The slowest (most perceptible) beat
chord.beat_pulse # 189.6 Hz
Transposition
-------------
Shift an entire chord up or down by any number of semitones:
.. code-block:: python
>>> Chord.from_name("C").transpose(7).identify()
'G major'
>>> Chord.from_name("Am7").transpose(-2).identify()
'G minor 7th'
Chord Manipulation
------------------
Add or remove individual tones from a chord:
.. code-block:: python
from pytheory import Chord, Tone
c_major = Chord.from_tones("C", "E", "G")
# Add a tone to build a seventh chord
b4 = Tone.from_string("B4", system="western")
cmaj7 = c_major.add_tone(b4)
cmaj7.identify() # 'C major 7th'
# Remove a tone
c_again = cmaj7.remove_tone("B")
c_again.identify() # 'C major'
Chord Identification
--------------------
@@ -267,23 +300,25 @@ against 17 known chord types (triads, 7ths, 9ths, sus, power chords).
.. code-block:: python
from pytheory import Chord, Tone
from pytheory import Chord
# Build a chord and identify it
chord = Chord([
Tone.from_string("A4", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("E5", system="western"),
])
chord.identify() # 'A minor'
# From note names
Chord.from_tones("A", "C", "E").identify() # 'A minor'
Chord.from_tones("G", "B", "D", "F").identify() # 'G dominant 7th'
# Works with any voicing or inversion
chord2 = Chord([
Tone.from_string("E4", system="western"),
Tone.from_string("G4", system="western"),
Tone.from_string("C5", system="western"),
])
chord2.identify() # 'C major' (first inversion detected)
Chord.from_tones("E", "G", "C").identify() # 'C major'
# Flats work too
Chord.from_tones("Bb", "D", "F").identify() # 'Bb major'
You can also access the root and quality separately:
.. code-block:: python
chord = Chord.from_name("Am7")
chord.root # <Tone A4>
chord.quality # 'minor 7th'
Harmonic Analysis
-----------------
+129
View File
@@ -0,0 +1,129 @@
Command-Line Interface
======================
PyTheory includes a CLI for quick music theory lookups from the terminal.
Tone Lookup
-----------
Look up any note's frequency, MIDI number, enharmonic spelling, and
overtones::
$ pytheory tone A4
Note: A4
Frequency: 440.00 Hz (equal temperament)
MIDI: 69
Overtones: 440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0
Compare temperaments with ``--temperament``::
$ pytheory tone C5 --temperament pythagorean
Note: C5
Frequency: 521.48 Hz (pythagorean temperament)
Equal temp: 523.25 Hz (diff: -5.9 cents)
Scale Display
-------------
Show any scale in any system::
$ pytheory scale C major
C major: C D E F G A B C
Intervals: C4 -2- D4 -2- E4 -1- F4 -2- G4 -2- A4 -2- B4 -1- C5
$ pytheory scale C dorian
$ pytheory scale Sa bhairav --system indian
Chord Identification
--------------------
Identify a chord from its notes::
$ pytheory chord C E G
Chord: C major
Tones: C4 E4 G4
Intervals: [4, 3]
Harmony: 0.5833
Dissonance: 0.0712
Tension: 0.00 (tritones=0)
$ pytheory chord G B D F
Chord: G dominant 7th
Key Explorer
------------
Get a complete breakdown of any key — signature, diatonic triads,
seventh chords, relative and parallel keys::
$ pytheory key G major
Key: G major
Signature: 1 sharps, 0 flats (F#)
Scale: G A B C D E F#
Triads:
I G major
ii A minor
iii B minor
IV C major
V D major
vi E minor
vii° F# diminished
7th chords:
G major 7th
A minor 7th
...
Relative: <Key E minor>
Parallel: <Key G minor>
Guitar Fingerings
-----------------
Get tablature for any of the 144 built-in chords::
$ pytheory fingering Am
Am
E|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--0--
Use ``--capo`` to see fingerings with a capo::
$ pytheory fingering G --capo 2
Chord Progressions
------------------
Build progressions from Roman numerals::
$ pytheory progression G major I V vi IV
Key: G major
Progression: I → V → vi → IV
I G major
V D major
vi E minor
IV C major
Key Detection
-------------
Detect the most likely key from a set of notes::
$ pytheory detect C E G A D
Detected key: C major
Scale: C D E F G A B C
Audio Playback
--------------
Play individual notes or chords (requires PortAudio)::
$ pytheory play A4 # Single note
$ pytheory play C E G # Notes as chord
$ pytheory play Am7 # Chord by name
$ pytheory play C E G --synth saw # Sawtooth wave
$ pytheory play A4 --duration 2000 # 2 seconds
$ pytheory play C E G --temperament meantone
+92 -14
View File
@@ -274,23 +274,101 @@ structure. In the key of A::
# The 12-bar blues progression
blues_12 = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
Parallel Major and Minor
~~~~~~~~~~~~~~~~~~~~~~~~~
Key Signatures
~~~~~~~~~~~~~~
Two scales are **relative** if they share the same notes (C major and
A minor). Two scales are `parallel <https://en.wikipedia.org/wiki/Parallel_key>`_ if they share the same tonic but
have different notes (C major and C minor).
The ``signature`` property tells you how many sharps or flats a key has:
Mixing parallel major and minor is a powerful compositional tool —
borrowing chords from the parallel minor in a major key creates
dramatic color shifts. The bVI and bVII chords (Ab and Bb in C major)
are borrowed from C minor and appear constantly in rock and film music.
.. code-block:: python
>>> Key("G", "major").signature
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
>>> Key("F", "major").signature
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
>>> Key("C", "major").signature
{'sharps': 0, 'flats': 0, 'accidentals': []}
Relative and Parallel Keys
~~~~~~~~~~~~~~~~~~~~~~~~~~
Two keys are **relative** if they share the same notes (C major and
A minor). Two keys are `parallel <https://en.wikipedia.org/wiki/Parallel_key>`_ if they share the same tonic but
have different notes (C major and C minor):
.. code-block:: python
>>> Key("C", "major").relative
<Key A minor>
>>> Key("A", "minor").relative
<Key C major>
>>> Key("C", "major").parallel
<Key C minor>
Borrowed Chords
~~~~~~~~~~~~~~~
`Modal interchange <https://en.wikipedia.org/wiki/Borrowed_chord>`_
borrowing chords from the parallel key — is one of the most powerful
tools in songwriting. The bVI and bVII chords (Ab and Bb in C major)
are borrowed from C minor and appear constantly in rock and film music:
.. code-block:: python
>>> Key("C", "major").borrowed_chords
# Chords from C minor that aren't in C major
Secondary Dominants
~~~~~~~~~~~~~~~~~~~
A `secondary dominant <https://en.wikipedia.org/wiki/Secondary_dominant>`_
is the V chord *of* a non-tonic chord. It creates a momentary pull
toward that chord, adding harmonic color:
.. code-block:: python
key = Key("C", "major")
# V/V — the dominant of the dominant (D7 → G)
key.secondary_dominant(5) # D dominant 7th
# V/ii — the dominant of the supertonic (A7 → Dm)
key.secondary_dominant(2) # A dominant 7th
Random Progressions
~~~~~~~~~~~~~~~~~~~
Need inspiration? Generate weighted random progressions. The weights
favor common chord functions (I and vi most likely, vii least):
.. code-block:: python
key = Key("C", "major")
chords = key.random_progression(4) # 4 chords
[c.identify() for c in chords]
# e.g. ['C major', 'F major', 'A minor', 'G major']
All Keys
~~~~~~~~
Enumerate all 24 major and minor keys:
.. code-block:: python
>>> Key.all_keys()
[<Key C major>, <Key C minor>, <Key C# major>, <Key C# minor>, ...]
Scale Transposition
~~~~~~~~~~~~~~~~~~~
Transpose an entire scale by a number of semitones:
.. code-block:: python
c_major = TonedScale(tonic="C4")["major"]
c_minor = TonedScale(tonic="C4")["minor"]
# Compare: same tonic, different notes
c_major.note_names # ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
c_minor.note_names # ['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
d_major = c_major.transpose(2) # Up a whole step
d_major.note_names
# ['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
+69 -4
View File
@@ -44,9 +44,10 @@ Creating Tones
from pytheory import Tone
# From a string (most common)
# From a string (most common) — sharps and flats both work
c4 = Tone.from_string("C4")
cs4 = Tone.from_string("C#4")
db4 = Tone.from_string("Db4") # Same pitch as C#4
# Direct construction
d = Tone(name="D", octave=3)
@@ -54,20 +55,32 @@ Creating Tones
# With a specific system
a4 = Tone.from_string("A4", system="western")
# From a frequency (finds the nearest note)
Tone.from_frequency(440) # <Tone A4>
Tone.from_frequency(261.63) # <Tone C4>
# From a MIDI note number
Tone.from_midi(60) # <Tone C4> (middle C)
Tone.from_midi(69) # <Tone A4>
Properties
----------
.. code-block:: python
>>> c4 = Tone.from_string("C4")
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.name
'C'
>>> c4.octave
4
>>> c4.full_name
'C4'
>>> str(c4)
'C4'
>>> c4.letter # Note letter without accidentals
'C'
>>> c4.midi # MIDI note number
60
>>> c4.exists # Is this note in the system?
True
Pitch and Frequency
-------------------
@@ -216,6 +229,58 @@ Subtracting two tones gives the semitone distance:
>>> c5 - c4 # Octave = 12 semitones
12
Naming Intervals
~~~~~~~~~~~~~~~~
The ``interval_to`` method gives the musical name of the interval
between two tones, including compound intervals that span more than
one octave:
.. code-block:: python
>>> c4.interval_to(g4)
'perfect 5th'
>>> c4.interval_to(c4 + 4)
'major 3rd'
>>> c4.interval_to(c5)
'octave'
# Compound intervals (more than an octave)
>>> c4.interval_to(c4 + 19) # Octave + perfect 5th
'perfect 5th + 1 octave'
Transposition
~~~~~~~~~~~~~
The ``transpose`` method returns a new tone shifted by a number of
semitones — equivalent to the ``+`` operator but reads more clearly
in some contexts:
.. code-block:: python
>>> c4.transpose(7) # Same as c4 + 7
<Tone G4>
>>> c4.transpose(-2) # Two semitones down
<Tone A#3>
MIDI
~~~~
Every tone maps to a `MIDI note number <https://en.wikipedia.org/wiki/MIDI>`_
(0127), the standard for communicating with synthesizers, DAWs, and
digital instruments:
.. code-block:: python
>>> c4.midi
60 # Middle C
>>> Tone.from_string("A4", system="western").midi
69 # Concert A
# Round-trip: MIDI → Tone → MIDI
>>> Tone.from_midi(60).midi
60
Comparison and Sorting
----------------------
+1
View File
@@ -34,6 +34,7 @@ Work with tones, scales, chords, and fretboards using a clean, Pythonic API.
guide/fretboard
guide/systems
guide/playback
guide/cli
.. toctree::
:maxdepth: 2
+46
View File
@@ -0,0 +1,46 @@
"""Identify chords from notes or guitar fingerings."""
from pytheory import Chord, Fretboard
print("=== Chord Identification from Notes ===")
print()
test_chords = [
("C", "E", "G"),
("A", "C", "E"),
("G", "B", "D", "F"),
("D", "F#", "A"),
("Bb", "D", "F"),
("E", "G#", "B"),
("C", "Eb", "Gb"),
("C", "G"),
("C", "F", "G"),
("C", "D", "G"),
]
for notes in test_chords:
chord = Chord.from_tones(*notes)
name = chord.identify() or "Unknown"
print(f" {', '.join(notes):20s}{name}")
print()
print("=== Chord Identification from Guitar Fingerings ===")
print()
fb = Fretboard.guitar()
# Common guitar chord shapes
shapes = [
("Open C", (0, 1, 0, 2, 3, 0)),
("Open G", (3, 0, 0, 0, 2, 3)),
("Open D", (2, 3, 2, 0, 0, 0)),
("Open Am", (0, 1, 2, 2, 0, 0)),
("Open Em", (0, 0, 0, 2, 2, 0)),
("Barre F", (1, 1, 2, 3, 3, 1)),
("Power E5", (0, 0, 0, 0, 2, 0)),
]
for label, positions in shapes:
f = fb.fingering(*positions)
name = f.identify() or "Unknown"
print(f" {label:12s} {f}{name}")
+52
View File
@@ -0,0 +1,52 @@
"""Analyze harmonic tension and resolution across chords."""
from pytheory import Chord
print("Chord Tension Analysis")
print("=" * 70)
print()
print(f"{'Chord':>20s} {'Tension':>8s} {'Harmony':>8s} {'Dissonance':>11s} {'Notes'}")
print(f"{'' * 20} {'' * 8} {'' * 8} {'' * 11} {'' * 15}")
chords = [
# Stable chords
"C", "Am",
# Moderate tension
"Dm7", "Cmaj7",
# High tension
"G7", "Bdim",
# Extended
"Am7", "Cmaj9",
]
for name in chords:
chord = Chord.from_name(name)
t = chord.tension
tones = " ".join(tone.name for tone in chord.tones)
print(
f"{name:>20s} {t['score']:>8.2f} {chord.harmony:>8.4f}"
f" {chord.dissonance:>11.4f} {tones}"
)
# Show the V7 → I resolution
print()
print("" * 70)
print()
print("The V7 → I resolution (the strongest pull in tonal music):")
print()
g7 = Chord.from_name("G7")
c = Chord.from_name("C")
print(f" G7 (dominant): tension={g7.tension['score']:.2f} "
f"tritones={g7.tension['tritones']} "
f"dominant_function={g7.tension['has_dominant_function']}")
print(f" C (tonic): tension={c.tension['score']:.2f} "
f"tritones={c.tension['tritones']} "
f"dominant_function={c.tension['has_dominant_function']}")
print()
print("Voice leading (G7 → C):")
for src, dst, motion in g7.voice_leading(c):
direction = "" if motion > 0 else "" if motion < 0 else "="
print(f" {src.name:3s}{dst.name:3s} ({direction} {abs(motion)} semitones)")
+34
View File
@@ -0,0 +1,34 @@
"""Visualize the circle of fifths with key signatures."""
from pytheory import Tone, Key
c = Tone.from_string("C4", system="western")
print("╔══════════════════════════════════════════════╗")
print("║ THE CIRCLE OF FIFTHS ║")
print("╠══════════════════════════════════════════════╣")
print("║ Key Sig Accidentals ║")
print("╠══════════════════════════════════════════════╣")
for tone in c.circle_of_fifths():
key = Key(tone.name, "major")
sig = key.signature
relative = key.relative
if sig["sharps"]:
mark = f'{sig["sharps"]}#'
elif sig["flats"]:
mark = f'{sig["flats"]}b'
else:
mark = "--"
accidentals = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
print(f"{tone.name:3s} {mark:3s} {accidentals:20s} rel: {relative.tonic_name} {relative.mode:5s}")
print("╚══════════════════════════════════════════════╝")
# Show that 12 fifths returns to the start
print()
print("Proof: 12 perfect fifths cycle through all 12 tones")
names = [t.name for t in c.circle_of_fifths()]
print(f" {''.join(names)}{names[0]}")
+74
View File
@@ -0,0 +1,74 @@
"""Explore instruments, tunings, and chord fingerings."""
from pytheory import Fretboard, CHARTS
# ── Compare Instruments ─────────────────────────────────────────────────
print("Instrument Tunings")
print("=" * 55)
instruments = [
("Guitar (standard)", Fretboard.guitar()),
("Guitar (drop D)", Fretboard.guitar("drop d")),
("Guitar (open G)", Fretboard.guitar("open g")),
("Guitar (DADGAD)", Fretboard.guitar("dadgad")),
("Bass", Fretboard.bass()),
("Ukulele", Fretboard.ukulele()),
("Mandolin", Fretboard.mandolin()),
("Violin", Fretboard.violin()),
("Banjo", Fretboard.banjo()),
("Bouzouki (Irish)", Fretboard.bouzouki()),
]
for name, fb in instruments:
tuning = " ".join(t.full_name for t in fb.tones)
print(f" {name:22s} {tuning}")
# ── Guitar Chord Chart ──────────────────────────────────────────────────
print()
print("Guitar Chord Chart (standard tuning)")
print("=" * 55)
fb = Fretboard.guitar()
chart = CHARTS["western"]
for chord_name in ["C", "G", "D", "Am", "Em", "F", "A", "E", "Dm", "G7", "C7", "Am7"]:
f = chart[chord_name].fingering(fretboard=fb)
print(f" {chord_name:5s} {f}")
# ── Capo Magic ──────────────────────────────────────────────────────────
print()
print("Capo Transposition")
print("=" * 55)
print(" Playing open chord shapes with a capo changes the key:")
print()
open_shapes = ["C", "G", "D", "Am", "Em"]
for capo_fret in range(1, 6):
fb_capo = Fretboard.guitar(capo=capo_fret)
results = []
for shape in open_shapes:
f = chart[shape].fingering(fretboard=fb_capo)
actual = f.identify() or "?"
results.append(f"{shape}{actual.split()[0]}")
print(f" Capo {capo_fret}: {', '.join(results)}")
# ── Same Chord on Different Instruments ─────────────────────────────────
print()
print("C Major on Different Instruments")
print("=" * 55)
c_chord = chart["C"]
for name, fb in [("Guitar", Fretboard.guitar()),
("Ukulele", Fretboard.ukulele()),
("Mandolin", Fretboard.mandolin()),
("Banjo", Fretboard.banjo())]:
try:
f = c_chord.fingering(fretboard=fb)
print(f" {name:12s} {f}")
except Exception:
print(f" {name:12s} (not available for this tuning)")
+93
View File
@@ -0,0 +1,93 @@
"""Learn intervals — names, sounds, and relationships."""
from pytheory import Tone, Chord, Interval
c4 = Tone.from_string("C4", system="western")
# ── Interval Reference ──────────────────────────────────────────────────
print("Interval Reference (from C4)")
print("=" * 70)
print()
print(f"{'Semitones':>10s} {'Note':>5s} {'Interval Name':>18s} {'Sound / Song'}")
print(f"{'' * 10} {'' * 5} {'' * 18} {'' * 30}")
songs = {
0: "Same note",
1: "Jaws",
2: "Happy Birthday",
3: "Greensleeves",
4: "Here Comes the Sun",
5: "Here Comes the Bride",
6: "The Simpsons",
7: "Star Wars (main theme)",
8: "Love Story",
9: "My Bonnie Lies Over the Ocean",
10: "Somewhere (West Side Story)",
11: "Take On Me (chorus)",
12: "Somewhere Over the Rainbow",
}
for semitones in range(13):
tone = c4 + semitones
name = c4.interval_to(tone)
song = songs.get(semitones, "")
print(f"{semitones:>10d} {tone.name:>5s} {name:>18s} {song}")
# ── Interval Constants ──────────────────────────────────────────────────
print()
print("Interval Constants (pytheory.Interval)")
print("=" * 40)
constants = [
("UNISON", Interval.UNISON),
("MINOR_SECOND", Interval.MINOR_SECOND),
("MAJOR_SECOND", Interval.MAJOR_SECOND),
("MINOR_THIRD", Interval.MINOR_THIRD),
("MAJOR_THIRD", Interval.MAJOR_THIRD),
("PERFECT_FOURTH", Interval.PERFECT_FOURTH),
("TRITONE", Interval.TRITONE),
("PERFECT_FIFTH", Interval.PERFECT_FIFTH),
("MINOR_SIXTH", Interval.MINOR_SIXTH),
("MAJOR_SIXTH", Interval.MAJOR_SIXTH),
("MINOR_SEVENTH", Interval.MINOR_SEVENTH),
("MAJOR_SEVENTH", Interval.MAJOR_SEVENTH),
("OCTAVE", Interval.OCTAVE),
]
for name, value in constants:
print(f" Interval.{name:16s} = {value}")
# ── Compound Intervals ─────────────────────────────────────────────────
print()
print("Compound Intervals (beyond one octave)")
print("=" * 50)
for semitones in [13, 14, 15, 16, 19, 24]:
tone = c4 + semitones
name = c4.interval_to(tone)
print(f" {semitones:2d} semitones {tone.full_name:5s} {name}")
# ── Consonance Ranking ──────────────────────────────────────────────────
print()
print("Intervals Ranked by Consonance")
print("=" * 50)
intervals = []
for semitones in range(1, 13):
tone = c4 + semitones
dyad = Chord.from_tones("C", tone.name)
name = c4.interval_to(tone)
intervals.append((dyad.harmony, dyad.dissonance, semitones, name))
# Sort by harmony score (descending)
intervals.sort(key=lambda x: x[0], reverse=True)
print(f"{'Rank':>5s} {'Interval':>18s} {'Harmony':>8s} {'Dissonance':>11s}")
print(f"{'' * 5} {'' * 18} {'' * 8} {'' * 11}")
for rank, (harmony, dissonance, _, name) in enumerate(intervals, 1):
print(f"{rank:>5d} {name:>18s} {harmony:>8.4f} {dissonance:>11.4f}")
+64
View File
@@ -0,0 +1,64 @@
"""Detect the key of a melody or chord progression."""
from pytheory import Key, Chord
print("Key Detection")
print("=" * 55)
print()
# ── Detect from Melody Notes ────────────────────────────────────────────
melodies = [
("Twinkle Twinkle", ["C", "G", "A", "F", "E", "D"]),
("Happy Birthday", ["G", "A", "B", "C", "D", "F#"]),
("Yesterday", ["F", "E", "D", "C", "Bb", "A", "G"]),
("Minor melody", ["A", "B", "C", "D", "E", "F", "G"]),
("Blues lick", ["E", "G", "A", "B", "D"]),
("Chromatic fragment", ["C", "C#", "D", "D#", "E"]),
]
print("Detecting key from melody notes:")
print()
for label, notes in melodies:
key = Key.detect(*notes)
print(f" {label:22s} {', '.join(notes):30s}{key}")
# ── Detect from Chord Progression ──────────────────────────────────────
print()
print("Detecting key from chord tones:")
print()
progressions = [
("I-IV-V", [("C", "E", "G"), ("F", "A", "C"), ("G", "B", "D")]),
("Pop in G", [("G", "B", "D"), ("D", "F#", "A"), ("E", "G", "B"), ("C", "E", "G")]),
("Jazz ii-V-I", [("D", "F", "A"), ("G", "B", "D", "F"), ("C", "E", "G", "B")]),
]
for label, chord_tones in progressions:
# Collect all unique note names
all_notes = set()
for tones in chord_tones:
all_notes.update(tones)
key = Key.detect(*all_notes)
chord_names = [Chord.from_tones(*t).identify() for t in chord_tones]
print(f" {label:15s} {''.join(chord_names):40s}{key}")
# ── All 24 Keys ─────────────────────────────────────────────────────────
print()
print("All 24 Major and Minor Keys")
print("=" * 55)
print()
for key in Key.all_keys():
sig = key.signature
acc = ", ".join(sig["accidentals"]) if sig["accidentals"] else "none"
rel = key.relative
print(
f" {str(key):12s} "
f"{sig['sharps']}# {sig['flats']}b "
f"({acc:15s}) "
f"rel: {rel}"
)
+58
View File
@@ -0,0 +1,58 @@
"""Explore a key — its chords, progressions, and relationships."""
from pytheory import Key
def explore_key(tonic, mode="major"):
key = Key(tonic, mode)
sig = key.signature
acc = ", ".join(sig["accidentals"]) or "none"
print(f"{'=' * 60}")
print(f" {key}")
print(f"{'=' * 60}")
print()
print(f" Scale: {' '.join(key.note_names)}")
print(f" Signature: {sig['sharps']} sharps, {sig['flats']} flats ({acc})")
print(f" Relative: {key.relative}")
print(f" Parallel: {key.parallel}")
print()
# Diatonic triads
print(" Diatonic Triads:")
for chord in key.scale.harmonize():
numeral = chord.analyze(tonic, mode) or "?"
print(f" {numeral:6s} {chord.identify()}")
print()
# Seventh chords
print(" Seventh Chords:")
for name in key.seventh_chords:
print(f" {name}")
print()
# Common progressions
print(" Common Progressions:")
progressions = {
"Pop": ("I", "V", "vi", "IV"),
"Blues": ("I", "IV", "V"),
"50s": ("I", "vi", "IV", "V"),
"Jazz": ("ii", "V", "I"),
}
for label, numerals in progressions.items():
chords = key.progression(*numerals)
names = [c.identify() for c in chords]
print(f" {label:8s} {''.join(numerals):20s} {''.join(names)}")
print()
# Borrowed chords
borrowed = key.borrowed_chords
if borrowed:
print(f" Borrowed from {key.parallel}:")
for chord in borrowed[:4]:
print(f" {chord.identify()}")
print()
# Explore several keys
for tonic, mode in [("C", "major"), ("G", "major"), ("A", "minor"), ("E", "major")]:
explore_key(tonic, mode)
+35
View File
@@ -0,0 +1,35 @@
"""Convert between MIDI note numbers, frequencies, and note names."""
from pytheory import Tone
print("MIDI ↔ Note ↔ Frequency Reference")
print("=" * 50)
print()
print(f"{'MIDI':>5s} {'Note':>5s} {'Freq (Hz)':>10s} {'Octave':>6s}")
print(f"{'' * 5} {'' * 5} {'' * 10} {'' * 6}")
# Show all notes from C2 to C7
for midi in range(36, 97):
tone = Tone.from_midi(midi)
freq = tone.frequency
print(f"{midi:>5d} {tone.full_name:>5s} {freq:>10.2f} {tone.octave:>6d}")
# Useful reference points
print()
print("Key Reference Points:")
print(f" Lowest piano note: A0 = MIDI {Tone.from_string('A0', system='western').midi}")
print(f" Middle C: C4 = MIDI {Tone.from_string('C4', system='western').midi}")
print(f" Concert A: A4 = MIDI {Tone.from_string('A4', system='western').midi}")
print(f" Highest piano note: C8 = MIDI {Tone.from_string('C8', system='western').midi}")
# Round-trip demo
print()
print("Round-trip conversions:")
for start in ["C4", "A4", "F#3", "Bb5"]:
tone = Tone.from_string(start, system="western")
midi = tone.midi
freq = tone.frequency
from_midi = Tone.from_midi(midi)
from_freq = Tone.from_frequency(freq)
print(f" {start:4s} → MIDI {midi}{from_midi.full_name:4s} | "
f"{start:4s}{freq:.2f} Hz → {from_freq.full_name}")
+68
View File
@@ -0,0 +1,68 @@
"""Explore the overtone series — nature's chord."""
from pytheory import Tone, Chord
a4 = Tone.from_string("A4", system="western")
print("The Overtone Series")
print("=" * 65)
print()
print("When you play a note, you're actually hearing many frequencies")
print("at once. The fundamental plus its integer multiples:")
print()
print(f"{'Harmonic':>9s} {'Frequency':>10s} {'Nearest Note':>13s} {'Interval from Root'}")
print(f"{'' * 9} {'' * 10} {'' * 13} {'' * 25}")
overtones = a4.overtones(16)
for i, hz in enumerate(overtones, 1):
nearest = Tone.from_frequency(hz)
if i == 1:
interval = "Fundamental"
else:
interval = a4.interval_to(nearest)
print(f"{i:>9d} {hz:>10.1f} {nearest.full_name:>13s} {interval}")
# ── Why Chords Sound Good ───────────────────────────────────────────────
print()
print("Why the Major Triad Sounds 'Natural'")
print("=" * 65)
print()
print("The first 6 harmonics contain: root, octave, 5th, 2nd octave, 3rd, 5th")
print("That's a major triad! The major chord is literally embedded in physics.")
print()
c4 = Tone.from_string("C4", system="western")
harmonics = c4.overtones(6)
harmonic_names = [Tone.from_frequency(hz).name for hz in harmonics]
unique = []
for n in harmonic_names:
if n not in unique:
unique.append(n)
print(f" First 6 harmonics of C: {', '.join(harmonic_names)}")
print(f" Unique pitch classes: {', '.join(unique)}")
print(f" C major triad: C, E, G")
print()
# ── Shared Overtones = Consonance ───────────────────────────────────────
print("Shared Overtones Between Intervals")
print("=" * 65)
print()
print("The more overtones two notes share, the more consonant they sound.")
print()
root = Tone.from_string("C4", system="western")
root_overtones = set(round(h, 1) for h in root.overtones(12))
for semitones, label in [(7, "Perfect 5th (C→G)"),
(4, "Major 3rd (C→E)"),
(5, "Perfect 4th (C→F)"),
(3, "Minor 3rd (C→Eb)"),
(6, "Tritone (C→F#)"),
(1, "Minor 2nd (C→C#)")]:
other = root + semitones
other_overtones = set(round(h, 1) for h in other.overtones(12))
shared = root_overtones & other_overtones
print(f" {label:25s} {len(shared):2d} shared overtones (of first 12)")
+81
View File
@@ -0,0 +1,81 @@
"""Build and analyze chord progressions in any key."""
from pytheory import Key, Chord
def show_progression(key, numerals, label=""):
chords = key.progression(*numerals)
if label:
print(f" {label}")
print(f" Key: {key}")
print(f" Progression: {' '.join(numerals)}")
print()
for numeral, chord in zip(numerals, chords):
t = chord.tension
print(
f" {numeral:6s} {chord.identify():20s} "
f"tension={t['score']:.2f} "
f"{'*** DOMINANT ***' if t['has_dominant_function'] else ''}"
)
print()
# ── Famous Progressions ─────────────────────────────────────────────────
print("Famous Chord Progressions")
print("=" * 65)
print()
key_c = Key("C", "major")
show_progression(key_c, ("I", "V", "vi", "IV"),
"The Pop Progression (Let It Be, No Woman No Cry, Someone Like You)")
show_progression(key_c, ("I", "vi", "IV", "V"),
"The 50s Progression (Stand By Me, Every Breath You Take)")
show_progression(key_c, ("ii", "V", "I"),
"Jazz iiVI (the backbone of jazz harmony)")
show_progression(key_c, ("I", "IV", "V", "I"),
"The Three-Chord Trick (blues, rock, country)")
# ── Same Progression in Different Keys ──────────────────────────────────
print("" * 65)
print()
print("I V vi IV in every key:")
print()
for tonic in ["C", "G", "D", "A", "E", "F", "Bb", "Eb"]:
key = Key(tonic, "major")
chords = key.progression("I", "V", "vi", "IV")
names = [c.identify() for c in chords]
print(f" {tonic} major: {''.join(names)}")
# ── Nashville Number System ─────────────────────────────────────────────
print()
print("" * 65)
print()
print("Nashville Number System:")
print(" (Same thing as Roman numerals, but with integers)")
print()
key_g = Key("G", "major")
chords = key_g.nashville(1, 5, 6, 4)
names = [c.identify() for c in chords]
print(f" G major: 1 5 6 4 → {''.join(names)}")
# ── Random Progression Generator ────────────────────────────────────────
print()
print("" * 65)
print()
print("Random 8-bar progressions:")
print()
for _ in range(3):
key = Key("C", "major")
chords = key.random_progression(8)
names = [c.identify().split()[0] for c in chords] # Just root names
print(f" | {' | '.join(names)} |")
+201 -63
View File
@@ -1,78 +1,216 @@
from time import sleep
"""Play melodies and chord progressions with PyTheory.
from pytheory import TonedScale, Tone, CHARTS, play
Requires PortAudio: brew install portaudio (macOS)
"""
from pytheory import Tone, Chord, Key, TonedScale, play, Synth
# ── Helpers ─────────────────────────────────────────────────────────────
BPM = 180
BEAT = 60_000 // BPM # ms per beat
# Add this constant at the top of the file, after the imports
EIGHTH_NOTE = 0.25
QUARTER_NOTE = 0.5
# Add scale definition after the constants
C_MAJOR = TonedScale(tonic="C4")
def play_melody(notes, synth=Synth.SINE):
"""Play a sequence of (note_string, beats) tuples."""
try:
for note, beats in notes:
if note == "REST":
import time
time.sleep(beats * BEAT / 1000)
else:
tone = Tone.from_string(note, system="western")
play(tone, synth=synth, t=int(beats * BEAT))
except KeyboardInterrupt:
print("\n Stopped.")
def play_note(note, t=0.1):
# Convert scale degree (1-7) to note name (0-based index)
scale_notes = ["C4", "D4", "E4", "F4", "G4", "A4", "B4"]
note_name = scale_notes[note - 1] # Subtract 1 because scale degrees are 1-based
tone = Tone(note_name)
play(tone, t=t * 1_000)
sleep(t)
def play_progression(chords, beats_each=2, synth=Synth.SINE):
"""Play a list of Chord objects."""
try:
for chord in chords:
name = chord.identify() or "?"
tones = " ".join(t.full_name for t in chord.tones)
print(f" {name:20s} {tones}")
play(chord, synth=synth, t=int(beats_each * BEAT))
except KeyboardInterrupt:
print("\n Stopped.")
# Twinkle Twinkle Little Star in C major
# C C G G A A G (first line)
# F F E E D D C (second line)
# G G F F E E D (third line)
# G G F F E E D (fourth line)
# C C G G A A G (fifth line)
# F F E E D D C (sixth line)
# ── Songs ───────────────────────────────────────────────────────────────
def twinkle_twinkle():
"""Twinkle Twinkle Little Star — C major."""
print("Twinkle Twinkle Little Star")
print("=" * 40)
def play_twinkle():
# Define the patterns using scale degrees instead of note names
line1 = [
(1, EIGHTH_NOTE), # C4
(1, EIGHTH_NOTE), # C4
(5, EIGHTH_NOTE), # G4
(5, EIGHTH_NOTE), # G4
(6, EIGHTH_NOTE), # A4
(6, EIGHTH_NOTE), # A4
(5, QUARTER_NOTE), # G4
]
line2 = [
(4, EIGHTH_NOTE), # F4
(4, EIGHTH_NOTE), # F4
(3, EIGHTH_NOTE), # E4
(3, EIGHTH_NOTE), # E4
(2, EIGHTH_NOTE), # D4
(2, EIGHTH_NOTE), # D4
(1, QUARTER_NOTE), # C4
]
line3 = [
(5, EIGHTH_NOTE), # G4
(5, EIGHTH_NOTE), # G4
(4, EIGHTH_NOTE), # F4
(4, EIGHTH_NOTE), # F4
(3, EIGHTH_NOTE), # E4
(3, EIGHTH_NOTE), # E4
(2, QUARTER_NOTE), # D4
melody = [
# Twinkle twinkle little star
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
("A4", 1), ("A4", 1), ("G4", 2),
# How I wonder what you are
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("C4", 2),
# Up above the world so high
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
("E4", 1), ("E4", 1), ("D4", 2),
# Like a diamond in the sky
("G4", 1), ("G4", 1), ("F4", 1), ("F4", 1),
("E4", 1), ("E4", 1), ("D4", 2),
# Twinkle twinkle little star
("C4", 1), ("C4", 1), ("G4", 1), ("G4", 1),
("A4", 1), ("A4", 1), ("G4", 2),
# How I wonder what you are
("F4", 1), ("F4", 1), ("E4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("C4", 2),
]
# Construct the full melody using the patterns
melody = (
line1 # Twinkle twinkle little star
+ line2 # How I wonder what you are
+ line3 # Up above the world so high
+ line3 # Like a diamond in the sky
+ line1 # Twinkle twinkle little star
+ line2 # How I wonder what you are
)
play_melody(melody)
print("Playing Twinkle Twinkle Little Star...")
for note, duration in melody:
play_note(note, duration)
def ode_to_joy():
"""Ode to Joy — Beethoven's 9th Symphony, D major."""
print("Ode to Joy (Beethoven)")
print("=" * 40)
melody = [
# Main theme
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
("F#4", 1.5), ("E4", 0.5), ("E4", 2),
# Repeat with variation
("F#4", 1), ("F#4", 1), ("G4", 1), ("A4", 1),
("A4", 1), ("G4", 1), ("F#4", 1), ("E4", 1),
("D4", 1), ("D4", 1), ("E4", 1), ("F#4", 1),
("E4", 1.5), ("D4", 0.5), ("D4", 2),
]
play_melody(melody)
def happy_birthday():
"""Happy Birthday — G major."""
print("Happy Birthday")
print("=" * 40)
melody = [
# Happy birthday to you
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
("C5", 1), ("B4", 2),
# Happy birthday to you
("G4", 0.75), ("G4", 0.25), ("A4", 1), ("G4", 1),
("D5", 1), ("C5", 2),
# Happy birthday dear [name]
("G4", 0.75), ("G4", 0.25), ("G5", 1), ("E5", 1),
("C5", 1), ("B4", 1), ("A4", 2),
# Happy birthday to you
("F5", 0.75), ("F5", 0.25), ("E5", 1), ("C5", 1),
("D5", 1), ("C5", 2),
]
play_melody(melody)
def fur_elise():
"""Fur Elise — opening bars (A minor)."""
print("Fur Elise (opening)")
print("=" * 40)
melody = [
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("D#5", 0.5),
("E5", 0.5), ("B4", 0.5), ("D5", 0.5), ("C5", 0.5),
("A4", 1), ("REST", 0.5),
("C4", 0.5), ("E4", 0.5), ("A4", 0.5),
("B4", 1), ("REST", 0.5),
("E4", 0.5), ("G#4", 0.5), ("B4", 0.5),
("C5", 1), ("REST", 0.5),
("E4", 0.5), ("E5", 0.5), ("D#5", 0.5),
("E5", 0.5), ("D#5", 0.5), ("E5", 0.5), ("B4", 0.5),
("D5", 0.5), ("C5", 0.5),
("A4", 1),
]
play_melody(melody)
def pop_progression():
"""The IVviIV pop progression in C major."""
print("Pop Progression (I-V-vi-IV in C)")
print("=" * 40)
print()
key = Key("C", "major")
chords = key.progression("I", "V", "vi", "IV")
# Play it twice
play_progression(chords * 2)
def blues_in_a():
"""12-bar blues in A."""
print("12-Bar Blues in A")
print("=" * 40)
print()
key = Key("A", "major")
I = key.triad(0)
IV = key.triad(3)
V = key.triad(4)
bars = [I, I, I, I, IV, IV, I, I, V, IV, I, V]
play_progression(bars, beats_each=1.5)
def jazz_ii_v_i():
"""Jazz iiVI turnaround through several keys."""
print("Jazz ii-V-I Turnaround")
print("=" * 40)
print()
for tonic in ["C", "F", "Bb", "Eb"]:
key = Key(tonic, "major")
chords = key.progression("ii", "V", "I")
print(f" Key of {tonic}:")
play_progression(chords, beats_each=1.5)
print()
# ── Main ────────────────────────────────────────────────────────────────
SONGS = {
"1": ("Twinkle Twinkle Little Star", twinkle_twinkle),
"2": ("Ode to Joy", ode_to_joy),
"3": ("Happy Birthday", happy_birthday),
"4": ("Fur Elise (opening)", fur_elise),
"5": ("Pop Progression (I-V-vi-IV)", pop_progression),
"6": ("12-Bar Blues in A", blues_in_a),
"7": ("Jazz ii-V-I Turnaround", jazz_ii_v_i),
}
if __name__ == "__main__":
play_twinkle()
try:
print("PyTheory Song Player")
print("=" * 40)
print()
for key, (name, _) in SONGS.items():
print(f" {key}. {name}")
print()
choice = input("Pick a song (1-7, or 'all'): ").strip()
if choice == "all":
for _, (_, fn) in SONGS.items():
fn()
print()
elif choice in SONGS:
SONGS[choice][1]()
else:
print("Playing all melodies...")
for _, (_, fn) in SONGS.items():
fn()
print()
except KeyboardInterrupt:
print("\n\nBye!")
+49
View File
@@ -0,0 +1,49 @@
"""Compare equal, Pythagorean, and meantone temperaments."""
import math
from pytheory import Tone
a4 = Tone.from_string("A4", system="western")
print("Temperament Comparison")
print("=" * 75)
print()
print(f"{'Note':>5s} {'Equal (Hz)':>12s} {'Pythag (Hz)':>12s} {'Meantone (Hz)':>14s} {'P diff':>8s} {'M diff':>8s}")
print(f"{'' * 5} {'' * 12} {'' * 12} {'' * 14} {'' * 8} {'' * 8}")
for semitones in range(13):
tone = a4 + semitones
equal = tone.pitch(temperament="equal")
pyth = tone.pitch(temperament="pythagorean")
mean = tone.pitch(temperament="meantone")
# Difference in cents (1 cent = 1/100 of a semitone)
pyth_cents = 1200 * math.log2(pyth / equal) if pyth > 0 else 0
mean_cents = 1200 * math.log2(mean / equal) if mean > 0 else 0
print(
f"{tone.name:>5s} {equal:>12.3f} {pyth:>12.3f} {mean:>14.3f}"
f" {pyth_cents:>+7.1f}¢ {mean_cents:>+7.1f}¢"
)
print()
print("Key intervals to listen for:")
print()
intervals = [
(4, "Major 3rd", "Meantone is pure (5:4), equal is sharp, Pythagorean sharper still"),
(7, "Perfect 5th", "Pythagorean is pure (3:2), equal is slightly flat, meantone flatter"),
(6, "Tritone", "The 'devil's interval' — all three temperaments handle it differently"),
]
for semitones, name, note in intervals:
tone = a4 + semitones
equal = tone.pitch(temperament="equal")
pyth = tone.pitch(temperament="pythagorean")
mean = tone.pitch(temperament="meantone")
print(f" {name} ({a4.name}{tone.name}):")
print(f" Equal: {equal:.3f} Hz | Pythagorean: {pyth:.3f} Hz | Meantone: {mean:.3f} Hz")
print(f" {note}")
print()
+65
View File
@@ -0,0 +1,65 @@
"""Explore scales from six musical traditions around the world."""
from pytheory import TonedScale
systems = [
("western", "C4", [
("major", "The foundation of Western tonal music"),
("minor", "Natural minor — dark and introspective"),
("harmonic minor", "Raised 7th — classical, Middle Eastern flavor"),
("dorian", "Jazz, funk, soul (So What, Scarborough Fair)"),
("mixolydian", "Blues, rock (Norwegian Wood, Sweet Home Alabama)"),
("phrygian", "Flamenco, metal (White Rabbit)"),
("lydian", "Dreamy, floating (The Simpsons theme)"),
]),
("indian", "Sa4", [
("bilawal", "Equivalent to Western major scale"),
("bhairav", "Morning raga — devotional, meditative"),
("kafi", "Equivalent to Dorian mode — romantic, earthy"),
("bhairavi", "Equivalent to Phrygian — melancholic, devotional"),
("kalyan", "Equivalent to Lydian — serene, uplifting"),
]),
("arabic", "Do4", [
("ajam", "Equivalent to Western major scale"),
("hijaz", "The quintessential 'Middle Eastern' sound"),
("bayati", "Contemplative, spiritual — most common maqam"),
("rast", "Bright, festive — the 'mother' of maqamat"),
("nahawand", "Equivalent to Western minor — melancholic"),
]),
("japanese", "C4", [
("hirajoshi", "Haunting pentatonic — koto music"),
("miyako-bushi", "Urban folk — shamisen music"),
("yo", "Bright pentatonic — folk songs, festival music"),
("in", "Dark pentatonic — court music, Buddhist chant"),
("ritsu", "Elegant pentatonic — gagaku court music"),
]),
("blues", "C4", [
("blues", "The 6-note blues scale with the 'blue note'"),
("minor pentatonic", "The backbone of rock guitar solos"),
("major pentatonic", "Bright, open — country, folk, pop"),
]),
("gamelan", "C4", [
("slendro", "5-note near-equal division — metallic, shimmering"),
("pelog", "7-note unequal — mysterious, otherworldly"),
]),
]
for system_name, tonic, scales in systems:
print(f"{'' * 65}")
print(f" {system_name.upper()}")
print(f"{'' * 65}")
ts = TonedScale(tonic=tonic, system=system_name)
for scale_name, description in scales:
try:
scale = ts[scale_name]
notes = " ".join(scale.note_names)
print(f" {scale_name:20s} {notes}")
print(f" {'':20s} {description}")
print()
except (KeyError, IndexError):
print(f" {scale_name:20s} (not available)")
print()
print(f"{'' * 65}")
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.6.0"
version = "0.6.1"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+4 -3
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.6.0"
__version__ = "0.6.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
@@ -9,9 +9,10 @@ from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, Synth
from .play import play, save, Synth
except OSError:
play = None
save = None
Synth = None
# Aliases for discoverability.
@@ -21,5 +22,5 @@ __all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "Synth",
"play", "save", "Synth",
]
-14
View File
@@ -175,20 +175,6 @@ SCALES = {
# "melodic minor": {"minor": True, "melodic": True, "hemitonic": True},
},
],
# TODO: understand this
# "hexatonic": (
# 6,
# {
# # name, arguments to scale generator.
# "wholetone": {},
# "augmented": {},
# "prometheus": {},
# "blues": {},
# },
# ),
# "pentatonic": (5, {}),
# "tetratonic": (4, {}),
# "monotonic": (1, {"monotonic": {"hemitonic": False}}),
}
}
+77 -33
View File
@@ -5,8 +5,8 @@ import sounddevice as sd
from .tones import Tone
SAMPLE_RATE = 44_100
SAMPLE_PEAK = 4_096
SAMPLE_RATE = 44_100 # CD-quality sample rate (Hz)
SAMPLE_PEAK = 4_096 # Peak amplitude for 16-bit integer samples
def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
@@ -20,41 +20,33 @@ def sine_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
return numpy.resize(onecycle, (n_samples,)).astype(numpy.int16)
def sawtooth_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=1, n_samples=SAMPLE_RATE):
"""Compute N samples of a sine wave with given frequency and peak amplitude.
def sawtooth_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a sawtooth wave with given frequency and peak amplitude.
Defaults to one second.
rising_ramp_width is the percentage of the ramp spend rising:
.5 is a triangle wave with equal rising and falling times.
"""
t = numpy.linspace(0, 1, int(500 * 440 / hz), endpoint=False)
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
wave = numpy.resize(wave, (n_samples,))
# Sawtooth waves sound very quiet, so multiply peak by 4.
return peak * 6 * wave.astype(numpy.int16)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=1)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
def triangle_wave(hz, peak=SAMPLE_PEAK, rising_ramp_width=0.5, n_samples=SAMPLE_RATE):
def triangle_wave(hz, peak=SAMPLE_PEAK, n_samples=SAMPLE_RATE):
"""Compute N samples of a triangle wave with given frequency and peak amplitude.
Defaults to one second.
rising_ramp_width is the percentage of the ramp spend rising:
.5 is a triangle wave with equal rising and falling times.
"""
hz_value = float(hz)
num_samples = int(500 * 440 / hz_value)
t = numpy.linspace(0, 1, num_samples, endpoint=False)
wave = scipy.signal.sawtooth(2 * numpy.pi * 5 * t, width=rising_ramp_width)
wave = numpy.resize(wave, (n_samples,))
# Use same amplitude as sawtooth_wave for testing
return peak * 6 * wave.astype(numpy.int16)
length = SAMPLE_RATE / float(hz)
omega = numpy.pi * 2 / length
xvalues = numpy.arange(int(length)) * omega
onecycle = scipy.signal.sawtooth(xvalues, width=0.5)
onecycle = (peak * onecycle).astype(numpy.int16)
return numpy.resize(onecycle, (n_samples,))
def _play_for(sample_wave, ms):
"""Play the given NumPy array, as a sound, for ms milliseconds."""
# sounddevice expects float32 samples between -1 and 1
"""Play the given NumPy sample array through the speakers."""
normalized_wave = sample_wave.astype(numpy.float32) / SAMPLE_PEAK
# Play the audio and wait
sd.play(normalized_wave, SAMPLE_RATE)
sd.wait()
@@ -65,18 +57,70 @@ class Synth(Enum):
TRIANGLE = triangle_wave
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Play a tone or chord."""
def _render(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Render a tone or chord to a NumPy sample array.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds.
Returns:
A NumPy int16 array of audio samples.
"""
n_samples = int(SAMPLE_RATE * t / 1_000)
if isinstance(tone_or_chord, Tone):
chord = [synth(tone_or_chord.pitch(temperament=temperament))]
waves = [synth(tone_or_chord.pitch(temperament=temperament), n_samples=n_samples)]
else:
chord = [
synth(tone.pitch(temperament=temperament))
waves = [
synth(tone.pitch(temperament=temperament), n_samples=n_samples)
for tone in tone_or_chord.tones
]
_play_for(sum(chord), ms=t)
return sum(waves)
# 69 + 12*np.log2(hz_nonneg/440.)
def play(tone_or_chord, temperament="equal", synth=Synth.SINE, t=1_000):
"""Play a tone or chord through the speakers.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to play.
temperament: Tuning temperament (``"equal"``, ``"pythagorean"``,
or ``"meantone"``).
synth: Waveform type — ``Synth.SINE``, ``Synth.SAW``, or
``Synth.TRIANGLE``.
t: Duration in milliseconds (default 1000).
Example::
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
"""
_play_for(_render(tone_or_chord, temperament=temperament, synth=synth, t=t), ms=t)
def save(tone_or_chord, path, temperament="equal", synth=Synth.SINE, t=1_000):
"""Render a tone or chord and save it as a WAV file.
Args:
tone_or_chord: A :class:`Tone` or :class:`Chord` to render.
path: Output file path (e.g. ``"chord.wav"``).
temperament: Tuning temperament.
synth: Waveform type.
t: Duration in milliseconds (default 1000).
Example::
>>> save(Chord.from_name("C"), "c_major.wav", t=2_000)
"""
import scipy.io.wavfile
samples = _render(tone_or_chord, temperament=temperament, synth=synth, t=t)
normalized = samples.astype(numpy.float32) / SAMPLE_PEAK
# Convert to 16-bit PCM
pcm = (normalized * 32767).astype(numpy.int16)
scipy.io.wavfile.write(path, SAMPLE_RATE, pcm)
+1 -2
View File
@@ -236,7 +236,6 @@ class Scale:
return [self.triad(i) for i in range(unique)]
def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
# TODO: cleanup degrees.
# Ensure that both major and minor aren't passed.
if all((major, minor)):
@@ -653,7 +652,7 @@ class TonedScale:
try:
return self._scales[scale]
except KeyError:
pass
return None
@property
def scales(self) -> tuple[str, ...]:
-1
View File
@@ -115,7 +115,6 @@ class System:
yield step
else:
for i in range(tones):
# TODO: figure out how to make this work with monotonic.
yield 1
scale = [
+149 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.6.0"
assert pytheory.__version__ == "0.6.1"
def test_all_exports():
@@ -3724,3 +3724,151 @@ def test_system_resolve_name_natural():
def test_system_resolve_name_unknown():
assert SYSTEMS["western"].resolve_name("X") is None
# ── CLI tests ─────────────────────────────────────────────────────────────────
def test_cli_tone(capsys):
from pytheory.cli import cmd_tone
import argparse
args = argparse.Namespace(note="A4", temperament="equal")
cmd_tone(args)
out = capsys.readouterr().out
assert "440.00" in out
assert "A4" in out
assert "MIDI" in out
def test_cli_tone_pythagorean(capsys):
from pytheory.cli import cmd_tone
import argparse
args = argparse.Namespace(note="C5", temperament="pythagorean")
cmd_tone(args)
out = capsys.readouterr().out
assert "Equal temp" in out
assert "cents" in out
def test_cli_scale(capsys):
from pytheory.cli import cmd_scale
import argparse
args = argparse.Namespace(tonic="C", mode="major", system="western")
cmd_scale(args)
out = capsys.readouterr().out
assert "C D E F G A B C" in out
def test_cli_chord(capsys):
from pytheory.cli import cmd_chord
import argparse
args = argparse.Namespace(notes=["C", "E", "G"])
cmd_chord(args)
out = capsys.readouterr().out
assert "C major" in out
assert "Harmony" in out
assert "Tension" in out
def test_cli_key(capsys):
from pytheory.cli import cmd_key
import argparse
args = argparse.Namespace(tonic="G", mode="major")
cmd_key(args)
out = capsys.readouterr().out
assert "G major" in out
assert "Signature" in out
assert "Relative" in out
def test_cli_fingering(capsys):
from pytheory.cli import cmd_fingering
import argparse
args = argparse.Namespace(chord="Am", capo=0)
cmd_fingering(args)
out = capsys.readouterr().out
assert "Am" in out
assert "|--" in out
def test_cli_progression(capsys):
from pytheory.cli import cmd_progression
import argparse
args = argparse.Namespace(tonic="C", mode="major", numerals=["I", "V", "vi", "IV"])
cmd_progression(args)
out = capsys.readouterr().out
assert "C major" in out
assert "I → V → vi → IV" in out
def test_cli_detect(capsys):
from pytheory.cli import cmd_detect
import argparse
args = argparse.Namespace(notes=["C", "E", "G", "A", "D"])
cmd_detect(args)
out = capsys.readouterr().out
assert "C major" in out
def test_cli_detect_no_match(capsys):
from pytheory.cli import cmd_detect
import argparse
args = argparse.Namespace(notes=[])
cmd_detect(args)
out = capsys.readouterr().out
assert "Could not detect" in out
def test_cli_main_no_args(capsys):
from pytheory.cli import main
import sys
old_argv = sys.argv
sys.argv = ["pytheory"]
try:
main()
except SystemExit:
pass
sys.argv = old_argv
# ── Play module tests ─────────────────────────────────────────────────────────
def test_play_render():
"""_render produces a numpy array of the right length."""
from pytheory.play import _render, Synth, SAMPLE_RATE
tone = Tone.from_string("A4", system="western")
samples = _render(tone, synth=Synth.SINE, t=500)
expected = int(SAMPLE_RATE * 500 / 1000)
assert len(samples) == expected
def test_play_render_chord():
from pytheory.play import _render, Synth
chord = Chord.from_tones("C", "E", "G")
samples = _render(chord, synth=Synth.SINE, t=200)
assert len(samples) > 0
def test_play_render_all_synths():
from pytheory.play import _render, Synth
tone = Tone.from_string("C4", system="western")
for synth in Synth:
samples = _render(tone, synth=synth, t=100)
assert len(samples) > 0
def test_play_save(tmp_path):
"""save() writes a valid WAV file."""
from pytheory.play import save, Synth
path = tmp_path / "test.wav"
tone = Tone.from_string("A4", system="western")
save(tone, str(path), synth=Synth.SINE, t=200)
assert path.exists()
assert path.stat().st_size > 44 # WAV header is 44 bytes
def test_play_save_chord(tmp_path):
from pytheory.play import save
path = tmp_path / "chord.wav"
chord = Chord.from_tones("C", "E", "G")
save(chord, str(path), t=200)
assert path.exists()