Compare commits

..

108 Commits

Author SHA1 Message Date
kennethreitz b29b33524f v0.30.0: Drums as Parts, split drums, kick-only sidechain, MIDI import
- Drums are real Parts with full effects pipeline
- split=True creates kick/snare/hats/toms/cymbals/percussion Parts
- Sidechain triggers on kick only
- Score.from_midi() imports Standard MIDI Files
- Document split drums workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:27:10 -04:00
kennethreitz 25f25c1f23 Split drums into separate Parts: kick, snare, hats, toms, cymbals, percussion
score.drums("rock", split=True) creates independent Parts per group.
Each gets its own effects chain. set_drum_effects() applies to all.
Sidechain triggers on kick only. Render loop handles multiple drum Parts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:26:08 -04:00
kennethreitz 3f1d632285 Sidechain triggers on kick only, not all drum hits
Hi-hats and snares no longer duck the pad — only the kick does.
This is how sidechain compression works in real mixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:22:20 -04:00
kennethreitz 1938037458 Update changelog: drums as Part
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:20:05 -04:00
kennethreitz f7c05e1b31 Drums are now a real Part — same effects pipeline, zero duplication
_drum_hits and _drum_pattern_beats proxy through score.parts['drums'].
Drum Part goes through _apply_part_effects like any other Part.
set_drum_effects() is now sugar over the Part's attributes.
All 789 tests pass with no API changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:17:05 -04:00
kennethreitz c375785bb9 Update changelog for drum bus effects
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:12:06 -04:00
kennethreitz 9ebd54b7fc Add drum bus effects — same engine as parts, zero duplication
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
Uses _apply_effects_with_params on each stereo channel.
Supports all effects: reverb, delay, lowpass, distortion, chorus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:11:40 -04:00
kennethreitz ce68ad8f19 Update changelog for v0.29.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:09:00 -04:00
kennethreitz f402e76480 Rename song.py → songs.py, polish all 20 songs with effects
Every song now has: stereo panning, convolution reverb (plate/cathedral),
humanize (0.2), detune (8-12) on pads, sidechain on electronic tracks,
lowpass on bass, delay on leads. No melodies changed — just better sound.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:03:15 -04:00
kennethreitz 4d3c7e0d6c v0.29.0: MIDI import — Score.from_midi()
Load any Standard MIDI File into a Score. Zero-dependency parser
handles Type 0 and Type 1 files. Each channel becomes a Part,
channel 10 becomes drum hits. Roundtrip with save_midi works.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:25:19 -04:00
kennethreitz 5a74a6f715 v0.28.3: Better demo songs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:39:14 -04:00
kennethreitz 5416674858 Rewrite demo songs: stereo, effects, humanize, 8 moods
Each demo now uses pan, detune, spread, convolution reverb,
sidechain, humanize, velocity accents, genre-matched fills.
Added Dub and Temple moods.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:38:19 -04:00
kennethreitz 9a5f305ac6 Add CLAUDE.md with release process and music preferences
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:05:24 -04:00
kennethreitz bc38ce73f0 Update changelog for v0.28.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:00:17 -04:00
kennethreitz 081b924d29 v0.28.2: Tighter drum humanize default (0.15)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:59:41 -04:00
kennethreitz 427ff44ce9 Lower drum_humanize default to 0.15 — tighter feel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:54:13 -04:00
kennethreitz 360a908464 v0.28.1: Humanized drum hits
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:53:45 -04:00
kennethreitz a8dd4d6542 Humanize drum hits — random timing jitter and velocity variation
Drums now have micro-timing and velocity imperfections like a real
drummer. Default 0.3 (subtle). Control via Score(drum_humanize=0.5).
Kick stays tightest, hats and ghost notes drift naturally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:51:18 -04:00
kennethreitz 866b110afa Sync all summary pages with current feature set
index.rst: add figured bass, pitch class sets, scale recommendation,
stereo, detune, pan/spread, master compressor, REPL
quickstart.rst: same updates to "What's in the Box"
README.md: add stereo, sidechain, compressor, repl, forte numbers
drums.rst: document stereo drum panning
playback.rst: document stereo output and master compressor
cli.rst: add REPL section with cross-reference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:29:12 -04:00
kennethreitz 0fc0b87017 Move closing line below toctree
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:22:53 -04:00
kennethreitz 1a724a94b0 Add closing line to homepage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:18:42 -04:00
kennethreitz b239e9a997 Add warm closing paragraphs to all 11 guide pages
Every page now ends on prose instead of a code block.
Chords, tones, scales, effects, drums, CLI, cookbook,
fretboard, playback, systems, theory — each with a
sentence that ties the page together.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:17:36 -04:00
kennethreitz a766737707 v0.28.0: Figured bass, pitch class sets, scale recommendation
- Chord.figured_bass: classical inversion notation (6, 6/4, 7, 6/5, 4/3, 2)
- Chord.analyze_figured(): Roman numerals with figured bass (V6/5, ii6)
- Chord.pitch_classes, normal_form, prime_form, forte_number: set theory
- Scale.recommend(): ranked scale suggestions from note sets
- Forte catalog: all trichords and tetrachords
- Documented in chords and scales guides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:13:26 -04:00
kennethreitz 0843c21884 v0.27.2: Temple Bell song
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:59:03 -04:00
kennethreitz eb7a2bf27d Add Temple Bell (Japanese) to song player — #20
Sparse triangle koto over E hirajoshi scale, Taj Mahal reverb,
sine drone, FM bells. Silence as instrument.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:58:07 -04:00
kennethreitz c78530611d v0.27.1: Tab completion in REPL
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:52:50 -04:00
kennethreitz 7267d25fb5 Add tab completion to REPL
Context-aware: commands on first word, drum presets after 'drums',
synth names after 'part', chord symbols after 'arp'/'chord',
note names after 'add', systems after 'system', LFO params after
'set'/'lfo', envelope names as third arg for 'part'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:52:08 -04:00
kennethreitz 53db299b5f v0.27.0: High-quality drum sounds, Dance Party song
All 15 drum sounds rewritten with inharmonic partials, proper
transients, multi-mode resonance, and saturation. Song #19:
Dance Party at the Reitz House — for Sarah and Malachi.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:41:57 -04:00
kennethreitz a4fa233edf Rewrite all drum sounds for higher quality
Every percussion voice improved with proper transients, inharmonic
partials, multi-mode resonance, and saturation:
- Clap: 808-style layered bursts
- Rimshot: dual resonance (rim+head)
- Toms: pitch sweep + shell resonance + stick attack
- Crash: 5 inharmonic metallic partials
- Ride: 4 partials + stick click
- Cowbell: 808 square-ish tones
- Clave: dual wood resonance
- Conga: pitch drop + shell mode + slap
- Shaker: shaped envelope + sparkle
- Tambourine: 4 jingle frequencies
- Timbale: metal shell overtones
- Agogo: 3 bell modes
- Guiro: rhythmic ridge scrapes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:39:27 -04:00
kennethreitz e7e90382c5 v0.26.3: Stereo drums, stereo convolution reverb, 2 new songs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:37:10 -04:00
kennethreitz 1ef32ecc92 Stereo drum panning — each sound placed in the stereo field
Kick/snare center, hat right, crash left, toms spread L-to-R,
congas/timbales/agogos across the field. Sounds like a real kit.
Mono sidechain trigger preserved for compression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:36:22 -04:00
kennethreitz f9af708f0a Add Glass and Silk (sine+triangle waltz) to song player — #18
Pure sine and triangle only. Ab major waltz at 72bpm. Cathedral
and Taj Mahal reverb. Triangle legato melody with glide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:32:50 -04:00
kennethreitz 0e93e45853 v0.26.2: Stereo convolution reverb for all 7 IR presets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:29:08 -04:00
kennethreitz f0802ae614 Add stereo convolution reverb — different IR per L/R channel
Two independently generated impulse responses create natural stereo
width in the convolution reverb tail. Works with all 7 presets
(Taj Mahal, cathedral, plate, spring, cave, parking garage, canyon).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:24:02 -04:00
kennethreitz 4a992eba2b Add Neon Grid (stereo acid) to song player — #17
Dual 303s arping in opposite directions on opposite sides,
supersaw pad with full spread, ping-pong FM bells, sidechain sub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:17:24 -04:00
kennethreitz 398cc68166 Document pan, spread, and stereo reverb in synths guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:12:57 -04:00
kennethreitz 3b0a63d57c v0.26.1: Stereo reverb with different L/R early reflections
Different comb filter delay times per channel create natural stereo
width in the reverb tail. Effects chain skips mono reverb in favor
of stereo reverb applied in the mixer after panning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:12:09 -04:00
kennethreitz c86ae7b118 v0.26.0: Stereo output with pan and spread
- render_score() outputs stereo (N, 2) arrays
- pan: -1.0 (left) to 1.0 (right), constant-power panning
- spread: detuned oscillators go to opposite L/R channels
- Master compressor runs per-channel
- Drums center, parts panned independently

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:08:38 -04:00
kennethreitz d2044f1f53 v0.25.7: Detune, drum swing, improved drum sounds
- detune parameter: ±cents oscillator spread on any synth
- Drum swing: offbeats shift with score groove
- Snare: 220Hz + transient click + saturation
- Hi-hats: metallic harmonics (6k+8.5k+12k), crisper
- Detune documented in synths guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:04:08 -04:00
kennethreitz 7991516c3e Add detune parameter — ±cents oscillator spread on any synth
Renders three oscillators per note: center + up + down by the
specified cents. Creates analog-style beating/width on any waveform.
detune=15 is classic Juno drift, detune=25 is trance supersaw territory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:02:41 -04:00
kennethreitz a0e0dbc807 v0.25.6: Drum swing + improved snare/hat sounds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:50:08 -04:00
kennethreitz a77db557f3 Apply swing to drum hits — offbeats shift with the groove
Drum hits on fractional beat positions now get pushed later by
the score's swing amount. Everything locks into the same pocket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:48:31 -04:00
kennethreitz f4c3b2dd88 v0.25.5: Improved snare and hi-hat sounds
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:46:05 -04:00
kennethreitz 32387e2d23 Improve snare and hi-hat drum sounds
Snare: 220Hz body (was 180), faster decay, transient click, tanh saturation.
Closed hat: 30ms (was 50), metallic harmonics (6k+8.5k+12k), decay=100.
Open hat: 150ms (was 250), same metallic harmonics, decay=18.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:45:29 -04:00
kennethreitz 2ecb1e5ce8 Use CHANGELOG.md directly in docs via myst-parser
No more maintaining a separate RST changelog. The docs now include
the markdown file directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:37:28 -04:00
kennethreitz 30dacc4fbf Sync docs changelog with CHANGELOG.md (0.15.1 through 0.25.4)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:36:22 -04:00
kennethreitz 2a5ffcf78a v0.25.4: Master bus compressor/limiter
Feed-forward compression (threshold=0.5, ratio=4:1) with envelope
following, makeup gain, and brick-wall limiter at 0.95. Replaces
simple normalization. Everything sounds louder and punchier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:34:49 -04:00
kennethreitz fe00644e3f v0.25.3: Interactive REPL — pytheory repl
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:28:46 -04:00
kennethreitz 89df5c4201 Merge pull request #37 from kennethreitz/repl
Add interactive REPL — music theory scratchpad
2026-03-25 21:26:20 -04:00
kennethreitz b396f42f84 Add REPL guide: theory scratchpad, composition, effects, complete example
Covers the prompt, theory commands, composition flow, effects,
automation, LFOs, playback, export, and a full start-to-finish session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:26:11 -04:00
kennethreitz 45789f7af0 Fix all 6 PR review issues
1. readline: try/except for Windows compatibility
2. drums: only persist preset after successful load
3. chords: use analyze() for correct Roman numerals in minor keys
4. clear: full reset to initial state (key, bpm, drums, parts)
5. progression: add as alias for prog
6. lint: split one-line if statements (ruff E701)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:24:23 -04:00
kennethreitz e0bf637de5 Add theory commands, guitar, systems, intervals, 22 REPL tests
Theory: circle, interval, identify, system (with correct per-system tonics)
Guitar: fingering, diagram (scale on fretboard)
22 new tests covering all REPL commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:20:25 -04:00
kennethreitz e5f258bc21 Add guided welcome: 5 commands that teach the flow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:15:11 -04:00
kennethreitz a294887215 Multiline prompt: compact when short, stacks when context grows
Single line: pytheory[key=Am | bpm=140]>
Multiline when >60 chars:
  key=Am | bpm=140 | drums=bossa nova | →lead(saw) rev=0.3 lp=2000
♫>

Shows active part synth and effects in the prompt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:14:00 -04:00
kennethreitz 20932f48ab Context-aware prompt: pytheory[key=Am bpm=140 drums=bossa nova →lead]>
Shows key, bpm, drums preset, and active part in the prompt.
Fix: Part with 0 notes was falsy due to __len__, use 'is not None'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:12:54 -04:00
kennethreitz 9e0faf840b Add interactive REPL — music theory scratchpad and composition tool
pytheory repl — commands mirror the Python API:
  key Am, chords, prog I V vi IV, modes, scales (theory)
  drums bossa nova, part lead saw pluck, add C5 1 (composition)
  arp Am updown 2 2, reverb 0.4, lfo lowpass... (effects)
  play_score, save_midi, render (output)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:10:36 -04:00
