Merge pull request #37 from kennethreitz/repl

Add interactive REPL — music theory scratchpad
This commit is contained in:
2026-03-25 21:26:20 -04:00
committed by GitHub
5 changed files with 1180 additions and 0 deletions
+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.
+1
View File
@@ -101,6 +101,7 @@ What's Inside
guide/effects
guide/drums
guide/playback
guide/repl
guide/cli
guide/cookbook
+4
View File
@@ -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,
+697
View File
@@ -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()
+202
View File
@@ -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