Add theory commands, guitar, systems, intervals, 22 REPL tests

Theory: circle, interval, identify, system (with correct per-system tonics)
Guitar: fingering, diagram (scale on fretboard)
22 new tests covering all REPL commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 21:20:25 -04:00
parent e5f258bc21
commit e0bf637de5
2 changed files with 331 additions and 1 deletions
+129 -1
View File
@@ -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,
}
+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