kennethreitz c7c733044c Remove Claude Code links from homepage, keep text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:03:08 -04:00
kennethreitz 866065d7d7 Add connective prose to homepage — warm but concise
Brief intro paragraph, theory/composition section intros, pytheory demo
context. Enough personality without the wall of text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:53:20 -04:00
kennethreitz aa9d4282c9 Trim homepage: two examples, compact feature list, get out of the way
197 → 85 lines. Theory example, composition example, pytheory demo,
one-line feature summary per category. No more walls of text
before the toctree.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:09:57 -04:00
kennethreitz 3593735243 Polish systems, fretboard, and theory docs
Systems: fix "four" to "six", add cultural context for each system
(Indian ragas, Arabic maqam, Japanese koto, Blues Delta origins,
Gamelan's influence on Debussy). Each system feels alive now.
Fretboard: add scale_diagram chord highlighting, non-string instruments note.
Theory: warmer opening, cross-reference to composition guide.
Tones, scales, chords, cli: verified complete — no changes needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:09:01 -04:00
kennethreitz 54fd4c2241 Make theory-only path first-class in quickstart
Two clear paths: theory (no audio needed) and composition.
Theory section expanded with tones, intervals, keys, chords,
analysis, modulation, 6 systems, guitar — all pure Python.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:05:25 -04:00
kennethreitz a4b11e6f35 Rewrite quickstart, update CLI docs, add composition recipes to cookbook
Quickstart: zero to arrangement in 5 minutes, pytheory demo, MIDI export.
CLI: add demo command docs at the top.
Cookbook: acid house, dub reggae, jazz ballad, song sections, MIDI export recipes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:04:46 -04:00
kennethreitz 044e9a7eac Fix CI: lazy-import sounddevice so MIDI/render work without PortAudio
sounddevice is now only imported when actually playing audio through
speakers. All other functions (save_midi, render_score, save) work
without PortAudio installed. Fixes 9 test failures in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 19:43:39 -04:00
kennethreitz 094887c849 v0.25.1: pytheory demo command, README rewrite
- `pytheory demo` plays a randomly generated track (6 moods, different every time)
- README rewritten to showcase full feature set: composition, effects, drums,
  sidechain, automation, MIDI export, AI collaboration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:28:42 -04:00
kennethreitz 2fc5aae678 Document sidechain compression and song structure sections
Effects: sidechain pump with parameters, practical examples, tip
about not sidechaining everything.
Sequencing: song sections with verse/chorus/repeat workflow,
custom names, real songwriting analogy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:22:51 -04:00
kennethreitz ac0cc0b6ce v0.25.0: Sidechain compression, song sections, punchier kick
- Sidechain: kick ducks pad/bass for EDM pump (sidechain=0.85)
- Song structure: score.section("verse"), score.repeat("chorus")
- Kick: 808-style 200→45Hz sweep, sub thump, soft saturation
- Section repeat copies notes, drums, automation with offset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:21:33 -04:00
kennethreitz ce5f3e7626 Document humanize in sequencing guide
Values, use cases, combo with swing for realistic feel.
Convolution reverb was already documented in effects guide.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:06:24 -04:00
kennethreitz 0c4ba83b0c v0.24.1: Humanize — random micro-timing and velocity variation
Makes programmed parts feel like a real player. Random timing jitter
and velocity wobble applied at render time, score data stays clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:04:07 -04:00
kennethreitz f81b1e882d Document velocity, swing, tempo changes, and fades in sequencing guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:50:24 -04:00
kennethreitz ebf26cfbfa v0.24.0: Velocity, swing, tempo changes, fade in/out
- Per-note velocity for dynamics and accents
- Swing/groove parameter on Score and per-Part override
- score.set_tempo() for mid-song tempo changes with tempo map engine
- Part.fade_in() and Part.fade_out() volume envelopes
- Arpeggiator velocity support

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:44:50 -04:00
kennethreitz f9654fcdea Clean up code formatting: black-style, remove >>> from config snippets
Multi-line part creation uses vertical layout with trailing commas.
Configuration snippets use code-block:: python (clean, no >>>).
Interactive exploration keeps pycon format.
Mixed blocks split into setup + exploration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:36:58 -04:00
kennethreitz f3f4174783 Mention AI collaboration (Claude Code) on homepage
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:14:38 -04:00
kennethreitz 119dd2d921 Add conversational prose throughout all 5 guide pages
Sequencing: Score concept, time signatures for non-musicians, Parts as
DAW tracks, arpeggiator/legato/glide context with TB-303 and acid history.
Synths: synthesis philosophy, DX7/Juno/JP-8000 history, practical combos.
Effects: real-music context (The Edge, DJ knobs, shower reverb), why signal
chain order matters, automation as breathing, LFO as repeating automation.
Drums: drums-as-genre foundation, genre group cultural context, fills as
transition signals, drum synthesis as real drum machine techniques.
Playback: three output options context, MIDI as the working musician's path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:13:44 -04:00
kennethreitz 80698ccc3a Restructure docs: 5 focused pages + why-compose-in-Python intro
Break rhythm.rst and playback.rst into:
- sequencing.rst: Score, Parts, Duration, arpeggiator, legato
- synths.rst: 10 waveforms, 8 envelopes, combo recommendations
- effects.rst: signal chain, 5 effects, automation, LFOs
- drums.rst: DrumSound, 58 patterns, 21 fills, synthesis
- playback.rst: simplified output functions only

Rewrite index.rst with "Why compose in Python?" section explaining
the sketch→hear→export→DAW workflow, plus comprehensive highlights.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:03:27 -04:00
kennethreitz a41f20e805 v0.22.0: LFO automation for parameter modulation
- Part.lfo() generates automation points from oscillator shapes
- 4 shapes: sine, triangle, saw, square
- Rate, range, duration, and resolution all configurable
- Stack LFOs on different params for complex modulation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:52:40 -04:00
kennethreitz 2de263c814 v0.21.0: Effect automation via Part.set(), chorus effect
- Part.set() inserts automation markers for mid-song parameter changes
- Chorus effect (LFO-modulated delay, Juno-style thickening)
- Renderer segments at automation points for per-section processing
- Chain: distortion → chorus → lowpass → delay → reverb
- Docs: automation, chorus, updated signal chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:49:12 -04:00
kennethreitz a7ad8a374b v0.20.0: Arpeggiator, flat numeral parser, generative showoff
- Part.arpeggio() with 5 patterns, octave spanning, division control
- Roman numeral parser handles bVI, bVII, bIII, #IV prefixes
- song_showoff.py: generative composition using every feature,
  different every time (4 moods, matched keys/drums/effects)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:41:03 -04:00
kennethreitz e75c35a099 v0.19.1: Arpeggiator, legato/glide docs, sequencing page rename
- Part.arpeggio() with up/down/updown/downup/random patterns
- Octave spanning and division control for arps
- Document legato, glide, and arpeggiator in rhythm guide
- Rename docs page to "Sequencing: Rhythm and Scores"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:33:19 -04:00
kennethreitz b9f0a3870a v0.19.0: Legato mode with pitch glide/portamento
- Part(legato=True) renders continuous waveform without per-note retriggering
- Part(glide=0.04) adds 303-style pitch slides between notes
- Phase-accumulating oscillator for smooth frequency changes
- Exponential pitch interpolation for perceptually linear slides

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:28:50 -04:00
kennethreitz d425d6b624 Document per-part effects chain: distortion → lowpass → delay → reverb
Comprehensive effects docs with signal chain diagram, per-effect
reference, and combination examples (dub, acid, Drake-style 808).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:22:56 -04:00
kennethreitz 8c61cb146b Bump version to 0.18.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:06:29 -04:00
kennethreitz a11523e889 v0.18.1: Distortion effect, 3 new songs (dub delay, DnB, Drake)
- Soft-clip distortion (tanh waveshaping) with drive and mix controls
- Dub Delay Madness: separate snare track with massive delay/reverb
- Liquid DnB: 174bpm rollers with flowing lead
- Late Night Texts: Drake-style trap with 808 bass + distortion
- 16 total example songs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:05:56 -04:00
kennethreitz cf061e3783 Rewrite all 13 songs with per-part effects (reverb, delay, lowpass)
Every song now uses the full effects chain. Added 3 new songs:
Kingston After Dark (dub), Minimal Techno, Gospel Shuffle.
Each demonstrates different effect combinations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:01:56 -04:00
kennethreitz 5d746ed0b1 v0.18.0: Per-part audio effects — reverb, delay, lowpass filter
- Schroeder reverb (4 comb + 2 allpass filters) with mix/decay
- Tempo-synced delay with feedback
- 12 dB/oct biquad lowpass with resonance (Q) control
- Effects set at part creation, applied per-part before mixing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:56:48 -04:00
kennethreitz 89323c0eb3 v0.17.0: 10 new grooves + 10 new fills (58 grooves, 21 fills total)
Grooves: country, ska, dub, jungle, techno, gospel, swing, bolero, tango, flamenco
Fills: reggae, afrobeat, bossa nova, house, trap, hip hop, disco, cumbia, highlife, second line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:49:14 -04:00
kennethreitz 5a44d619d0 v0.16.0: Drum fills — 11 genre presets with auto-fill support
- Pattern.fill() with 11 presets: rock, jazz, salsa, samba, funk, metal, blast, buildup, breakdown
- Score.fill() inserts a fill at the current position
- Score.drums() auto-fill support: fill_every=4 replaces every 4th bar

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:43:46 -04:00
kennethreitz f5bf7ce505 v0.15.1: PWM synths, score.drums() shorthand, docs update
- Synth.PWM_SLOW: pulse width modulation with 0.3 Hz LFO (Juno pads)
- Synth.PWM_FAST: pulse width modulation with 3 Hz LFO (chorus/vibrato)
- Score.drums("preset", repeats=N) shorthand
- All docs updated with score.drums() syntax and 10-synth reference table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:37:36 -04:00
kennethreitz 4f3b706336 v0.15.0: Add square, pulse, FM, noise, and supersaw synth waveforms
- Square wave: chiptune / 8-bit (odd harmonics at 1/n)
- Pulse wave: variable duty cycle NES-style timbres
- FM synthesis: DX7-style carrier/modulator for bells, e-piano, brass
- Noise: white noise for percussion and texture
- Supersaw: 7 detuned saws for trance/EDM pads
- Refactor Synth enum to string-valued with callable dispatch
- All 8 waveforms available via API, Part strings, and CLI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:37:21 -04:00
kennethreitz 83a988d085 Update rhythm and playback docs with Part class, multi-part examples
- Rhythm guide: full Part API docs with synths, envelopes, chaining,
  raw float beats, headless rendering, complete bossa nova example
- Playback guide: rewrite intro with quick-start showing both simple
  and expressive usage, update all Score examples to use named parts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:30:09 -04:00
kennethreitz d0e8e43b56 v0.14.0: Multi-part Score API with Part class
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:24:27 -04:00
kennethreitz c67a08a34e Rewrite songs with multi-part API, accept raw float durations
- All 10 songs now use score.part() for lead, bass, and chords
- Part.add() and .rest() accept raw float beats alongside Duration enums
- _RawDuration duck-type wrapper for arbitrary beat values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:23:13 -04:00
kennethreitz e72ef4a6a7 Add Part class for multi-voice Score arrangements
- Part: named voice with synth, envelope, and volume settings
- Score.part() creates and registers parts
- Score.add_pattern() for cleaner drum pattern attachment
- render_score() renders all parts + drums into one buffer
- play_score() updated to use the new multi-part renderer
- Backwards compatible: Score.add() still works for simple cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:20:26 -04:00
kennethreitz 994c4e244a Rewrite song.py with 10 full arrangements using drums, chords, and synth leads
Bossa nova, bebop, salsa, afrobeat, reggae, funk, 12/8 blues, samba,
jazz waltz, and house — each with drum patterns, chord progressions,
and hand-written melody lines rendered through the synthesizer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:15:06 -04:00
kennethreitz adfbc3079a v0.13.1: Fix drum pattern repeat sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:10:35 -04:00
kennethreitz 772fa84b4f Fix drum pattern repeats: offset hit positions for each cycle
Hits were piling up at the same positions instead of being spread
across repeats. Now each repeat offsets by pattern.beats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:10:07 -04:00
kennethreitz b97378c836 Add Playing a Score section to playback docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:04:29 -04:00
kennethreitz f00cf10c41 v0.13.0: Drum synthesizer with play_pattern() and play_score()
- 27 synthesized drum voices (kick, snare, hat, conga, timbale, etc.)
- play_pattern() renders and plays drum patterns through speakers
- play_score() mixes drum patterns + chord progressions together
- Comprehensive drum synthesis docs with sound descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:01:02 -04:00
kennethreitz d57e780f6f v0.12.0: Rhythm module with 48 drum pattern presets
- Duration, TimeSignature, Score for note-level rhythm
- DrumSound enum (27 GM percussion sounds)
- Pattern class with 48 presets: rock, jazz, bebop, salsa, bossa nova,
  samba, afrobeat, funk, reggae, house, trap, metal, Afro-Cuban claves,
  cumbia, merengue, breakbeat, and many more
- Pattern.to_score() + save_midi() for drum MIDI export
- Comprehensive rhythm guide documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 05:02:01 -04:00
kennethreitz 4f03bb6616 v0.12.0: Rhythm module with Duration, TimeSignature, Score, MIDI export
- Duration enum (whole through sixteenth, dotted, triplet)
- TimeSignature with string parsing (4/4, 3/4, 6/8, 12/8)
- Score class with fluent .add()/.rest() chaining
- Measure-aware MIDI export with time signature meta events
- Rhythm guide documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:56:46 -04:00
kennethreitz 4aafd8d0b0 v0.11.0: Drop voicings, modulation, degree names, extensions, solfege, CLI identify/midi, docs
- Chord.close_voicing(), open_voicing(), drop2(), drop3()
- Key.modulation_path() for pivot-chord modulation paths
- Scale.degree_name() for traditional function names
- Chord.extensions() for available 9th/11th/13th suggestions
- Tone.solfege for fixed-Do solfege syllables
- CLI identify and midi commands
- Comprehensive docs update covering all v0.9.0–v0.11.0 features

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:49:17 -04:00
kennethreitz c74600d42f v0.10.0: Scale fitness, chord suggestions, Helmholtz, slash chords, MIDI export
- Scale.fitness() scores note-to-scale fit
- Key.suggest_next() for functional harmony chord suggestions
- Tone.helmholtz/scientific notation properties
- Chord.slash() and slash_name for slash chord notation
- save_midi() for Standard MIDI File export
- Fretboard.scale_diagram() highlights chord tones vs passing tones
- Chord.analyze() recognizes borrowed chords (bVI, bVII, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:39:27 -04:00
kennethreitz b3ef0ddc58 Add tests for all new features (fitness, suggest_next, helmholtz, slash, MIDI)
Fix Helmholtz octave 2 notation and update borrowed chord test expectation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:38:43 -04:00
kennethreitz dd3b7bd03e Add MIDI export via save_midi()
Zero-dependency Standard MIDI File writer for tones, chords, and progressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:36:41 -04:00
kennethreitz 62111de2da Add Helmholtz notation, cents_difference, Scale.fitness, Key.suggest_next
- Tone.helmholtz and Tone.scientific properties for pitch notation
- Tone.cents_difference() for fine pitch measurement
- Scale.fitness() scores how well notes fit a scale
- Key.suggest_next() suggests likely next chords from functional harmony

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:35:49 -04:00
kennethreitz c3ae02ec4f Add slash chords, borrowed chord analysis, scale_diagram chord highlighting
- Chord.slash(bass) and slash_name property for slash chord notation
- Chord.analyze() now returns bVI, bVII etc. for borrowed/chromatic chords
- Fretboard.scale_diagram() highlights chord tones in uppercase when chord given

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:34:25 -04:00
kennethreitz f4d2cca663 v0.9.0: ADSR envelopes, chord symbol parser, modulation, parallel modes, cents
- Add Envelope enum with 8 ADSR presets (piano, organ, pluck, pad, strings, bell, staccato, none)
- Add Chord.from_symbol() to parse any standard chord symbol without lookup tables
- Add Key.pivot_chords() for finding modulation pivot chords between keys
- Add Scale.parallel_modes() to show all modes sharing the same notes
- Add Tone.cents_difference() for fine pitch comparison in cents
- Add --envelope flag to CLI play command
- Extract C_INDEX constant, removing hardcoded magic number

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:30:46 -04:00
kennethreitz 3b5a07dfce Add changelog with retroactive history, include in docs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:20:50 -04:00
kennethreitz aa454ea7e9 v0.8.3: Chord symbols, common progressions, CLI modes/circle/progressions
- Add Chord.symbol property for standard shorthand notation (Cmaj7, Dm, G7)
- Add Key.common_progressions() to realize all named progressions in a key
- Add CLI commands: modes, circle, progressions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 04:19:29 -04:00
36 changed files with 14840 additions and 723 deletions
+414
View File
@@ -0,0 +1,414 @@
# Changelog
All notable changes to PyTheory are documented here.
## 0.30.0
- Drums are a real Part — same effects pipeline as any voice
- `score.drums("rock", split=True)` splits kit into kick/snare/hats/toms/cymbals/percussion Parts
- Each split Part gets independent effects (reverb on snare, LP on hats, etc.)
- `set_drum_effects()` applies to all drum Parts (split or not)
- Sidechain triggers on kick only — hats and snare don't duck the pad
- MIDI import via `Score.from_midi(path)`
## 0.29.3
- Drums are now a real Part — same effects pipeline as any other voice, zero code duplication
- `score.parts["drums"]` is a standard Part with reverb, delay, lowpass, etc.
- `set_drum_effects()` is sugar over the Part's attributes
## 0.29.2
- Add `score.set_drum_effects()` — reverb, delay, lowpass, distortion, chorus on the drum bus
- Same effects engine as parts, zero code duplication
## 0.29.1
- Rename song.py → songs.py
- Polish all 20 example songs with stereo, convolution reverb, humanize, detune, sidechain
## 0.29.0
- Add `Score.from_midi(path)` — import any Standard MIDI File into a Score
- Minimal zero-dependency MIDI parser (Type 0 and Type 1)
- Each channel becomes a named Part, channel 10 becomes drum hits
- Tempo, time signature, velocities, and note durations preserved
- Roundtrip: save_midi → from_midi works
## 0.28.3
- Rewrite `pytheory demo` — 8 moods with stereo, effects, humanize, convolution reverb, sidechain
- Added Dub and Temple moods
## 0.28.2
- Lower drum_humanize default to 0.15 — tighter, more professional feel
## 0.28.1
- Humanize drum hits — random timing jitter and velocity variation (default 0.3)
- Control via `Score(drum_humanize=0.5)` — 0.0 = quantized, 0.3 = natural, 0.5+ = loose
## 0.28.0
- Add figured bass notation: `Chord.figured_bass` and `Chord.analyze_figured()` for classical inversion symbols
- Add pitch class set theory: `pitch_classes`, `normal_form`, `prime_form`, `forte_number` on Chord
- Add `Scale.recommend()` — ranked scale suggestions for a set of notes
- Forte number catalog covers all trichords and tetrachords
## 0.27.1
- Tab completion in REPL — context-aware for commands, drum presets, synths, envelopes, chords, notes, systems
## 0.27.0
- Rewrite all 15 drum sounds for higher quality (inharmonic partials, proper transients, multi-mode resonance, saturation)
- 19 example songs including Dance Party at the Reitz House
## 0.26.3
- Stereo drum panning — each sound placed in the stereo field (hat right, crash left, toms spread, kick/snare center)
- Stereo convolution reverb — different IR per L/R channel for all 7 presets
- 2 new songs: Neon Grid (stereo acid), Glass and Silk (sine+triangle waltz)
## 0.26.2
- Stereo convolution reverb — different IR per L/R channel for all 7 presets
- Both algorithmic and convolution reverbs now output true stereo
## 0.26.1
- Stereo reverb — L and R channels get different early reflection patterns for natural width
- Effects chain now skips mono reverb in favor of stereo reverb in the mixer
## 0.26.0
- **Stereo output** — render_score() now returns stereo (N, 2) arrays
- Add `pan` parameter: -1.0 (left) to 1.0 (right), constant-power panning
- Add `spread` parameter: detuned oscillators spread across L/R channels
- Master bus compressor runs per-channel for stereo
- All playback functions handle stereo natively
## 0.25.7
- Add `detune` parameter — ±cents oscillator spread on any synth (3 oscillators per note)
- Swing now applies to drum hits (offbeats shift with the groove)
- Improved snare and hi-hat sounds (metallic harmonics, faster attack)
## 0.25.6
- Swing now applies to drum hits — offbeats shift with the groove, everything locks into the same pocket
- Improved snare: 220Hz body, transient click, tanh saturation
- Improved hi-hats: metallic harmonics (6k+8.5k+12k Hz), crisper attack, shorter decay
## 0.25.5
- Improved snare: 220Hz body, transient click, tanh saturation — snappier and more present
- Improved hi-hats: metallic harmonics (6k+8.5k+12k Hz), shorter decay, crisper attack
## 0.25.4
- Add master bus compressor/limiter — louder, punchier, more cohesive mixes
- Feed-forward compression with configurable threshold, ratio, attack, release
- Makeup gain restores loudness after compression
- Brick-wall limiter at 0.95 prevents clipping
- Replaces simple normalization in render_score()
## 0.25.3
- Add `pytheory repl` — interactive music theory scratchpad and composition tool
- Context-aware prompt shows key, bpm, drums, active part + effects
- Theory commands: key, chords, modes, scales, circle, interval, identify, system
- Composition: drums, part, add, rest, arp, prog, effects, automation, LFO
- Guitar: fingering, scale diagram
- 6 musical systems with correct default tonics
- REPL guide documentation
## 0.25.1
- Add `pytheory demo` CLI command — plays a randomly generated track, different every time
- Rewrite README to showcase the full feature set (composition, effects, drums, MIDI export)
## 0.25.0
- Add sidechain compression — kick ducks pad/bass for the classic EDM pump effect
- Add song structure: `score.section("verse")`, `score.section("chorus")`, `score.repeat("verse")`
- Punchier kick drum: 808-style with faster pitch sweep (200→45Hz), sub thump, and soft saturation
- Section repeat copies all part notes, drum hits, and automation with proper offset
## 0.24.1
- Add `humanize` parameter on Parts — random micro-timing and velocity variation
- Makes programmed parts feel like a real player (0.1 = subtle, 0.3 = natural, 0.5+ = loose)
## 0.24.0
- Add per-note velocity: `lead.add("C5", Duration.QUARTER, velocity=90)` — dynamics, accents, ghost notes
- Add swing/groove: `Score("4/4", bpm=120, swing=0.5)` — shuffles every other note for human feel
- Add tempo changes mid-song: `score.set_tempo(140)` — accelerando, ritardando, tempo drops
- Add `Part.fade_in(bars)` and `Part.fade_out(bars)` — volume envelopes over sections
- Arpeggiator supports velocity parameter
- Per-part swing override (set independently from score swing)
- Tempo map engine: beat-to-sample conversion handles variable BPM throughout a score
## 0.23.0
- Add convolution reverb with 7 synthetic impulse responses: Taj Mahal, cathedral, plate, spring, cave, parking garage, canyon
- Each IR models real acoustic properties: early reflections, frequency-dependent absorption, diffusion density, and modulation
- FFT-based convolution via `scipy.signal.fftconvolve` for fast processing even with long tails (12s Taj Mahal)
- Select via `reverb_type` parameter on `Score.part()` — drop-in alongside existing algorithmic reverb
- IR cache for zero-cost reuse across parts
- Automatable via `Part.set(reverb_type="cathedral")` mid-song
## 0.22.0
- Add `Part.lfo()` for automated parameter modulation (filter sweeps, tremolo, auto-wah)
- 4 LFO shapes: sine, triangle, saw, square
- Configurable rate (cycles per bar), min/max range, duration, and resolution
- Stack multiple LFOs on different parameters for complex modulation
## 0.21.0
- Add `Part.set()` for mid-song effect automation (filter sweeps, reverb swells, distortion kicks)
- Add chorus effect (LFO-modulated delay, Juno-style)
- Renderer segments audio at automation points for per-section effect processing
- Updated effect chain: distortion → chorus → lowpass → delay → reverb
- Document automation, chorus, and updated signal chain
## 0.20.0
- Add `Part.arpeggio()` — arpeggiator with up/down/updown/downup/random patterns, octave spanning
- Fix Roman numeral parser to handle flat/sharp degree prefixes (bVI, bVII, bIII, #IV)
- Add `song_showoff.py` — generative composition that's different every time, uses every feature
- 4 mood palettes (dark, bright, ethereal, aggressive) with matched keys, progressions, drums, and effects
## 0.19.1
- Add `Part.arpeggio()` — arpeggiator with up/down/updown/downup/random patterns, octave spanning, and division control
- Arpeggiator chains with legato + glide for classic acid/trance sequencer sound
- Rename rhythm docs to "Sequencing: Rhythm and Scores"
- Document arpeggiator, legato, and glide in rhythm guide
## 0.19.0
- Add legato mode for parts — continuous waveform without retriggering envelope per note
- Add glide/portamento — smooth pitch slides between consecutive notes (303-style)
- Legato renders entire phrase as one oscillator with phase-accumulating frequency changes
- Glide uses exponential interpolation for perceptually linear pitch slides
## 0.18.1
- Add distortion effect (tanh soft-clip waveshaping) with drive and mix controls
- 3 new example songs: Dub Delay Madness (separate delay snare), Liquid DnB (174bpm), Late Night Texts (Drake-style trap)
- 16 total songs in the song player
## 0.18.0
- Add per-part audio effects: reverb, delay, and lowpass filter
- Reverb: Schroeder algorithm with configurable mix and decay
- Delay: tempo-synced echoes with feedback control
- Lowpass: 12 dB/octave biquad filter with resonance (Q) control
- All effects set at part creation: `score.part("lead", reverb=0.3, delay=0.25, lowpass=2000, lowpass_q=1.5)`
- Effects applied per-part before mixing for independent processing
## 0.17.0
- Add 10 new groove presets: country, ska, dub, jungle, techno, gospel, swing, bolero, tango, flamenco (58 total)
- Add 10 new fill presets: reggae, afrobeat, bossa nova, house, trap, hip hop, disco, cumbia, highlife, second line (21 total)
- Every major genre family now has matching groove + fill presets
## 0.16.0
- Add drum fill system with 11 genre-specific presets: rock, rock crash, jazz, jazz brush, salsa, samba, funk, metal, blast, buildup, breakdown
- `Pattern.fill("rock")` returns a 1-bar fill pattern
- `Score.fill("rock")` inserts a fill at the current position
- `Score.drums("rock", repeats=8, fill="rock", fill_every=4)` auto-fills every Nth bar
- Without `fill_every`, fill replaces only the last bar
## 0.15.1
- Add `Synth.PWM_SLOW` and `Synth.PWM_FAST` — pulse width modulation with LFO sweep (Juno-style pads)
- Add `Score.drums()` shorthand for `score.add_pattern(Pattern.preset(...), repeats=...)`
- Update all docs to use `score.drums()` syntax and document all 10 synth waveforms
## 0.15.0
- Add 5 new synth waveforms: `Synth.SQUARE`, `Synth.PULSE`, `Synth.FM`, `Synth.NOISE`, `Synth.SUPERSAW`
- Square wave: classic chiptune / 8-bit sound (odd harmonics at 1/n)
- Pulse wave: variable duty cycle for NES-style timbres (25%, 12.5%)
- FM synthesis: DX7-style frequency modulation (electric piano, bells, brass, metallic)
- Noise: white noise for percussion textures and effects
- Supersaw: 7 detuned saw oscillators for trance/EDM pads
- All 8 synths available in both the API (`Synth.FM`) and Part strings (`synth="fm"`)
- CLI play command supports all 8 waveforms
## 0.14.0
- Add `Part` class for multi-voice Score arrangements (lead, bass, pads, etc.)
- `Score.part()` creates named parts with independent synth, envelope, and volume
- `Score.add_pattern()` for attaching drum patterns
- `render_score()` exported for headless buffer rendering
- Parts accept raw float beat values alongside `Duration` enums
- All 10 example songs rewritten with drums + chords + lead + bass parts
## 0.13.1
- Fix drum pattern repeats: hits now correctly offset across cycles instead of piling up on the first bar
## 0.13.0
- Add drum synthesizer with 27 individual instrument voices (kick, snare, hat, conga, timbale, etc.)
- Add `play_pattern()` for playing drum patterns through the speakers
- Add `play_score()` for playing mixed drum patterns + chord progressions together
- Every `DrumSound` has a dedicated synthesis algorithm (pitch sweeps, noise bursts, membrane resonance, metallic rings)
## 0.12.0
- Add rhythm module: `Duration`, `TimeSignature`, `Note`, `Rest`, `Score`
- `Duration` enum with 8 note lengths (whole through sixteenth, dotted, triplet)
- `TimeSignature` with string parsing ("4/4", "3/4", "6/8", "12/8") and beats_per_measure
- `Score` class with fluent `.add()` / `.rest()` chaining, measure counting, and `save_midi()` export
- Measure-aware MIDI export with proper time signature and tempo meta events
- Add `DrumSound` enum with 27 General MIDI percussion sounds
- Add `Pattern` class with 48 drum pattern presets covering:
- **Rock/Pop**: rock, half time, double time, disco, motown, train beat
- **Jazz**: jazz, bebop, shuffle, linear, paradiddle
- **Latin**: salsa, bossa nova, samba, cumbia, merengue, baiao, maracatu
- **Afro-Cuban**: son clave 3-2/2-3, rumba clave 3-2/2-3, cascara, guaguanco, mozambique, nanigo, bembe, 6/8 afro-cuban, tresillo, habanera
- **African**: afrobeat, highlife
- **Caribbean**: reggae, dancehall
- **Electronic**: house, trap, drum and bass, breakbeat
- **Metal/Punk**: metal, blast beat, punk
- **Other**: funk, hip hop, bo diddley, second line, new orleans, waltz, 12/8 blues
- `Pattern.to_score()` renders drum patterns to Score for MIDI export
## 0.11.0
- Add drop voicings: `Chord.close_voicing()`, `Chord.open_voicing()`, `Chord.drop2()`, `Chord.drop3()`
- Add `Key.modulation_path(target)` for chord-by-chord modulation suggestions via pivot chords
- Add `Scale.degree_name(n)` returning traditional names (tonic, dominant, leading tone, etc.)
- Add `Chord.extensions()` to suggest available 9th/11th/13th extensions
- Add `Tone.solfege` property for fixed-Do solfege syllables (Do, Re, Mi, Fi, etc.)
- Add CLI `identify` command for full chord analysis from a symbol
- Add CLI `midi` command for exporting progressions to Standard MIDI Files
- Expand documentation: solfege, Helmholtz, cents, slash chords, drop voicings, chord extensions, borrowed chord analysis, ADSR envelopes, MIDI export, new CLI commands
## 0.10.0
- Add `Scale.fitness()` to score how well a set of notes fits a scale (0.01.0)
- Add `Key.suggest_next(chord)` for chord progression suggestions based on functional harmony
- Add `Tone.helmholtz` and `Tone.scientific` properties for alternate pitch notation
- Add `Chord.slash(bass)` and `Chord.slash_name` for slash chord notation (C/G, Am/E)
- Add `save_midi()` for exporting tones, chords, and progressions as Standard MIDI Files
- Add chord tone highlighting in `Fretboard.scale_diagram()` — chord tones uppercase, passing tones lowercase
- Extend `Chord.analyze()` to recognize borrowed chords (bVI, bVII, bIII, etc.)
## 0.9.0
- Add ADSR envelope system with 8 presets: `Envelope.PIANO`, `ORGAN`, `PLUCK`, `PAD`, `STRINGS`, `BELL`, `STACCATO`, `NONE`
- Add `Chord.from_symbol()` parser — handles any standard chord symbol (e.g. "F#m7b5", "Bbmaj9", "Gsus4") without lookup tables
- Add `Key.pivot_chords(target)` for finding modulation pivot chords between two keys
- Add `Scale.parallel_modes()` to show all modes sharing the same notes (C major → D dorian, E phrygian, etc.)
- Add `Tone.cents_difference(other)` for measuring fine pitch differences in cents
- Add `--envelope` flag to CLI play command
- CLI play command now uses `Chord.from_symbol()` for broader chord parsing
- Replace hardcoded `c_index = 3` with named `C_INDEX` constant throughout
## 0.8.3
- Add `Chord.symbol` property for standard shorthand notation (Cmaj7, Dm, G7, m7b5, etc.)
- Add `Key.common_progressions()` to realize all named progressions in a key
- Add CLI commands: `modes`, `circle`, `progressions`
## 0.8.2
- Use flat spellings in CHARTS `acceptable_tone_names` (e.g. Bbm now shows Bb/Db/F instead of A#/C#/F)
## 0.8.1
- Use musically correct flat spellings in flat keys (F major gives Bb, not A#)
## 0.8.0
- Add `Fretboard.scale_diagram()` for visual scale layouts on any instrument
- Add `play_progression()` for sequential chord playback with gaps
- Add cookbook documentation page with practical recipes
- Curated guitar fingering overrides for common open chords
- Fingering memoization with bounded cache, barre detection, 4-fret span constraint
- API ergonomics: `Fretboard.chord()`, convenience constructors, slow test markers
## 0.7.0
- Add `Fretboard.chord()` method for named chord lookups
- Improve fingering algorithm with better voicing selection
- Rewrite all documentation in REPL style with verified output
## 0.6.1
- Fix sawtooth and triangle wave generation
- Add WAV export via `save()`
- Add CLI tests and play module tests
- Skip play module tests when PortAudio is not available
## 0.6.0
- Support flat note names (Db, Bb, Eb, etc.) throughout the system
- Add `Fingering` class for labeled chord fingerings
- Add `pytheory play` CLI command for playing notes and chords
- Add 12 example scripts showcasing pytheory features
- Expand documentation with undocumented features and CLI guide
## 0.4.1
- Add `--temperament` flag to CLI tone command
- Add Symbolic Pitch section to tones docs
## 0.4.0
- Add key signatures, scale diagrams, chord building, and progression analysis
- Add CLI tool (`pytheory tone`, `pytheory chord`, `pytheory key`, etc.)
- Add Jupyter notebook tutorial
- Improve test coverage from 93% to 97% (476 tests)
- Add type hints, docstrings, and property caching throughout
## 0.3.2
- Add type hints and docstrings throughout the library
## 0.3.1
- Add capo support, chord merging (`+`), tritone substitution
- Add secondary dominants, Nashville number system
- Add more common progressions (blues, jazz, flamenco, modal)
## 0.3.0
- Add interval naming (`Tone.interval_to()`)
- Add MIDI conversion (`Tone.midi`, `Tone.from_midi()`)
- Add `Tone.from_frequency()`, `Tone.transpose()`
- Add `Chord.root`, `Chord.quality` properties
- Add `Chord.from_name()`, `Chord.from_intervals()`, `Chord.from_midi_message()`
- Add `Interval` constants (MINOR_THIRD, PERFECT_FIFTH, etc.)
- Add `PROGRESSIONS` dict with common named progressions
- Add `Tone.enharmonic` property
- Add inversions, harmonize, and Roman numeral progressions
- Add `Key` class with detection, signatures, relative/parallel keys
- Add `Scale.detect()` and `Chord.from_tones()` convenience constructors
- Add 25 instrument presets (mandolin family, violin family, banjo, harp, world instruments, keyboard)
- Add `Tone.circle_of_fifths()` and `Tone.circle_of_fourths()`
- Add chord identification (17 types), voice leading, tension scoring
- Add beat frequencies, Plomp-Levelt dissonance model, harmony scoring
## 0.2.0
- Add `Fretboard` class for guitar fretboards
- Add `play()` function with sine, sawtooth, and triangle wave synthesis
- Add chord harmony and dissonance calculations
- Modernize project structure (pyproject.toml, sounddevice)
## 0.1.0
- Initial release
- Western 12-tone system with tones, scales, and basic chord support
- Temperament support (equal, Pythagorean, meantone)
- Indian (Hindustani), Arabic, Japanese, Blues, and Gamelan systems
+38
View File
@@ -0,0 +1,38 @@
# Claude Code Instructions
## Release Process
When releasing to PyPI, always do all three:
1. **Tag the commit**: `git tag v0.X.Y`
2. **Push the tag**: `git push origin --tags`
3. **Create a GitHub release**: `gh release create v0.X.Y --title "v0.X.Y: Short description" --notes "Release notes" --latest`
Don't forget to update `CHANGELOG.md` *before* the release commit.
## Version Bumping
- `pyproject.toml` and `pytheory/__init__.py` must match
- Run `uv lock` after changing the version
- Patch releases (0.X.Y) for bug fixes and small additions
- Minor releases (0.X.0) for new features
## Testing
```
uv run python -m pytest test_pytheory.py -x -q --tb=short -m "not slow"
```
## Publishing
```
uv build && uv publish --token <token> dist/pytheory-0.X.Y*
```
## Music Preferences
- Detune: keep at 8-15, don't go above 25
- Humanize: 0.2 is the sweet spot for melodic parts
- Drum humanize: 0.15 default is good
- No swing unless specifically asked
- Sine and triangle are underrated — use them more
+105 -124
View File
@@ -1,180 +1,161 @@
# PyTheory: Music Theory for Humans
This library makes exploring music theory approachable and fun, treating Python as a musical instrument.
## Installation
Explore music theory, compose multi-part arrangements, and export to MIDI — all in Python.
```
$ pip install pytheory
```
## Tones
## Sketch Ideas Fast
```pycon
>>> from pytheory import Tone
```python
from pytheory import Score, Pattern, Key, Duration, Chord
from pytheory.play import play_score
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.frequency
261.63
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
>>> c4 + 7 # perfect fifth
<Tone G4>
chords = score.part("chords", synth="fm", envelope="pad", reverb=0.4)
lead = score.part("lead", synth="saw", envelope="pluck", delay=0.3, lowpass=3000)
bass = score.part("bass", synth="sine", lowpass=500)
>>> c4.interval_to(c4 + 7)
'perfect 5th'
for sym in ["Am", "Dm", "E7", "Am"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
>>> c4.midi
60
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
lead.arpeggio("Dm", bars=2, pattern="updown", octaves=2)
lead.set(lowpass=5000, reverb=0.4)
lead.arpeggio("E7", bars=2, pattern="up", octaves=2)
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
>>> Tone.from_frequency(440)
<Tone A4>
for n in ["A2", "E2", "A2", "C3"] * 4:
bass.add(n, Duration.QUARTER)
>>> Tone.from_midi(69)
<Tone A4>
play_score(score) # hear it now
score.save_midi("sketch.mid") # open in your DAW
```
## Scales and Modes
## Hear It Instantly
```pycon
>>> from pytheory import TonedScale
>>> c_major = TonedScale(tonic="C4")["major"]
>>> c_major.note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> TonedScale(tonic="C4")["dorian"].note_names
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
```
$ pytheory demo
```
## Diatonic Harmony
## Music Theory
```pycon
>>> c_major.triad(0).identify()
'C major'
>>> from pytheory import Key, Chord, Tone
>>> c_major.seventh(4).identify()
'G dominant 7th'
>>> [c.identify() for c in c_major.harmonize()]
>>> Key("C", "major").chords
['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
>>> [c.identify() for c in c_major.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
>>> [c.symbol for c in Key("G", "major").progression("I", "V", "vi", "IV")]
['G', 'D', 'Em', 'C']
>>> Chord.from_symbol("F#m7b5").identify()
'F# half-diminished 7th'
>>> Tone.from_string("C4").interval_to(Tone.from_string("G4"))
'perfect 5th'
>>> Key("C", "major").pivot_chords(Key("G", "major"))
['A minor', 'B minor', 'C major', 'D major', 'E minor', 'G major']
>>> Chord.from_tones("C", "E", "G").forte_number
'3-11'
>>> from pytheory.scales import Scale
>>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb", top=3)
[('C', 'blues', 1.0), ...]
```
## Keys and Progressions
## Composition
```pycon
>>> from pytheory import Key
```python
score = Score("4/4", bpm=124)
score.drums("house", repeats=16, fill="house", fill_every=8)
>>> key = Key("G", "major")
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
pad = score.part("pad", synth="supersaw", envelope="pad",
reverb=0.5, chorus=0.3, sidechain=0.85)
lead = score.part("lead", synth="saw", envelope="pluck",
legato=True, glide=0.03, humanize=0.3)
bass = score.part("bass", synth="sine", lowpass=300, sidechain=0.7)
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['G major', 'D major', 'E minor', 'C major']
# Song structure
score.section("verse")
# ... add notes ...
score.section("chorus")
lead.set(lowpass=5000, reverb=0.3)
# ... add notes ...
score.end_section()
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
score.repeat("verse")
score.repeat("chorus", times=2)
```
## Chord Analysis
## 10 Synth Waveforms
```pycon
>>> from pytheory import Chord, Tone
sine, saw, triangle, square, pulse, FM, noise, supersaw, PWM slow, PWM fast — with detune, stereo pan, and spread.
>>> C4 = Tone.from_string("C4", system="western")
>>> G4 = Tone.from_string("G4", system="western")
## 58 Drum Patterns
>>> g7 = Chord([G4, G4+4, G4+7, G4+10])
>>> g7.identify()
'G dominant 7th'
rock, jazz, bebop, bossa nova, salsa, samba, afrobeat, funk, reggae, house, trap, metal, drum and bass — and 45 more. Plus 21 fill presets. Stereo panned like a real kit.
>>> g7.analyze("C")
'V7'
## 6 Effects with Automation
>>> g7.tension
{'score': 0.6, 'tritones': 1, 'minor_seconds': 0, 'has_dominant_function': True}
```python
lead = score.part("lead", synth="saw",
distortion=0.7, lowpass=1000, lowpass_q=5.0,
delay=0.3, reverb=0.4, reverb_type="plate",
chorus=0.3)
>>> g7.transpose(-7).identify()
'C dominant 7th'
# Automate mid-song
lead.set(lowpass=4000, distortion=0.9)
# LFO modulation
lead.lfo("lowpass", rate=0.5, min=400, max=3000, bars=8)
```
## Six Musical Systems
Signal chain: distortion → chorus → lowpass → delay → reverb. Sidechain compression. Master bus compressor/limiter. Stereo output.
```pycon
>>> from pytheory import TonedScale
## Convolution Reverb
>>> TonedScale(tonic="Sa4", system="indian")["bhairav"].note_names
['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
7 synthetic impulse responses: Taj Mahal (12s), cathedral, plate, spring, cave, parking garage, canyon.
>>> TonedScale(tonic="Do4", system="arabic")["hijaz"].note_names
['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
>>> TonedScale(tonic="C4", system="japanese")["hirajoshi"].note_names
['C', 'D', 'D#', 'G', 'G#', 'C']
>>> TonedScale(tonic="C4", system="blues")["blues"].note_names
['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
```python
pad = score.part("pad", synth="supersaw",
reverb=0.85, reverb_type="taj_mahal")
```
## 6 Musical Systems
Western, Indian (Hindustani), Arabic (Maqam), Japanese, Blues/Pentatonic, Javanese Gamelan — 40+ scales.
## 25 Instrument Presets
```pycon
>>> from pytheory import Fretboard, CHARTS
Guitar (8 tunings), bass, ukulele, mandolin family, violin family, banjo, harp, oud, sitar, erhu, and more — with chord fingering generation.
>>> Fretboard.guitar() # standard tuning
>>> Fretboard.guitar("drop d") # 8 alternate tunings
>>> Fretboard.mandolin() # + mandola, octave mandolin, mandocello
>>> Fretboard.violin() # + viola, cello, double bass
>>> Fretboard.ukulele() # + banjo, harp, charango, erhu...
>>> Fretboard.keyboard() # 88-key piano
>>> Fretboard.keyboard(25, "C3") # 25-key MIDI controller
>>> CHARTS['western']['Am'].fingering(fretboard=Fretboard.guitar())
Fingering(e=0, B=1, G=2, D=2, A=0, E=0)
>>> Fretboard.guitar().fingering(0, 1, 0, 2, 3, 0).identify()
'C major'
```
## Audio Playback
```pycon
>>> from pytheory import play, Synth, Tone
>>> 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
## Command Line
```
$ 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
$ pytheory repl # interactive scratchpad
$ pytheory demo # hear a generated track
$ pytheory key G major # explore a key
$ pytheory identify Cmaj7 # analyze a chord symbol
$ pytheory progression C major I V vi IV # build a progression
$ pytheory midi C major I V vi IV -o out.mid
$ pytheory play Am7 --synth saw --envelope pluck
$ pytheory modes C # show all modes
$ pytheory circle C # circle of fifths
```
## Features
## Why Python?
- **6 musical systems**: Western, Indian (Hindustani), Arabic (Maqam), Japanese, Blues/Pentatonic, Javanese Gamelan
- **40+ scales**: major, minor, harmonic minor, 7 modes, 10 thaats, 10 maqamat, pentatonic, blues, hirajoshi, pelog, slendro, and more
- **Chord analysis**: identification (17 types), Roman numeral analysis, tension scoring, voice leading, Plomp-Levelt dissonance, beat frequencies
- **Diatonic harmony**: triads, seventh chords, harmonize entire scales, build progressions from Roman numerals
- **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 + WAV export
A DAW is great for tweaking sounds. But when you're *thinking about music* — code is faster than clicking. Sketch ideas, hear them instantly, export MIDI, finish in your DAW.
Tools like [Claude Code](https://claude.ai/code) can use PyTheory to prototype musical ideas from natural language — "write a bossa nova in A minor with a saw lead and reverb" becomes real, playable music.
## Documentation
Full documentation with music theory guides: **[pytheory.kennethreitz.org](https://pytheory.kennethreitz.org)**
**[pytheory.kennethreitz.org](https://pytheory.kennethreitz.org)**
+2
View File
@@ -0,0 +1,2 @@
```{include} ../CHANGELOG.md
```
+1
View File
@@ -19,6 +19,7 @@ extensions = [
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx.ext.intersphinx",
"myst_parser",
]
autodoc_member_order = "bysource"
+154
View File
@@ -446,3 +446,157 @@ almost no overtones — the waves clash.
>>> a4 = Tone.from_string("A4", system="western")
>>> [round(f, 1) for f in a4.overtones(8)]
[440.0, 880.0, 1320.0, 1760.0, 2200.0, 2640.0, 3080.0, 3520.0]
Chord Symbols
-------------
The ``symbol`` property returns compact lead-sheet notation, while
``from_symbol()`` parses any standard chord symbol — no lookup table needed:
.. code-block:: pycon
>>> Chord.from_tones("C", "E", "G").symbol
'C'
>>> Chord.from_name("Am7").symbol
'Am7'
>>> Chord.from_symbol("F#m7b5").identify()
'F# half-diminished 7th'
>>> Chord.from_symbol("Bbmaj9").symbol
'Bbmaj9'
Slash Chords
------------
`Slash chords <https://en.wikipedia.org/wiki/Slash_chord>`_ place a specific
note in the bass below the chord. They're written as Chord/Bass in lead sheets:
.. code-block:: pycon
>>> c = Chord.from_symbol("C")
>>> c_over_g = c.slash("G")
>>> c_over_g.slash_name
'C/G'
>>> c.slash("E").slash_name
'C/E'
Drop Voicings
-------------
`Drop voicings <https://en.wikipedia.org/wiki/Voicing_(music)#Drop_voicings>`_
are standard arranging techniques for spreading chord tones across registers:
- **Close voicing** — all tones packed within one octave
- **Open voicing** — alternating tones raised an octave for wider spacing
- **Drop 2** — second-highest voice dropped an octave (standard jazz guitar)
- **Drop 3** — third-highest voice dropped an octave
.. code-block:: pycon
>>> cmaj7 = Chord.from_symbol("Cmaj7")
>>> cmaj7.close_voicing()
<Chord C major 7th>
>>> cmaj7.drop2()
<Chord C major 7th>
Chord Extensions
----------------
The ``extensions()`` method suggests available extensions (9th, 11th, 13th)
that don't clash with existing chord tones:
.. code-block:: pycon
>>> from pytheory import Chord, TonedScale
>>> cm = Chord.from_symbol("C")
>>> cm.extensions()
[...]
>>> # Filter extensions against a scale for diatonic correctness:
>>> scale = TonedScale(tonic="C4")["major"]
>>> cm.extensions(scale=scale)
[...]
Borrowed Chord Analysis
-----------------------
``analyze()`` now recognizes chromatic chords from modal interchange,
labeling them with flat-degree prefixes:
.. code-block:: pycon
>>> Chord.from_symbol("Ab").analyze("C", "major")
'bVI'
>>> Chord.from_symbol("Bb").analyze("C", "major")
'bVII'
Figured Bass
------------
`Figured bass <https://en.wikipedia.org/wiki/Figured_bass>`_ is the
classical notation for chord inversions — numbers below the bass note
describing the intervals above it. It's how Bach, Handel, and every
Baroque composer communicated harmony.
.. code-block:: pycon
>>> from pytheory import Chord, Tone
>>> root = Chord([Tone.from_string("C4"), Tone.from_string("E4"), Tone.from_string("G4")])
>>> root.figured_bass
''
>>> first_inv = Chord([Tone.from_string("E3"), Tone.from_string("G3"), Tone.from_string("C4")])
>>> first_inv.figured_bass
'6'
>>> second_inv = Chord([Tone.from_string("G3"), Tone.from_string("C4"), Tone.from_string("E4")])
>>> second_inv.figured_bass
'6/4'
For seventh chords: root position → ``"7"``, first inversion → ``"6/5"``,
second inversion → ``"4/3"``, third inversion → ``"2"``.
Combine with Roman numeral analysis using ``analyze_figured()``:
.. code-block:: pycon
>>> first_inv.analyze_figured("C")
'I6'
Pitch Class Sets
----------------
`Pitch class set theory <https://en.wikipedia.org/wiki/Set_theory_(music)>`_
is the framework for analyzing atonal and post-tonal music. It reduces
any collection of notes to abstract pitch classes (011, where C=0),
finds the most compact form, and catalogs it with a Forte number.
If you're studying Schoenberg, Webern, Bartók, or any 20th-century
music that doesn't follow traditional harmony, this is the tool.
.. code-block:: pycon
>>> Chord.from_tones("C", "E", "G").pitch_classes
{0, 4, 7}
>>> Chord.from_tones("C", "E", "G").prime_form
(0, 3, 7)
>>> Chord.from_tones("A", "C", "E").prime_form
(0, 3, 7)
Major and minor triads share the same prime form — they're inversions
of each other in pitch class space.
.. code-block:: pycon
>>> Chord.from_tones("C", "E", "G").forte_number
'3-11'
>>> Chord.from_tones("C", "E", "G", "B").forte_number
'4-20'
>>> Chord.from_tones("C", "E", "G#").forte_number
'3-12'
Chords are the vertical dimension of music -- melody tells you where you're going, but harmony tells you how it feels to be there. Between construction, identification, voice leading, tension analysis, and pitch class sets, you've got tools to look at any chord from every angle. Pick a song you love, grab its chords, and start asking questions.
+98 -1
View File
@@ -1,7 +1,28 @@
Command-Line Interface
======================
PyTheory includes a CLI for quick music theory lookups from the terminal.
PyTheory includes a CLI for music theory lookups, composition, and
playback — all from the terminal.
Interactive REPL
----------------
For extended exploration, the REPL is a music theory scratchpad with
tab completion. See the :doc:`repl` guide for details::
$ pytheory repl
Demo
----
The fastest way to hear what PyTheory can do. Generates and plays a
random multi-part track — different every time::
$ pytheory demo
♫ Jazz Club
Bb major | 105 bpm
Bb → Gm → Cm → F
jazz drums | saw lead | fm pad
Tone Lookup
-----------
@@ -127,3 +148,79 @@ Play individual notes or chords (requires PortAudio)::
$ pytheory play C E G --synth saw # Sawtooth wave
$ pytheory play A4 --duration 2000 # 2 seconds
$ pytheory play C E G --temperament meantone
$ pytheory play Am7 --envelope pad # With ADSR envelope
$ pytheory play C4 --envelope bell # Bell-like ring
Chord Identification (from symbol)
-----------------------------------
Parse any chord symbol and get a full analysis::
$ pytheory identify Cmaj7
Chord: C major 7th
Symbol: Cmaj7
Tones: C4 E4 G4 B4
Intervals: [4, 3, 4]
Harmony: 0.5833
Dissonance: 1.2345
Tension: score=0.00 tritones=0 minor_2nds=0 dominant=False
$ pytheory identify F#m7b5
MIDI Export
-----------
Export a chord progression to a Standard MIDI File::
$ pytheory midi C major I V vi IV -o pop.mid
Key: C major
Progression: I V vi IV
BPM: 120
Duration: 500 ms
Output: pop.mid
$ pytheory midi G major ii V I -o jazz.mid --bpm 140 --duration 800
Modes
-----
Show all 7 modes starting from a note::
$ pytheory modes C
Modes of C:
ionian C D E F G A B C
dorian C D Eb F G A Bb C
phrygian C Db Eb F G Ab Bb C
lydian C D E F# G A B C
mixolydian C D E F G A Bb C
aeolian C D Eb F G Ab Bb C
locrian C Db Eb F Gb Ab Bb C
Circle of Fifths
----------------
Display the circle of fifths and fourths from any note::
$ pytheory circle C
Circle of fifths from C:
→ C → G → D → A → E → B → F# → C# → G# → D# → A# → F
Circle of fourths from C:
→ C → F → A# → D# → G# → C# → F# → B → E → A → D → G
Common Progressions
-------------------
Show all named progressions realized in a key::
$ pytheory progressions C major
Common progressions in C major:
I-IV-V-I C → F → G → C
I-V-vi-IV C → G → Am → F
12-bar blues C → C → C → C → F → F → C → C → G → F → C → G
ii-V-I Dm → G7 → C
...
The CLI is there for quick lookups when you don't want to open a Python session -- just ask your question and get back to playing.
+165
View File
@@ -364,3 +364,168 @@ the most-played scale in rock:
D| D | - | E | - | - | G | - | A | - | B | - | - | D |
A| A | - | B | - | - | D | - | E | - | - | G | - | A |
E| E | - | - | G | - | A | - | B | - | - | D | - | E |
Composition Recipes
-------------------
These recipes go beyond theory into actual music-making.
Acid House Track
~~~~~~~~~~~~~~~~
303-style acid with sidechain pump:
.. code-block:: python
from pytheory import Score, Pattern, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=132)
score.drums("house", repeats=8, fill="house", fill_every=8)
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
reverb=0.4,
chorus=0.3,
sidechain=0.85,
)
acid = score.part(
"acid",
synth="saw",
envelope="pad",
legato=True,
glide=0.03,
distortion=0.8,
distortion_drive=8.0,
lowpass=1000,
lowpass_q=5.0,
)
acid.lfo("lowpass", rate=0.5, min=600, max=2500, bars=8)
for sym in ["Cm", "Fm", "Abm", "Gm"]:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
acid.arpeggio(sym, bars=2, pattern="up", octaves=2)
play_score(score)
Dub Reggae with Delay Madness
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Sparse notes into infinite echo:
.. code-block:: python
score = Score("4/4", bpm=72)
score.drums("dub", repeats=8)
melodica = score.part(
"melodica",
synth="triangle",
envelope="pluck",
delay=0.5,
delay_time=0.66,
delay_feedback=0.55,
reverb=0.4,
reverb_type="cathedral",
)
bass = score.part("bass", synth="sine", lowpass=400, lowpass_q=1.5)
# Play almost nothing — let the delay do the work
melodica.add("A4", 2).rest(6)
melodica.add("E5", 1.5).rest(6.5)
melodica.add("D5", 1).add("C5", 1).add("A4", 2).rest(4)
for n in ["A1"] * 16:
bass.add(n, Duration.HALF)
play_score(score)
Jazz Ballad with Humanize
~~~~~~~~~~~~~~~~~~~~~~~~~~
The difference between a robot and a musician:
.. code-block:: python
score = Score("4/4", bpm=72, swing=0.5)
score.drums("jazz", repeats=8)
rhodes = score.part(
"rhodes",
synth="fm",
envelope="piano",
reverb=0.4,
reverb_type="plate",
humanize=0.3,
)
lead = score.part(
"lead",
synth="triangle",
envelope="strings",
delay=0.25,
reverb=0.3,
humanize=0.35,
)
key = Key("Bb", "major")
for chord in key.progression("I", "vi", "ii", "V") * 2:
rhodes.add(chord, Duration.WHOLE)
for n, d in [("D5", 1.5), ("F5", 0.5), ("Bb5", 2), (None, 4),
("A5", 1), ("G5", 1), ("F5", 2), (None, 4)]:
lead.rest(d) if n is None else lead.add(n, d)
play_score(score)
Song with Sections
~~~~~~~~~~~~~~~~~~~
Define once, arrange freely:
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("rock", repeats=16, fill="rock", fill_every=4)
chords = score.part("chords", synth="saw", envelope="pad")
lead = score.part("lead", synth="triangle", envelope="pluck")
score.section("verse")
for sym in ["Am", "F", "C", "G"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
lead.add("A4", 1).add("C5", 1).add("E5", 1).rest(1)
lead.add("F5", 1).add("E5", 1).add("C5", 2)
score.section("chorus")
lead.set(reverb=0.4, lowpass=5000)
for sym in ["F", "G", "Am", "C"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
lead.add("C6", 2).add("A5", 1).add("G5", 1)
lead.add("F5", 2).add("E5", 2)
score.end_section()
score.repeat("verse")
score.repeat("chorus", times=2)
play_score(score)
score.save_midi("my_song.mid")
Export Everything to MIDI
~~~~~~~~~~~~~~~~~~~~~~~~~~
The whole point — sketch fast, finish in your DAW:
.. code-block:: python
# Any Score can be saved as MIDI
score.save_midi("track.mid")
# Simple progressions too
from pytheory import save_midi
chords = Key("C", "major").progression("I", "V", "vi", "IV")
save_midi(chords, "pop.mid", t=500, bpm=120)
These are all starting points. Change the key, swap the chords, layer in your own ideas -- the best way to learn is to take something that works and make it yours.
+321
View File
@@ -0,0 +1,321 @@
Drums
=====
Drums are the foundation of almost everything. Change the drum pattern
and you change the genre. The same four chords over a bossa nova
pattern sound like you're in a cafe in Rio. Put those same chords over
a rock beat and you're in a garage in Seattle. Over a trap beat, you're
in Atlanta. Over a dancehall pattern, you're in Kingston. The drums ARE
the genre -- they tell the listener's body how to move before a single
melodic note is played.
PyTheory includes a complete drum system -- 27 synthesized percussion
sounds, 58 pattern presets across dozens of genres, and 21 fill presets.
Every sound is generated from waveforms; no samples needed.
Drum Sounds
-----------
Drum hits are **humanized by default** — each hit gets a tiny random
timing offset and velocity wobble, just like a real drummer who's never
perfectly on the grid. Control the amount with ``drum_humanize`` on the
Score:
.. code-block:: python
score = Score("4/4", bpm=120, drum_humanize=0.4) # natural feel
score = Score("4/4", bpm=120, drum_humanize=0.0) # perfectly quantized
score = Score("4/4", bpm=120, drum_humanize=0.1) # studio tight
The default is 0.15 — just enough to feel alive without sounding loose.
Drums Are Parts
~~~~~~~~~~~~~~~~
Drums are a real Part — the same as any melodic voice. You can set
effects on them the same way:
.. code-block:: python
score.drums("rock", repeats=4)
score.parts["drums"].reverb_mix = 0.2
score.parts["drums"].reverb_type = "plate"
Or use the shorthand:
.. code-block:: python
score.set_drum_effects(reverb=0.2, reverb_type="plate", lowpass=8000)
Split Drums
~~~~~~~~~~~
For maximum control, split the kit into separate Parts — kick, snare,
hats, toms, cymbals, and percussion — each with independent effects:
.. code-block:: python
score.drums("rock", repeats=4, split=True)
# Now each group is its own Part
score.parts["snare"].reverb_mix = 0.3
score.parts["snare"].reverb_type = "plate"
score.parts["hats"].lowpass = 7000
score.parts["kick"] # dry, no effects
# set_drum_effects still works — applies to all drum Parts
score.set_drum_effects(reverb=0.1)
This is how real studios work — the snare gets its own reverb send,
the hats get their own EQ, the kick stays dry and punchy. Now you
can do the same thing in Python.
Sidechain compression triggers on kick hits only — hi-hats and snares
don't duck the pad.
Every drum sound is stereo-panned like a real kit — kick and snare
center, hi-hat right, crash left, toms spread across the field,
percussion instruments placed naturally. Put on headphones and you'll
hear the kit in front of you.
The ``DrumSound`` enum maps to General MIDI percussion note numbers:
.. code-block:: pycon
>>> from pytheory import DrumSound
>>> DrumSound.KICK.value
36
>>> DrumSound.SNARE.value
38
>>> DrumSound.CLOSED_HAT.value
42
All 27 sounds, organized by type:
**Kicks:** KICK (36)
**Snares:** SNARE (38), RIMSHOT (37), CLAP (39)
**Hi-hats:** CLOSED_HAT (42), OPEN_HAT (46), PEDAL_HAT (44)
**Toms:** LOW_TOM (45), MID_TOM (47), HIGH_TOM (50)
**Cymbals:** CRASH (49), RIDE (51), RIDE_BELL (53)
**Percussion:** COWBELL (56), CLAVE (75), SHAKER (70), TAMBOURINE (54),
CONGA_HIGH (63), CONGA_LOW (64), BONGO_HIGH (60), BONGO_LOW (61),
TIMBALE_HIGH (65), TIMBALE_LOW (66), AGOGO_HIGH (67), AGOGO_LOW (68),
GUIRO (73), MARACAS (70)
Drum Synthesis
--------------
Every drum sound here is synthesized from scratch using the same
techniques that real drum machines use. This isn't a shortcut -- it's
the real thing. The 808 kick that defined hip hop is literally a sine
wave with a pitch envelope sweeping from 150 Hz down to 50 Hz. The 909
snare that powered techno is a sine wave body mixed with white noise
rattle. The hi-hat is just filtered noise with a short decay. When
Roland built the TR-808 and TR-909, they weren't sampling real drums;
they were synthesizing them from basic waveforms. PyTheory does the
same thing.
Each sound has a dedicated synthesizer:
- **KICK** -- sine wave with pitch envelope sweep (150 to 50 Hz) + sub click
- **SNARE** -- pitched body (180 Hz) + white noise rattle
- **CLOSED_HAT** -- high-frequency noise, 50ms decay
- **OPEN_HAT** -- high-frequency noise, 250ms decay
- **CLAP** -- layered noise bursts with spacers
- **RIMSHOT** -- bright 800 Hz click + noise
- **TOMS** -- pitched sine with sweep (low=100, mid=150, high=200 Hz)
- **CRASH** -- long noise decay (1.5s)
- **RIDE** -- metallic ring (3500+5100 Hz) + noise
- **RIDE_BELL** -- brighter ring, more sustain
- **COWBELL** -- two detuned tones (545+815 Hz)
- **CLAVE** -- short 2500 Hz click
- **CONGAS/BONGOS** -- pitched membrane with slap transient
- **TIMBALES** -- bright metallic ring with overtones
- **AGOGO** -- pitched bell with harmonics
- **SHAKER/MARACAS** -- short noise burst
- **TAMBOURINE** -- noise + 7000 Hz jingle ring
- **GUIRO** -- scraped noise bursts
Pattern Presets
---------------
58 patterns spanning genres from rock to Afro-Cuban to electronic.
Load them with ``Pattern.preset()``:
.. code-block:: pycon
>>> from pytheory import Pattern
>>> Pattern.list_presets()
['12/8 blues', '6/8 afro-cuban', 'afrobeat', 'baiao', 'bebop', ...]
>>> rock = Pattern.preset("rock")
>>> rock
<Pattern 'rock' 4/4 4.0 beats 12 hits>
**Rock/Pop:** rock, half time, double time, disco, motown, train beat
-- The backbone of Western popular music. Kick on 1 and 3, snare on 2
and 4. Simple, effective, universal.
**Jazz:** jazz, bebop, shuffle, swing, linear, paradiddle -- The ride
cymbal drives everything. The kick and snare comp and converse rather
than keeping strict time. These patterns swing.
**Latin:** salsa, bossa nova, samba, cumbia, merengue, baiao, maracatu,
bolero, tango -- Rich, layered patterns built on clave rhythms, with
congas, timbales, and shakers creating interlocking polyrhythmic webs.
Some of the most sophisticated drumming traditions on the planet.
**Afro-Cuban:** son clave 3-2, son clave 2-3, rumba clave 3-2,
rumba clave 2-3, cascara, guaguanco, mozambique, nanigo, bembe,
6/8 afro-cuban, tresillo, habanera -- The clave is the key that
unlocks all Latin and Afro-Cuban music. It's a five-note rhythmic
cell that everything else revolves around. If you learn one concept
from world music, learn the clave.
**African:** afrobeat, highlife -- Born in West Africa. Fela Kuti's
afrobeat layers multiple percussion voices into hypnotic,
polyrhythmic grooves that can go on for twenty minutes.
**Caribbean:** reggae, dancehall, ska, dub -- The offbeat is king.
Reggae flips rock drumming inside out by emphasizing the "and" of each
beat instead of the beat itself. Ska doubles the tempo, dancehall
adds syncopation.
**Electronic:** house, techno, trap, drum and bass, breakbeat, jungle
-- Machine music. The four-on-the-floor kick of house and techno, the
rattling hi-hats of trap, the breakneck tempo of drum and bass. These
patterns were born in drum machines and they still live there.
**Metal/Punk:** metal, blast beat, punk -- Speed and aggression.
The blast beat is both feet and both hands going as fast as humanly
possible. Punk strips everything to its essentials.
**Other:** funk, hip hop, bo diddley, second line, new orleans, waltz,
12/8 blues, country, gospel, flamenco -- Everything else. The syncopated
groove of funk, the sampled feel of hip hop, the street-parade swing
of New Orleans second line.
Playing Patterns
----------------
``play_pattern()`` synthesizes every drum sound in real-time:
.. code-block:: python
from pytheory import Pattern
from pytheory.play import play_pattern
play_pattern(Pattern.preset("rock"), repeats=4, bpm=120)
play_pattern(Pattern.preset("bossa nova"), repeats=4, bpm=140)
play_pattern(Pattern.preset("salsa"), repeats=4, bpm=180)
play_pattern(Pattern.preset("afrobeat"), repeats=8, bpm=110)
Fills
-----
A fill is the drummer's way of saying "something's about to change."
It's that moment at the end of a verse where the drummer breaks the
pattern and rolls around the toms before crashing into the chorus. Fills
signal transitions -- they tell the listener's ear that the section is
ending and a new one is about to begin. Without fills, a drum pattern
just loops. With them, it breathes and has structure.
``Pattern.fill()`` loads a 1-bar drum fill -- a short break that
transitions between sections. 21 fill presets are available:
.. code-block:: pycon
>>> Pattern.list_fills()
['afrobeat', 'blast', 'bossa nova', 'breakdown', 'buildup',
'cumbia', 'disco', 'funk', 'highlife', 'hip hop', 'house',
'jazz', 'jazz brush', 'metal', 'reggae', 'rock', 'rock crash',
'salsa', 'samba', 'second line', 'trap']
>>> fill = Pattern.fill("rock")
>>> fill
<Pattern 'rock fill' 4/4 4.0 beats ...>
Score Integration
-----------------
The ``score.drums()`` shorthand attaches a drum pattern to a score:
.. code-block:: python
from pytheory import Score
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
Auto-Fills
~~~~~~~~~~
The ``fill`` and ``fill_every`` parameters automatically insert drum
fills at regular intervals:
.. code-block:: python
score = Score("4/4", bpm=120)
score.drums("rock", repeats=8, fill="rock", fill_every=4)
This plays the rock pattern for 8 bars, replacing every 4th bar with
a rock fill. Useful for adding natural phrasing to longer sections.
.. code-block:: python
# Jazz with brush fills every 8 bars
score.drums("bebop", repeats=16, fill="jazz brush", fill_every=8)
# Salsa with fills every 4 bars
score.drums("salsa", repeats=8, fill="salsa", fill_every=4)
Layering Patterns
-----------------
Combine drum patterns with melodic parts for full arrangements. The
drum pattern and all named parts are mixed together by ``play_score()``:
.. code-block:: python
from pytheory import Score, Key, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=180)
score.drums("salsa", repeats=4, fill="salsa", fill_every=4)
pads = score.part("pads", synth="sine", envelope="pad", volume=0.3)
lead = score.part("lead", synth="saw", envelope="pluck", volume=0.4)
bass = score.part("bass", synth="sine", envelope="pluck", volume=0.45)
for chord in Key("D", "minor").progression("ii", "V", "i", "i") * 2:
pads.add(chord, Duration.WHOLE)
lead.add("A5", 0.67).add("G5", 0.33).add("F5", 0.67).add("E5", 0.33)
for n in ["D2", "A2", "D2", "F2"] * 2:
bass.add(n, Duration.QUARTER)
play_score(score)
MIDI Export
-----------
Convert any pattern to a Score, then export to MIDI (drums are written
to channel 10):
.. code-block:: python
pattern = Pattern.preset("bossa nova")
score = pattern.to_score(repeats=8, bpm=140)
score.save_midi("bossa.mid")
Pattern.preset("afrobeat").to_score(repeats=8, bpm=110).save_midi("afrobeat.mid")
Drums are the foundation. The same chords over a bossa nova feel like a different song than over a rock beat -- change the pattern and you change the genre. Try swapping presets under the same progression and hear how much the drums are really doing.
+597
View File
@@ -0,0 +1,597 @@
Effects
=======
Effects are how recorded music gets its character. A guitar without
reverb sounds like it's being played in a closet. A vocal without
compression sounds thin and amateur. A synth without filtering sounds
like a test signal. Effects are the difference between "notes" and
"music" -- they put the sound in a space, give it texture, and make it
feel alive.
Every record you've ever loved was shaped by effects. The cavernous
reverb on a Phil Collins drum hit. The tape delay on a reggae vocal.
The distortion on a Hendrix guitar. The chorus on an 80s synth pad.
These aren't decorations added after the fact; they're fundamental to
the sound itself.
Each part in a Score can have its own effects chain. Effects are set at
part creation and applied per-part before mixing, so every voice gets
independent processing.
Signal Chain
------------
The order of effects matters -- a lot. Distortion before a lowpass
filter means you're generating all those rich, crunchy harmonics and
then sculpting them with the filter. That's warm, controllable,
musical. Filter before distortion means you're distorting the already-
filtered signal -- a different, often harsher character. The fixed
order in PyTheory matches classic analog synth architecture, the same
signal path used by the Moog, the TB-303, and most hardware synths.
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
- **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).
- **Reverb** last: places everything in a space (room / hall).
Distortion
----------
You know what distortion sounds like -- it's the sound of rock and roll.
An electric guitar through a cranked amplifier. But at lower levels,
distortion is subtler: it adds warmth, presence, and harmonic richness.
This is why producers run clean signals through tape machines and tube
preamps. A little saturation makes everything sound more "real."
Soft-clip waveshaping using ``tanh`` -- models the warm saturation of an
overdriven tube amplifier. At low drive levels it adds harmonic warmth;
at high levels it becomes an aggressive fuzz.
Parameters:
- ``distortion``: Wet/dry mix, 0.0--1.0.
- ``distortion_drive``: Gain before clipping (default 3.0).
- 0.5--2 = subtle warmth (tube preamp)
- 3--8 = overdrive (cranked amp)
- 10+ = fuzz
.. code-block:: python
# Warm tube saturation on a bass
bass = score.part(
"bass",
synth="sine",
envelope="pluck",
distortion=0.3,
distortion_drive=2.0,
)
# Heavy fuzz on a lead
lead = score.part(
"lead",
synth="saw",
envelope="staccato",
distortion=0.8,
distortion_drive=10.0,
)
Chorus
------
That shimmery, wide, slightly-out-of-focus sound that defined the
1980s? That's chorus. Think of the intro to "Come As You Are" by
Nirvana, or literally any synth pad from 1983 to 1989. It makes one
instrument sound like two or three playing together, slightly out of
tune with each other -- which is exactly how a real string section or
choir sounds rich and full.
A slightly detuned, LFO-modulated delayed copy mixed back in. Thickens
the sound like two musicians playing the same part -- the signature
effect of the Roland Juno synthesizers.
Parameters:
- ``chorus``: Wet/dry mix, 0.0--1.0.
- ``chorus_rate``: LFO speed in Hz. 0.5--1 = slow shimmer, 2--4 = vibrato.
- ``chorus_depth``: Modulation depth in seconds (default 0.003).
.. code-block:: python
# Juno-style pad chorus
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
chorus=0.5,
chorus_rate=1.5,
chorus_depth=0.003,
)
# Subtle thickening on a clean lead
lead = score.part(
"lead",
synth="triangle",
envelope="pluck",
chorus=0.2,
chorus_rate=0.8,
)
Lowpass Filter
--------------
You know that sound when a DJ turns the knob and everything goes
underwater? That's a lowpass filter closing down. It removes
high-frequency content, leaving only the warm, round, bassy
frequencies below the cutoff point. The lowpass filter is arguably the
most important effect in all of electronic music -- it's the entire
sound of acid house, the "wah" in auto-wah, and the reason analog
synths sound warm instead of harsh.
A 12 dB/octave biquad lowpass filter with resonance -- the sound of
analog synthesizers. Removes frequencies above the cutoff; the resonance
(Q) parameter adds a peak at the cutoff frequency for that classic
"acid squelch."
Parameters:
- ``lowpass``: Cutoff frequency in Hz (0 = off). Reference points:
- 200--400 Hz = deep sub bass
- 800--1500 Hz = warm / muffled
- 2000--4000 Hz = present lead
- 5000+ Hz = subtle rolloff
- ``lowpass_q``: Resonance / Q factor (default 0.707 = Butterworth flat).
- 1.0 = slight peak
- 2.0 = pronounced
- 5.0+ = aggressive acid squelch
.. code-block:: python
# Round bass with gentle filtering
bass = score.part(
"bass",
synth="sine",
envelope="pluck",
lowpass=400,
lowpass_q=1.5,
)
# Acid squelch on a saw lead
acid = score.part(
"acid",
synth="saw",
envelope="staccato",
lowpass=1500,
lowpass_q=5.0,
legato=True,
glide=0.03,
)
Delay
-----
Delay is echo. Literally. The Edge from U2 built his entire guitar
sound around dotted-eighth-note delays. Dub reggae producers like Lee
"Scratch" Perry and King Tubby turned delay into an art form, feeding
echoes back into themselves until they spiraled into infinity. At short
times with low feedback, delay adds rhythmic interest. At long times
with high feedback, it creates cascading, psychedelic soundscapes.
Tempo-synced echoes with feedback. Each repeat feeds back into the
delay line, creating rhythmic echo trails. High feedback values produce
the cascading, self-oscillating echoes of dub reggae.
Parameters:
- ``delay``: Wet/dry mix, 0.0--1.0.
- ``delay_time``: Time between echoes in seconds. Musically useful
values at 120 bpm: 0.25 (8th note), 0.375 (dotted 8th),
0.5 (quarter note).
- ``delay_feedback``: How much each echo feeds back (0.0--1.0).
- 0.3 = a few repeats
- 0.5 = many repeats
- 0.7+ = runaway (dub style)
.. code-block:: python
# Dotted-eighth slapback on a lead
lead = score.part(
"lead",
synth="triangle",
envelope="strings",
delay=0.3,
delay_time=0.375,
delay_feedback=0.4,
)
# Dub-style runaway echoes
melodica = score.part(
"melodica",
synth="triangle",
envelope="pluck",
delay=0.5,
delay_time=0.66,
delay_feedback=0.55,
)
Reverb
------
Everyone knows what reverb sounds like, even if they don't know the
word -- it's the sound of singing in the shower, or clapping in a
cathedral. It's the natural echo of a space. Without reverb, sounds
feel uncomfortably close and dry, like someone whispering directly into
your ear. With it, sounds feel like they exist in a real place. Reverb
is the most universally used effect in all of recorded music.
PyTheory offers two reverb engines: a fast **algorithmic** reverb for
general use, and **convolution** reverb for photorealistic acoustic
spaces.
Algorithmic Reverb
~~~~~~~~~~~~~~~~~~
A Schroeder reverb using 4 parallel comb filters and 2 series allpass
filters. Fast, lightweight, and good for general-purpose room
simulation.
Parameters:
- ``reverb``: Wet/dry mix, 0.0--1.0.
- 0.2--0.4 = subtle space
- 0.5--0.8 = ambient / dub
- ``reverb_decay``: Tail length in seconds.
- 0.5 = small room
- 1.5 = hall
- 3.0+ = cathedral / dub
.. code-block:: python
# Jazz club ambience
rhodes = score.part(
"rhodes",
synth="fm",
envelope="piano",
reverb=0.4,
reverb_decay=1.8,
)
Convolution Reverb
~~~~~~~~~~~~~~~~~~
Convolution reverb works by convolving your audio with an *impulse
response* -- a recording (or simulation) of a real acoustic space.
Where algorithmic reverb approximates the math of reflections,
convolution reverb *is* the space. You hear every surface, every
angle, every material.
PyTheory generates synthetic impulse responses that model the acoustic
properties of real spaces: early reflection patterns, exponential
decay envelopes, frequency-dependent absorption (high frequencies die
faster in stone), diffusion density, and subtle pitch modulation from
irregular surfaces. The result is dramatically more realistic than
algorithmic reverb, especially for long tails and large spaces.
Set ``reverb_type`` to any preset name instead of ``"algorithmic"``:
- ``"taj_mahal"`` -- Massive marble dome. 12-second tail, bright early
reflections, enormously dense and diffuse. The most dramatic verb
you've ever heard.
- ``"cathedral"`` -- Gothic stone cathedral. 6 seconds, strong early
reflections off parallel walls, dark reverberant tail.
- ``"plate"`` -- EMT 140 plate reverb. 4 seconds, dense, bright, smooth.
The studio classic that defined pop records from the 60s onward.
- ``"spring"`` -- Spring reverb tank. 3 seconds, metallic, boingy, lo-fi.
The sound of surf rock and guitar amps.
- ``"cave"`` -- Natural cave. 8 seconds, very dark, irregular reflections.
High frequencies are aggressively absorbed by rock.
- ``"parking_garage"`` -- Concrete box. 3 seconds, bright, flutter echoes
from parallel hard walls.
- ``"canyon"`` -- Open canyon. 5 seconds, sparse discrete echoes (the
walls are far apart) dissolving into a diffuse tail.
Parameters:
- ``reverb``: Wet/dry mix, 0.0--1.0.
- ``reverb_type``: Preset name (default ``"algorithmic"``).
.. code-block:: python
# FM flute through the Taj Mahal
flute = score.part(
"flute",
synth="fm",
envelope="bell",
reverb=0.85,
reverb_type="taj_mahal",
delay=0.65,
delay_time=0.375,
delay_feedback=0.55,
)
# Cathedral wash for ambient pads
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
reverb=0.7,
reverb_type="cathedral",
)
# Classic plate on a vocal-style lead
lead = score.part(
"lead",
synth="triangle",
envelope="strings",
reverb=0.5,
reverb_type="plate",
)
# Algorithmic reverb still works as before
rhodes = score.part(
"rhodes",
synth="fm",
envelope="piano",
reverb=0.4,
reverb_decay=1.8,
)
You can switch reverb types mid-song with automation:
.. code-block:: python
lead = score.part("lead", synth="fm", envelope="bell",
reverb=0.5, reverb_type="plate")
lead.add("C5", Duration.WHOLE)
# Switch to cathedral for the big section
lead.set(reverb_type="cathedral", reverb=0.8)
lead.add("E5", Duration.WHOLE)
Combining Effects
-----------------
Effects stack naturally. Here are some real-world combinations:
Dub
~~~
Distortion warmth into filtered delay into deep reverb:
.. code-block:: python
melodica = score.part(
"melodica",
synth="triangle",
envelope="pluck",
distortion=0.2,
distortion_drive=2.0,
lowpass=2000,
lowpass_q=1.2,
delay=0.5,
delay_time=0.66,
delay_feedback=0.55,
reverb=0.4,
reverb_decay=2.5,
)
Acid
~~~~
Resonant lowpass with distortion and delay:
.. code-block:: python
acid = score.part(
"acid",
synth="saw",
envelope="staccato",
lowpass=1500,
lowpass_q=3.0,
distortion=0.4,
distortion_drive=4.0,
delay=0.3,
delay_time=0.242,
delay_feedback=0.4,
)
Ambient
~~~~~~~
Wide chorus, long reverb, gentle delay:
.. code-block:: python
ambient = score.part(
"ambient",
synth="supersaw",
envelope="pad",
chorus=0.4,
chorus_rate=0.5,
delay=0.3,
delay_time=0.5,
delay_feedback=0.5,
reverb=0.7,
reverb_decay=4.0,
)
808 Bass
~~~~~~~~
Subtle saturation and deep filtering for hip-hop sub bass:
.. code-block:: python
bass = score.part(
"bass",
synth="sine",
envelope="pluck",
lowpass=200,
lowpass_q=1.8,
distortion=0.4,
distortion_drive=2.0,
)
Sidechain Compression
---------------------
If you've ever heard a house track where the pad *breathes* — gets
quiet every time the kick hits and swells back up between beats —
that's sidechain compression. It's the pumping effect that defines
modern electronic music. The kick drum triggers a compressor on
another part, ducking its volume in rhythm with the beat.
In PyTheory, the drum hits are the trigger. Any part with
``sidechain > 0`` gets ducked whenever the kick (or any drum) hits:
.. code-block:: python
# Classic EDM pump — pad ducks hard on every kick
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
sidechain=0.85,
sidechain_release=0.15,
)
# Bass breathes with the kick too, but less aggressively
bass = score.part(
"bass",
synth="sine",
lowpass=250,
sidechain=0.7,
sidechain_release=0.1,
)
Parameters:
- ``sidechain``: How much to duck, 0.01.0 (default 0, off).
0.5 = subtle pump, 0.7 = noticeable, 0.85 = classic EDM, 1.0 = full silence on hits.
- ``sidechain_release``: How fast the volume comes back, in seconds
(default 0.1). Shorter = tighter, longer = more dramatic pump.
The lead stays above the pump — don't sidechain everything or the
whole mix will gasp for air:
.. code-block:: python
# Lead cuts through — no sidechain
lead = score.part(
"lead",
synth="saw",
envelope="pluck",
delay=0.2,
)
Automation
----------
Static effects are fine for a loop, but music breathes. The filter
*opens* during the chorus. The reverb *swells* before the drop. The
distortion *kicks in* when the guitar solo starts. Automation is what
makes a track feel alive instead of robotic -- it's the difference
between a static loop and a piece of music that has dynamics, tension,
and release. If you've ever felt a song "build" toward something,
you're hearing automation at work.
``Part.set()`` changes effect parameters mid-song at the current beat
position. The renderer splits the audio at automation points and
processes each section independently:
.. code-block:: python
lead = score.part("lead", synth="saw", lowpass=400, lowpass_q=3.0)
# Verse: filtered and clean
lead.arpeggio("Cm", bars=4, pattern="up", octaves=2)
# Chorus: filter opens, chorus kicks in
lead.set(lowpass=2000, chorus=0.3)
lead.arpeggio("Fm", bars=4, pattern="updown", octaves=2)
# Drop: full send
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``.
LFO Automation
--------------
An LFO -- Low Frequency Oscillator -- is just automation that repeats.
Instead of manually setting parameter changes, you let a wave shape do
it for you, cycling back and forth continuously. You already know what
LFOs sound like, even if you don't know the term. The wobble bass in
dubstep? That's an LFO on the filter cutoff. Tremolo on a guitar amp?
LFO on volume. Auto-wah? LFO on filter cutoff with resonance cranked
up. Vibrato? LFO on pitch. It's one simple concept that produces a
huge range of effects.
``Part.lfo()`` automates a parameter with a low-frequency oscillator,
generating smooth sweeps over time. This is how filter sweeps, tremolo,
and auto-wah effects work.
.. code-block:: python
lead = score.part("lead", synth="saw", lowpass=400)
# Slow filter sweep: 400 -> 3000 Hz over 8 bars
lead.lfo("lowpass", rate=0.125, min=400, max=3000, bars=8)
lead.arpeggio("Cm", bars=8, pattern="up", octaves=2)
Parameters:
- ``param``: Parameter name to modulate (``"lowpass"``, ``"reverb"``,
``"distortion"``, ``"volume"``, ``"chorus"``, ``"delay"``).
- ``rate``: LFO speed in cycles per bar (default 0.5 = one sweep
every 2 bars). 0.25 = very slow, 1 = once per bar, 4 = four times
per bar.
- ``min`` / ``max``: Parameter value range.
- ``bars``: Number of bars to run the LFO over (default 4).
- ``shape``: Waveform shape.
- ``"sine"`` -- smooth, natural sweep
- ``"triangle"`` -- linear up/down
- ``"saw"`` -- ramp up, snap back
- ``"square"`` -- abrupt on/off
- ``resolution``: How often to insert automation points, in beats
(default 0.25 = every 16th note). Lower values = smoother curves.
Stacking Multiple LFOs
~~~~~~~~~~~~~~~~~~~~~~~
Call ``.lfo()`` multiple times to modulate different parameters
simultaneously:
.. code-block:: python
lead = score.part("lead", synth="saw", lowpass=800, reverb=0.1)
# Filter opens over 8 bars
lead.lfo("lowpass", rate=0.125, min=400, max=4000, bars=8)
# Reverb swells in and out every 2 bars
lead.lfo("reverb", rate=0.5, min=0.1, max=0.6, bars=8, shape="triangle")
# Volume tremolo
lead.lfo("volume", rate=2, min=0.3, max=0.6, bars=8, shape="sine")
lead.arpeggio("Cm", bars=8, pattern="updown", octaves=2)
Effects are what turn notes into music -- the space, the movement, the character. A dry signal is just information; reverb, delay, and filtering are what make it feel like something. Experiment freely, trust your ears.
+29
View File
@@ -267,6 +267,33 @@ Generate fingerings for every chord at once:
>>> uke_chart = Fretboard.ukulele().chart()
>>> mando_chart = Fretboard.mandolin().chart()
Scale Diagrams with Chord Highlighting
---------------------------------------
The ``scale_diagram()`` method renders an ASCII fretboard showing where
scale notes fall on each string. Pass an optional ``chord`` argument to
highlight chord tones in UPPERCASE while scale-only tones appear in
lowercase — a quick way to visualize target notes for soloing:
.. code-block:: pycon
>>> from pytheory import Fretboard, TonedScale, Chord
>>> fb = Fretboard.guitar()
>>> pentatonic = TonedScale(tonic="A4")["minor pentatonic"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
>>> # Highlight Am chord tones within the scale:
>>> am = Chord.from_symbol("Am")
>>> print(fb.scale_diagram(pentatonic, frets=5, chord=am))
Non-String Instruments
----------------------
Looking for drums and percussion? PyTheory also supports drum pattern
programming through the sequencing engine. See the :doc:`drums` guide
for drum kits, patterns, and fills.
Custom Instruments
------------------
@@ -290,3 +317,5 @@ Any instrument can be modeled with custom string tunings:
... Tone.from_string("B3"),
... Tone.from_string("G3"),
... ])
If it has strings, you can model it. Define the tuning, and PyTheory handles the rest -- fingerings, charts, scale diagrams, all of it. Got a weird instrument or a custom tuning? That's what the ``Fretboard`` constructor is for.
+162 -72
View File
@@ -1,8 +1,21 @@
Audio Playback
==============
Playback and Export
===================
PyTheory can synthesize and play tones and chords through your speakers
using basic `waveform <https://en.wikipedia.org/wiki/Waveform>`_ synthesis.
This is the output layer. You've built your theory, composed your
arrangement, shaped your sounds -- now you need to hear it. PyTheory
gives you three ways to get your music out: speakers, WAV files, and
MIDI files.
Use **speakers** for immediate feedback while you're sketching and
experimenting. Use **WAV export** when you want to share actual audio
-- post it, send it, drop it into a video. Use **MIDI export** when you
want to bring your sketch into a real DAW and finish it with
professional instruments, mixing, and mastering. Each output serves a
different stage of the creative process.
PyTheory can play audio through your speakers, save to WAV, or export
to MIDI. Everything is synthesized from waveforms -- no samples or
external audio files needed.
.. note::
@@ -10,110 +23,187 @@ using basic `waveform <https://en.wikipedia.org/wiki/Waveform>`_ synthesis.
installed on your system. On macOS: ``brew install portaudio``.
On Ubuntu: ``apt install libportaudio2``.
Playing a Tone
--------------
play() -- Single Tones and Chords
---------------------------------
The simplest way to hear something:
.. code-block:: python
from pytheory import Tone, Chord, play
play(Tone.from_string("A4"), t=1_000) # A440 for 1 second
play(Chord.from_symbol("Am7"), t=2_000) # chord for 2 seconds
Optional parameters for synth, envelope, and temperament:
.. code-block:: python
from pytheory import Synth, Envelope
play(Tone.from_string("C4"), synth=Synth.SAW, envelope=Envelope.PLUCK, t=1_000)
play(Tone.from_string("C4"), temperament="pythagorean", t=1_000)
play_score() -- Full Arrangements
---------------------------------
Plays a ``Score`` with all its parts and drums mixed together.
Output is **stereo** — each part is panned according to its ``pan``
setting, drums are stereo-panned like a real kit, and reverb tails
have natural stereo width. A **master bus compressor/limiter** (4:1
ratio, brick-wall at 0.95) is applied to prevent clipping and make
the mix louder and punchier:
.. code-block:: python
from pytheory import Score, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
chords = score.part("chords", synth="sine", envelope="pad")
for sym in ["Am", "Dm", "E7", "Am"]:
chords.add(Chord.from_symbol(sym), Duration.WHOLE)
play_score(score)
See :doc:`sequencing` for how to build scores and parts.
render_score() -- Headless Rendering
------------------------------------
Returns a raw audio buffer (numpy float32 array) without playing it.
Useful for saving to WAV or further processing:
.. code-block:: pycon
>>> from pytheory import Tone, play
>>> from pytheory.play import render_score
>>> buf = render_score(score) # numpy float32 array
>>> len(buf)
604800
>>> a4 = Tone.from_string("A4", system="western")
>>> play(a4, t=1_000) # Play A440 for 1 second
save() -- WAV Export
--------------------
Playing a Chord
---------------
Render tones or chords to a WAV file. Works without speakers or
PortAudio:
.. code-block:: pycon
.. code-block:: python
>>> from pytheory import Chord, play
from pytheory import save, Chord, Tone, Synth
>>> # From a chord name
>>> play(Chord.from_name("Am7"), t=2_000)
save(Tone.from_string("A4"), "a440.wav", t=1_000)
save(Chord.from_name("Am7"), "am7.wav", t=2_000)
save(
Chord.from_name("C"),
"c_triangle.wav",
synth=Synth.TRIANGLE,
temperament="meantone",
t=3_000,
)
>>> # From note names
>>> play(Chord.from_tones("C", "E", "G"), t=2_000)
save_midi() -- MIDI Export
--------------------------
Waveform Types
--------------
MIDI export is probably the most useful feature here for working
musicians. The idea is simple: sketch your ideas in Python -- where
iteration is fast, where you can use loops and randomness and music
theory functions -- and then export to MIDI. Open that MIDI file in
Logic, Ableton, Reaper, FL Studio, or whatever you use, and now you've
got your chord progressions, melodies, and bass lines on real tracks.
Swap in your favorite soft synths, add real mixing, finish the track
properly. Python is the sketchpad; the DAW is the canvas.
The waveform shape determines the `timbre <https://en.wikipedia.org/wiki/Timbre>`_ (tonal color) of the sound.
Different waveforms contain different combinations of **harmonics**
integer multiples of the fundamental frequency.
Export tones, chords, progressions, or full scores as Standard MIDI
Files. MIDI files can be opened in any DAW, edited, transposed, and
assigned to any instrument.
- `Sine wave <https://en.wikipedia.org/wiki/Sine_wave>`_ — the purest tone. Contains only the fundamental
frequency with no harmonics. Sounds smooth, clear, and "electronic."
This is the building block of all other waveforms (`Fourier's theorem <https://en.wikipedia.org/wiki/Fourier_series>`_).
Simple export (single tone, chord, or progression):
- `Sawtooth wave <https://en.wikipedia.org/wiki/Sawtooth_wave>`_ — contains all harmonics (both odd and even),
each at amplitude 1/n. Sounds bright, buzzy, and aggressive.
Named for its shape. Used extensively in `additive synthesis <https://en.wikipedia.org/wiki/Additive_synthesis>`_ and analog synthesizers.
.. code-block:: python
- `Triangle wave <https://en.wikipedia.org/wiki/Triangle_wave>`_ — contains only odd harmonics, each at amplitude
1/n². Sounds softer and more mellow than sawtooth — somewhere between
sine and sawtooth. Often described as "woody" or "hollow."
from pytheory import save_midi, Key, Tone, Chord
.. code-block:: pycon
save_midi(Tone.from_string("C4"), "middle_c.mid", t=1000)
save_midi(Chord.from_symbol("Am7"), "am7.mid")
>>> from pytheory import play, Synth, Tone
chords = Key("C", "major").progression("I", "V", "vi", "IV")
save_midi(chords, "pop.mid", t=500, bpm=120)
>>> tone = Tone.from_string("C4", system="western")
Score-based export (with time signature, tempo, and parts):
>>> play(tone, synth=Synth.SINE) # Pure, clean
>>> play(tone, synth=Synth.SAW) # Bright, buzzy
>>> play(tone, synth=Synth.TRIANGLE) # Mellow, hollow
.. code-block:: python
Temperaments
------------
from pytheory import Score, Duration, Key
Hear the difference between tuning systems:
score = Score("4/4", bpm=140)
for chord in Key("G", "major").progression("I", "IV", "V", "I"):
score.add(chord, Duration.WHOLE)
score.save_midi("progression.mid")
.. code-block:: pycon
play_pattern() -- Drum Patterns
-------------------------------
>>> play(tone, temperament="equal") # Modern standard (since ~1917)
>>> play(tone, temperament="pythagorean") # Pure fifths, wolf intervals
>>> play(tone, temperament="meantone") # Pure thirds, Renaissance sound
Play a drum pattern through the speakers:
Try playing a C major chord in each temperament — you'll hear subtle
differences in the "color" of the major third. Equal temperament is
a compromise; the other systems sacrifice some keys to make the good
keys sound better.
.. code-block:: python
Chord Progressions
-------------------
from pytheory import Pattern
from pytheory.play import play_pattern
Play an entire chord progression in sequence with a single call:
play_pattern(Pattern.preset("rock"), repeats=4, bpm=120)
play_pattern(Pattern.preset("bossa nova"), repeats=4, bpm=140)
.. code-block:: pycon
See :doc:`drums` for the full list of 58 presets and 21 fills.
>>> from pytheory import Key, play_progression
play_progression() -- Quick Chord Playback
------------------------------------------
>>> chords = Key("C", "major").progression("I", "V", "vi", "IV")
>>> play_progression(chords, t=800)
Play a chord progression in sequence with a single call:
You can customize the waveform and the gap (silence) between chords:
.. code-block:: python
.. code-block:: pycon
from pytheory import Key, play_progression
>>> from pytheory import Synth
chords = Key("C", "major").progression("I", "V", "vi", "IV")
play_progression(chords, t=800)
>>> play_progression(chords, t=1000, synth=Synth.TRIANGLE, gap=200)
Optional synth, envelope, and gap parameters:
Saving to WAV
-------------
.. code-block:: python
Render tones or chords to a WAV file instead of playing them live.
This works even without speakers or PortAudio:
from pytheory import Synth, Envelope
.. code-block:: pycon
play_progression(chords, t=1000, synth=Synth.TRIANGLE, gap=200)
play_progression(chords, t=2000, envelope=Envelope.PAD)
>>> from pytheory import save, Chord, Tone, Synth
That's the workflow: hear it, tweak it, hear it again. When it sounds right, export to WAV or MIDI and take it somewhere bigger.
>>> # Save a single tone
>>> save(Tone.from_string("A4"), "a440.wav", t=1_000)
MIDI Import
-----------
>>> # Save a chord
>>> save(Chord.from_name("Am7"), "am7.wav", t=2_000)
Load any Standard MIDI File into a Score — then play it through
PyTheory's synth engine with effects, or analyze the theory:
>>> # Choose waveform and temperament
>>> save(Chord.from_name("C"), "c_triangle.wav",
... synth=Synth.TRIANGLE, temperament="meantone", t=3_000)
.. code-block:: python
from pytheory import Score
from pytheory.play import play_score
score = Score.from_midi("song.mid")
# See what's inside
for name, part in score.parts.items():
print(f"{name}: {len(part.notes)} notes")
# Change the synth and add effects
score.parts["ch1"].synth = "saw"
score.parts["ch1"].reverb_mix = 0.3
play_score(score)
Each MIDI channel becomes a named Part (``ch1``, ``ch2``, etc.).
Channel 10 (drums) becomes drum hits. Tempo, time signature,
note durations, and velocities are all preserved.
Download any MIDI file from the internet, load it, play it through
the synth engine with reverb and delay. That's the whole idea.
+204 -158
View File
@@ -1,6 +1,18 @@
Quickstart
==========
PyTheory works at two levels — pick the one that fits what you need:
1. **Music theory** — explore scales, chords, keys, intervals, and
harmony. No audio required. Works anywhere Python runs.
2. **Composition** — build multi-part arrangements with drums, synths,
effects, and export to MIDI. Needs PortAudio for live playback.
Both are first-class. You can use PyTheory purely as a theory
reference and never touch the audio side, or you can jump straight
into composing. This guide covers both paths.
Installation
------------
@@ -8,200 +20,234 @@ Installation
$ pip install pytheory
For audio playback, you'll also need `PortAudio <http://www.portaudio.com/>`_:
For audio playback through your speakers, you'll also need
`PortAudio <http://www.portaudio.com/>`_:
- macOS: ``brew install portaudio``
- Ubuntu: ``apt install libportaudio2``
- Windows: included with the ``sounddevice`` package
Tones
-----
PortAudio is only needed for live playback. MIDI export, WAV export,
and all theory functions work without it.
A :class:`~pytheory.tones.Tone` is a single musical note:
Hear Something Immediately
--------------------------
::
$ pytheory demo
This generates and plays a random track — different every time. It's
the fastest way to hear what PyTheory can do.
Explore Music Theory
--------------------
The theory layer is where most people start. No audio setup needed —
this works everywhere Python runs. Every concept in Western music
theory (and five other systems) has a clean Python API.
Tones and intervals:
.. code-block:: pycon
>>> from pytheory import Tone
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.frequency
440.0
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.frequency
261.6255653005986
>>> c4.midi
60
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_midi(60)
<Tone C4>
>>> c4 + 4
<Tone E4>
>>> c4 + 7
<Tone G4>
>>> g4 = c4 + 7
>>> g4 - c4
7
>>> c4.interval_to(g4)
>>> c4.interval_to(c4 + 7)
'perfect 5th'
>>> Tone.from_string("C#4", system="western").enharmonic
'Db'
>>> Tone.from_frequency(440)
<Tone A4>
>>> Tone.from_midi(69)
<Tone A4>
Scales
------
Keys, scales, and chords:
Build scales in any key and mode:
.. code-block:: pycon
>>> from pytheory import Key, Chord
>>> key = Key("C", "major")
>>> key.chords
['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
>>> [c.symbol for c in key.progression("I", "V", "vi", "IV")]
['C', 'G', 'Am', 'F']
>>> key.signature
{'sharps': 0, 'flats': 0, 'accidentals': []}
>>> Key("F", "major").signature
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
>>> Chord.from_symbol("Am7").identify()
'A minor 7th'
>>> Chord.from_tones("G", "B", "D", "F").analyze("C")
'V7'
Harmonic analysis and modulation:
.. code-block:: pycon
>>> Key("C", "major").pivot_chords(Key("G", "major"))
['A minor', 'B minor', 'C major', 'D major', 'E minor', 'G major']
>>> Key("C", "major").relative
<Key A minor>
>>> key.suggest_next(key.triad(4)) # what follows V?
[<Chord C major>, <Chord A minor>, <Chord F major>]
Scales across 6 musical systems:
.. code-block:: pycon
>>> from pytheory import TonedScale
>>> c = TonedScale(tonic="C4")
>>> TonedScale(tonic="Sa4", system="indian")["bhairav"].note_names
['Sa', 'komal Re', 'Ga', 'Ma', 'Pa', 'komal Dha', 'Ni', 'Sa']
>>> c["major"].note_names
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
>>> TonedScale(tonic="Do4", system="arabic")["hijaz"].note_names
['Do', 'Reb', 'Mi', 'Fa', 'Sol', 'Solb', 'Sib', 'Do']
>>> c["minor"].note_names
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
>>> TonedScale(tonic="C4", system="japanese")["hirajoshi"].note_names
['C', 'D', 'D#', 'G', 'G#', 'C']
>>> c["dorian"].note_names
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
>>> major = c["major"]
>>> major["tonic"]
C4
>>> major["dominant"]
G4
>>> major["V"]
G4
Keys and Chords
---------------
The :class:`~pytheory.scales.Key` class ties everything together —
scales, chords, and progressions:
.. code-block:: pycon
>>> from pytheory import Key
>>> key = Key("G", "major")
>>> key.note_names
['G', 'A', 'B', 'C', 'D', 'E', 'F#', 'G']
>>> key.chords
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor', 'F# diminished']
>>> chords = key.progression("I", "V", "vi", "IV")
>>> [c.identify() for c in chords]
['G major', 'D major', 'E minor', 'C major']
>>> Key.detect("C", "E", "G", "A", "D")
<Key C major>
Build chords directly:
.. code-block:: pycon
>>> from pytheory import Chord
>>> Chord.from_tones("C", "E", "G")
<Chord C major>
>>> Chord.from_name("Am7")
<Chord A minor 7th>
>>> Chord.from_intervals("G", 4, 7, 10)
<Chord G dominant 7th>
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
>>> Chord.from_name("G7").analyze("C")
'V7'
Guitar Fingerings
-----------------
Guitar fingerings:
.. code-block:: pycon
>>> from pytheory import Fretboard
>>> fb = Fretboard.guitar()
>>> fb.chord("Am")
Fingering(e=0, B=1, G=2, D=2, A=0, E=x)
>>> fb.chord("C")
Fingering(e=0, B=1, G=0, D=2, A=3, E=x)
All of the above works without PortAudio, without sounddevice,
without any audio setup at all. It's pure Python music theory.
>>> fb.chord("C")['A']
3
>>> fb.fingering(0, 0, 0, 2, 2, 0).identify()
'E minor'
>>> print(fb.tab("Am"))
A minor
e|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--x--
>>> from pytheory import Scale
>>> pentatonic = Scale(tonic="A4", system="blues")["minor pentatonic"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
0 1 2 3 4 5
E| E | - | - | G | - | A |
B| - | C | - | D | - | E |
G| G | - | A | - | - | C |
D| D | - | E | - | - | G |
A| A | - | - | C | - | D |
E| E | - | - | G | - | A |
Audio Playback
--------------
.. code-block:: pycon
>>> from pytheory import Tone, Chord, play, save, Synth
>>> play(Tone.from_string("A4"), t=1_000)
>>> play(Chord.from_name("Am7"), synth=Synth.TRIANGLE, t=2_000)
>>> save(Chord.from_name("C"), "c_major.wav", t=2_000)
Command Line
------------
PyTheory also works from the terminal::
$ pytheory tone A4
$ pytheory chord C E G
$ pytheory key G major
$ pytheory scale C dorian
$ pytheory fingering Am
$ pytheory progression C major I V vi IV
$ pytheory detect C E G A D
$ pytheory play Am7 --synth triangle
What's Included
Compose a Track
---------------
- **6 musical systems**: Western, Indian (Hindustani), Arabic (Maqam),
Japanese, Blues/Pentatonic, Javanese Gamelan
- **40+ scales**: major, minor, harmonic minor, 7 modes, 10 thaats,
10 maqamat, 6 Japanese pentatonic scales, blues, pentatonic,
slendro, pelog, and more
- **Pitch calculation** in equal, Pythagorean, and meantone temperaments
- **Chord identification**: name any chord from its notes, intervals, or
MIDI numbers (17 chord types recognized)
- **Chord charts** with 144 pre-built chords (12 roots x 12 qualities)
- **Chord analysis**: consonance scoring, Plomp-Levelt dissonance,
beat frequency calculation, harmonic tension, voice leading
- **Key detection** and **Roman numeral analysis** (I-IV-V-I progressions)
- **Fingering generation** for 25 instruments with labeled string names,
including guitar (8 tunings), bass, ukulele, mandolin, and more
- **Audio playback** with sine, sawtooth, and triangle wave synthesis
- **WAV export** for saving rendered audio to disk
This is where it gets fun. A ``Score`` is your arrangement — drums,
chords, melody, bass, each with their own synth and effects:
.. code-block:: python
from pytheory import Score, Pattern, Key, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
chords = score.part(
"chords",
synth="fm",
envelope="pad",
reverb=0.4,
)
lead = score.part(
"lead",
synth="saw",
envelope="pluck",
delay=0.3,
lowpass=3000,
humanize=0.2,
)
bass = score.part(
"bass",
synth="sine",
lowpass=500,
)
key = Key("A", "minor")
for chord in key.progression("i", "iv", "V", "i"):
chords.add(chord, Duration.WHOLE)
chords.add(chord, Duration.WHOLE)
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
lead.arpeggio("Dm", bars=2, pattern="updown", octaves=2)
lead.set(lowpass=5000, reverb=0.3)
lead.arpeggio("E7", bars=2, pattern="up", octaves=2)
lead.arpeggio("Am", bars=2, pattern="updown", octaves=2)
for n in ["A2", "E2", "A2", "C3"] * 4:
bass.add(n, Duration.QUARTER)
play_score(score)
Export to Your DAW
------------------
The whole point: sketch in Python, finish in Logic / Ableton / Reaper.
.. code-block:: python
score.save_midi("my_sketch.mid")
Open that file in any DAW and you'll see all the notes laid out on
the timeline, ready to assign to real instruments and mix.
You can also save rendered audio:
.. code-block:: python
from pytheory import save
save(Chord.from_symbol("Am7"), "am7.wav", t=2_000)
What's in the Box
-----------------
**Theory** — tones, scales (40+ across 6 musical systems), chords
(17 types, Roman numeral analysis, figured bass, tension scoring,
voice leading, pitch class sets with Forte numbers), keys (detection,
signatures, modulation paths, borrowed chords), scale recommendation.
**Sequencing** — Score, Part, Duration, TimeSignature. Arpeggiator
with 5 patterns. Legato with pitch glide. Per-note velocity. Swing.
Tempo changes. Fade in/out. Song sections with repeat. Humanize.
**Synthesis** — 10 waveforms: sine, saw, triangle, square, pulse, FM,
noise, supersaw, PWM slow, PWM fast. 8 ADSR envelopes. Detune.
Stereo pan and spread.
**Effects** — distortion, chorus, lowpass filter (with resonance),
delay, reverb (algorithmic + 7 stereo convolution presets including
Taj Mahal with 12-second tail). All per-part with automation and
LFO modulation. Sidechain compression. Master bus compressor/limiter.
**Drums** — 58 pattern presets (rock, jazz, salsa, bossa nova,
afrobeat, house, trap, and 50+ more). 21 fill presets. 27 synthesized
drum voices with stereo panning.
**Instruments** — 25 presets (guitar with 8 tunings, bass, ukulele,
mandolin family, violin family, banjo, harp, oud, sitar, erhu, and
more) with chord fingering generation and scale diagrams.
**Output** — stereo playback, WAV export, MIDI import/export.
**Interface** — REPL with tab completion (``pytheory repl``), CLI with
15 commands. ``pytheory demo``, ``pytheory key``, ``pytheory chord``,
``pytheory identify``, ``pytheory midi``, ``pytheory play``, and more.
Where to Go Next
-----------------
- :doc:`theory` — music theory fundamentals
- :doc:`tones` — working with individual notes
- :doc:`scales` — scales, modes, and keys
- :doc:`chords` — chord construction, analysis, and progressions
- :doc:`sequencing` — composing multi-part arrangements
- :doc:`synths` — the 10 waveforms and 8 envelopes
- :doc:`effects` — reverb, delay, distortion, chorus, lowpass, automation
- :doc:`drums` — 58 patterns, 21 fills, drum synthesis
- :doc:`playback` — play, save, export
+276
View File
@@ -0,0 +1,276 @@
Interactive REPL
================
PyTheory includes an interactive scratchpad for exploring music theory,
hearing ideas instantly, and building arrangements — all without writing
a Python script.
::
$ pytheory repl
The REPL is two things at once: a **theory calculator** (what chords
are in this key? what's the interval between these notes?) and a
**composition sketchpad** (add drums, layer parts, tweak effects, hear
it, export MIDI). Use whichever side you need.
Getting Started
---------------
The welcome screen tells you everything you need::
♫ PyTheory REPL
════════════════════════════════════════
try: key Am — set a key
chords — see its chords
prog I V vi IV — hear a progression
drums bossa nova
play_score — hear it all
help for all commands, quit to exit
Type those five things in order and you'll have music playing in
30 seconds.
The Prompt
----------
The prompt shows your current state — key, tempo, drums, active part,
and effects. It starts compact and grows as you add context::
pytheory[key=C | bpm=120]>
pytheory[key=Am | bpm=140]>
pytheory[key=Am | bpm=140 | drums=bossa nova]>
pytheory[key=Am | bpm=140 | drums=bossa nova | →lead(saw)]>
When it gets long, it stacks into two lines::
key=Am | bpm=140 | drums=bossa nova | →lead(saw) rev=0.3 lp=2000
♫>
You always know where you are.
Theory Commands
---------------
These work without any audio setup. Pure theory exploration.
Set a key and explore it::
pytheory> key Am
A minor: A B C D E F G A
pytheory> chords
i A minor
ii° B diminished
III C major
iv D minor
v E minor
VI F major
VII G major
pytheory> modes
ionian A B C# D E F# G# A
dorian A B C D E F# G A
phrygian A Bb C D E F G A
...
pytheory> scales
major A B C# D E F# G# A
minor A B C D E F G A
harmonic minor A B C D E F G# A
...
Build progressions::
pytheory> prog I V vi IV
Am → Em → F → Dm
pytheory> progression i iv V i
Am → Dm → E → Am
Explore intervals and chords::
pytheory> interval C4 G4
C4 → G4: perfect 5th
7 semitones
pytheory> identify C E G
C major
symbol: C
pytheory> identify F#m7b5
F# half-diminished 7th
symbol: F#m7b5
tones: F#4 A4 C5 E5
intervals: [3, 3, 4]
Circle of fifths::
pytheory> circle
fifths: A → E → B → F# → C# → G# → D# → A# → F → C → G → D
fourths: A → D → G → C → F → A# → D# → G# → C# → F# → B → E
Other musical systems::
pytheory> system indian
system: indian
scales: chromatic, bilawal, khamaj, kafi, ...
pytheory> system arabic
system: arabic
scales: chromatic, ajam, nahawand, kurd, hijaz, ...
Guitar::
pytheory> fingering Am
Am
E|--0--
B|--1--
G|--2--
D|--2--
A|--0--
E|--x--
pytheory> diagram minor 5
0 1 2 3 4 5
E| E | F | - | G | - | A |
...
Composition Commands
--------------------
When you're ready to make sound, add drums and parts.
Drums::
pytheory> drums bossa nova
score.drums("bossa nova", repeats=4)
pytheory> drums
(lists all 58 presets)
Parts — each with its own synth and envelope::
pytheory> part lead saw pluck
score.part("lead", synth="saw", envelope="pluck")
pytheory> part chords fm pad
score.part("chords", synth="fm", envelope="pad")
pytheory> part bass sine pluck
score.part("bass", synth="sine", envelope="pluck")
pytheory> part
lead: synth=saw envelope=pluck vol=0.5 ←
chords: synth=fm envelope=pad vol=0.5
bass: synth=sine envelope=pluck vol=0.5
The arrow (````) shows which part is active. Switch with
``part <name>``.
Add notes, chords, arpeggios::
pytheory> add C5 1
.add("C5", 1.0)
pytheory> add Am 4
.add(Chord.from_symbol("Am"), 4.0)
pytheory> add E5 0.67 110
.add("E5", 0.67, velocity=110)
pytheory> rest 2
.rest(2.0)
pytheory> arp Am updown 2 2
.arpeggio("Am", pattern="updown", bars=2.0, octaves=2)
pytheory> prog i iv V i
Am → Dm → E → Am
Effects
-------
Set effects on the active part — mirrors the Python API::
pytheory> reverb 0.4
pytheory> delay 0.3 0.375
pytheory> lowpass 2000 3
pytheory> dist 0.5
pytheory> chorus 0.3
pytheory> sidechain 0.8
pytheory> humanize 0.3
pytheory> legato on
pytheory> glide 0.04
pytheory> volume 0.4
Automation — change effects mid-song::
pytheory> set lowpass 3000
.set(lowpass=3000)
LFO modulation::
pytheory> lfo lowpass 0.5 400 3000 8 sine
.lfo("lowpass", rate=0.5, min=400, max=3000, bars=8, shape="sine")
Playback and Export
-------------------
Hear your work::
pytheory> play_score
♫ play_score()
pytheory> play_pattern
♫ play_pattern("bossa nova")
Export::
pytheory> save_midi sketch.mid
save_midi("sketch.mid")
pytheory> render sketch.wav
saved: sketch.wav
Session management::
pytheory> show
<Score 4/4 140bpm 3 parts 8.0 measures>
lead: saw+pluck 32 notes reverb=0.3 delay=0.25 ←
chords: fm+pad 8 notes
drums: bossa nova (76 hits)
pytheory> status
key=A minor bpm=140 swing=0.0
drums=bossa nova parts=[lead, chords, bass] active=lead
pytheory> clear
cleared (C major, 120 bpm)
Complete Example
----------------
A full session from start to playable track::
pytheory[key=C | bpm=120]> key Am
pytheory[key=Am | bpm=120]> bpm 140
pytheory[key=Am | bpm=140]> drums bossa nova
pytheory[key=Am | bpm=140 | drums=bossa nova]> part chords fm pad
pytheory[...| →chords(fm)]> prog i iv V i
pytheory[...| →chords(fm)]> part lead saw pluck
pytheory[...| →lead(saw)]> reverb 0.3
pytheory[...| →lead(saw) rev=0.3]> delay 0.25
pytheory[...| →lead(saw) rev=0.3 del=0.25]> arp Am updown 4 2
pytheory[...]> play_score
♫ play_score()
pytheory[...]> save_midi my_bossa.mid
save_midi("my_bossa.mid")
Every command you typed maps 1:1 to the Python API. When you're
ready to move from the REPL to a script, the translation is direct.
+115
View File
@@ -391,3 +391,118 @@ Transpose an entire scale by a number of semitones:
>>> d_major = c_major.transpose(2)
>>> d_major.note_names
['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D']
Degree Names
~~~~~~~~~~~~
Get the traditional function name for any scale degree:
.. code-block:: pycon
>>> major = TonedScale(tonic="C4")["major"]
>>> major.degree_name(0)
'tonic'
>>> major.degree_name(4)
'dominant'
>>> major.degree_name(6)
'leading tone'
>>> major.degree_name(6, minor=True)
'subtonic'
Scale Fitness
~~~~~~~~~~~~~
Score how well a set of notes fits a scale (0.01.0). Useful for melody
analysis or detecting which scale a phrase belongs to:
.. code-block:: pycon
>>> major = TonedScale(tonic="C4")["major"]
>>> major.fitness("C", "D", "E", "G")
1.0
>>> major.fitness("C", "D", "F#", "G")
0.75
Scale Recommendation
~~~~~~~~~~~~~~~~~~~~
Given a melody or set of notes, find the best-matching scales ranked
by fitness. Useful for figuring out what key you're in or finding
alternative scales to improvise over:
.. code-block:: pycon
>>> from pytheory.scales import Scale
>>> Scale.recommend("C", "D", "E", "G", "A", top=3)
[('C', 'major', 1.0), ('A', 'aeolian', 1.0), ...]
>>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb", top=3)
[('C', 'blues', 1.0), ...]
Chromatic scales are deprioritized since they match everything.
Parallel Modes
~~~~~~~~~~~~~~
See all 7 modes that share the same notes as a scale:
.. code-block:: pycon
>>> major = TonedScale(tonic="C4")["major"]
>>> for name, notes in major.parallel_modes().items():
... print(f"{name}: {' '.join(notes)}")
C ionian: C D E F G A B C
D dorian: D E F G A B C D
E phrygian: E F G A B C D E
...
Common Progressions
~~~~~~~~~~~~~~~~~~~
Get all named progressions realized in a key with chord symbols:
.. code-block:: pycon
>>> key = Key("C", "major")
>>> progs = key.common_progressions()
>>> for name, chords in list(progs.items())[:3]:
... symbols = [c.symbol for c in chords]
... print(f"{name}: {' → '.join(symbols)}")
I-IV-V-I: C → F → G → C
I-V-vi-IV: C → G → Am → F
I-vi-IV-V: C → Am → F → G
Chord Suggestions
~~~~~~~~~~~~~~~~~
Given a chord in a key, ``suggest_next()`` returns likely next chords
based on functional harmony voice-leading rules:
.. code-block:: pycon
>>> key = Key("C", "major")
>>> g_major = key.triad(4) # V chord
>>> [c.symbol for c in key.suggest_next(g_major)]
['C', 'Am', 'F']
Modulation
~~~~~~~~~~
``modulation_path()`` suggests a chord-by-chord route from one key to
another, using pivot chords when available:
.. code-block:: pycon
>>> path = Key("C", "major").modulation_path(Key("G", "major"))
>>> [c.symbol for c in path]
['C', 'Em', 'D', 'G']
``pivot_chords()`` shows which chords are shared between two keys:
.. code-block:: pycon
>>> Key("C", "major").pivot_chords(Key("G", "major"))
['A minor', 'B minor', 'C major', 'D major', 'E minor', 'G major']
Scales are the map; the key is the territory. Once you know the landscape, you can wander freely -- and you'll always know how to get home.
+576
View File
@@ -0,0 +1,576 @@
Sequencing
==========
The sequencing system lets you compose multi-part arrangements with
durations, time signatures, and instrument voices. This is where
PyTheory goes from theory tool to composition tool.
At the center of everything is the ``Score``. Think of it as your
arrangement, your song, your sketch pad. It holds the tempo, the time
signature, the drum pattern, and every instrument part you create. If
you've ever used a DAW, the Score is your session file. If you haven't,
it's the sheet of paper where the whole piece lives. Everything you
compose -- melodies, chord progressions, bass lines, arpeggios -- gets
added to a Score before you can hear it, export it, or do anything
useful with it.
Duration
--------
In music, all rhythm boils down to one convention: the quarter note
equals one beat. Everything else is relative to that. A whole note is
four beats. An eighth note is half a beat. This is how musicians have
communicated timing for centuries, and it's how PyTheory works too.
Once you internalize "quarter note = 1 beat," durations become
intuitive arithmetic.
A ``Duration`` represents a note length in beats (quarter note = 1 beat):
.. code-block:: pycon
>>> from pytheory import Duration
>>> Duration.WHOLE.value
4.0
>>> Duration.HALF.value
2.0
>>> Duration.QUARTER.value
1.0
>>> Duration.EIGHTH.value
0.5
>>> Duration.SIXTEENTH.value
0.25
>>> Duration.DOTTED_HALF.value
3.0
>>> Duration.DOTTED_QUARTER.value
1.5
>>> Duration.TRIPLET_QUARTER.value
0.6666666666666666
Time Signatures
---------------
If you're not a musician, time signatures can seem mysterious. They're
not. The top number tells you how many beats are in a bar. The bottom
number tells you which note value gets one beat. That's it.
In practice, you only need to know a handful:
- **4/4** -- four beats per bar. This is the default. Almost all pop,
rock, hip hop, electronic, and R&B music is in 4/4. If you're not
sure, use this.
- **3/4** -- three beats per bar. The waltz feel. Think "Blue Danube"
or Radiohead's "Everything in Its Right Place."
- **6/8** -- six eighth notes per bar, grouped in two sets of three.
Each group feels like one big swaying beat. Folk music, slow jams,
ballads.
- **12/8** -- twelve eighth notes per bar, grouped in four sets of
three. The slow blues shuffle, the gospel feel, "At Last" by Etta
James. Each "big beat" has a triplet swing baked into it.
A ``TimeSignature`` holds the meter of a piece -- how many beats per
measure and which note value gets one beat:
.. code-block:: pycon
>>> from pytheory.rhythm import TimeSignature
>>> ts = TimeSignature.from_string("4/4")
>>> ts.beats_per_measure
4.0
>>> TimeSignature.from_string("3/4").beats_per_measure
3.0
>>> TimeSignature.from_string("6/8").beats_per_measure
3.0
>>> TimeSignature.from_string("12/8").beats_per_measure
6.0
The ``beats_per_measure`` is always in quarter-note units. In 6/8,
there are 6 eighth notes per bar = 3 quarter-note beats. In 12/8,
12 eighth notes = 6 quarter-note beats, grouped in four dotted-quarter
pulses.
Score Basics
------------
A ``Score`` is a sequence of notes and rests with a time signature and
tempo. Use ``.add()`` and ``.rest()`` for fluent chaining:
.. code-block:: python
from pytheory import Score, Duration, Tone
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4", system="western"), Duration.QUARTER)
score.add(Tone.from_string("E4", system="western"), Duration.QUARTER)
score.add(Tone.from_string("G4", system="western"), Duration.HALF)
.. code-block:: pycon
>>> score.total_beats
4.0
>>> score.measures
1.0
>>> score.duration_ms
2000.0
Rests
~~~~~
Add silence with ``.rest()``:
.. code-block:: python
score = Score("4/4", bpm=120)
score.add(Tone.from_string("C4", system="western"), Duration.HALF)
score.rest(Duration.HALF)
.. code-block:: pycon
>>> score.measures
1.0
Chords
~~~~~~
Chords work just like tones — pass any ``Chord`` object:
.. code-block:: python
from pytheory import Score, Duration, Key
key = Key("C", "major")
chords = key.progression("I", "V", "vi", "IV")
score = Score("4/4", bpm=120)
for chord in chords:
score.add(chord, Duration.WHOLE)
.. code-block:: pycon
>>> score.measures
4.0
>>> score.duration_ms
8000.0
Compound Time
~~~~~~~~~~~~~
12/8 is a compound meter — 12 eighth notes per bar grouped in four
groups of three. Each group feels like one "big beat":
.. code-block:: python
from pytheory import Score, Duration, Key
key = Key("A", "minor")
chords = key.random_progression(4)
score = Score("12/8", bpm=120)
for c in chords:
score.add(c, Duration.DOTTED_HALF)
score.add(c, Duration.DOTTED_HALF)
.. code-block:: pycon
>>> score.measures
4.0
Parts
-----
Parts are like tracks in a DAW. Each one has its own instrument sound
(synth waveform + envelope), its own volume level, and its own effects
chain. When you call ``play_score()``, all the parts get mixed together
into a single audio stream -- just like hitting play in Logic or
Ableton. You might have a pad part holding down chords, a lead part
playing a melody, and a bass part holding down the low end. Each one
is independent: different synth, different envelope, different effects.
The ``Part`` class lets you layer multiple instrument voices -- each with
its own synth waveform, ADSR envelope, and volume level. Create parts
with ``Score.part()``:
.. code-block:: python
from pytheory import Score, Key, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=140)
chords = score.part("chords", synth="sine", envelope="pad", volume=0.35)
lead = score.part("lead", synth="saw", envelope="pluck", volume=0.5)
bass = score.part("bass", synth="triangle", envelope="pluck", volume=0.45)
Adding Notes to Parts
~~~~~~~~~~~~~~~~~~~~~
Parts accept note strings directly — no need to wrap in
``Tone.from_string()``. ``.add()`` and ``.rest()`` return self for
fluent chaining:
.. code-block:: python
lead.add("E5", Duration.QUARTER).add("D5", Duration.EIGHTH).rest(Duration.EIGHTH)
Raw float beats work too — useful for swing and tuplets:
.. code-block:: python
lead.add("C5", 0.67).add("B4", 0.33).add("A4", 1.0)
Chords and Tone objects work the same way:
.. code-block:: python
for chord in Key("A", "minor").progression("i", "iv", "V", "i"):
chords.add(chord, Duration.WHOLE)
for note in ["A2", "C3", "E3", "A2", "D2", "F2", "A2", "D2"]:
bass.add(note, Duration.QUARTER)
Arpeggiator
------------
An arpeggiator takes a chord and plays its notes one at a time, in a
pattern, automatically. You hold down a chord and it ripples through
the notes -- up, down, up-and-down, random. It's one of the most
iconic sounds in electronic music. The bubbly bass lines of acid house,
the cascading runs of 80s synth pop (think "Jump" or "Take On Me"),
the hypnotic patterns of trance -- all arpeggiators. It turns a simple
three-note chord into a rhythmic, melodic engine.
``Part.arpeggio()`` takes a chord and sequences through its notes
automatically -- like a hardware arpeggiator on a synth:
.. code-block:: python
lead = score.part(
"lead",
synth="saw",
legato=True,
glide=0.03,
distortion=0.8,
lowpass=1000,
lowpass_q=5.0,
)
lead.arpeggio(
Chord.from_symbol("Cm"),
bars=2,
pattern="up",
division=Duration.SIXTEENTH,
octaves=2,
)
Parameters:
- ``chord``: A Chord object or string like ``"Am"``.
- ``bars``: Number of bars to fill (default 1).
- ``pattern``: ``"up"``, ``"down"``, ``"updown"``, ``"downup"``, ``"random"``.
- ``division``: Step length (default ``Duration.SIXTEENTH``).
- ``octaves``: Octave span (default 1). With 2, the pattern repeats one octave up.
Chain arpeggios through a progression:
.. code-block:: python
for sym in ["Cm", "Fm", "Abm", "Gm"]:
lead.arpeggio(sym, bars=2, pattern="updown", octaves=2)
Combined with legato, glide, distortion, and a resonant lowpass, this
produces the classic acid/trance arpeggiator sound.
Legato and Glide
----------------
Normally, every note you play has its own life cycle -- the sound
attacks, sustains, and releases before the next note begins. You hear
each note as a separate event. Legato changes that. The Italian word
means "tied together," and that's exactly what it does: the envelope
flows continuously from one note to the next with no retriggering. The
pitch changes, but the sound never dies and restarts.
Glide (also called portamento) takes this further. Instead of the pitch
jumping instantly from one note to the next, it *slides* -- a smooth,
continuous pitch sweep. This is THE sound of the Roland TB-303, the
little silver box that accidentally invented acid house. A saw wave
with legato, glide, a resonant lowpass filter, and some distortion --
that's the entire genre right there.
By default, each note gets its own attack/release envelope. ``legato=True``
renders the entire part as one continuous waveform -- the pitch changes
at note boundaries but the envelope flows unbroken. Add ``glide`` for
portamento (pitch slides between notes):
.. code-block:: python
acid = score.part(
"acid",
synth="saw",
envelope="pad",
legato=True,
glide=0.04,
)
acid.add("C2", 0.25).add("C3", 0.25).add("G2", 0.25).add("C2", 0.25)
- ``legato``: If True, no envelope retrigger between notes (default False).
- ``glide``: Portamento time in seconds (default 0, instant).
0.03--0.05 = quick 303 slide, 0.1--0.2 = slow glide.
Complete Example
----------------
A full multi-part arrangement built from scratch — bossa nova with FM
rhodes, triangle lead, and filtered bass:
.. code-block:: python
from pytheory import Score, Pattern, Key, Duration, Chord
from pytheory.play import play_score
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
# FM rhodes with reverb
rhodes = score.part(
"rhodes",
synth="fm",
envelope="piano",
volume=0.3,
reverb=0.4,
reverb_decay=1.8,
)
# Triangle lead with delay
lead = score.part(
"lead",
synth="triangle",
envelope="pluck",
volume=0.45,
delay=0.25,
delay_time=0.32,
delay_feedback=0.35,
reverb=0.2,
)
# Filtered bass
bass = score.part(
"bass",
synth="sine",
envelope="pluck",
volume=0.45,
lowpass=600,
)
for sym in ["Am", "Am", "Dm", "Dm", "E7", "E7", "Am", "Am"]:
rhodes.add(Chord.from_symbol(sym), Duration.WHOLE)
for n, d in [
("E5", 0.67), ("D5", 0.33), ("C5", 0.67), ("B4", 0.33),
("A4", 1), ("C5", 0.67), ("E5", 0.33), ("D5", 0.67), ("C5", 0.33),
("A4", 1),
]:
lead.add(n, d)
for n in ["A2", "E2", "A2", "C3", "D2", "A2", "D2", "F2"]:
bass.add(n, Duration.QUARTER)
play_score(score)
Velocity
--------
Real music has dynamics — accents are louder, ghost notes are barely
there, phrases crescendo and decrescendo. Every note can have its own
velocity (1127, where 100 is the default):
.. code-block:: python
lead.add("C5", Duration.QUARTER, velocity=120) # loud accent
lead.add("D5", Duration.QUARTER, velocity=40) # ghost note
lead.add("E5", Duration.QUARTER) # default (100)
The arpeggiator also accepts velocity:
.. code-block:: python
lead.arpeggio("Am", bars=2, pattern="up", velocity=80)
Swing and Groove
----------------
Perfectly quantized music sounds robotic. Swing delays every other
subdivision by a percentage, giving the rhythm a human, shuffled feel.
Jazz swings hard. Bossa nova swings gently. Hip hop has its own pocket.
Set swing on the Score (applies to everything) or per-Part:
.. code-block:: python
# Triplet swing — lazy jazz feel
score = Score("4/4", bpm=100, swing=0.55)
# Per-part override — the lead swings harder than the bass
lead = score.part("lead", synth="saw", swing=0.6)
bass = score.part("bass", synth="sine", swing=0.4)
Swing values:
- **0.0** = perfectly straight (default)
- **0.3** = subtle shuffle (pop, R&B)
- **0.5** = triplet feel (jazz, blues)
- **0.67** = hard swing (bebop)
Tempo Changes
-------------
Real music doesn't stay at one tempo. Songs speed up for energy,
slow down for endings, and sometimes shift abruptly. Use
``score.set_tempo()`` to change BPM at the current position:
.. code-block:: python
score = Score("4/4", bpm=90)
# Verse: slow and moody
lead.add("D5", Duration.WHOLE)
lead.add("F5", Duration.WHOLE)
# Chorus: speeds up
score.set_tempo(110)
lead.add("A5", Duration.WHOLE)
lead.add("D6", Duration.WHOLE)
# Outro: slows way down
score.set_tempo(70)
lead.add("D5", Duration.WHOLE)
The tempo map engine handles the math — beat positions are converted
to sample positions accounting for every tempo change.
Fades
-----
``Part.fade_in()`` and ``Part.fade_out()`` ramp the volume over a
number of bars. They work by generating automation points, so they
integrate naturally with the rest of the automation system:
.. code-block:: python
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
volume=0.3,
reverb=0.5,
)
# Fade in over first 4 bars
pad.fade_in(bars=4)
for chord in chords:
pad.add(chord, Duration.WHOLE)
# Fade out over last 2 bars
pad.fade_out(bars=2)
pad.rest(Duration.WHOLE)
pad.rest(Duration.WHOLE)
Humanize
--------
Perfectly quantized music sounds like a machine made it — because it
did. Real musicians are never exactly on the beat. Their timing drifts
by a few milliseconds, their velocity varies from note to note. These
imperfections are what make music feel *alive*.
The ``humanize`` parameter adds random micro-variations in both timing
and velocity at render time. The score data stays clean and
deterministic — the randomness is only applied during playback.
.. code-block:: python
# Subtle — like a very tight session player
lead = score.part("lead", synth="saw", humanize=0.1)
# Natural — like a good live take
rhodes = score.part("rhodes", synth="fm", humanize=0.3)
# Loose — like a late-night jam after a few drinks
bass = score.part("bass", synth="sine", humanize=0.5)
Humanize values:
- **0.0** = perfectly quantized (default)
- **0.1** = subtle, studio-tight
- **0.20.3** = natural, like a real player
- **0.40.5** = loose, relaxed, human
- **0.6+** = sloppy (sometimes that's what you want)
Combine with swing for the most realistic feel:
.. code-block:: python
score = Score("4/4", bpm=95, swing=0.45)
lead = score.part(
"lead",
synth="saw",
envelope="pluck",
humanize=0.3,
delay=0.2,
reverb=0.25,
)
Song Structure
--------------
Real songs aren't one long stream of notes — they have verses,
choruses, bridges, drops. The section system lets you name blocks
of your arrangement, then repeat them without rewriting everything.
This is how actual songwriting works: you write a verse, you write
a chorus, then you arrange them — verse, verse, chorus, verse,
chorus, chorus, outro. The sections are the building blocks;
the arrangement is the order you play them in.
Define sections with ``score.section()`` and repeat them with
``score.repeat()``:
.. code-block:: python
score = Score("4/4", bpm=124)
score.drums("house", repeats=16)
pad = score.part("pad", synth="supersaw", envelope="pad")
lead = score.part("lead", synth="saw", envelope="pluck")
bass = score.part("bass", synth="sine", lowpass=300)
# ── Define the verse ──
score.section("verse")
for sym in ["Cm", "Ab", "Eb", "Bb"]:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
lead.add("C5", 1).add("Eb5", 1).rest(2)
for n in ["C1", "C1", "Ab0", "Ab0", "Eb1", "Eb1", "Bb0", "Bb0"]:
bass.add(n, Duration.HALF)
# ── Define the chorus ──
score.section("chorus")
lead.set(lowpass=5000, reverb=0.3)
for sym in ["Cm", "Fm", "Ab", "Gm"]:
pad.add(Chord.from_symbol(sym), Duration.WHOLE)
lead.add("C6", 1).add("Bb5", 1).add("G5", 1).rest(1)
for n in ["C1", "C1", "F1", "F1", "Ab0", "Ab0", "G1", "G1"]:
bass.add(n, Duration.HALF)
score.end_section()
# ── Arrange: verse, chorus, verse, chorus, chorus ──
score.repeat("verse")
score.repeat("chorus")
score.repeat("verse")
score.repeat("chorus", times=2)
Use any names you want — ``"intro"``, ``"verse"``, ``"chorus"``,
``"bridge"``, ``"drop"``, ``"breakdown"``, ``"outro"``, or anything
that makes sense for your song. The names are just labels.
+372
View File
@@ -0,0 +1,372 @@
Synthesizers
============
PyTheory includes 10 built-in waveforms and 8 ADSR envelope presets.
Every sound is generated from scratch -- no samples or external audio
files needed.
Here's the beautiful thing about synthesis: all of it comes from math.
Sine waves, addition, and shaping. That's the entire foundation. Every
legendary synth in history -- the Moog Minimoog, the Sequential Prophet-5,
the Yamaha DX7, the Roland Juno-106, the Roland TB-303 -- uses some
combination of these building blocks. When you choose a waveform in
PyTheory, you're reaching for the same raw materials that defined
decades of music. The difference between a Moog bass and a DX7 bell
isn't magic; it's which waveforms you start with and how you shape them.
Classic Waveforms
-----------------
These four are the fundamentals. Every analog synthesizer ever built
starts here. If you learn nothing else about synthesis, learn these --
they're the primary colors you mix everything else from.
Sine
~~~~
The purest tone possible. Contains only the fundamental frequency with
no harmonics. Sounds smooth, clean, and "electronic." This is the
building block of all other waveforms (Fourier's theorem).
**Use for:** sub bass, clean pads, test tones, blending under other voices.
.. code-block:: python
from pytheory import play, Synth, Tone
tone = Tone.from_string("C4", system="western")
play(tone, synth=Synth.SINE)
Sawtooth
~~~~~~~~
Contains all harmonics (both odd and even), each at amplitude 1/n.
The richest of the classic waveforms — bright, buzzy, and aggressive.
Named for its ramp shape.
**Use for:** leads, brass, pads, anything that needs presence and bite.
.. code-block:: python
play(tone, synth=Synth.SAW)
Triangle
~~~~~~~~
Contains only odd harmonics, each at amplitude 1/n-squared. Softer and
more mellow than sawtooth — somewhere between sine and saw. Often
described as "woody" or "hollow."
**Use for:** flute-like leads, mellow bass, gentle pads.
.. code-block:: python
play(tone, synth=Synth.TRIANGLE)
Square
~~~~~~
Contains only odd harmonics, each at amplitude 1/n. Sounds hollow and
punchy — the classic chiptune / 8-bit sound. A special case of the
pulse wave with a 50% duty cycle.
**Use for:** chiptune, 8-bit game music, hollow leads, sub-octave bass.
.. code-block:: python
play(tone, synth=Synth.SQUARE)
Extended Waveforms
------------------
These go beyond the basics into territory that defined specific
instruments and eras. The pulse wave is the sound of the NES. FM
synthesis is the sound of the 1980s. If the classic waveforms are
primary colors, these are the specific pigments that painters actually
reach for.
Pulse
~~~~~
A pulse wave with a variable duty cycle. Narrower pulses sound thinner
and more nasal. At 50% it equals a square wave; at 10--20% it produces
the classic NES-style buzzy tone.
**Use for:** NES/chiptune sounds, nasal leads, retro textures.
.. code-block:: python
lead = score.part("lead", synth="pulse", envelope="pluck")
FM Synthesis
~~~~~~~~~~~~
Frequency modulation -- one oscillator (the modulator) modulates the
frequency of another (the carrier), producing complex inharmonic
spectra. This is how the Yamaha DX7 works -- the best-selling
synthesizer of all time. Released in 1983, it was suddenly everywhere:
the electric piano in every Whitney Houston ballad, the bass in every
Depeche Mode track, the bells in a thousand TV jingles. If you heard
pop music in the 80s, you heard FM synthesis.
**Use for:** electric piano (rhodes), bells, metallic leads, jazz chords.
.. code-block:: python
rhodes = score.part(
"rhodes",
synth="fm",
envelope="piano",
volume=0.3,
reverb=0.4,
)
Noise
-----
White Noise
~~~~~~~~~~~
Equal energy at all frequencies — pure randomness with no pitch.
Useful as a texture layer, a percussion source, or a wind/ocean effect.
**Use for:** snare layers, hi-hats, wind effects, risers, ambient texture.
.. code-block:: python
texture = score.part(
"texture",
synth="noise",
envelope="pad",
volume=0.1,
lowpass=2000,
)
Ensemble Waveforms
------------------
These all create "bigger" sounds by layering or modulating multiple
oscillators. Where a single sawtooth wave sounds like one instrument,
these sound like a section -- a string ensemble, a choir, a wall of
synths. They're the pad and atmosphere machines, the sounds that fill
out a mix and make it feel wide and immersive.
Supersaw
~~~~~~~~
Seven detuned sawtooth oscillators stacked together. The slight pitch
differences create a shimmering, massive wall of sound. This is the
Roland JP-8000's gift to the world -- the waveform that launched an
entire genre. Every trance anthem from the late 90s and early 2000s,
every euphoric EDM drop, every J-pop power chord owes something to the
supersaw.
**Use for:** trance pads, EDM leads, massive chords, anthem hooks.
.. code-block:: python
pad = score.part(
"pad",
synth="supersaw",
envelope="pad",
volume=0.4,
chorus=0.3,
reverb=0.5,
)
PWM Slow
~~~~~~~~
Pulse width modulation with a slow LFO. The duty cycle sweeps back and
forth, creating a lush, animated pad sound. This is the Roland Juno-106
in a nutshell -- arguably the most recorded synth pad sound in history.
That warm, slowly evolving, slightly chorused wash you hear in everything
from Boards of Canada to Drake? PWM with a slow LFO.
**Use for:** lush analog pads, slow evolving textures, Juno-style warmth.
.. code-block:: python
pad = score.part(
"pad",
synth="pwm_slow",
envelope="pad",
volume=0.35,
reverb=0.4,
)
PWM Fast
~~~~~~~~
Pulse width modulation with a fast LFO. The rapid duty cycle sweep
produces a natural chorus/vibrato effect built into the waveform itself.
**Use for:** animated leads, vibrato textures, movement without effects.
.. code-block:: python
lead = score.part("lead", synth="pwm_fast", envelope="pluck", volume=0.5)
ADSR Envelopes
--------------
Here's a question: a piano and an organ can play the exact same note at
the exact same frequency. Why do they sound completely different? The
answer is the envelope -- the *shape* of the sound over time. A piano
hits hard and immediately starts fading. An organ snaps on at full
volume and stays there until you lift the key. A violin swells in
gradually. The frequency is the same; the envelope is what makes each
instrument feel like itself.
ADSR stands for Attack, Decay, Sustain, Release -- four stages that
describe how any sound's volume changes from the moment you press a key
to the moment it falls silent. Understanding ADSR is the single most
important thing you can learn about synthesis, because it's the
difference between a sound that feels like an instrument and a sound
that feels like a test tone.
Raw waveforms click at the start and end of each note. An ADSR envelope
shapes the amplitude over time for natural-sounding notes:
- **Attack** -- how quickly the sound reaches full volume.
- **Decay** -- how quickly it drops to the sustain level.
- **Sustain** -- the held volume while the note is on.
- **Release** -- how quickly it fades to silence after the note ends.
PyTheory includes 8 presets:
.. code-block:: python
from pytheory import play, Envelope, Tone
tone = Tone.from_string("C4", system="western")
play(tone, envelope=Envelope.PIANO) # Quick attack, natural decay
play(tone, envelope=Envelope.PLUCK) # Sharp attack, fast decay
play(tone, envelope=Envelope.PAD) # Slow fade in, lush sustain
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.STACCATO) # Short and punchy
play(tone, envelope=Envelope.NONE) # Raw waveform, no shaping
Envelope Descriptions
~~~~~~~~~~~~~~~~~~~~~
=============== ================================================
Name Character
=============== ================================================
``"piano"`` Quick attack, natural decay -- acoustic piano feel
``"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
``"strings"`` Gradual bow attack -- orchestral strings, slow
``"staccato"`` Short and punchy -- funk stabs, percussive hits
``"none"`` Raw waveform, no amplitude shaping at all
=============== ================================================
Detune
------
Any synth can be fattened with the ``detune`` parameter — it renders
three oscillators per note: the center pitch plus one shifted up and
one shifted down by the specified number of cents. The slight frequency
differences create beating and width, like an analog synth with
oscillator drift.
.. code-block:: python
# Juno-style analog drift — subtle, warm
pad = score.part("pad", synth="saw", detune=15)
# Trance supersaw territory — wide, shimmery
lead = score.part("lead", synth="saw", detune=25)
# Subtle thickening on a bass
bass = score.part("bass", synth="pulse", detune=8)
# Works on any synth — even FM
bells = score.part("bells", synth="fm", detune=12)
Detune values:
- **510** = subtle thickening (barely noticeable, just warmer)
- **1218** = classic analog drift (Juno, Prophet)
- **2030** = wide and shimmery (trance, EDM)
- **40+** = extreme, almost chorus-like
This is different from the ``chorus`` effect — detune creates
additional oscillators at render time (three per note), while chorus
processes the audio after rendering with a modulated delay line.
Detune is "wider at the source," chorus is "wider after the fact."
Stack both for maximum fatness.
Stereo Placement
----------------
Every part can be placed in the stereo field with ``pan`` and ``spread``.
**Pan** positions a part left or right. Constant-power panning keeps
the perceived loudness even as you move across the field:
.. code-block:: python
rhythm = score.part("rhythm", synth="saw", pan=-0.7) # left
lead = score.part("lead", synth="saw", pan=0.6) # right
bass = score.part("bass", synth="sine", pan=0.0) # center
hats = score.part("hats", synth="noise", pan=0.3) # slightly right
Pan values: -1.0 (hard left), 0.0 (center), 1.0 (hard right).
**Spread** works with ``detune`` — the up-detuned oscillator goes to
the right channel and the down-detuned goes to the left, creating
stereo width at the source:
.. code-block:: python
# Wide pad: detuned + spread across the stereo field
pad = score.part(
"pad",
synth="saw",
detune=20,
spread=1.0, # full L/R separation of detuned voices
reverb=0.4,
)
Spread values: 0.0 (detuned voices stay mono), 1.0 (full L/R split).
Stack with pan to offset the center of the spread.
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.
Choosing Synth and Envelope Combos
----------------------------------
The right combination of synth and envelope defines the character of a
voice. This is where you stop thinking about waveforms and start
thinking about *instruments*. Here are some starting points:
- **Funk stabs:** ``saw`` + ``staccato`` -- bright, punchy, rhythmic.
- **Jazz keys:** ``fm`` + ``bell`` -- glassy DX7 electric piano.
- **Ambient pads:** ``supersaw`` + ``pad`` -- massive, slow-building wash.
- **Acid bass:** ``saw`` + ``pluck`` with lowpass and glide -- 303-style.
- **Chiptune lead:** ``square`` + ``none`` -- raw 8-bit.
- **Film strings:** ``triangle`` + ``strings`` -- soft, bowed, organic.
- **Sub bass:** ``sine`` + ``pluck`` with lowpass -- deep and round.
- **Retro synth:** ``pwm_slow`` + ``pad`` -- Juno-style analog warmth.
- **Percussive hit:** ``noise`` + ``staccato`` with lowpass -- snare layer.
- **E-piano ballad:** ``fm`` + ``piano`` with reverb -- intimate jazz club.
Some practical combos worth memorizing:
- ``saw`` + ``staccato`` + legato = **acid 303 line.** Add a resonant
lowpass and some glide and you're in a warehouse in 1988.
- ``fm`` + ``bell`` = **jazz vibraphone.** The glassy, harmonic-rich
attack with a long ring-out. Add reverb for a late-night club feel.
- ``supersaw`` + ``pad`` = **ambient wash.** The slow attack lets the
detuned oscillators build into a shimmering wall. Add chorus and
long reverb and you're scoring a nature documentary.
- ``saw`` + ``pluck`` = **funk stab.** Short, sharp, bright. The
sound of Nile Rodgers' right hand.
+62 -5
View File
@@ -1,14 +1,21 @@
Musical Systems
===============
PyTheory supports four musical systems, each with its own tone names
and scale patterns.
PyTheory supports **six musical systems**, each with its own tone names,
scale patterns, and centuries of tradition behind them. Every system
maps onto the same 12-tone equal temperament backbone, so you can
compare scales across cultures and even combine them in your own music.
Western
-------
The standard 12-tone equal temperament system with major/minor scales
and all seven modes.
The standard 12-tone equal temperament system — the common language of
European classical music, American popular music, and virtually all
commercially recorded music since the early 20th century. Its
major/minor tonality system, seven diatonic modes, and rich harmonic
vocabulary form the foundation that most listeners around the world
grew up hearing. If you've ever hummed along to a pop song, played
piano, or picked up a guitar, you've been working within this system.
.. code-block:: pycon
@@ -27,6 +34,16 @@ lydian, mixolydian, aeolian, locrian, chromatic
Indian Classical (Hindustani)
-----------------------------
One of the oldest and most sophisticated musical traditions on earth,
`Hindustani classical music <https://en.wikipedia.org/wiki/Hindustani_classical_music>`_
dates back over two thousand years to the *Natya Shastra*. Where Western
music emphasizes harmony (chords), Indian music emphasizes *raga*
melodic frameworks that evoke specific moods, times of day, and seasons.
The sound is meditative, ornamental, and deeply expressive. You'll hear
it in classical sitar and tabla performances, Bollywood film scores,
and the improvisatory traditions that influenced musicians from
George Harrison to John Coltrane.
The Hindustani system uses **swaras** (Sa, Re, Ga, Ma, Pa, Dha, Ni) and
organizes scales into `thaats <https://en.wikipedia.org/wiki/Thaat>`_ — the 10 parent scales from which `ragas <https://en.wikipedia.org/wiki/Raga>`_
are derived.
@@ -58,6 +75,16 @@ poorvi, marwa, todi
Arabic Maqam
------------
The `maqam <https://en.wikipedia.org/wiki/Arabic_maqam>`_ tradition spans
the entire Arab world, Turkey, Iran, and Central Asia — a vast musical
heritage stretching from medieval Andalusia to modern Cairo. Maqam music
is melodically rich, often featuring microtonal inflections, elaborate
ornamentation, and a sense of yearning that's unmistakable once you've
heard it. Think of the oud-driven classical traditions of Umm Kulthum
and Fairuz, the call to prayer, Sufi devotional music, and the
underpinning of much Middle Eastern and North African popular music
today.
The Arabic system uses **solfège-based names** (Do, Re, Mi, Fa, Sol, La, Si)
and organizes scales into **maqamat** (plural of `maqam <https://en.wikipedia.org/wiki/Maqam>`_).
@@ -88,6 +115,17 @@ sikah, jiharkah
Japanese
--------
Japan's traditional scales have a hauntingly beautiful quality that is
immediately recognizable — dark, sparse, and full of tension. The
pentatonic scales (especially *hirajoshi* and *in*) use semitone steps
that give them an unmistakably Japanese character, distinct from the
wider-spaced pentatonics found in Chinese and Western folk music.
You'll hear these scales in `koto <https://en.wikipedia.org/wiki/Koto_(instrument)>`_
and `shamisen <https://en.wikipedia.org/wiki/Shamisen>`_ music, the
`gagaku <https://en.wikipedia.org/wiki/Gagaku>`_ court orchestra,
Kabuki and Noh theater, taiko drumming, anime and video game
soundtracks, and the compositions of Toru Takemitsu.
The Japanese system uses Western note names with traditional pentatonic
and heptatonic scales from Japanese music.
@@ -116,6 +154,13 @@ and heptatonic scales from Japanese music.
Blues and Pentatonic
--------------------
The blues is America's deepest musical root — born from the African
American experience in the Mississippi Delta, it gave rise to jazz,
rock and roll, R&B, soul, funk, and hip-hop. The *blue note* (a
flattened 5th that bends between major and minor) is the sound of
emotional truth in music, from Robert Johnson to B.B. King to
Jimi Hendrix.
The blues system provides the scales foundational to blues, rock, jazz,
and folk music worldwide. `Pentatonic scales <https://en.wikipedia.org/wiki/Pentatonic_scale>`_ (5 notes) are the oldest
known musical scales, found independently in cultures across every
@@ -154,7 +199,17 @@ minor (Dorian — the jazz minor sound)
Javanese Gamelan
----------------
The `gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ system approximates the scales of the Javanese and Balinese
`Gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ is the shimmering,
interlocking percussion orchestra of Java and Bali — one of the most
otherworldly sounds in all of music. Ensembles of bronze metallophones,
gongs, drums, and bamboo flutes create waves of resonance that
influenced Claude Debussy, Steve Reich, and countless ambient and
electronic artists. Each gamelan is tuned uniquely, so no two
ensembles sound exactly alike. The music is communal, ceremonial, and
deeply tied to Javanese and Balinese culture — it accompanies
shadow puppet theater (*wayang*), dance, and religious ritual.
The gamelan system approximates the scales of the Javanese and Balinese
gamelan orchestra in 12-tone equal temperament. True gamelan tuning is
unique to each ensemble and does not conform to Western intonation —
these are the closest 12-TET approximations.
@@ -215,3 +270,5 @@ produce the same pitches:
261.6255653005986
>>> do4.frequency
261.6255653005986
Music is universal, but every culture hears it differently. These systems are different maps of the same territory -- explore one you've never played in before and see what you find.
+17 -1
View File
@@ -2,7 +2,11 @@ Music Theory Fundamentals
=========================
This page covers the essential concepts of music theory — the framework
behind everything PyTheory does.
behind everything PyTheory does. Don't worry if you're new to this:
music theory isn't a set of rules you have to memorize, it's a
vocabulary for describing what you already hear. Every concept below
connects to something you've felt while listening to music — this page
just gives it a name.
Sound and Pitch
---------------
@@ -314,6 +318,16 @@ paired instruments as a core aesthetic.
>>> g7.tension['has_dominant_function']
True
From Theory to Composition
--------------------------
Everything on this page — tones, intervals, chords, scales, keys — is
the foundation. But PyTheory goes further: you can use these building
blocks to compose and play actual music. See the :doc:`sequencing`
guide to learn how to arrange multi-part scores with melodies, chord
pads, bass lines, drum patterns, and audio effects — all driven by the
theory concepts you've just learned.
Further Reading
---------------
@@ -327,3 +341,5 @@ Further Reading
- `Gamelan <https://en.wikipedia.org/wiki/Gamelan>`_ — Indonesian ensemble music
- `Blues <https://en.wikipedia.org/wiki/Blues>`_ — the foundation of American popular music
- `Twelve-bar blues <https://en.wikipedia.org/wiki/Twelve-bar_blues>`_ — the most common blues form
Theory is just a vocabulary for what you already hear. You don't need it to make music -- but once you have the words, you can talk about what you're doing, understand why it works, and find new places to go.
+51
View File
@@ -377,3 +377,54 @@ to the starting note:
Each step clockwise adds one sharp to the key signature; each step
counter-clockwise (ascending by fourths = 5 semitones) adds one flat.
Solfege
-------
The fixed-Do `solfege <https://en.wikipedia.org/wiki/Solf%C3%A8ge>`_ system
maps each note to a singable syllable. PyTheory uses fixed Do (C is always Do):
.. code-block:: pycon
>>> Tone.from_string("C4").solfege
'Do'
>>> Tone.from_string("D4").solfege
'Re'
>>> Tone.from_string("F#4").solfege
'Fi'
>>> Tone.from_string("Bb4").solfege
'Te'
Helmholtz Notation
------------------
The older `Helmholtz notation <https://en.wikipedia.org/wiki/Helmholtz_pitch_notation>`_
uses case and tick marks instead of numbers:
.. code-block:: pycon
>>> Tone.from_string("C3").helmholtz # Great octave
'C'
>>> Tone.from_string("C4").helmholtz # Middle C
'c'
>>> Tone.from_string("C5").helmholtz # One-line octave
"c'"
>>> Tone.from_string("C2").helmholtz # Contra octave
'CC'
Cents
-----
A **cent** is 1/100th of a semitone — the standard unit for measuring
fine pitch differences. Use ``cents_difference`` to compare tones or
temperaments:
.. code-block:: pycon
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.cents_difference(c4 + 1) # One semitone = 100 cents
100.0
>>> c4.cents_difference(c4 + 7) # Perfect fifth
700.0
Tones are the atoms of music -- everything else is built from them. Get comfortable here, and chords, scales, and harmony all start to make intuitive sense.
+80 -53
View File
@@ -1,79 +1,92 @@
PyTheory: Music Theory for Humans
=================================
**PyTheory** is a Python library that makes exploring music theory
approachable and fun. Work with tones, scales, chords, keys, and
instruments using a clean, Pythonic API.
**PyTheory** is a Python library for exploring music theory, composing
multi-part arrangements, and exporting them to MIDI for your DAW.
Use it to learn theory by doing — build chords from intervals and hear
the result. Use it to sketch song ideas faster than clicking through a
DAW. Use it with Claude Code to prototype
music from natural language. Or just use it to answer "what chords are
in G major?" without opening a browser.
::
$ pip install pytheory
Theory
------
The theory layer works everywhere Python runs — no audio setup needed.
Tones, scales, chords, keys, intervals, harmony, 6 musical systems,
25 instruments:
.. code-block:: pycon
>>> from pytheory import Key, Chord, Tone, Scale, Fretboard
>>> from pytheory import Key, Chord, Tone
>>> key = Key("C", "major")
>>> key.chords
['C major', 'D minor', 'E minor', 'F major',
'G major', 'A minor', 'B diminished']
>>> Key("C", "major").chords
['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished']
>>> [c.identify() for c in key.progression("I", "V", "vi", "IV")]
['C major', 'G major', 'A minor', 'F major']
>>> [c.symbol for c in Key("G", "major").progression("I", "V", "vi", "IV")]
['G', 'D', 'Em', 'C']
>>> Chord.from_tones("Bb", "D", "F").identify()
'Bb major'
>>> Chord.from_symbol("F#m7b5").identify()
'F# half-diminished 7th'
>>> c4 = Tone.from_string("C4", system="western")
>>> c4.interval_to(c4 + 7)
>>> Tone.from_string("C4").interval_to(Tone.from_string("G4"))
'perfect 5th'
>>> fb = Fretboard.guitar()
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
Composition
-----------
>>> pentatonic = Scale(tonic="A4", system="blues")["minor pentatonic"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
0 1 2 3 4 5
E| E | - | - | G | - | A |
B| - | C | - | D | - | E |
G| G | - | A | - | - | C |
D| D | - | E | - | - | G |
A| A | - | - | C | - | D |
E| E | - | - | G | - | A |
When you're ready to make noise, the composition layer adds drums,
synths, effects, and multi-part arrangements. Sketch an idea, hear
it through your speakers, export MIDI, finish in your DAW:
Highlights
----------
.. code-block:: python
- **Tones**: frequencies, MIDI, intervals, transposition, circle of fifths,
overtone series, 3 temperaments (equal, Pythagorean, meantone)
- **Scales**: 40+ scales across 6 musical systems — Western, Indian,
Arabic, Japanese, Blues, Javanese Gamelan
- **Chords**: 17 chord types identified automatically, Roman numeral
analysis, tension scoring, voice leading, consonance/dissonance
- **Keys**: key detection, signatures, progressions (Roman numerals and
Nashville numbers), borrowed chords, secondary dominants
- **Instruments**: 25 presets (guitar, bass, ukulele, mandolin, violin,
banjo, oud, sitar, erhu, and more) with fingering generation
- **Audio**: sine, sawtooth, and triangle wave playback + WAV export
from pytheory import Score, Pattern, Key, Duration, Chord
from pytheory.play import play_score
It also works from the command line::
score = Score("4/4", bpm=140)
score.drums("bossa nova", repeats=4)
$ pytheory key G major
Key: G major
Signature: 1 sharps, 0 flats (F#)
Scale: G A B C D E F# G
...
chords = score.part("chords", synth="fm", envelope="pad", reverb=0.4)
lead = score.part("lead", synth="saw", envelope="pluck", delay=0.3)
bass = score.part("bass", synth="sine", lowpass=500)
$ pytheory chord C E G
Chord: C major
Tones: C4 E4 G4
Intervals: [4, 3]
...
for chord in Key("A", "minor").progression("i", "iv", "V", "i"):
chords.add(chord, Duration.WHOLE)
$ pytheory play Am7 --synth triangle
Playing: A minor 7th (A4 C4 E4 G4)
Synth: triangle
lead.arpeggio("Am", bars=4, pattern="updown", octaves=2)
play_score(score)
score.save_midi("sketch.mid")
Or hear a randomly generated track from the command line — different
every time::
$ pytheory demo
What's Inside
-------------
- **Theory** — tones, scales (40+ across 6 systems), chords (17 types),
keys, Roman numeral analysis, figured bass, pitch class sets (Forte
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
- **Effects** — reverb (algorithmic + 7 convolution IRs, stereo), delay,
lowpass (with resonance), distortion, chorus, 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``
- **AI-friendly** — Claude Code can compose
and play music through PyTheory from natural language
.. toctree::
:maxdepth: 2
@@ -86,7 +99,12 @@ It also works from the command line::
guide/chords
guide/fretboard
guide/systems
guide/sequencing
guide/synths
guide/effects
guide/drums
guide/playback
guide/repl
guide/cli
guide/cookbook
@@ -100,3 +118,12 @@ It also works from the command line::
api/charts
api/play
api/systems
.. toctree::
:maxdepth: 1
:caption: Project
changelog.md
Music is math that makes you feel something. PyTheory gives you the
math. What you feel is up to you.
-216
View File
@@ -1,216 +0,0 @@
"""Play melodies and chord progressions with PyTheory.
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
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_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.")
# ── Songs ───────────────────────────────────────────────────────────────
def twinkle_twinkle():
"""Twinkle Twinkle Little Star — C major."""
print("Twinkle Twinkle Little Star")
print("=" * 40)
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),
]
play_melody(melody)
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__":
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!")
+335
View File
@@ -0,0 +1,335 @@
"""PyTheory Showoff — a generative composition that's different every time.
Demonstrates every feature of the library in one elegant piece:
- Key detection and modulation
- Chord progressions with Roman numerals
- 58 drum patterns with genre-matched fills
- 10 synth waveforms and 8 envelope presets
- Arpeggiator with legato and glide
- Per-part effects: reverb, delay, lowpass filter, distortion
- Random but musically coherent always sounds good, never repeats
Usage:
python examples/song_showoff.py
"""
import random
import sounddevice as sd
from pytheory import Chord, Key, Pattern, Duration, Score
from pytheory.play import render_score, SAMPLE_RATE
# ── Musical building blocks ────────────────────────────────────────────────
MOODS = {
"dark": {
"keys": [("D", "minor"), ("A", "minor"), ("E", "minor"),
("B", "minor"), ("G", "minor"), ("C", "minor")],
"progressions": [
("i", "iv", "V", "i"),
("i", "bVI", "bVII", "i"),
("i", "iv", "bVI", "V"),
("i", "bVII", "bVI", "V"),
],
"drums": ["afrobeat", "dub", "trap", "reggae", "techno",
"hip hop", "drum and bass"],
"fills": ["afrobeat", "trap", "buildup", "breakdown"],
"tempo_range": (70, 140),
},
"bright": {
"keys": [("C", "major"), ("G", "major"), ("D", "major"),
("A", "major"), ("F", "major"), ("Bb", "major")],
"progressions": [
("I", "V", "vi", "IV"),
("I", "IV", "V", "I"),
("I", "vi", "ii", "V"),
("I", "IV", "vi", "V"),
],
"drums": ["bossa nova", "samba", "funk", "disco", "gospel",
"ska", "swing", "country"],
"fills": ["rock", "funk", "samba", "buildup"],
"tempo_range": (100, 170),
},
"ethereal": {
"keys": [("Eb", "minor"), ("Ab", "minor"), ("F", "minor"),
("Bb", "minor"), ("Db", "major"), ("Gb", "major")],
"progressions": [
("I", "V", "vi", "IV"),
("i", "bVI", "bIII", "bVII"),
("i", "iv", "V", "i"),
("I", "vi", "IV", "V"),
],
"drums": ["jazz", "waltz", "house", "bebop", "bolero"],
"fills": ["jazz", "jazz brush", "house", "bossa nova"],
"tempo_range": (68, 120),
},
"aggressive": {
"keys": [("E", "minor"), ("A", "minor"), ("D", "minor"),
("B", "minor"), ("F#", "minor"), ("C", "minor")],
"progressions": [
("i", "bVII", "bVI", "V"),
("i", "iv", "V", "i"),
("i", "bVI", "bVII", "i"),
("i", "V", "bVI", "iv"),
],
"drums": ["metal", "punk", "drum and bass", "jungle",
"breakbeat", "techno"],
"fills": ["metal", "blast", "rock crash", "buildup"],
"tempo_range": (130, 180),
},
}
SYNTH_PALETTES = [
# (lead_synth, pad_synth, bass_synth, arp_synth)
("saw", "supersaw", "sine", "saw"),
("triangle", "pwm_slow", "sine", "fm"),
("fm", "supersaw", "pulse", "saw"),
("square", "pwm_slow", "sine", "square"),
("saw", "pwm_fast", "pulse", "fm"),
("triangle", "supersaw", "sine", "saw"),
]
ARP_PATTERNS = ["up", "down", "updown", "downup"]
# ── The generator ──────────────────────────────────────────────────────────
def generate():
"""Generate a unique composition. Different every time."""
# Pick a mood
mood_name = random.choice(list(MOODS.keys()))
mood = MOODS[mood_name]
# Pick a key
tonic, mode = random.choice(mood["keys"])
key = Key(tonic, mode)
# Pick a progression
numerals = random.choice(mood["progressions"])
# Pick tempo
bpm = random.randint(*mood["tempo_range"])
# Pick drum pattern and fill
drum_preset = random.choice(mood["drums"])
fill_preset = random.choice(mood["fills"])
# Pick synth palette
lead_synth, pad_synth, bass_synth, arp_synth = random.choice(SYNTH_PALETTES)
# Pick time signature based on drum pattern
waltz_patterns = {"waltz", "bolero"}
time_sig = "3/4" if drum_preset in waltz_patterns else "4/4"
bars_per_chord = 2
total_bars = bars_per_chord * len(numerals) * 2 # play progression twice
# Determine repeats based on time signature
pattern_obj = Pattern.preset(drum_preset)
beats_per_bar = 3.0 if time_sig == "3/4" else 4.0
pattern_bars = pattern_obj.beats / beats_per_bar
drum_repeats = max(1, int(total_bars / pattern_bars))
# ── Build the score ──────────────────────────────────────────
score = Score(time_sig, bpm=bpm)
score.drums(drum_preset, repeats=drum_repeats,
fill=fill_preset, fill_every=len(numerals) * bars_per_chord)
# Effect amounts scale with mood intensity
reverb_amount = {"dark": 0.35, "bright": 0.25,
"ethereal": 0.55, "aggressive": 0.2}[mood_name]
delay_amount = {"dark": 0.3, "bright": 0.15,
"ethereal": 0.35, "aggressive": 0.15}[mood_name]
dist_amount = {"dark": 0.3, "bright": 0.0,
"ethereal": 0.0, "aggressive": 0.7}[mood_name]
filter_cutoff = {"dark": 2500, "bright": 5000,
"ethereal": 2000, "aggressive": 3500}[mood_name]
# Pad — lush background
pad = score.part("pad", synth=pad_synth, envelope="pad",
volume=0.2,
reverb=min(0.8, reverb_amount * 2),
reverb_decay=random.uniform(2.0, 4.0),
lowpass=filter_cutoff)
# Lead melody
lead_envelope = random.choice(["pluck", "strings", "piano"])
lead = score.part("lead", synth=lead_synth, envelope=lead_envelope,
volume=0.4,
reverb=reverb_amount,
reverb_decay=random.uniform(1.0, 2.0),
delay=delay_amount,
delay_time=random.choice([0.25, 0.375, 0.5]) * 60 / bpm,
delay_feedback=random.uniform(0.25, 0.45),
lowpass=filter_cutoff,
distortion=dist_amount * 0.3,
distortion_drive=random.uniform(2.0, 5.0))
# Arp layer
arp_pattern = random.choice(ARP_PATTERNS)
arp_octaves = random.choice([1, 2])
arp_division = random.choice([Duration.EIGHTH, Duration.SIXTEENTH])
use_legato = random.random() > 0.4
glide_time = random.uniform(0.02, 0.06) if use_legato else 0.0
arp = score.part("arp", synth=arp_synth,
envelope="staccato" if not use_legato else "pad",
volume=0.3,
legato=use_legato, glide=glide_time,
distortion=dist_amount,
distortion_drive=random.uniform(3.0, 10.0),
lowpass=random.uniform(800, 2000),
lowpass_q=random.uniform(1.0, 5.0),
delay=delay_amount * 0.7,
delay_time=random.uniform(0.15, 0.3),
delay_feedback=random.uniform(0.2, 0.4))
# Bass
bass = score.part("bass", synth=bass_synth, envelope="pluck",
volume=0.5,
lowpass=random.uniform(300, 600),
lowpass_q=random.uniform(1.0, 1.8),
distortion=dist_amount * 0.5,
distortion_drive=random.uniform(2.0, 4.0))
# Bells / texture — sparse accents
bells = score.part("bells", synth="fm", envelope="bell",
volume=0.15,
reverb=min(0.7, reverb_amount * 2.5),
reverb_decay=random.uniform(2.0, 4.0),
delay=0.2,
delay_time=random.uniform(0.3, 0.8),
delay_feedback=random.uniform(0.25, 0.4))
# ── Compose the parts ────────────────────────────────────────
chords = key.progression(*numerals)
scale = key.scale
# Get scale tones for melody generation
scale_tones = [t.name for t in scale.tones[:-1]]
for pass_num in range(2):
for i, chord in enumerate(chords):
chord_dur = Duration.DOTTED_HALF if time_sig == "3/4" else Duration.WHOLE
# Pad: whole notes
for _ in range(bars_per_chord):
pad.add(chord, chord_dur)
# Arp: arpeggiate the chord
arp.arpeggio(chord, bars=bars_per_chord,
pattern=arp_pattern, division=arp_division,
octaves=arp_octaves)
# Bass: root note pattern
root = chord.root
if root:
bass_note = f"{root.name}2"
fifth = root.add(7)
bass_fifth = f"{fifth.name}2"
if time_sig == "3/4":
for _ in range(bars_per_chord):
bass.add(bass_note, Duration.QUARTER)
bass.add(bass_fifth, Duration.QUARTER)
bass.add(bass_note, Duration.QUARTER)
else:
for _ in range(bars_per_chord):
bass.add(bass_note, Duration.QUARTER)
bass.add(bass_note, Duration.QUARTER)
bass.add(bass_fifth, Duration.QUARTER)
bass.add(bass_note, Duration.QUARTER)
# Lead: generate a melodic phrase from scale tones
chord_tones = [t.name for t in chord.tones]
beats_remaining = bars_per_chord * beats_per_bar
while beats_remaining > 0.5:
# Pick a note — prefer chord tones, allow scale tones
if random.random() < 0.6:
note_name = random.choice(chord_tones)
else:
note_name = random.choice(scale_tones)
octave = random.choice([4, 5]) if pass_num == 0 else random.choice([5, 5, 6])
# Pick a duration
dur_choices = [0.5, 0.67, 1.0, 1.5, 2.0]
dur = random.choice([d for d in dur_choices if d <= beats_remaining])
# Occasional rest for breathing room
if random.random() < 0.2:
rest_dur = random.choice([0.5, 1.0])
rest_dur = min(rest_dur, beats_remaining)
lead.rest(rest_dur)
beats_remaining -= rest_dur
if beats_remaining <= 0:
break
lead.add(f"{note_name}{octave}", dur)
beats_remaining -= dur
# Bells: sparse accents on chord tones
bell_beats = bars_per_chord * beats_per_bar
bell_pos = 0
while bell_pos < bell_beats:
if random.random() < 0.25:
note_name = random.choice(chord_tones)
bells.add(f"{note_name}6", random.choice([1.5, 2.0, 3.0]))
bell_pos += 3
else:
bells.rest(random.choice([1.0, 2.0]))
bell_pos += 2
return score, {
"mood": mood_name,
"key": f"{tonic} {mode}",
"progression": "".join(numerals),
"bpm": bpm,
"time_sig": time_sig,
"drums": f"{drum_preset} + {fill_preset} fill",
"lead": f"{lead_synth} ({lead_envelope})",
"arp": f"{arp_synth} {'legato+glide' if use_legato else 'staccato'} {arp_pattern} {arp_octaves}oct",
"pad": pad_synth,
"bass": bass_synth,
}
# ── Main ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
try:
print()
print(" PyTheory Showoff")
print(" " + "=" * 50)
print(" Generative composition — different every time")
print()
while True:
score, info = generate()
print(f" ♫ Mood: {info['mood']}")
print(f" Key: {info['key']}")
print(f" Progression: {info['progression']}")
print(f" Tempo: {info['bpm']} bpm ({info['time_sig']})")
print(f" Drums: {info['drums']}")
print(f" Lead: {info['lead']}")
print(f" Arp: {info['arp']}")
print(f" Pad: {info['pad']} | Bass: {info['bass']}")
print(f" {score}")
print()
buf = render_score(score)
sd.play(buf, SAMPLE_RATE)
sd.wait()
print()
again = input(" Play another? (y/n): ").strip().lower()
if again != "y":
break
print()
except KeyboardInterrupt:
sd.stop()
print("\n\n")
+1169
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.8.2"
version = "0.30.0"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
@@ -38,7 +38,7 @@ pytheory = "pytheory.cli:main"
[dependency-groups]
dev = ["pytest"]
docs = ["sphinx"]
docs = ["sphinx", "myst-parser"]
[build-system]
requires = ["setuptools"]
+10 -9
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.8.2"
__version__ = "0.30.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
@@ -8,13 +8,11 @@ from .scales import TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
try:
from .play import play, save, play_progression, Synth
except OSError:
play = None
save = None
play_progression = None
Synth = None
from .rhythm import Duration, TimeSignature, Rest, Score, Part, Section, DrumSound, Pattern
from .rhythm import Note as RhythmNote # rhythm.Note (tone + duration pairing)
from .play import (play, save, save_midi, play_progression, play_pattern,
play_score, render_score, Synth, Envelope)
# Aliases for discoverability.
Note = Tone
@@ -24,5 +22,8 @@ __all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "save", "play_progression", "Synth",
"play", "save", "save_midi", "play_progression", "play_pattern",
"play_score", "Synth", "Envelope",
"Duration", "TimeSignature", "RhythmNote", "Rest", "Score", "Part",
"DrumSound", "Pattern", "Section",
]
+5
View File
@@ -1,6 +1,11 @@
from pytuning import scales
REFERENCE_A = 440
# Index of C in the Western tone list (A=0, A#=1, B=2, C=3, ...).
# Scientific pitch notation changes octave at C, not A, so this offset
# is needed for all octave arithmetic.
C_INDEX = 3
TEMPERAMENTS = {
"equal": scales.create_edo_scale,
"pythagorean": scales.create_pythagorean_scale,
+682 -27
View File
@@ -90,6 +90,115 @@ class Chord:
from .tones import Tone
return cls(tones=[Tone.from_midi(n) for n in note_numbers])
# ── Symbol parsing ────────────────────────────────────────────────
# Maps chord suffix patterns to semitone interval tuples from root.
_SYMBOL_INTERVALS = {
# Triads
"maj": (4, 7),
"m": (3, 7),
"min": (3, 7),
"dim": (3, 6),
"aug": (4, 8),
"+": (4, 8),
"sus2": (2, 7),
"sus4": (5, 7),
"5": (7,),
# Seventh chords
"maj7": (4, 7, 11),
"M7": (4, 7, 11),
"m7": (3, 7, 10),
"min7": (3, 7, 10),
"7": (4, 7, 10),
"dom7": (4, 7, 10),
"dim7": (3, 6, 9),
"m7b5": (3, 6, 10),
"mMaj7": (3, 7, 11),
"aug7": (4, 8, 10),
# Ninth chords
"9": (4, 7, 10, 14),
"maj9": (4, 7, 11, 14),
"m9": (3, 7, 10, 14),
"min9": (3, 7, 10, 14),
# Sixth chords
"6": (4, 7, 9),
"m6": (3, 7, 9),
# Add chords
"add9": (4, 7, 14),
"add11": (4, 7, 17),
# Eleventh / thirteenth
"11": (4, 7, 10, 14, 17),
"13": (4, 7, 10, 14, 17, 21),
}
# Root note names — try longest match first (e.g. "C#" before "C").
_ROOT_NAMES = [
"A#", "Ab", "A", "Bb", "B", "C#", "Cb", "C",
"D#", "Db", "D", "Eb", "E", "F#", "Fb", "F",
"G#", "Gb", "G",
]
@classmethod
def from_symbol(cls, symbol: str, octave: int = 4) -> Chord:
"""Create a Chord by parsing a standard chord symbol.
Parses symbols like ``"Cmaj7"``, ``"F#m7b5"``, ``"Bbdim"``,
``"Gsus4"``, ``"Dadd9"`` any root note followed by a quality
suffix. Unlike ``from_name()``, this doesn't rely on a lookup
table and can handle any combination.
Args:
symbol: A chord symbol string (e.g. ``"Am7"``, ``"Ebmaj9"``).
octave: The octave for the root note (default 4).
Returns:
A new :class:`Chord` instance.
Raises:
ValueError: If the symbol can't be parsed.
Example::
>>> Chord.from_symbol("C").identify()
'C major'
>>> Chord.from_symbol("F#m7b5").identify()
'F# half-diminished 7th'
>>> Chord.from_symbol("Bbmaj7").symbol
'Bbmaj7'
"""
from .tones import Tone
# Parse root note
root_name = None
suffix = symbol
for name in cls._ROOT_NAMES:
if symbol.startswith(name):
root_name = name
suffix = symbol[len(name):]
break
if root_name is None:
raise ValueError(f"Cannot parse root note from: {symbol!r}")
# Empty suffix or just "maj" = major triad
if suffix == "" or suffix == "M":
intervals = (4, 7)
else:
# Try longest suffix match first
intervals = None
for length in range(len(suffix), 0, -1):
candidate = suffix[:length]
if candidate in cls._SYMBOL_INTERVALS:
intervals = cls._SYMBOL_INTERVALS[candidate]
break
if intervals is None:
raise ValueError(
f"Unknown chord quality: {suffix!r} in {symbol!r}")
root = Tone.from_string(f"{root_name}{octave}", system="western")
tones = [root] + [root.add(i) for i in intervals]
return cls(tones=tones)
def __repr__(self) -> str:
name = self.identify()
if name:
@@ -186,6 +295,151 @@ class Chord:
result._identify_cache = None
return result
def close_voicing(self) -> Chord:
"""Rearrange tones so they are packed within one octave ascending from root.
All tones are brought into the same octave as the root and sorted
ascending by pitch class.
Example::
>>> Chord.from_symbol("C").inversion(2).close_voicing().identify()
'C major'
"""
if not self.tones:
return Chord(tones=[])
root = self.tones[0]
root_octave = root.octave or 4
result = [root]
for t in self.tones[1:]:
# Bring into root octave, above root
interval = (t - root) % 12
if interval == 0:
interval = 12
new_tone = root.add(interval)
result.append(new_tone)
# Sort by interval from root (skip root itself)
result = [result[0]] + sorted(result[1:], key=lambda t: (t - root) % 12)
return Chord(tones=result)
def open_voicing(self) -> Chord:
"""Spread tones across two octaves by moving alternating tones up an octave.
Starting from close voicing, every other non-root tone (indices 1, 3, ...)
is raised by an octave, creating a wider, more open sound.
Example::
>>> c = Chord.from_symbol("Cmaj7").open_voicing()
>>> len(c.tones)
4
"""
closed = self.close_voicing()
tones = list(closed.tones)
for i in range(1, len(tones)):
if i % 2 == 1:
tones[i] = tones[i].add(12)
return Chord(tones=tones)
def drop2(self) -> Chord:
"""Drop-2 voicing: take the second-highest voice and drop it down an octave.
A standard jazz guitar voicing technique that creates wider spacing
between voices while maintaining harmonic function.
Example::
>>> Chord.from_symbol("Cmaj7").drop2()
<Chord C major 7th>
"""
closed = self.close_voicing()
tones = list(closed.tones)
if len(tones) < 2:
return Chord(tones=tones)
# Second-highest is index -2
dropped = tones[-2].add(-12)
new_tones = [dropped] + tones[:-2] + [tones[-1]]
return Chord(tones=new_tones)
def drop3(self) -> Chord:
"""Drop-3 voicing: take the third-highest voice and drop it down an octave.
Creates an even wider voicing than drop-2. Common in big band
arranging and guitar chord melody.
Example::
>>> Chord.from_symbol("Cmaj7").drop3()
<Chord C major 7th>
"""
closed = self.close_voicing()
tones = list(closed.tones)
if len(tones) < 3:
return Chord(tones=tones)
# Third-highest is index -3
dropped = tones[-3].add(-12)
new_tones = [dropped] + tones[:-3] + tones[-2:]
return Chord(tones=new_tones)
def extensions(self, scale=None) -> list:
"""Suggest available chord extensions (9th, 11th, 13th).
If a scale is provided, extensions are checked against the scale.
Otherwise, extensions are checked to be at least a whole step from
existing chord tones (the "avoid note" rule).
Args:
scale: Optional Scale object to check extensions against.
Returns:
A list of Tone objects representing valid extensions.
Example::
>>> Chord.from_symbol("C").extensions()
[<Tone D5>, <Tone A5>]
"""
from .tones import Tone
if not self.tones:
return []
root = self.tones[0]
# Extension intervals from root in semitones
ext_intervals = {
"9th": 14, # major 9th
"11th": 17, # perfect 11th
"13th": 21, # major 13th
}
chord_pcs = set()
for t in self.tones:
chord_pcs.add((t - root) % 12)
result = []
for name, interval in ext_intervals.items():
ext_tone = root.add(interval)
ext_pc = interval % 12
if scale is not None:
# Check if the extension is in the scale
scale_names = [st.name for st in scale.tones]
if ext_tone.name in scale_names:
result.append(ext_tone)
else:
# "Avoid note" rule: extension must be at least 2 semitones
# from every existing chord tone (pitch class)
is_available = True
for pc in chord_pcs:
diff = min((ext_pc - pc) % 12, (pc - ext_pc) % 12)
if diff < 2:
is_available = False
break
if is_available:
result.append(ext_tone)
return result
@property
def root(self) -> Optional[Tone]:
"""The root of this chord (if identifiable).
@@ -468,6 +722,53 @@ class Chord:
return self._identify_cache
return None
_SYMBOL_MAP = {
"major": "",
"minor": "m",
"diminished": "dim",
"augmented": "aug",
"sus2": "sus2",
"sus4": "sus4",
"power": "5",
"dominant 7th": "7",
"major 7th": "maj7",
"minor 7th": "m7",
"diminished 7th": "dim7",
"half-diminished 7th": "m7b5",
"minor-major 7th": "mMaj7",
"augmented 7th": "aug7",
"dominant 9th": "9",
"major 9th": "maj9",
"minor 9th": "m9",
}
@property
def symbol(self) -> Optional[str]:
"""Standard chord symbol (e.g. ``"Cmaj7"``, ``"Dm"``, ``"G7"``).
Returns the compact notation used in lead sheets and fake books,
or ``None`` if the chord can't be identified.
Example::
>>> Chord([C4, E4, G4]).symbol
'C'
>>> Chord([C4, E4, G4, B4]).symbol
'Cmaj7'
>>> Chord([A4, C5, E5]).symbol
'Am'
>>> Chord([G4, B4, D5, F5]).symbol
'G7'
"""
name = self.identify()
if not name:
return None
parts = name.split(" ", 1)
root = parts[0]
quality = parts[1] if len(parts) > 1 else "major"
suffix = self._SYMBOL_MAP.get(quality, quality)
return f"{root}{suffix}"
def voice_leading(self, other: Chord) -> list[tuple[Tone, Tone, int]]:
"""Find the smoothest voice leading to another chord.
@@ -571,26 +872,43 @@ class Chord:
quality = parts[1] if len(parts) > 1 else ""
scale_names = [t.name for t in scale.tones[:-1]]
if root_name not in scale_names:
return None
degree_idx = scale_names.index(root_name)
numeral_str = numeral_mod.int2roman(degree_idx + 1, only_ascii=True)
def _build_numeral(root, quality, degree_idx, prefix=""):
numeral_str = numeral_mod.int2roman(degree_idx + 1, only_ascii=True)
suffix = ""
if "minor" in quality:
numeral_str = numeral_str.lower()
if "diminished" in quality:
numeral_str = numeral_str.lower()
suffix = "dim"
if "augmented" in quality:
suffix = "+"
if "7th" in quality:
suffix += "7"
if "9th" in quality:
suffix += "9"
return prefix + numeral_str + suffix
suffix = ""
if "minor" in quality:
numeral_str = numeral_str.lower()
if "diminished" in quality:
numeral_str = numeral_str.lower()
suffix = "dim"
if "augmented" in quality:
suffix = "+"
if "7th" in quality:
suffix += "7"
if "9th" in quality:
suffix += "9"
# Diatonic match
if root_name in scale_names:
degree_idx = scale_names.index(root_name)
return _build_numeral(root_name, quality, degree_idx)
return numeral_str + suffix
# Chromatic / borrowed chord — find by semitone distance from tonic
tonic_tone = scale.tones[0]
root_tone = Tone.from_string(root_name + "4", system="western")
semitones = (root_tone - tonic_tone) % 12
# Map semitone distances to flat-degree labels
chromatic_degrees = {
1: ("b", 1), 3: ("b", 2), 6: ("b", 4),
8: ("b", 5), 10: ("b", 6),
}
if semitones in chromatic_degrees:
prefix, deg_idx = chromatic_degrees[semitones]
return _build_numeral(root_name, quality, deg_idx, prefix=prefix)
return None
@property
def tension(self) -> dict:
@@ -655,6 +973,59 @@ class Chord:
"has_dominant_function": has_dominant,
}
def slash(self, bass_note: str, *, octave: int = 3) -> Chord:
"""Return a slash chord — this chord over a different bass note.
Slash chords (e.g. C/G, Am/E) place a specific note in the
bass voice below the rest of the chord. They're written as
``Chord/Bass`` in lead sheets and are used for bass lines that
move stepwise beneath held chords.
Common uses:
- **C/E** first inversion, smooth bass line CDE
- **C/G** second inversion, strong bass on the fifth
- **D/F#** — passing tone in bass, very common in pop
Args:
bass_note: Note name for the bass (e.g. ``"G"``, ``"F#"``).
octave: Octave for the bass note (default 3, one below middle).
Returns:
A new Chord with the bass note prepended.
Example::
>>> Chord.from_symbol("C").slash("G")
<Chord C major>
"""
from .tones import Tone
bass = Tone.from_string(f"{bass_note}{octave}", system="western")
return Chord(tones=[bass] + list(self.tones))
@property
def slash_name(self) -> Optional[str]:
"""Slash chord name if the lowest tone isn't the root.
Returns ``"C/G"`` style notation when the bass differs from
the chord root, or the plain symbol otherwise.
Example::
>>> Chord.from_symbol("C").slash("E").slash_name
'C/E'
"""
sym = self.symbol
if not sym:
return None
root = self.root
if root is None:
return sym
bass = self.tones[0]
if bass.name != root.name:
return f"{sym}/{bass.name}"
return sym
def add_tone(self, tone) -> Chord:
"""Return a new Chord with an additional tone.
@@ -680,6 +1051,279 @@ class Chord:
"""
return Chord(tones=[t for t in self.tones if t.name != tone_name])
# ── Figured Bass ─────────────────────────────────────────────────
@property
def figured_bass(self) -> Optional[str]:
"""Return figured bass notation for this chord.
Figured bass describes the intervals above the lowest note.
Used in classical music theory and continuo playing.
Returns:
A string like ``"6"``, ``"6/4"``, ``"7"``, ``"6/5"``,
``"4/3"``, ``"2"``, or ``""`` for root position triads.
None if the chord can't be identified.
Example::
>>> Chord([C4, E4, G4]).figured_bass # root position
''
>>> Chord([E4, G4, C5]).figured_bass # first inversion
'6'
>>> Chord([G4, C5, E5]).figured_bass # second inversion
'6/4'
"""
chord_id = self.identify()
if not chord_id:
return None
# Find root name from identification
root_name = chord_id.split(" ", 1)[0]
quality = chord_id.split(" ", 1)[1] if " " in chord_id else ""
is_seventh = "7th" in quality or "9th" in quality
# Find the bass note (lowest by pitch)
bass = min(self.tones, key=lambda t: t.pitch())
bass_name = bass.name
# Check if bass is the root (handle enharmonics)
if bass_name == root_name:
# Root position
if is_seventh:
return "7"
return ""
# Find root tone object
root_tone = None
for t in self.tones:
if t.name == root_name:
root_tone = t
break
if root_tone is None:
return None
# Determine which chord degree the bass is
bass_interval = (bass - root_tone) % 12
# Get the pattern for this quality
pattern = self._CHORD_PATTERNS.get(quality)
if pattern is None:
return None
sorted_pattern = sorted(pattern)
if bass_interval not in sorted_pattern:
return None
inversion = sorted_pattern.index(bass_interval)
if is_seventh:
fb_map = {0: "7", 1: "6/5", 2: "4/3", 3: "2"}
return fb_map.get(inversion, None)
else:
fb_map = {0: "", 1: "6", 2: "6/4"}
return fb_map.get(inversion, None)
def analyze_figured(self, key_tonic, mode="major") -> Optional[str]:
"""Roman numeral analysis with figured bass inversion symbols.
Combines the Roman numeral from :meth:`analyze` with the
figured bass symbol from :attr:`figured_bass`.
Args:
key_tonic: The tonic note name (e.g. ``"C"``) or a Tone.
mode: ``"major"`` or ``"minor"`` (default ``"major"``).
Returns:
A string like ``"V7"``, ``"ii6"``, or ``None``.
Example::
>>> Chord([G4, B4, D5, F5]).analyze_figured("C")
'V7'
"""
roman = self.analyze(key_tonic, mode)
if roman is None:
return None
fb = self.figured_bass
if fb is None:
return roman
# Don't duplicate "7" — if the Roman numeral already ends with "7"
# and figured bass is just "7" (root position seventh), skip it.
if fb == "7" and roman.endswith("7"):
return roman
if fb:
return f"{roman}{fb}"
return roman
# ── Pitch Class Set Theory ─────────────────────────────────────
# Forte number catalog for trichords and tetrachords.
_FORTE_NUMBERS = {
# Trichords (3 notes)
(0, 1, 2): "3-1",
(0, 1, 3): "3-2",
(0, 1, 4): "3-3",
(0, 1, 5): "3-4",
(0, 1, 6): "3-5",
(0, 2, 4): "3-6",
(0, 2, 5): "3-7",
(0, 2, 6): "3-8",
(0, 2, 7): "3-9",
(0, 3, 6): "3-10",
(0, 3, 7): "3-11", # major/minor triad
(0, 4, 8): "3-12", # augmented triad
# Tetrachords (4 notes)
(0, 1, 2, 3): "4-1",
(0, 1, 2, 4): "4-2",
(0, 1, 3, 4): "4-3",
(0, 1, 2, 5): "4-4",
(0, 1, 2, 6): "4-5",
(0, 1, 2, 7): "4-6",
(0, 1, 4, 5): "4-7",
(0, 1, 5, 6): "4-8",
(0, 1, 6, 7): "4-9",
(0, 2, 3, 5): "4-10",
(0, 1, 3, 5): "4-11",
(0, 2, 3, 6): "4-12",
(0, 1, 3, 6): "4-13",
(0, 2, 3, 7): "4-14",
(0, 1, 4, 6): "4-z15",
(0, 1, 5, 7): "4-16",
(0, 3, 4, 7): "4-17",
(0, 1, 4, 7): "4-18",
(0, 1, 4, 8): "4-19",
(0, 1, 5, 8): "4-20",
(0, 2, 4, 6): "4-21",
(0, 2, 4, 7): "4-22",
(0, 2, 5, 7): "4-23",
(0, 2, 4, 8): "4-24",
(0, 2, 6, 8): "4-25",
(0, 3, 5, 8): "4-26",
(0, 2, 5, 8): "4-27",
(0, 3, 6, 9): "4-28", # diminished 7th
(0, 1, 3, 7): "4-z29",
}
@property
def pitch_classes(self) -> set:
"""Return the set of pitch classes (0-11) in this chord.
Pitch class 0 = C, 1 = C#/Db, 2 = D, ..., 11 = B.
Octave information is removed.
Example::
>>> Chord([C4, E4, G4]).pitch_classes
{0, 4, 7}
"""
from ._statics import C_INDEX
result = set()
for tone in self.tones:
pc = (tone._index - C_INDEX) % 12
result.add(pc)
return result
@staticmethod
def _find_normal_form(pcs_sorted):
"""Find the normal form of a sorted list of pitch classes."""
n = len(pcs_sorted)
if n <= 1:
return tuple(pcs_sorted)
best = None
best_span = 13
for start in range(n):
rotation = [pcs_sorted[(start + i) % n] for i in range(n)]
span = (rotation[-1] - rotation[0]) % 12
if span < best_span:
best_span = span
best = rotation
elif span == best_span:
# Tiebreak: compare intervals from bottom
for k in range(1, n):
a = (rotation[k] - rotation[0]) % 12
b = (best[k] - best[0]) % 12
if a < b:
best = rotation
break
elif a > b:
break
return tuple(best)
@property
def normal_form(self) -> tuple:
"""Return the normal form -- most compact ascending arrangement.
The normal form is the rotation of pitch classes that spans
the smallest interval. This is used in set theory analysis.
Example::
>>> Chord([C4, E4, G4]).normal_form
(0, 4, 7)
"""
pcs = sorted(self.pitch_classes)
return self._find_normal_form(pcs)
@property
def prime_form(self) -> tuple:
"""Return the prime form -- transposed to start on 0, most compact.
Prime form is the canonical representation used for Forte number
lookup. It compares the normal form of the set and its inversion,
picks whichever is more compact, and transposes to start on 0.
Example::
>>> Chord([C4, E4, G4]).prime_form
(0, 4, 7)
>>> Chord([A4, C5, E5]).prime_form # minor triad
(0, 3, 7)
"""
nf = self.normal_form
if len(nf) <= 1:
return (0,) * len(nf) if nf else ()
# Transpose normal form to start on 0
t0 = nf[0]
nf_transposed = tuple((pc - t0) % 12 for pc in nf)
# Compute inversion: 12 - each pc
inv_pcs = sorted(set((12 - pc) % 12 for pc in self.pitch_classes))
inv_nf = self._find_normal_form(inv_pcs)
inv_t0 = inv_nf[0]
inv_transposed = tuple((pc - inv_t0) % 12 for pc in inv_nf)
# Pick whichever is more compact (smaller intervals from bottom)
for a, b in zip(nf_transposed, inv_transposed):
if a < b:
return nf_transposed
elif a > b:
return inv_transposed
return nf_transposed
@property
def forte_number(self) -> Optional[str]:
"""Return the Forte number for this pitch class set.
Forte numbers catalog all possible pitch class sets by cardinality
and ordering. They are the standard reference in post-tonal theory.
Example::
>>> Chord([C4, E4, G4]).forte_number
'3-11'
>>> Chord([C4, E4, G4, Bb4]).forte_number
'4-27'
"""
pf = self.prime_form
return self._FORTE_NUMBERS.get(pf, None)
def fingering(self, *positions: int) -> "Fingering":
"""Apply fret positions to each tone, returning a Fingering.
@@ -1210,38 +1854,44 @@ class Fretboard:
]
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
def scale_diagram(self, scale, frets: int = 12) -> str:
def scale_diagram(self, scale, frets: int = 12, chord=None) -> str:
"""Render an ASCII diagram showing where scale notes fall on the neck.
Each string is shown with dots on frets where scale notes appear.
Useful for learning scale patterns on guitar, mandolin, etc.
Each string is shown with note names on frets where scale notes
appear. When a *chord* is provided, its tones are shown in
UPPERCASE and scale-only tones in lowercase, making chord
tones visually distinct from passing tones.
Args:
scale: A Scale object (or anything with a ``note_names`` attribute).
frets: Number of frets to display (default 12).
chord: Optional Chord object. Its tones are highlighted in
uppercase; other scale tones appear in lowercase.
Returns:
A multi-line string showing the fretboard diagram.
Example::
>>> from pytheory import Fretboard, TonedScale
>>> fb = Fretboard.guitar()
>>> pentatonic = TonedScale(tonic="A4")["minor"]
>>> print(fb.scale_diagram(pentatonic, frets=5))
>>> # Highlight Am chord tones within the scale:
>>> am = Chord.from_symbol("Am")
>>> print(fb.scale_diagram(pentatonic, frets=5, chord=am))
"""
scale_notes = set(scale.note_names)
chord_notes = set()
if chord is not None:
chord_notes = {t.name for t in chord.tones}
max_name = max(len(t.name) for t in self.tones)
lines = []
# Each cell is " X |" where X is a note name or dash.
# Cell content width is 3 chars (space + 2-char note/dash).
# Full cell with separator: 4 chars.
# Header must align fret numbers to the center of each cell.
header_parts = []
for f in range(frets + 1):
header_parts.append(f"{f:>2} ")
# Offset header to align with cell content (after "X|" prefix)
header = " " * (max_name + 2) + " ".join(header_parts)
lines.append(header)
@@ -1250,7 +1900,12 @@ class Fretboard:
for f in range(frets + 1):
note = tone.add(f)
if note.name in scale_notes:
fret_marks.append(f" {note.name:<2s}")
if chord_notes and note.name in chord_notes:
fret_marks.append(f" {note.name.upper():<2s}")
elif chord_notes:
fret_marks.append(f" {note.name.lower():<2s}")
else:
fret_marks.append(f" {note.name:<2s}")
else:
fret_marks.append(" - ")
line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|"
+282 -9
View File
@@ -94,22 +94,40 @@ def cmd_progression(args):
def cmd_play(args):
from .tones import Tone
from .chords import Chord
from .play import play, Synth
from .play import play, Synth, Envelope
synth_map = {"sine": Synth.SINE, "saw": Synth.SAW, "triangle": Synth.TRIANGLE}
synth_map = {
"sine": Synth.SINE, "saw": Synth.SAW, "triangle": Synth.TRIANGLE,
"square": Synth.SQUARE, "pulse": Synth.PULSE, "fm": Synth.FM,
"noise": Synth.NOISE, "supersaw": Synth.SUPERSAW,
"pwm_slow": Synth.PWM_SLOW, "pwm_fast": Synth.PWM_FAST,
}
synth = synth_map[args.synth]
envelope = Envelope[args.envelope.upper()]
duration = args.duration
# Try chord name first (e.g. "Am", "Cmaj7"), then fall back to individual notes.
# Try chord symbol first (e.g. "Am", "Cmaj7", "F#m7b5"),
# then chart lookup, then fall back to individual notes.
if len(args.notes) == 1:
note = args.notes[0]
# Try as chord name first (Am, G7, Cmaj7, etc.)
target = None
# Try as chord symbol first
try:
target = Chord.from_name(note)
target = Chord.from_symbol(note)
name = target.identify() or note
label = f"{name} ({' '.join(t.full_name for t in target.tones)})"
except (ValueError, KeyError):
# Fall back to single tone
except ValueError:
pass
# Try chart lookup
if target is None:
try:
target = Chord.from_name(note)
name = target.identify() or note
label = f"{name} ({' '.join(t.full_name for t in target.tones)})"
except (ValueError, KeyError):
pass
# Fall back to single tone
if target is None:
target = Tone.from_string(
note if any(c.isdigit() for c in note) else f"{note}4",
system="western")
@@ -123,8 +141,218 @@ def cmd_play(args):
print(f" Playing: {label}")
print(f" Synth: {args.synth}")
print(f" Envelope: {args.envelope}")
print(f" Duration: {duration} ms")
play(target, temperament=args.temperament, synth=synth, t=duration)
play(target, temperament=args.temperament, synth=synth, t=duration,
envelope=envelope)
def cmd_identify(args):
from .chords import Chord
chord = Chord.from_symbol(args.symbol)
name = chord.identify() or "Unknown"
sym = chord.symbol or args.symbol
tones_str = " ".join(t.full_name for t in chord.tones)
intervals = chord.intervals
harmony = chord.harmony
dissonance = chord.dissonance
tension = chord.tension
print(f" Chord: {name}")
print(f" Symbol: {sym}")
print(f" Tones: {tones_str}")
print(f" Intervals: {intervals}")
print(f" Harmony: {harmony:.4f}")
print(f" Dissonance: {dissonance:.4f}")
print(f" Tension: score={tension['score']:.2f} tritones={tension['tritones']} "
f"minor_2nds={tension['minor_seconds']} dominant={tension['has_dominant_function']}")
def cmd_midi(args):
from .scales import Key
from .play import save_midi
key = Key(args.tonic, args.mode)
chords = key.progression(*args.numerals)
save_midi(chords, args.output, t=args.duration, bpm=args.bpm)
print(f" Key: {key}")
print(f" Progression: {' '.join(args.numerals)}")
print(f" BPM: {args.bpm}")
print(f" Duration: {args.duration} ms")
print(f" Output: {args.output}")
def cmd_modes(args):
from .scales import TonedScale
ts = TonedScale(tonic=f"{args.tonic}4", system=args.system)
mode_names = ["ionian", "dorian", "phrygian", "lydian",
"mixolydian", "aeolian", "locrian"]
print(f" Modes of {args.tonic}:\n")
for mode in mode_names:
try:
scale = ts[mode]
notes = " ".join(scale.note_names)
print(f" {mode:<12s} {notes}")
except KeyError:
continue
def cmd_circle(args):
from .tones import Tone
tone = Tone.from_string(f"{args.tonic}4", system="western")
fifths = tone.circle_of_fifths()
fourths = tone.circle_of_fourths()
print(f" Circle of fifths from {args.tonic}:")
print(f"{''.join(t.name for t in fifths)}")
print()
print(f" Circle of fourths from {args.tonic}:")
print(f"{''.join(t.name for t in fourths)}")
def cmd_progressions(args):
from .scales import Key
key = Key(args.tonic, args.mode)
progs = key.common_progressions()
print(f" Common progressions in {key}:\n")
for name, chords in progs.items():
symbols = [c.symbol or str(c) for c in chords]
print(f" {name:<20s} {''.join(symbols)}")
def cmd_demo(args):
import random
from .rhythm import Score, Pattern, Duration
from .chords import Chord
from .scales import Key
from .play import play_score
moods = [
{"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),
"pad": ("fm", "pad", -0.2),
"bass_lp": 600, "reverb_type": "plate"},
{"name": "Jazz Club", "key": ("Bb", "major"), "drums": "jazz",
"fill": "jazz", "bpm": 108,
"prog": ("I", "vi", "ii", "V"),
"lead": ("triangle", "strings", 0.3, 0.2),
"pad": ("fm", "piano", -0.3),
"bass_lp": 700, "reverb_type": "plate"},
{"name": "Afrobeat", "key": ("E", "minor"), "drums": "afrobeat",
"fill": "afrobeat", "bpm": 115,
"prog": ("i", "iv", "V", "i"),
"lead": ("saw", "pluck", 0.15, 0.3),
"pad": ("supersaw", "pad", 0.0),
"bass_lp": 500, "reverb_type": "cathedral"},
{"name": "House", "key": ("C", "minor"), "drums": "house",
"fill": "house", "bpm": 124,
"prog": ("i", "IV", "V", "i"),
"lead": ("saw", "staccato", 0.2, 0.4),
"pad": ("supersaw", "pad", 0.0),
"bass_lp": 300, "reverb_type": "plate"},
{"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),
"bass_lp": 400, "reverb_type": "cathedral"},
{"name": "Funk", "key": ("E", "minor"), "drums": "funk",
"fill": "funk", "bpm": 100,
"prog": ("i", "iv", "V", "i"),
"lead": ("saw", "pluck", 0.15, 0.3),
"pad": ("square", "staccato", -0.4),
"bass_lp": 500, "reverb_type": "plate"},
{"name": "Dub", "key": ("A", "minor"), "drums": "dub",
"fill": "reggae", "bpm": 72,
"prog": ("i", "iv", "i", "V"),
"lead": ("triangle", "strings", 0.4, 0.2),
"pad": ("pwm_slow", "pad", -0.3),
"bass_lp": 350, "reverb_type": "cathedral"},
{"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),
"bass_lp": 200, "reverb_type": "taj_mahal"},
]
mood = random.choice(moods)
tonic, mode = mood["key"]
key = Key(tonic, mode)
chords = key.progression(*mood["prog"])
lead_synth, lead_env, lead_reverb, lead_pan = mood["lead"]
pad_synth, pad_env, pad_pan = mood["pad"]
score = Score("4/4", bpm=mood["bpm"], drum_humanize=0.15)
score.drums(mood["drums"], repeats=4, fill=mood["fill"])
pad = score.part(
"pad", synth=pad_synth, envelope=pad_env,
volume=0.2, pan=pad_pan,
detune=10, spread=0.5,
reverb=0.4, reverb_type=mood["reverb_type"],
chorus=0.2,
sidechain=0.4 if mood["bpm"] > 100 else 0.0,
)
lead = score.part(
"lead", synth=lead_synth, envelope=lead_env,
volume=0.4, pan=lead_pan,
delay=0.2, delay_time=round(30 / mood["bpm"], 3),
delay_feedback=0.35,
reverb=lead_reverb, reverb_type=mood["reverb_type"],
lowpass=3500,
humanize=0.2,
)
bass = score.part(
"bass", synth="sine", envelope="pluck",
volume=0.45, pan=0.0,
lowpass=mood["bass_lp"],
humanize=0.15,
)
for chord in chords * 2:
pad.add(chord, Duration.WHOLE)
# Melody: chord tones with passing tones, rests for breathing
scale_tones = [t.name for t in key.scale.tones[:-1]]
for i, chord in enumerate(chords):
chord_tones = [t.name for t in chord.tones]
beats_left = 4.0
while beats_left > 0.5:
if random.random() < 0.25:
r = random.choice([0.5, 1.0, 1.5])
r = min(r, beats_left)
lead.rest(r)
beats_left -= r
else:
n = random.choice(chord_tones if random.random() < 0.65 else scale_tones)
oct = 5 if random.random() < 0.6 else 4
dur = random.choice([0.67, 1.0, 1.5, 2.0])
dur = min(dur, beats_left)
vel = random.randint(65, 105)
lead.add(f"{n}{oct}", dur, velocity=vel)
beats_left -= dur
# Bass: root-fifth with velocity accents
for chord in chords * 2:
r = chord.root
if r:
fifth = r.add(7)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=95)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=55)
bass.add(f"{fifth.name}2", Duration.QUARTER, velocity=65)
bass.add(f"{r.name}2", Duration.QUARTER, velocity=75)
prog_str = "".join(c.symbol or str(c) for c in chords)
print(f"{mood['name']}")
print(f" {tonic} {mode} | {mood['bpm']} bpm")
print(f" {prog_str}")
print(f" {mood['drums']} | {lead_synth} lead | {pad_synth} pad | {mood['reverb_type']} reverb")
print()
play_score(score)
print("")
def cmd_detect(args):
@@ -181,18 +409,56 @@ def main():
p = sub.add_parser("play", help="Play notes or chords (e.g. pytheory play C E G)")
p.add_argument("notes", nargs="+", help="Note names, with optional octave (e.g. C4, A#3, or just C E G)")
p.add_argument("--synth", "-s", default="sine",
choices=["sine", "saw", "triangle"],
choices=["sine", "saw", "triangle", "square", "pulse",
"fm", "noise", "supersaw", "pwm_slow", "pwm_fast"],
help="Waveform (default: sine)")
p.add_argument("--duration", "-d", type=int, default=1000,
help="Duration in milliseconds (default: 1000)")
p.add_argument("--temperament", "-t", default="equal",
choices=["equal", "pythagorean", "meantone"],
help="Tuning temperament (default: equal)")
p.add_argument("--envelope", "-e", default="piano",
choices=["none", "piano", "organ", "pluck", "pad",
"strings", "bell", "staccato"],
help="ADSR envelope preset (default: piano)")
# identify
p = sub.add_parser("identify", help="Identify a chord symbol (e.g. pytheory identify Cmaj7)")
p.add_argument("symbol", help="Chord symbol (e.g. Cmaj7, Am, F#m7b5)")
# midi
p = sub.add_parser("midi", help="Export a progression to MIDI (e.g. pytheory midi C major I V vi IV)")
p.add_argument("tonic", help="Tonic note")
p.add_argument("mode", help="Mode (e.g. major, minor)")
p.add_argument("numerals", nargs="+", help="Roman numerals (e.g. I V vi IV)")
p.add_argument("-o", "--output", default="output.mid", help="Output file (default: output.mid)")
p.add_argument("--bpm", type=int, default=120, help="BPM (default: 120)")
p.add_argument("--duration", type=int, default=500, help="Duration per chord in ms (default: 500)")
# demo
sub.add_parser("demo", help="Play a randomly generated track (different every time)")
# repl
sub.add_parser("repl", help="Interactive music theory scratchpad")
# detect
p = sub.add_parser("detect", help="Detect key from notes (e.g. pytheory detect C E G)")
p.add_argument("notes", nargs="+", help="Note names")
# modes
p = sub.add_parser("modes", help="Show all modes of a note (e.g. pytheory modes C)")
p.add_argument("tonic", help="Tonic note (e.g. C, G)")
p.add_argument("--system", default="western", help="Musical system (default: western)")
# circle
p = sub.add_parser("circle", help="Circle of fifths/fourths (e.g. pytheory circle C)")
p.add_argument("tonic", help="Starting note (e.g. C, G)")
# progressions
p = sub.add_parser("progressions", help="Common progressions in a key (e.g. pytheory progressions C major)")
p.add_argument("tonic", help="Tonic note")
p.add_argument("mode", nargs="?", default="major", help="Mode (default: major)")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -206,7 +472,14 @@ def main():
"fingering": cmd_fingering,
"progression": cmd_progression,
"play": cmd_play,
"identify": cmd_identify,
"midi": cmd_midi,
"demo": cmd_demo,
"repl": lambda args: __import__('pytheory.repl', fromlist=['main']).main(),
"detect": cmd_detect,
"modes": cmd_modes,
"circle": cmd_circle,
"progressions": cmd_progressions,
}
commands[args.command](args)
+2026 -14
View File
File diff suppressed because it is too large Load Diff
+769
View File
@@ -0,0 +1,769 @@
"""PyTheory REPL — make music interactively.
Commands mirror the Python API so there's no new vocabulary to learn.
What you type in the REPL is what you'd type in a script.
Usage:
pytheory repl
"""
try:
import readline
except ImportError:
readline = None
import sys
from .scales import Key, TonedScale
from .chords import Chord
from .tones import Tone
from .rhythm import Score, Pattern, Duration, Part
# ── State ──────────────────────────────────────────────────────────────────
class Session:
"""The live session state."""
def __init__(self):
self.key = Key("C", "major")
self.bpm = 120
self.time_sig = "4/4"
self.swing = 0.0
self.score = Score(self.time_sig, bpm=self.bpm)
self.current_part = None
self.parts = {}
self._drum_preset = None
def rebuild(self):
"""Rebuild score from settings."""
self.score = Score(self.time_sig, bpm=self.bpm, swing=self.swing)
self.parts = {}
self.current_part = None
if self._drum_preset:
self.score.drums(self._drum_preset, repeats=4)
def ensure_part(self, name="lead"):
if name not in self.parts:
self.parts[name] = self.score.part(name)
return self.parts[name]
# ── Commands ───────────────────────────────────────────────────────────────
def cmd_help(session, args):
print("""
PyTheory REPL commands mirror the Python API
Theory:
key Am Key("A", "minor")
key G major Key("G", "major")
chords key.chords
prog I V vi IV key.progression(...)
modes show all modes
scales list available scales
circle [C] circle of fifths/fourths
interval C4 G4 name the interval
identify C E G identify a chord from notes
identify Cmaj7 analyze a chord symbol
system [indian] switch musical system
Score:
bpm 140 Score("4/4", bpm=140)
time 3/4 TimeSignature
swing 0.5 Score(swing=0.5)
drums bossa nova score.drums("bossa nova")
drums list all presets
Parts:
part lead saw pluck score.part("lead", synth="saw", envelope="pluck")
part bass sine score.part("bass", synth="sine")
part list all parts
Notes (on active part):
add C5 1 part.add("C5", 1.0)
add Am 4 part.add(Chord.from_symbol("Am"), 4.0)
rest 2 part.rest(2.0)
arp Am updown 2 2 part.arpeggio("Am", pattern="updown", bars=2, octaves=2)
prog I V vi IV part adds key.progression(...)
Effects (on active part):
reverb 0.4 reverb=0.4
delay 0.3 0.375 delay=0.3, delay_time=0.375
lowpass 2000 3 lowpass=2000, lowpass_q=3
distortion 0.5 distortion=0.5
chorus 0.3 chorus=0.3
sidechain 0.8 sidechain=0.8
humanize 0.3 humanize=0.3
volume 0.5 volume=0.5
legato on legato=True
glide 0.04 glide=0.04
set lowpass 3000 part.set(lowpass=3000)
lfo lowpass 0.5 400 3000 8 part.lfo("lowpass", rate=0.5, ...)
Playback:
play_score play the full score
play_pattern play just the drums
render sketch.wav render to WAV
save_midi sketch.mid save as MIDI
Guitar:
fingering Am guitar chord fingering
diagram [mode] [frets] scale diagram on guitar
Session:
show score info
status current state
clear reset everything
help this message
quit exit
""")
def cmd_key(session, args):
if not args:
notes = " ".join(session.key.note_names)
print(f" {session.key}: {notes}")
return
if len(args) == 1:
name = args[0]
if name.endswith("m") and len(name) <= 3:
tonic, mode = name[:-1], "minor"
else:
tonic, mode = name, "major"
else:
tonic, mode = args[0], " ".join(args[1:])
try:
session.key = Key(tonic, mode)
notes = " ".join(session.key.note_names)
print(f" {session.key}: {notes}")
except (KeyError, ValueError) as e:
print(f" error: {e}")
def cmd_bpm(session, args):
if not args:
print(f" bpm={session.bpm}")
return
session.bpm = int(args[0])
session.score.bpm = session.bpm
print(f" bpm={session.bpm}")
def cmd_swing(session, args):
if not args:
print(f" swing={session.swing}")
return
session.swing = float(args[0])
session.score.swing = session.swing
print(f" swing={session.swing}")
def cmd_drums(session, args):
if not args:
presets = Pattern.list_presets()
cols = 4
for i in range(0, len(presets), cols):
row = presets[i:i + cols]
print(" " + " ".join(f"{p:<18s}" for p in row))
return
preset = " ".join(args[:-1]) if args[-1].isdigit() else " ".join(args)
repeats = int(args[-1]) if args[-1].isdigit() else 4
try:
session.score.drums(preset, repeats=repeats)
session._drum_preset = preset # only persist after success
print(f" score.drums(\"{preset}\", repeats={repeats})")
except ValueError as e:
print(f" error: {e}")
def cmd_time(session, args):
if not args:
print(f" time={session.time_sig}")
return
session.time_sig = args[0]
session.rebuild()
print(f" time={session.time_sig}")
def cmd_part(session, args):
if not args:
if session.parts:
for name, part in session.parts.items():
active = "" if part is session.current_part else ""
print(f" {name}: synth={part.synth} envelope={part.envelope} "
f"vol={part.volume}{active}")
else:
print(" no parts (type: part lead saw pluck)")
return
name = args[0]
synth = args[1] if len(args) > 1 else "saw"
envelope = args[2] if len(args) > 2 else "pluck"
if name not in session.parts:
session.parts[name] = session.score.part(name, synth=synth, envelope=envelope)
print(f" score.part(\"{name}\", synth=\"{synth}\", envelope=\"{envelope}\")")
else:
print(f"{name}")
session.current_part = session.parts[name]
def _require_part(session):
if session.current_part is None:
session.parts["lead"] = session.score.part("lead", synth="saw", envelope="pluck")
session.current_part = session.parts["lead"]
print(" (auto-created lead: saw + pluck)")
return session.current_part
def cmd_add(session, args):
if not args:
print(" usage: add C5 1 or add Am 4")
return
part = _require_part(session)
name = args[0]
beats = float(args[1]) if len(args) > 1 else 1.0
velocity = int(args[2]) if len(args) > 2 else 100
# Try as chord first, then as note
try:
chord = Chord.from_symbol(name)
part.add(chord, beats)
print(f" .add(Chord.from_symbol(\"{name}\"), {beats})")
return
except (ValueError, KeyError):
pass
try:
part.add(name, beats, velocity=velocity)
vel_str = f", velocity={velocity}" if velocity != 100 else ""
print(f" .add(\"{name}\", {beats}{vel_str})")
except Exception as e:
print(f" error: {e}")
def cmd_rest(session, args):
part = _require_part(session)
beats = float(args[0]) if args else 1.0
part.rest(beats)
print(f" .rest({beats})")
def cmd_arp(session, args):
if not args:
print(" usage: arp Am [pattern] [bars] [octaves]")
return
part = _require_part(session)
chord_name = args[0]
pattern = args[1] if len(args) > 1 else "up"
bars = float(args[2]) if len(args) > 2 else 2
octaves = int(args[3]) if len(args) > 3 else 1
try:
part.arpeggio(chord_name, bars=bars, pattern=pattern, octaves=octaves)
print(f" .arpeggio(\"{chord_name}\", pattern=\"{pattern}\", "
f"bars={bars}, octaves={octaves})")
except Exception as e:
print(f" error: {e}")
def cmd_prog(session, args):
if not args:
print(" usage: prog I V vi IV")
return
part = _require_part(session)
try:
chords = session.key.progression(*args)
for chord in chords:
part.add(chord, Duration.WHOLE)
symbols = [c.symbol or str(c) for c in chords]
print(f" {''.join(symbols)}")
except Exception as e:
print(f" error: {e}")
def _set_effect(session, param, args, default=0.3):
part = _require_part(session)
value = float(args[0]) if args else default
attr_map = {
"reverb": "reverb_mix", "delay": "delay_mix",
"distortion": "distortion_mix", "chorus": "chorus_mix",
}
attr = attr_map.get(param, param)
setattr(part, attr, value)
print(f" {part.name}: {param}={value}")
if param == "delay" and len(args) > 1:
part.delay_time = float(args[1])
print(f" {part.name}: delay_time={part.delay_time}")
if param == "lowpass" and len(args) > 1:
part.lowpass_q = float(args[1])
print(f" {part.name}: lowpass_q={part.lowpass_q}")
def cmd_set(session, args):
"""Automation: part.set() at current beat."""
if len(args) < 2:
print(" usage: set lowpass 3000")
return
part = _require_part(session)
param = args[0]
value = float(args[1])
part.set(**{param: value})
print(f" .set({param}={value})")
def cmd_lfo(session, args):
"""LFO automation."""
if len(args) < 4:
print(" usage: lfo lowpass 0.5 400 3000 [bars] [shape]")
return
part = _require_part(session)
param = args[0]
rate = float(args[1])
min_val = float(args[2])
max_val = float(args[3])
bars = float(args[4]) if len(args) > 4 else 4
shape = args[5] if len(args) > 5 else "sine"
part.lfo(param, rate=rate, min=min_val, max=max_val, bars=bars, shape=shape)
print(f" .lfo(\"{param}\", rate={rate}, min={min_val}, max={max_val}, "
f"bars={bars}, shape=\"{shape}\")")
def cmd_legato(session, args):
part = _require_part(session)
part.legato = not (args and args[0] == "off")
print(f" legato={'on' if part.legato else 'off'}")
def cmd_play_score(session, args):
try:
from .play import play_score
print(" ♫ play_score()")
play_score(session.score)
except Exception as e:
print(f" error: {e}")
def cmd_play_pattern(session, args):
if not session._drum_preset:
print(" no drums set")
return
try:
from .play import play_pattern
print(f" ♫ play_pattern(\"{session._drum_preset}\")")
play_pattern(Pattern.preset(session._drum_preset),
repeats=4, bpm=session.bpm)
except Exception as e:
print(f" error: {e}")
def cmd_render(session, args):
path = args[0] if args else "output.wav"
try:
from .play import render_score, SAMPLE_RATE
import scipy.io.wavfile
import numpy
buf = render_score(session.score)
pcm = (buf * 32767).astype(numpy.int16)
scipy.io.wavfile.write(path, SAMPLE_RATE, pcm)
print(f" saved: {path}")
except Exception as e:
print(f" error: {e}")
def cmd_save_midi(session, args):
path = args[0] if args else "output.mid"
session.score.save_midi(path)
print(f" save_midi(\"{path}\")")
def cmd_show(session, args):
s = session.score
print(f" {s}")
for name, part in session.parts.items():
active = "" if part is session.current_part else ""
fx = []
if part.reverb_mix > 0:
fx.append(f"reverb={part.reverb_mix}")
if part.delay_mix > 0:
fx.append(f"delay={part.delay_mix}")
if part.lowpass > 0:
fx.append(f"lp={part.lowpass}")
if part.distortion_mix > 0:
fx.append(f"dist={part.distortion_mix}")
if part.chorus_mix > 0:
fx.append(f"chorus={part.chorus_mix}")
if part.legato:
fx.append("legato")
if part.humanize > 0:
fx.append(f"humanize={part.humanize}")
fx_str = " " + " ".join(fx) if fx else ""
print(f" {name}: {part.synth}+{part.envelope} "
f"{len(part.notes)} notes{fx_str}{active}")
if s._drum_hits:
print(f" drums: {session._drum_preset} ({len(s._drum_hits)} hits)")
def cmd_chords(session, args):
chords = session.key.chords
for i, chord in enumerate(chords):
from .chords import Chord as ChordClass
# Build actual chord to get proper Roman numeral analysis
c = session.key.triad(i)
analysis = c.analyze(session.key.tonic_name, session.key.mode)
label = analysis or str(i + 1)
print(f" {label:6s} {chord}")
def cmd_modes(session, args):
ts = TonedScale(tonic=f"{session.key.tonic_name}4")
for mode in ["ionian", "dorian", "phrygian", "lydian",
"mixolydian", "aeolian", "locrian"]:
try:
print(f" {mode:<12s} {' '.join(ts[mode].note_names)}")
except KeyError:
continue
def cmd_scales(session, args):
ts = TonedScale(tonic=f"{session.key.tonic_name}4")
for name in ts.scales:
print(f" {name:<20s} {' '.join(ts[name].note_names)}")
def cmd_fingering(session, args):
"""Show guitar fingering for a chord."""
if not args:
print(" usage: fingering Am")
return
from .chords import Fretboard
from .charts import CHARTS
fb = Fretboard.guitar()
name = args[0]
chart = CHARTS.get("western", {})
if name in chart:
print(chart[name].tab(fretboard=fb))
else:
# Try from_symbol
try:
f = fb.chord(name)
print(f" {f}")
except (ValueError, KeyError) as e:
print(f" error: {e}")
def cmd_diagram(session, args):
"""Show a scale diagram on guitar."""
from .chords import Fretboard
fb = Fretboard.guitar()
mode = args[0] if args else session.key.mode
frets = int(args[1]) if len(args) > 1 else 12
ts = TonedScale(tonic=f"{session.key.tonic_name}4")
try:
scale = ts[mode]
print(fb.scale_diagram(scale, frets=frets))
except KeyError:
print(f" unknown scale: {mode}")
def cmd_system(session, args):
"""Switch musical system or show current."""
if not args:
from .systems import SYSTEMS
for name in SYSTEMS:
print(f" {name}")
return
system = args[0]
# Default tonics per system
default_tonics = {
"western": "C", "indian": "Sa", "arabic": "Do",
"japanese": "C", "blues": "C", "gamelan": "C",
}
tonic = args[1] if len(args) > 1 else default_tonics.get(system, "C")
try:
ts = TonedScale(tonic=f"{tonic}4", system=system)
available = list(ts.scales)[:10]
print(f" system: {system}")
print(f" scales: {', '.join(available)}")
if available:
first = ts[available[0]]
print(f" {available[0]}: {' '.join(first.note_names)}")
except Exception as e:
print(f" error: {e}")
def cmd_interval(session, args):
"""Show the interval between two notes."""
if len(args) < 2:
print(" usage: interval C4 G4")
return
try:
t1 = Tone.from_string(args[0], system="western")
t2 = Tone.from_string(args[1], system="western")
print(f" {t1.full_name}{t2.full_name}: {t1.interval_to(t2)}")
print(f" {abs(t1 - t2)} semitones")
except Exception as e:
print(f" error: {e}")
def cmd_identify(session, args):
"""Identify a chord from notes or a symbol."""
if not args:
print(" usage: identify C E G or identify Cmaj7")
return
if len(args) == 1:
try:
chord = Chord.from_symbol(args[0])
print(f" {chord.identify()}")
print(f" symbol: {chord.symbol}")
print(f" tones: {' '.join(t.full_name for t in chord.tones)}")
print(f" intervals: {chord.intervals}")
return
except ValueError:
pass
# Try as individual notes
try:
tones = [Tone.from_string(f"{n}4", system="western") for n in args]
chord = Chord(tones=tones)
name = chord.identify() or "unknown"
print(f" {name}")
if chord.symbol:
print(f" symbol: {chord.symbol}")
except Exception as e:
print(f" error: {e}")
def cmd_circle(session, args):
"""Show circle of fifths."""
tonic = args[0] if args else session.key.tonic_name
tone = Tone.from_string(f"{tonic}4", system="western")
fifths = [t.name for t in tone.circle_of_fifths()]
fourths = [t.name for t in tone.circle_of_fourths()]
print(f" fifths: {''.join(fifths)}")
print(f" fourths: {''.join(fourths)}")
def cmd_clear(session, args):
"""Full reset — back to initial state."""
session.key = Key("C", "major")
session.bpm = 120
session.time_sig = "4/4"
session.swing = 0.0
session._drum_preset = None
session.score = Score(session.time_sig, bpm=session.bpm)
session.parts = {}
session.current_part = None
print(" cleared (C major, 120 bpm)")
def cmd_status(session, args):
parts = ", ".join(session.parts.keys()) if session.parts else "none"
active = session.current_part.name if session.current_part else "none"
print(f" key={session.key} bpm={session.bpm} swing={session.swing}")
print(f" drums={session._drum_preset or 'none'} parts=[{parts}] active={active}")
# ── Dispatch ───────────────────────────────────────────────────────────────
COMMANDS = {
"help": cmd_help, "?": cmd_help,
"key": cmd_key,
"bpm": cmd_bpm,
"swing": cmd_swing,
"drums": cmd_drums,
"time": cmd_time,
"part": cmd_part,
"add": cmd_add,
"rest": cmd_rest,
"arp": cmd_arp,
"prog": cmd_prog, "progression": cmd_prog,
"reverb": lambda s, a: _set_effect(s, "reverb", a),
"delay": lambda s, a: _set_effect(s, "delay", a),
"lowpass": lambda s, a: _set_effect(s, "lowpass", a, 2000),
"lp": lambda s, a: _set_effect(s, "lowpass", a, 2000),
"distortion": lambda s, a: _set_effect(s, "distortion", a),
"dist": lambda s, a: _set_effect(s, "distortion", a),
"chorus": lambda s, a: _set_effect(s, "chorus", a),
"sidechain": lambda s, a: _set_effect(s, "sidechain", a),
"humanize": lambda s, a: _set_effect(s, "humanize", a),
"volume": lambda s, a: _set_effect(s, "volume", a, 0.5),
"vol": lambda s, a: _set_effect(s, "volume", a, 0.5),
"glide": lambda s, a: _set_effect(s, "glide", a, 0.04),
"legato": cmd_legato,
"set": cmd_set,
"lfo": cmd_lfo,
"play_score": cmd_play_score,
"play_pattern": cmd_play_pattern,
"render": cmd_render,
"save_midi": cmd_save_midi,
"show": cmd_show,
"chords": cmd_chords,
"modes": cmd_modes,
"scales": cmd_scales,
"fingering": cmd_fingering, "f": cmd_fingering,
"diagram": cmd_diagram,
"system": cmd_system,
"interval": cmd_interval,
"identify": cmd_identify, "id": cmd_identify,
"circle": cmd_circle,
"clear": cmd_clear,
"status": cmd_status,
}
# ── Main ───────────────────────────────────────────────────────────────────
def _prompt(session):
"""Build a context-aware multiline prompt."""
key_str = f"{session.key.tonic_name}{('m' if session.key.mode == 'minor' else '')}"
ctx = [f"key={key_str}", f"bpm={session.bpm}"]
if session.swing > 0:
ctx.append(f"swing={session.swing}")
if session._drum_preset:
ctx.append(f"drums={session._drum_preset}")
if session.current_part is not None:
p = session.current_part
fx = []
if p.reverb_mix > 0:
fx.append(f"rev={p.reverb_mix}")
if p.delay_mix > 0:
fx.append(f"del={p.delay_mix}")
if p.lowpass > 0:
fx.append(f"lp={int(p.lowpass)}")
if p.distortion_mix > 0:
fx.append(f"dist={p.distortion_mix}")
if p.legato:
fx.append("legato")
part_str = f"{p.name}({p.synth})"
if fx:
part_str += f" {' '.join(fx)}"
ctx.append(f"{part_str}")
# Single line if short, multiline if long
oneline = f"pytheory[{' | '.join(ctx)}]> "
if len(oneline) <= 60:
return oneline
# Multiline
lines = " " + " | ".join(ctx)
return f"{lines}\n♫> "
# ── Tab completion ─────────────────────────────────────────────────────────
_SYNTH_NAMES = ["sine", "saw", "triangle", "square", "pulse", "fm",
"noise", "supersaw", "pwm_slow", "pwm_fast"]
_ENVELOPE_NAMES = ["piano", "pluck", "pad", "organ", "bell", "strings",
"staccato", "none"]
_ARP_PATTERNS = ["up", "down", "updown", "downup", "random"]
_LFO_SHAPES = ["sine", "triangle", "saw", "square"]
_SYSTEMS = ["western", "indian", "arabic", "japanese", "blues", "gamelan"]
_NOTE_NAMES = ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb",
"G", "G#", "Ab", "A", "A#", "Bb", "B"]
_CHORD_SUFFIXES = ["", "m", "7", "m7", "maj7", "dim", "aug", "sus2", "sus4",
"m7b5", "dim7", "9", "m9", "maj9"]
# Context-aware completions for the second word
_ARG_COMPLETIONS = {
"drums": lambda: Pattern.list_presets(),
"part": lambda: _SYNTH_NAMES,
"key": lambda: [f"{n}m" for n in _NOTE_NAMES[:12]] + _NOTE_NAMES[:12],
"arp": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
"add": lambda: [f"{n}{o}" for n in _NOTE_NAMES[:12] for o in ["3", "4", "5"]],
"chord": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
"fingering": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:4]],
"system": lambda: _SYSTEMS,
"lfo": lambda: ["lowpass", "reverb", "delay", "distortion", "chorus", "volume"],
"set": lambda: ["lowpass", "reverb", "delay", "distortion", "chorus", "volume",
"lowpass_q", "reverb_decay", "delay_time", "delay_feedback",
"distortion_drive"],
"identify": lambda: [f"{n}{s}" for n in _NOTE_NAMES[:7] for s in _CHORD_SUFFIXES[:6]],
}
def _completer(text, state):
"""Tab completion for the REPL."""
line = readline.get_line_buffer() if readline else ""
tokens = line.split()
if len(tokens) <= 1:
# First word: complete command names
options = [cmd for cmd in COMMANDS if cmd.startswith(text)]
else:
# Second+ word: context-aware
cmd = tokens[0].lower()
if cmd in _ARG_COMPLETIONS:
try:
candidates = _ARG_COMPLETIONS[cmd]()
options = [c for c in candidates if c.startswith(text)]
except Exception:
options = []
elif cmd == "part" and len(tokens) == 3:
# Third arg for part is envelope
options = [e for e in _ENVELOPE_NAMES if e.startswith(text)]
elif cmd == "arp" and len(tokens) == 3:
# Pattern for arp
options = [p for p in _ARP_PATTERNS if p.startswith(text)]
elif cmd == "lfo" and len(tokens) >= 7:
# Shape for lfo
options = [s for s in _LFO_SHAPES if s.startswith(text)]
else:
options = []
if state < len(options):
return options[state] + " "
return None
def main():
session = Session()
# Set up tab completion
if readline:
readline.set_completer(_completer)
readline.parse_and_bind("tab: complete")
readline.set_completer_delims(" ")
print()
print(" ♫ PyTheory REPL")
print(" ════════════════════════════════════════")
print()
print(" try: key Am — set a key")
print(" chords — see its chords")
print(" prog I V vi IV — hear a progression")
print(" drums bossa nova")
print(" play_score — hear it all")
print()
print(" help for all commands, quit to exit")
print()
while True:
try:
line = input(_prompt(session)).strip()
except (EOFError, KeyboardInterrupt):
print("\n")
break
if not line:
continue
if line in ("quit", "exit", "q"):
print("")
break
tokens = line.split()
cmd = tokens[0].lower()
args = tokens[1:]
if cmd in COMMANDS:
try:
COMMANDS[cmd](session, args)
except Exception:
import traceback
traceback.print_exc()
else:
print(f" unknown: {cmd} (type 'help')")
if __name__ == "__main__":
main()
+2563
View File
File diff suppressed because it is too large Load Diff
+309 -2
View File
@@ -74,6 +74,67 @@ class Scale:
"""List of note names in this scale."""
return [t.name for t in self.tones]
def fitness(self, *note_names: str) -> float:
"""Score how well a set of notes fits this scale (0.01.0).
Returns the fraction of the given notes that appear in the
scale. Useful for melody analysis testing whether a phrase
belongs to a particular scale or mode.
Args:
*note_names: Note name strings (e.g. ``"C"``, ``"F#"``).
Returns:
A float from 0.0 (no notes match) to 1.0 (all notes match).
Example::
>>> c_major = TonedScale(tonic="C4")["major"]
>>> c_major.fitness("C", "D", "E", "G")
1.0
>>> c_major.fitness("C", "D", "F#", "G")
0.75
>>> c_major.fitness("C#", "D#", "F#")
0.0
"""
if not note_names:
return 0.0
scale_notes = set(self.note_names)
matches = sum(1 for n in note_names if n in scale_notes)
return matches / len(note_names)
_DEGREE_NAMES = [
"tonic", "supertonic", "mediant", "subdominant",
"dominant", "submediant", "leading tone",
]
_MINOR_DEGREE_NAMES = [
"tonic", "supertonic", "mediant", "subdominant",
"dominant", "submediant", "subtonic",
]
def degree_name(self, n: int, *, minor: bool = False) -> str:
"""Return the traditional name for the nth scale degree (0-indexed).
Args:
n: The scale degree index (0 = tonic, 1 = supertonic, etc.).
minor: If True, use "subtonic" instead of "leading tone" for degree 6.
Returns:
A string like "tonic", "dominant", etc.
Example::
>>> TonedScale(tonic="C4")["major"].degree_name(0)
'tonic'
>>> TonedScale(tonic="C4")["major"].degree_name(4)
'dominant'
"""
names = self._MINOR_DEGREE_NAMES if minor else self._DEGREE_NAMES
if 0 <= n < len(names):
return names[n]
return f"degree {n}"
def chord(self, *degrees: int) -> Chord:
"""Build a Chord from scale degrees (0-indexed).
@@ -144,11 +205,22 @@ class Scale:
for num in numerals:
is_seventh = num.endswith("7")
clean = num.rstrip("7")
# Handle flat-degree prefixes: bVI, bVII, bIII, etc.
flat_offset = 0
if clean.startswith("b") and len(clean) > 1:
clean = clean[1:]
flat_offset = -1 # one semitone down
elif clean.startswith("#") and len(clean) > 1:
clean = clean[1:]
flat_offset = 1 # one semitone up
degree = numeral_mod.roman2int(clean.upper()) - 1
if is_seventh:
chords.append(self.seventh(degree))
chord = self.seventh(degree)
else:
chords.append(self.triad(degree))
chord = self.triad(degree)
if flat_offset != 0:
chord = chord.transpose(flat_offset)
chords.append(chord)
return chords
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
@@ -221,6 +293,60 @@ class Scale:
return (best[1], best[2], best[3])
return None
@staticmethod
def recommend(*note_names: str, top: int = 5) -> list[tuple[str, str, float]]:
"""Recommend the best-matching scales for a set of notes, ranked by fitness.
Tests the given notes against every scale in the Western system
and returns the top matches. Useful for figuring out what scale
a melody or chord progression belongs to, or finding alternative
scales to play over a set of changes.
Args:
*note_names: Note name strings (e.g. ``"C"``, ``"E"``, ``"G"``).
top: Number of results to return (default 5).
Returns:
A list of ``(tonic, scale_name, fitness)`` tuples sorted
by fitness descending. Fitness is 0.01.0.
Example::
>>> Scale.recommend("C", "D", "E", "G", "A")
[('C', 'major', 1.0), ('G', 'major', 1.0), ...]
>>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb")
[('C', 'blues', 1.0), ...]
"""
if not note_names:
return []
results = []
chromatic = ["C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"]
for tonic in chromatic:
ts = TonedScale(tonic=f"{tonic}4")
for scale_name in ts.scales:
try:
scale = ts[scale_name]
fit = scale.fitness(*note_names)
if fit > 0:
results.append((tonic, scale_name, fit))
except (KeyError, ValueError):
continue
# Penalize chromatic scale — it matches everything but tells you nothing
# Also prefer scales whose length is closer to the input length
input_len = len(note_names)
def _score(r):
tonic, name, fit = r
penalty = 0.5 if "chromatic" in name else 0
return (-fit + penalty, abs(input_len - 7), name, tonic)
results.sort(key=_score)
return results[:top]
def harmonize(self) -> list[Chord]:
"""Build diatonic triads on every scale degree.
@@ -235,6 +361,41 @@ class Scale:
unique = len(self.tones) - 1
return [self.triad(i) for i in range(unique)]
def parallel_modes(self) -> dict[str, list[str]]:
"""All modes that share the same notes as this scale.
For example, C major shares its notes with D dorian,
E phrygian, F lydian, G mixolydian, A aeolian, and
B locrian.
Returns:
A dict mapping ``"tonic mode"`` to note name lists.
Example::
>>> c_major = TonedScale(tonic="C4")["major"]
>>> c_major.parallel_modes()
{'C ionian': ['C', 'D', 'E', ...], 'D dorian': ['D', 'E', 'F', ...], ...}
"""
mode_names = ["ionian", "dorian", "phrygian", "lydian",
"mixolydian", "aeolian", "locrian"]
unique = len(self.tones) - 1
if unique != 7:
return {}
result = {}
for i, mode in enumerate(mode_names):
if i >= unique:
break
tonic = self.tones[i]
ts = TonedScale(tonic=tonic.full_name)
try:
scale = ts[mode]
result[f"{tonic.name} {mode}"] = scale.note_names
except KeyError:
continue
return result
def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
# Ensure that both major and minor aren't passed.
@@ -465,6 +626,30 @@ class Key:
root = target.add(7)
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
def common_progressions(self) -> dict[str, list]:
"""Named chord progressions realized in this key.
Returns a dict mapping progression names (from ``PROGRESSIONS``)
to lists of Chord objects built in this key.
Example::
>>> key = Key("C", "major")
>>> for name, chords in key.common_progressions().items():
... symbols = [c.symbol or str(c) for c in chords]
... print(f"{name}: {''.join(symbols)}")
I-IV-V-I: C F G C
I-V-vi-IV: C G Am F
...
"""
result = {}
for name, numerals in PROGRESSIONS.items():
try:
result[name] = self.progression(*numerals)
except (KeyError, ValueError, IndexError):
continue
return result
@classmethod
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
@@ -588,6 +773,58 @@ class Key:
chords.append(random.choice([harmonized[0], harmonized[4 % unique]]))
return chords
# Common chord movement tendencies in major keys (0-indexed degrees).
# Maps each degree to a list of likely next degrees, ordered by frequency.
_TENDENCY = {
0: [3, 4, 5, 1], # I → IV, V, vi, ii
1: [4, 0, 6], # ii → V, I, vii
2: [5, 3, 1], # iii → vi, IV, ii
3: [4, 0, 1], # IV → V, I, ii
4: [0, 5, 3], # V → I, vi, IV
5: [1, 3, 4], # vi → ii, IV, V
6: [0, 5], # vii° → I, vi
}
def suggest_next(self, chord) -> list:
"""Suggest likely next chords based on voice-leading tendencies.
Given a chord in this key, returns a ranked list of chords
that commonly follow it, based on standard functional harmony
rules (e.g. V I, ii V, IV V).
Args:
chord: A Chord object currently being played.
Returns:
A list of Chord objects, most likely first.
Example::
>>> key = Key("C", "major")
>>> g = key.triad(4) # G major (V)
>>> [c.symbol for c in key.suggest_next(g)]
['C', 'Am', 'F']
"""
harmonized = self._scale.harmonize()
unique = len(harmonized)
# Find which degree this chord is
chord_id = chord.identify()
if not chord_id:
return harmonized[:3]
degree = None
for i, h in enumerate(harmonized):
if h.identify() == chord_id:
degree = i
break
if degree is None:
return harmonized[:3]
tendencies = self._TENDENCY.get(degree, [0])
return [harmonized[d % unique] for d in tendencies]
@property
def relative(self) -> Optional[Key]:
"""The relative major or minor key.
@@ -614,6 +851,76 @@ class Key:
return Key(self.tonic_name, "major")
return None
def modulation_path(self, target: Key) -> list:
"""Suggest a chord-by-chord path from this key to a target key.
Strategy:
- Find pivot chords (common to both keys)
- Build: [I of current key, pivot chord, V of target key, I of target key]
- If no pivot chord exists, use chromatic approach:
[current I, target V, target I]
Args:
target: The target Key to modulate to.
Returns:
A list of Chord objects forming a modulation path.
Example::
>>> path = Key("C", "major").modulation_path(Key("G", "major"))
>>> len(path)
4
"""
from .chords import Chord
current_I = self.triad(0)
target_V = target.triad(4)
target_I = target.triad(0)
# Find pivot chords
own_chords = self._scale.harmonize()
target_chord_names = set(target.chords)
pivot = None
for c in own_chords:
cid = c.identify()
if cid and cid in target_chord_names and cid != current_I.identify():
pivot = c
break
if pivot is not None:
return [current_I, pivot, target_V, target_I]
else:
# Chromatic approach - no pivot chord
return [current_I, target_V, target_I]
def pivot_chords(self, target: Key) -> list[str]:
"""Find chords common to this key and a target key.
Pivot chords are the bridge for modulation they belong to
both keys, so a listener accepts them in either context. The
more pivot chords two keys share, the smoother the modulation.
Closely related keys (e.g. C major G major) share many
pivot chords. Distant keys (e.g. C major F# major) share
few or none.
Args:
target: The key to modulate to.
Returns:
A list of chord name strings common to both keys.
Example::
>>> Key("C", "major").pivot_chords(Key("G", "major"))
['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor']
"""
own = set(self.chords)
other = set(target.chords)
return sorted(own & other)
class TonedScale:
def __init__(self, *, system: Union[str, System] = SYSTEMS["western"], tonic: Union[str, Tone]) -> None:
+149 -22
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Optional, Union
from ._statics import REFERENCE_A, TEMPERAMENTS
from ._statics import REFERENCE_A, TEMPERAMENTS, C_INDEX
class Interval:
@@ -100,6 +100,60 @@ class Tone:
"""Return a list containing the primary name and all alternate names."""
return [self.name] + self.alt_names
@property
def scientific(self) -> str:
"""Scientific pitch notation (e.g. ``'C4'``, ``'A#3'``).
This is the default notation used throughout PyTheory
note name followed by octave number. Middle C is C4.
Same as ``full_name``.
"""
return self.full_name
@property
def helmholtz(self) -> str:
"""Helmholtz pitch notation.
The older European convention still used in some contexts:
- C2 ``CC`` (sub-contra)
- C3 ``C`` (great octave)
- C4 ``c`` (small octave / middle C)
- C5 ``c'`` (one-line)
- C6 ``c''`` (two-line)
- C7 ``c'''``
Accidentals are preserved as-is (e.g. ``c#'``).
Example::
>>> Tone.from_string("C4").helmholtz
'c'
>>> Tone.from_string("C3").helmholtz
'C'
>>> Tone.from_string("C5").helmholtz
"c'"
>>> Tone.from_string("A2").helmholtz
'AA'
"""
if self.octave is None:
return self.name
letter = self.name[0]
accidental = self.name[1:]
if self.octave <= 2:
# Contra and sub-contra: uppercase repeated
# Octave 2 = contra (CC), 1 = sub-contra (CCC), 0 = (CCCC)
repeats = 4 - self.octave
return (letter.upper() * repeats) + accidental
elif self.octave == 3:
# Great octave: single uppercase
return letter.upper() + accidental
else:
# Octave 4+: lowercase with tick marks
ticks = self.octave - 4
tick_str = "'" * ticks if ticks > 0 else ""
return letter.lower() + accidental + tick_str
@property
def is_natural(self) -> bool:
"""True if this is a natural note (no sharp or flat)."""
@@ -153,6 +207,61 @@ class Tone:
pass
return None
_SOLFEGE_MAP = {
"C": "Do", "D": "Re", "E": "Mi", "F": "Fa",
"G": "Sol", "A": "La", "B": "Ti",
}
_SOLFEGE_SHARP_MAP = {
"C#": "Di", "D#": "Ri", "F#": "Fi", "G#": "Si", "A#": "Li",
"E#": "Mi", "B#": "Do",
}
_SOLFEGE_FLAT_MAP = {
"Db": "Ra", "Eb": "Me", "Gb": "Se", "Ab": "Le", "Bb": "Te",
"Fb": "Mi", "Cb": "Ti",
}
@property
def solfege(self) -> str:
"""Map Western note names to fixed-Do solfege syllables.
Uses fixed Do system where C is always Do regardless of key.
- C->Do, D->Re, E->Mi, F->Fa, G->Sol, A->La, B->Ti
- Sharps: C#->Di, D#->Ri, F#->Fi, G#->Si, A#->Li
- Flats: Db->Ra, Eb->Me, Gb->Se, Ab->Le, Bb->Te
Returns the note name unchanged if the system isn't western
or the name isn't recognized.
Example::
>>> Tone.from_string("C4").solfege
'Do'
>>> Tone.from_string("F#4").solfege
'Fi'
"""
# Check system
sys_name = self.system_name
if sys_name is not None and sys_name != "western":
return self.name
if self._system is not None:
try:
if hasattr(self._system, 'name') and self._system.name != "western":
return self.name
except (AttributeError, TypeError):
pass
name = self.name
if name in self._SOLFEGE_MAP:
return self._SOLFEGE_MAP[name]
if name in self._SOLFEGE_SHARP_MAP:
return self._SOLFEGE_SHARP_MAP[name]
if name in self._SOLFEGE_FLAT_MAP:
return self._SOLFEGE_FLAT_MAP[name]
return name
def __repr__(self) -> str:
return f"<Tone {self.full_name}>"
@@ -168,13 +277,12 @@ class Tone:
return self.subtract(other)
# Tone - Tone: semitone distance
if isinstance(other, Tone):
c_index = 3
try:
mod = len(self.system.tones)
except AttributeError:
raise ValueError("Tone math can only be computed with an associated system!")
self_from_c0 = ((self._index - c_index) % mod) + ((self.octave or 0) * mod)
other_from_c0 = ((other._index - c_index) % mod) + ((other.octave or 0) * mod)
self_from_c0 = ((self._index - C_INDEX) % mod) + ((self.octave or 0) * mod)
other_from_c0 = ((other._index - C_INDEX) % mod) + ((other.octave or 0) * mod)
return self_from_c0 - other_from_c0
return NotImplemented
@@ -278,12 +386,11 @@ class Tone:
semitones = round(semitones_from_a4)
# A4 is index 0 in the Western system, octave 4
# Convert to absolute position from C0
c_index = 3
a4_from_c0 = ((0 - c_index) % 12) + (4 * 12) # = 57
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
index = (relative + C_INDEX) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
@@ -302,11 +409,10 @@ class Tone:
>>> Tone.from_midi(69)
<Tone A4>
"""
c_index = 3
adjusted = note_number - 12 # MIDI C0=12
octave = adjusted // 12
relative = adjusted % 12
index = (relative + c_index) % 12
index = (relative + C_INDEX) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
@@ -367,17 +473,13 @@ class Tone:
"Tone math can only be computed with an associated system!"
)
# C is at index 3 in the Western tone list (A=0, A#=1, B=2, C=3, ...)
# Scientific pitch notation changes octave at C, not A.
c_index = 3
# Convert to absolute semitones from C0
note_from_c0 = ((self._index - c_index) % mod) + (octave * mod)
note_from_c0 = ((self._index - C_INDEX) % 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_INDEX) % mod
return (new_index, new_octave)
@@ -453,8 +555,7 @@ class Tone:
"""
if self.octave is None:
return None
c_index = 3
semitones_from_c0 = ((self._index - c_index) % 12) + (self.octave * 12)
semitones_from_c0 = ((self._index - C_INDEX) % 12) + (self.octave * 12)
return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0)
def transpose(self, semitones: int) -> Tone:
@@ -465,6 +566,35 @@ class Tone:
"""
return self.add(semitones)
def cents_difference(self, other: Tone, *, temperament: str = "equal") -> float:
"""Difference in cents between this tone and another.
One semitone = 100 cents. Musicians use cents to measure fine
pitch differences e.g. comparing equal temperament to
Pythagorean tuning, or checking how far out of tune a note is.
Args:
other: The tone to compare against.
temperament: Tuning temperament for both tones.
Returns:
Signed float positive means *other* is higher.
Example::
>>> a4 = Tone.from_string("A4", system="western")
>>> a4.cents_difference(a4 + 1) # one semitone
100.0
>>> a4_pyth = a4.pitch(temperament="pythagorean")
>>> a4_equal = a4.pitch(temperament="equal")
"""
import math
f1 = self.pitch(temperament=temperament)
f2 = other.pitch(temperament=temperament)
if f1 <= 0 or f2 <= 0:
raise ValueError("Both tones must have positive frequencies")
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.
@@ -564,11 +694,8 @@ class Tone:
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave if self.octave is not None else 4
# C is at index 3; convert to semitones from C0 for both
# this note and the reference A4.
c_index = 3
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_INDEX) % tones) + (octave * tones)
a4_from_c0 = ((0 - C_INDEX) % tones) + (4 * tones) # A4
diff = note_from_c0 - a4_from_c0
octave_shift = diff // tones
+2534 -6
View File
File diff suppressed because it is too large Load Diff
Generated
+166 -2
View File
@@ -306,6 +306,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "mdurl", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "mdurl", marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
@@ -391,6 +422,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -400,6 +453,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
]
[[package]]
name = "myst-parser"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
dependencies = [
{ name = "docutils", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "jinja2", marker = "python_full_version < '3.11'" },
{ name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "mdit-py-plugins", marker = "python_full_version < '3.11'" },
{ name = "pyyaml", marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985, upload-time = "2025-02-12T10:53:03.833Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579, upload-time = "2025-02-12T10:53:02.078Z" },
]
[[package]]
name = "myst-parser"
version = "5.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
"python_full_version == '3.11.*'",
]
dependencies = [
{ name = "docutils", version = "0.22.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "jinja2", marker = "python_full_version >= '3.11'" },
{ name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "mdit-py-plugins", marker = "python_full_version >= '3.11'" },
{ name = "pyyaml", marker = "python_full_version >= '3.11'" },
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/fa/7b45eef11b7971f0beb29d27b7bfe0d747d063aa29e170d9edd004733c8a/myst_parser-5.0.0.tar.gz", hash = "sha256:f6f231452c56e8baa662cc352c548158f6a16fcbd6e3800fc594978002b94f3a", size = 98535, upload-time = "2026-01-15T09:08:18.036Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" },
]
[[package]]
name = "numeral"
version = "0.1.0.17"
@@ -612,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.4.1"
version = "0.30.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },
@@ -627,6 +722,8 @@ dev = [
{ name = "pytest" },
]
docs = [
{ name = "myst-parser", version = "4.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "myst-parser", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
@@ -642,7 +739,10 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [{ name = "pytest" }]
docs = [{ name = "sphinx" }]
docs = [
{ name = "myst-parser" },
{ name = "sphinx" },
]
[[package]]
name = "pytuning"
@@ -657,6 +757,70 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/59/e2c2fc91688f788587fb387ef6120c9a1ad3a8b88771fba9fc6a9c9a969d/PyTuning-0.7.3-py3-none-any.whl", hash = "sha256:db0b1231c012c1cf6a3c73aa7d791b4cff79a72f2ec6535f159c873fe302214b", size = 108174, upload-time = "2023-09-02T21:11:00.657Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"