mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-07-21 18:19:30 +00:00
87da8a2d0d
New pytheory.diagrams renders fretboard data as clean, dependency-free SVG you can embed in videos, slides, and worksheets: - Fretboard.tab_image(name) / Fingering.to_svg() — vertical chord-box diagrams with open/muted markers, automatic barre detection, and the root highlighted in red. - Fretboard.scale_shapes(scale) — splits a scale into positional boxes (the five pentatonic positions, etc.), each a small fret window with roots marked; .to_svg() per shape. Boxes follow the slant via a notes-per-string cap so they connect like real CAGED shapes. - Fretboard.arpeggio_diagram(chord) — every chord tone across the neck, labelled by role (R/3/5/7…), roots highlighted. SVG is pure text (no deps); pass fmt="png" to rasterize via the optional cairosvg package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
4.7 KiB
Python
146 lines
4.7 KiB
Python
import xml.dom.minidom as minidom
|
|
|
|
import pytest
|
|
|
|
from pytheory import Fretboard, TonedScale, Chord
|
|
from pytheory.diagrams import (chord_svg, scale_shapes, arpeggio_svg,
|
|
ScaleShape, _ROOT)
|
|
|
|
|
|
def _valid(svg):
|
|
minidom.parseString(svg) # raises on malformed XML
|
|
assert svg.startswith("<svg")
|
|
return svg
|
|
|
|
|
|
# ── chord diagrams ───────────────────────────────────────────────────
|
|
|
|
def test_chord_image_is_valid_svg():
|
|
svg = Fretboard.guitar().tab_image("Am")
|
|
_valid(svg)
|
|
assert "Am" in svg
|
|
|
|
|
|
def test_chord_image_marks_root_in_red():
|
|
# C major has two C roots (A string fret 3, B string fret 1).
|
|
svg = Fretboard.guitar().tab_image("C")
|
|
assert svg.count(_ROOT) == 2
|
|
|
|
|
|
def test_chord_image_detects_barre():
|
|
# F is a full barre — expect a rect (the bar) in the output.
|
|
assert "<rect" in Fretboard.guitar().tab_image("F").replace(
|
|
'<rect width', '') # ignore the background rect
|
|
|
|
|
|
def test_chord_image_writes_file(tmp_path):
|
|
out = tmp_path / "Am.svg"
|
|
ret = Fretboard.guitar().tab_image("Am", str(out))
|
|
assert ret == str(out)
|
|
assert out.read_text().startswith("<svg")
|
|
|
|
|
|
def test_fingering_to_svg():
|
|
svg = Fretboard.guitar().chord("G").to_svg("G")
|
|
_valid(svg)
|
|
|
|
|
|
def test_iterate_chord_images(tmp_path):
|
|
fb = Fretboard.guitar()
|
|
for name in ["C", "Am", "F", "G"]:
|
|
out = tmp_path / f"{name}.svg"
|
|
fb.tab_image(name, str(out))
|
|
assert out.exists()
|
|
|
|
|
|
# ── scale shapes ─────────────────────────────────────────────────────
|
|
|
|
def _a_minor_pentatonic():
|
|
return TonedScale(tonic="A4", system="blues")["minor pentatonic"]
|
|
|
|
|
|
def test_pentatonic_has_five_positions():
|
|
shapes = Fretboard.guitar().scale_shapes(_a_minor_pentatonic())
|
|
assert len(shapes) == 5
|
|
assert all(isinstance(s, ScaleShape) for s in shapes)
|
|
|
|
|
|
def test_pentatonic_two_notes_per_string():
|
|
"""Each pentatonic box is two notes on every string."""
|
|
from collections import Counter
|
|
for shape in Fretboard.guitar().scale_shapes(_a_minor_pentatonic()):
|
|
per_string = Counter(s for (s, _, _, _) in shape.notes)
|
|
assert set(per_string.values()) == {2}
|
|
|
|
|
|
def test_scale_shape_marks_roots():
|
|
shapes = Fretboard.guitar().scale_shapes(_a_minor_pentatonic())
|
|
# Every box contains at least one A root.
|
|
for shape in shapes:
|
|
assert any(is_root for (_, _, _, is_root) in shape.notes)
|
|
svg = shapes[0].to_svg()
|
|
assert _ROOT in svg
|
|
_valid(svg)
|
|
|
|
|
|
def test_scale_shape_window_is_small():
|
|
for shape in Fretboard.guitar().scale_shapes(_a_minor_pentatonic()):
|
|
lo, hi = shape.fret_range
|
|
assert hi - lo <= 4 # a comfortable hand span
|
|
|
|
|
|
def test_diatonic_three_notes_per_string():
|
|
from collections import Counter
|
|
cmaj = TonedScale(tonic="C4")["major"]
|
|
shapes = Fretboard.guitar().scale_shapes(cmaj)
|
|
assert len(shapes) == 7
|
|
counts = Counter(s for (s, _, _, _) in shapes[1].notes)
|
|
assert set(counts.values()) == {3}
|
|
|
|
|
|
def test_scale_shape_image_position_bounds():
|
|
fb = Fretboard.guitar()
|
|
scale = _a_minor_pentatonic()
|
|
_valid(fb.scale_shape_image(scale, 1))
|
|
with pytest.raises(ValueError):
|
|
fb.scale_shape_image(scale, 99)
|
|
|
|
|
|
# ── arpeggio diagrams ────────────────────────────────────────────────
|
|
|
|
def test_arpeggio_diagram_valid_and_labelled():
|
|
svg = Fretboard.guitar().arpeggio_diagram("Am")
|
|
_valid(svg)
|
|
assert ">R<" in svg # root label
|
|
assert ">5<" in svg # fifth label
|
|
|
|
|
|
def test_arpeggio_accepts_chord_object():
|
|
svg = Fretboard.guitar().arpeggio_diagram(Chord.from_symbol("Cmaj7"))
|
|
_valid(svg)
|
|
|
|
|
|
def test_arpeggio_note_labels():
|
|
svg = Fretboard.guitar().arpeggio_diagram("Am", labels="note")
|
|
assert ">A<" in svg and ">C<" in svg
|
|
|
|
|
|
def test_arpeggio_respects_max_fret():
|
|
narrow = Fretboard.guitar().arpeggio_diagram("Am", max_fret=5)
|
|
wide = Fretboard.guitar().arpeggio_diagram("Am", max_fret=12)
|
|
assert len(narrow) < len(wide)
|
|
|
|
|
|
# ── other instruments ────────────────────────────────────────────────
|
|
|
|
def test_works_on_ukulele():
|
|
svg = Fretboard.ukulele().tab_image("C")
|
|
_valid(svg)
|
|
|
|
|
|
def test_png_export_requires_cairosvg(tmp_path):
|
|
pytest.importorskip("cairosvg")
|
|
out = tmp_path / "Am.png"
|
|
Fretboard.guitar().tab_image("Am", str(out), fmt="png")
|
|
assert out.exists()
|