mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 05:07:15 +00:00
feat(song): wire caged + guideTones chord-template fields (§6.6) (#544)
Mirror the voicing field for the two deferred FEP #24 harmony annotations on ChordTemplate: - caged: str ("C"/"A"/"G"/"E"/"D", "" = unset) - guideTones: list[int] (semitone offsets 0..11 above the root, [] = unset) Both are default-omitted on the wire and sanitized on decode (caged enum-guarded, guideTones filtered to in-range ints, rejecting bool) so a malformed value can't round-trip. GP import is untouched — GP carries no CAGED / guide-tone data. Teaching annotations only; never fed to a grader. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3fc077cbc1
commit
4195b73877
+36
-1
@@ -68,6 +68,14 @@ class ChordTemplate:
|
|||||||
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
|
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
|
||||||
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
|
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
|
||||||
voicing: str = ""
|
voicing: str = ""
|
||||||
|
# Harmony annotation (§6.6) — the CAGED shape the fingering derives from,
|
||||||
|
# one of "C"/"A"/"G"/"E"/"D" ("" = unset). Display/teaching only, never grading.
|
||||||
|
caged: str = ""
|
||||||
|
# Harmony annotation (§6.6) — chromatic semitone offsets 0..11 above the
|
||||||
|
# chord root marking the quality-defining tones (e.g. dom7 -> [4, 10]).
|
||||||
|
# snake_case attr; rides the wire as camelCase "guideTones" (like
|
||||||
|
# display_name -> "displayName"). Display/teaching only, never grading.
|
||||||
|
guide_tones: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -319,9 +327,34 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
|||||||
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
|
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
|
||||||
if ct.voicing:
|
if ct.voicing:
|
||||||
out["voicing"] = ct.voicing
|
out["voicing"] = ct.voicing
|
||||||
|
# CAGED shape + guide tones (§6.6) — default-omitted, mirroring voicing.
|
||||||
|
if ct.caged:
|
||||||
|
out["caged"] = ct.caged
|
||||||
|
if ct.guide_tones:
|
||||||
|
out["guideTones"] = list(ct.guide_tones)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# §6.6 CAGED shape enum — the only values accepted off the wire.
|
||||||
|
_CAGED_SHAPES = ("C", "A", "G", "E", "D")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_caged(val) -> str:
|
||||||
|
"""A wire `caged` is kept only when it is one of the CAGED shape letters;
|
||||||
|
anything else (None, int, list, unknown string) falls back to ""."""
|
||||||
|
return val if isinstance(val, str) and val in _CAGED_SHAPES else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_guide_tones(val) -> list:
|
||||||
|
"""A wire `guideTones` is kept only as the int entries in 0..11; non-list
|
||||||
|
input, non-ints (bool is an int subclass — rejected), and out-of-range
|
||||||
|
values are dropped so a malformed value can't round-trip."""
|
||||||
|
if not isinstance(val, list):
|
||||||
|
return []
|
||||||
|
return [v for v in val
|
||||||
|
if isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 11]
|
||||||
|
|
||||||
|
|
||||||
def _wire_int_optional(v, default=-1):
|
def _wire_int_optional(v, default=-1):
|
||||||
"""Parse optional wire ints; fall back to default on null/blank/invalid."""
|
"""Parse optional wire ints; fall back to default on null/blank/invalid."""
|
||||||
if v is None:
|
if v is None:
|
||||||
@@ -883,7 +916,9 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
|||||||
fingers=list(ct.get("fingers", [-1] * 6)),
|
fingers=list(ct.get("fingers", [-1] * 6)),
|
||||||
frets=list(ct.get("frets", [-1] * 6)),
|
frets=list(ct.get("frets", [-1] * 6)),
|
||||||
voicing=(ct.get("voicing")
|
voicing=(ct.get("voicing")
|
||||||
if isinstance(ct.get("voicing"), str) else ""))
|
if isinstance(ct.get("voicing"), str) else ""),
|
||||||
|
caged=_sanitize_caged(ct.get("caged")),
|
||||||
|
guide_tones=_sanitize_guide_tones(ct.get("guideTones")))
|
||||||
for ct in d.get("templates", [])
|
for ct in d.get("templates", [])
|
||||||
],
|
],
|
||||||
# `phrases` is optional — absent on single-level sources / older
|
# `phrases` is optional — absent on single-level sources / older
|
||||||
|
|||||||
@@ -485,6 +485,73 @@ def test_template_voicing_tolerates_malformed(bad):
|
|||||||
assert arr.chord_templates[0].voicing == ""
|
assert arr.chord_templates[0].voicing == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_caged_round_trip():
|
||||||
|
"""A valid CAGED shape survives the template wire + arrangement round-trip."""
|
||||||
|
ct = ChordTemplate(name="Am", display_name="Am", fingers=[-1, 0, 2, 2, 1, 0],
|
||||||
|
frets=[-1, 0, 2, 2, 1, 0], caged="E")
|
||||||
|
assert chord_template_to_wire(ct)["caged"] == "E"
|
||||||
|
arr = Arrangement(name="Rhythm", chord_templates=[ct])
|
||||||
|
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_caged_omitted_when_default():
|
||||||
|
"""An empty caged (the default) produces no `caged` key."""
|
||||||
|
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
|
||||||
|
assert "caged" not in chord_template_to_wire(ct)
|
||||||
|
arr = arrangement_from_wire(arrangement_to_wire(
|
||||||
|
Arrangement(name="Rhythm", chord_templates=[ct])))
|
||||||
|
assert arr.chord_templates[0].caged == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, 7, "X", "e", "", ["E"], {"c": "E"}])
|
||||||
|
def test_template_caged_tolerates_malformed(bad):
|
||||||
|
"""A non-enum caged on the wire falls back to the empty default."""
|
||||||
|
arr = arrangement_from_wire({
|
||||||
|
"name": "Rhythm",
|
||||||
|
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
|
||||||
|
"caged": bad}],
|
||||||
|
})
|
||||||
|
assert arr.chord_templates[0].caged == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_guide_tones_round_trip():
|
||||||
|
"""A non-empty guideTones list survives the template wire + round-trip."""
|
||||||
|
ct = ChordTemplate(name="G7", display_name="G7", fingers=[3, 2, 0, 0, 0, 1],
|
||||||
|
frets=[3, 2, 0, 0, 0, 1], guide_tones=[4, 10])
|
||||||
|
assert chord_template_to_wire(ct)["guideTones"] == [4, 10]
|
||||||
|
arr = Arrangement(name="Rhythm", chord_templates=[ct])
|
||||||
|
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_guide_tones_omitted_when_default():
|
||||||
|
"""An empty guide_tones (the default) produces no `guideTones` key."""
|
||||||
|
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
|
||||||
|
assert "guideTones" not in chord_template_to_wire(ct)
|
||||||
|
arr = arrangement_from_wire(arrangement_to_wire(
|
||||||
|
Arrangement(name="Rhythm", chord_templates=[ct])))
|
||||||
|
assert arr.chord_templates[0].guide_tones == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
(None, []),
|
||||||
|
(12, []),
|
||||||
|
("4,10", []),
|
||||||
|
([12], []),
|
||||||
|
([-1], []),
|
||||||
|
([True, 3], [3]), # bool is an int subclass — rejected
|
||||||
|
([4, "x", 10, 11], [4, 10, 11]),
|
||||||
|
([0, 11], [0, 11]), # boundary values kept
|
||||||
|
])
|
||||||
|
def test_template_guide_tones_tolerates_malformed(raw, expected):
|
||||||
|
"""Non-int / out-of-range guideTones entries are dropped off the wire."""
|
||||||
|
arr = arrangement_from_wire({
|
||||||
|
"name": "Rhythm",
|
||||||
|
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
|
||||||
|
"guideTones": raw}],
|
||||||
|
})
|
||||||
|
assert arr.chord_templates[0].guide_tones == expected
|
||||||
|
|
||||||
|
|
||||||
# ── Arrangement round-trip ───────────────────────────────────────────────────
|
# ── Arrangement round-trip ───────────────────────────────────────────────────
|
||||||
|
|
||||||
def test_arrangement_empty_round_trip():
|
def test_arrangement_empty_round_trip():
|
||||||
|
|||||||
Reference in New Issue
Block a user