mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Merge pull request #37 from kennethreitz/repl
Add interactive REPL — music theory scratchpad
This commit is contained in:
@@ -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.
|
||||
@@ -101,6 +101,7 @@ What's Inside
|
||||
guide/effects
|
||||
guide/drums
|
||||
guide/playback
|
||||
guide/repl
|
||||
guide/cli
|
||||
guide/cookbook
|
||||
|
||||
|
||||
@@ -386,6 +386,9 @@ def main():
|
||||
# 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")
|
||||
@@ -420,6 +423,7 @@ def main():
|
||||
"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,
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
"""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 # noqa: F401 — enables line editing on Unix
|
||||
except ImportError:
|
||||
pass # Windows: REPL works without line editing
|
||||
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♫> "
|
||||
|
||||
|
||||
def main():
|
||||
session = Session()
|
||||
|
||||
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()
|
||||
@@ -5975,3 +5975,205 @@ def test_section_unknown_raises():
|
||||
score = Score("4/4", bpm=120)
|
||||
with pytest.raises(ValueError, match="Unknown section"):
|
||||
score.repeat("nonexistent")
|
||||
|
||||
|
||||
# ── REPL ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_repl_session_defaults():
|
||||
from pytheory.repl import Session
|
||||
s = Session()
|
||||
assert str(s.key) == "C major"
|
||||
assert s.bpm == 120
|
||||
assert s.current_part is None
|
||||
assert s._drum_preset is None
|
||||
|
||||
|
||||
def test_repl_cmd_key():
|
||||
from pytheory.repl import Session, cmd_key
|
||||
s = Session()
|
||||
cmd_key(s, ["Am"])
|
||||
assert s.key.tonic_name == "A"
|
||||
assert s.key.mode == "minor"
|
||||
|
||||
|
||||
def test_repl_cmd_key_major():
|
||||
from pytheory.repl import Session, cmd_key
|
||||
s = Session()
|
||||
cmd_key(s, ["G", "major"])
|
||||
assert s.key.tonic_name == "G"
|
||||
assert s.key.mode == "major"
|
||||
|
||||
|
||||
def test_repl_cmd_bpm():
|
||||
from pytheory.repl import Session, cmd_bpm
|
||||
s = Session()
|
||||
cmd_bpm(s, ["140"])
|
||||
assert s.bpm == 140
|
||||
assert s.score.bpm == 140
|
||||
|
||||
|
||||
def test_repl_cmd_swing():
|
||||
from pytheory.repl import Session, cmd_swing
|
||||
s = Session()
|
||||
cmd_swing(s, ["0.5"])
|
||||
assert s.swing == 0.5
|
||||
|
||||
|
||||
def test_repl_cmd_drums():
|
||||
from pytheory.repl import Session, cmd_drums
|
||||
s = Session()
|
||||
cmd_drums(s, ["rock"])
|
||||
assert s._drum_preset == "rock"
|
||||
assert len(s.score._drum_hits) > 0
|
||||
|
||||
|
||||
def test_repl_cmd_part():
|
||||
from pytheory.repl import Session, cmd_part
|
||||
s = Session()
|
||||
cmd_part(s, ["lead", "saw", "pluck"])
|
||||
assert "lead" in s.parts
|
||||
assert s.current_part is not None
|
||||
assert s.current_part.synth == "saw"
|
||||
assert s.current_part.envelope == "pluck"
|
||||
|
||||
|
||||
def test_repl_cmd_add_note():
|
||||
from pytheory.repl import Session, cmd_add
|
||||
s = Session()
|
||||
cmd_add(s, ["C5", "1"])
|
||||
assert s.current_part is not None # auto-created
|
||||
assert len(s.current_part.notes) == 1
|
||||
|
||||
|
||||
def test_repl_cmd_add_chord():
|
||||
from pytheory.repl import Session, cmd_add
|
||||
s = Session()
|
||||
cmd_add(s, ["Am", "4"])
|
||||
assert len(s.current_part.notes) == 1
|
||||
|
||||
|
||||
def test_repl_cmd_rest():
|
||||
from pytheory.repl import Session, cmd_rest
|
||||
s = Session()
|
||||
s.ensure_part("lead")
|
||||
s.current_part = s.parts["lead"]
|
||||
cmd_rest(s, ["2"])
|
||||
assert len(s.current_part.notes) == 1
|
||||
assert s.current_part.notes[0].tone is None
|
||||
|
||||
|
||||
def test_repl_cmd_arp():
|
||||
from pytheory.repl import Session, cmd_part, cmd_arp
|
||||
s = Session()
|
||||
cmd_part(s, ["lead"])
|
||||
cmd_arp(s, ["Am", "updown", "2", "2"])
|
||||
assert len(s.current_part.notes) > 0
|
||||
|
||||
|
||||
def test_repl_cmd_prog():
|
||||
from pytheory.repl import Session, cmd_key, cmd_prog
|
||||
s = Session()
|
||||
cmd_key(s, ["Am"])
|
||||
cmd_prog(s, ["i", "iv", "V", "i"])
|
||||
assert len(s.current_part.notes) == 4
|
||||
|
||||
|
||||
def test_repl_cmd_effects():
|
||||
from pytheory.repl import Session, cmd_part, _set_effect
|
||||
s = Session()
|
||||
cmd_part(s, ["lead", "saw"])
|
||||
_set_effect(s, "reverb", ["0.4"])
|
||||
assert s.current_part.reverb_mix == 0.4
|
||||
_set_effect(s, "delay", ["0.3", "0.375"])
|
||||
assert s.current_part.delay_mix == 0.3
|
||||
assert s.current_part.delay_time == 0.375
|
||||
_set_effect(s, "lowpass", ["2000", "3"])
|
||||
assert s.current_part.lowpass == 2000
|
||||
assert s.current_part.lowpass_q == 3.0
|
||||
_set_effect(s, "distortion", ["0.5"])
|
||||
assert s.current_part.distortion_mix == 0.5
|
||||
|
||||
|
||||
def test_repl_cmd_legato():
|
||||
from pytheory.repl import Session, cmd_part, cmd_legato
|
||||
s = Session()
|
||||
cmd_part(s, ["lead"])
|
||||
cmd_legato(s, [])
|
||||
assert s.current_part.legato is True
|
||||
cmd_legato(s, ["off"])
|
||||
assert s.current_part.legato is False
|
||||
|
||||
|
||||
def test_repl_cmd_set():
|
||||
from pytheory.repl import Session, cmd_part, cmd_add, cmd_set
|
||||
s = Session()
|
||||
cmd_part(s, ["lead"])
|
||||
cmd_add(s, ["C5", "4"])
|
||||
cmd_set(s, ["lowpass", "3000"])
|
||||
assert len(s.current_part._automation) == 1
|
||||
|
||||
|
||||
def test_repl_cmd_lfo():
|
||||
from pytheory.repl import Session, cmd_part, cmd_lfo
|
||||
s = Session()
|
||||
cmd_part(s, ["lead"])
|
||||
cmd_lfo(s, ["lowpass", "0.5", "400", "3000", "4"])
|
||||
assert len(s.current_part._automation) > 0
|
||||
|
||||
|
||||
def test_repl_save_midi(tmp_path):
|
||||
from pytheory.repl import Session, cmd_key, cmd_prog, cmd_save_midi
|
||||
s = Session()
|
||||
cmd_key(s, ["Am"])
|
||||
cmd_prog(s, ["i", "iv", "V", "i"])
|
||||
path = str(tmp_path / "test.mid")
|
||||
cmd_save_midi(s, [path])
|
||||
assert (tmp_path / "test.mid").exists()
|
||||
|
||||
|
||||
def test_repl_prompt_compact():
|
||||
from pytheory.repl import Session, _prompt
|
||||
s = Session()
|
||||
p = _prompt(s)
|
||||
assert "key=C" in p
|
||||
assert "bpm=120" in p
|
||||
|
||||
|
||||
def test_repl_prompt_with_part():
|
||||
from pytheory.repl import Session, cmd_part, _prompt
|
||||
s = Session()
|
||||
cmd_part(s, ["lead", "saw"])
|
||||
p = _prompt(s)
|
||||
assert "→lead(saw)" in p
|
||||
|
||||
|
||||
def test_repl_prompt_multiline():
|
||||
from pytheory.repl import Session, cmd_part, cmd_drums, _prompt, _set_effect
|
||||
s = Session()
|
||||
cmd_drums(s, ["bossa", "nova"])
|
||||
cmd_part(s, ["lead", "saw"])
|
||||
_set_effect(s, "reverb", ["0.4"])
|
||||
_set_effect(s, "lowpass", ["2000"])
|
||||
_set_effect(s, "distortion", ["0.5"])
|
||||
p = _prompt(s)
|
||||
assert "♫>" in p # should be multiline
|
||||
|
||||
|
||||
def test_repl_clear():
|
||||
from pytheory.repl import Session, cmd_part, cmd_drums, cmd_clear
|
||||
s = Session()
|
||||
cmd_drums(s, ["rock"])
|
||||
cmd_part(s, ["lead"])
|
||||
cmd_clear(s, [])
|
||||
assert len(s.parts) == 0
|
||||
assert s.current_part is None
|
||||
|
||||
|
||||
def test_repl_chords(capsys):
|
||||
from pytheory.repl import Session, cmd_key, cmd_chords
|
||||
s = Session()
|
||||
cmd_key(s, ["C"])
|
||||
cmd_chords(s, [])
|
||||
out = capsys.readouterr().out
|
||||
assert "C major" in out
|
||||
assert "D minor" in out
|
||||
|
||||
Reference in New Issue
Block a user