mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
+129
-1
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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