From 9e0faf840b8f5e99b19ade23454bf5477372a0c0 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:10:36 -0400 Subject: [PATCH 1/7] =?UTF-8?q?Add=20interactive=20REPL=20=E2=80=94=20musi?= =?UTF-8?q?c=20theory=20scratchpad=20and=20composition=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pytheory/cli.py | 4 + pytheory/repl.py | 504 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 508 insertions(+) create mode 100644 pytheory/repl.py diff --git a/pytheory/cli.py b/pytheory/cli.py index 1b43ef2..826fdef 100644 --- a/pytheory/cli.py +++ b/pytheory/cli.py @@ -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, diff --git a/pytheory/repl.py b/pytheory/repl.py new file mode 100644 index 0000000..1f0744c --- /dev/null +++ b/pytheory/repl.py @@ -0,0 +1,504 @@ +"""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 +""" + +import readline +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 + progression I V vi IV key.progression(...) + modes show all modes + scales list available scales + + 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 + + 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._drum_preset = preset + session.score.drums(preset, repeats=repeats) + 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): + numerals = ["I", "ii", "iii", "IV", "V", "vi", "vii°"] + for num, chord in zip(numerals, session.key.chords): + print(f" {num: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_clear(session, args): + session.rebuild() + print(" cleared") + + +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, + "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, + "clear": cmd_clear, + "status": cmd_status, +} + + +# ── Main ─────────────────────────────────────────────────────────────────── + +def main(): + session = Session() + + print() + print(" ♫ PyTheory REPL") + print(" ════════════════════════════════════════") + print(f" key={session.key} bpm={session.bpm}") + print(" type 'help' for commands, 'quit' to exit") + print() + + while True: + try: + line = input("pytheory> ").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() From 20932f48aba746d1630e754c101492a1c505e1f0 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:12:54 -0400 Subject: [PATCH 2/7] =?UTF-8?q?Context-aware=20prompt:=20pytheory[key=3DAm?= =?UTF-8?q?=20bpm=3D140=20drums=3Dbossa=20nova=20=E2=86=92lead]>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pytheory/repl.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pytheory/repl.py b/pytheory/repl.py index 1f0744c..50ace03 100644 --- a/pytheory/repl.py +++ b/pytheory/repl.py @@ -463,19 +463,29 @@ COMMANDS = { # ── Main ─────────────────────────────────────────────────────────────────── +def _prompt(session): + """Build a context-aware prompt showing current state.""" + parts = [f"key={session.key.tonic_name}{('m' if session.key.mode == 'minor' else '')}"] + parts.append(f"bpm={session.bpm}") + if session._drum_preset: + parts.append(f"drums={session._drum_preset}") + if session.current_part is not None: + parts.append(f"→{session.current_part.name}") + return f"pytheory[{' '.join(parts)}]> " + + def main(): session = Session() print() print(" ♫ PyTheory REPL") print(" ════════════════════════════════════════") - print(f" key={session.key} bpm={session.bpm}") print(" type 'help' for commands, 'quit' to exit") print() while True: try: - line = input("pytheory> ").strip() + line = input(_prompt(session)).strip() except (EOFError, KeyboardInterrupt): print("\n ♫") break From a2948872152b44444a445839c1d7dc5fc6ca054d Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:14:00 -0400 Subject: [PATCH 3/7] Multiline prompt: compact when short, stacks when context grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pytheory/repl.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/pytheory/repl.py b/pytheory/repl.py index 50ace03..eb50b84 100644 --- a/pytheory/repl.py +++ b/pytheory/repl.py @@ -464,14 +464,34 @@ COMMANDS = { # ── Main ─────────────────────────────────────────────────────────────────── def _prompt(session): - """Build a context-aware prompt showing current state.""" - parts = [f"key={session.key.tonic_name}{('m' if session.key.mode == 'minor' else '')}"] - parts.append(f"bpm={session.bpm}") + """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: - parts.append(f"drums={session._drum_preset}") + ctx.append(f"drums={session._drum_preset}") if session.current_part is not None: - parts.append(f"→{session.current_part.name}") - return f"pytheory[{' '.join(parts)}]> " + 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(): From e5f258bc2196c7c8157ffb15036278da79f50075 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:15:11 -0400 Subject: [PATCH 4/7] Add guided welcome: 5 commands that teach the flow Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/repl.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pytheory/repl.py b/pytheory/repl.py index eb50b84..eee00a4 100644 --- a/pytheory/repl.py +++ b/pytheory/repl.py @@ -500,7 +500,14 @@ def main(): print() print(" ♫ PyTheory REPL") print(" ════════════════════════════════════════") - print(" type 'help' for commands, 'quit' to exit") + 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: From e0bf637de5d83d4809efe84d6e4bfee0b28d4521 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:20:25 -0400 Subject: [PATCH 5/7] 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) --- pytheory/repl.py | 130 +++++++++++++++++++++++++++++- test_pytheory.py | 202 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 1 deletion(-) diff --git a/pytheory/repl.py b/pytheory/repl.py index eee00a4..757de28 100644 --- a/pytheory/repl.py +++ b/pytheory/repl.py @@ -55,9 +55,14 @@ def cmd_help(session, args): key Am Key("A", "minor") key G major Key("G", "major") chords key.chords - progression I V vi IV key.progression(...) + 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) @@ -98,6 +103,10 @@ def cmd_help(session, args): 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 @@ -407,6 +416,119 @@ def cmd_scales(session, args): 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): session.rebuild() print(" cleared") @@ -456,6 +578,12 @@ COMMANDS = { "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, } diff --git a/test_pytheory.py b/test_pytheory.py index b2fbb0b..35fed46 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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 From 45789f7af06dc657e21187d78c0028a001c03b38 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:24:23 -0400 Subject: [PATCH 6/7] 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) --- pytheory/repl.py | 68 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/pytheory/repl.py b/pytheory/repl.py index 757de28..8a67080 100644 --- a/pytheory/repl.py +++ b/pytheory/repl.py @@ -7,7 +7,10 @@ Usage: pytheory repl """ -import readline +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 @@ -166,8 +169,8 @@ def cmd_drums(session, args): preset = " ".join(args[:-1]) if args[-1].isdigit() else " ".join(args) repeats = int(args[-1]) if args[-1].isdigit() else 4 try: - session._drum_preset = preset 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}") @@ -380,13 +383,20 @@ def cmd_show(session, args): 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}") + 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}") @@ -395,9 +405,14 @@ def cmd_show(session, args): def cmd_chords(session, args): - numerals = ["I", "ii", "iii", "IV", "V", "vi", "vii°"] - for num, chord in zip(numerals, session.key.chords): - print(f" {num:6s} {chord}") + 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): @@ -530,8 +545,16 @@ def cmd_circle(session, args): def cmd_clear(session, args): - session.rebuild() - print(" cleared") + """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): @@ -554,7 +577,7 @@ COMMANDS = { "add": cmd_add, "rest": cmd_rest, "arp": cmd_arp, - "prog": cmd_prog, + "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), @@ -602,11 +625,16 @@ def _prompt(session): 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") + 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)}" From b396f42f84511648d1f2ecc8a1c295bd26b718ab Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 21:26:11 -0400 Subject: [PATCH 7/7] 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) --- docs/guide/repl.rst | 276 ++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 277 insertions(+) create mode 100644 docs/guide/repl.rst diff --git a/docs/guide/repl.rst b/docs/guide/repl.rst new file mode 100644 index 0000000..313addf --- /dev/null +++ b/docs/guide/repl.rst @@ -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 ``. + +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 + + 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. diff --git a/docs/index.rst b/docs/index.rst index 0d84f25..8488aee 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -101,6 +101,7 @@ What's Inside guide/effects guide/drums guide/playback + guide/repl guide/cli guide/cookbook