mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-16 21:53:23 +00:00
feat(core): carry chord harmony fn + template voicing on the wire (§6.3.1, §6.6) (#540)
Add two OPTIONAL per-chord harmony annotations (feedpak 1.7.0), mirroring the
teaching-marks (fg/ch/sd) wire work:
- Chord.fn (instance): {rn, q, deg} harmonic-function object, key-dependent.
Validated by _validate_fn on BOTH decode and emit so a partial / out-of-range
fn (which would fail the schema's required-keys rule) never rides the wire.
Default-omitted, mirroring bend bnv.
- ChordTemplate.voicing (template): key-independent voicing-type string
("open", "triad", "shell", "drop2", "barre", ...). Emitted only when
non-empty; non-string wire values fall back to "".
Display/teaching only — never fed to a grader (honesty rule). fn auto-derivation
is DEFERRED (carry-only): a complete rn/q needs chord-quality analysis, and a
deg-only fn would be schema-invalid, so server.py carries author-provided fn
unchanged. GP import unchanged (no reliable per-chord function/voicing).
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
f182bd0ab7
commit
ea22791984
+48
-3
@@ -65,6 +65,9 @@ class ChordTemplate:
|
|||||||
frets: list[int]
|
frets: list[int]
|
||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
arpeggio: bool = False
|
arpeggio: bool = False
|
||||||
|
# Harmony annotation (§6.6) — key-independent voicing type, e.g. "open",
|
||||||
|
# "triad", "shell", "drop2", "barre". Display/teaching only, never grading.
|
||||||
|
voicing: str = ""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -73,6 +76,10 @@ class Chord:
|
|||||||
chord_id: int
|
chord_id: int
|
||||||
notes: list[Note] = field(default_factory=list)
|
notes: list[Note] = field(default_factory=list)
|
||||||
high_density: bool = False
|
high_density: bool = False
|
||||||
|
# Harmony annotation (§6.3.1) — key-dependent harmonic function on the chord
|
||||||
|
# INSTANCE: {rn: str, q: str, deg: int 0..11}. All three keys required when
|
||||||
|
# present (see _validate_fn). Display/teaching only, never grading.
|
||||||
|
fn: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -268,12 +275,19 @@ def chord_note_to_wire(cn: Note) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def chord_to_wire(c: Chord) -> dict:
|
def chord_to_wire(c: Chord) -> dict:
|
||||||
return {
|
out = {
|
||||||
"t": round(c.time, 3),
|
"t": round(c.time, 3),
|
||||||
"id": c.chord_id,
|
"id": c.chord_id,
|
||||||
"hd": c.high_density,
|
"hd": c.high_density,
|
||||||
"notes": [chord_note_to_wire(cn) for cn in c.notes],
|
"notes": [chord_note_to_wire(cn) for cn in c.notes],
|
||||||
}
|
}
|
||||||
|
# Harmony function (§6.3.1) — default-omitted, mirroring bend `bnv`. Re-validate
|
||||||
|
# on emit (not just decode) so a directly-constructed Chord can't put a partial
|
||||||
|
# or out-of-range fn on the wire, which would fail the schema's required-keys rule.
|
||||||
|
fn = _validate_fn(c.fn)
|
||||||
|
if fn:
|
||||||
|
out["fn"] = fn
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def anchor_to_wire(a: Anchor) -> dict:
|
def anchor_to_wire(a: Anchor) -> dict:
|
||||||
@@ -290,7 +304,7 @@ def hand_shape_to_wire(h: HandShape) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
||||||
return {
|
out = {
|
||||||
"name": ct.name,
|
"name": ct.name,
|
||||||
# ChordTemplate.display_name defaults to "" on the dataclass, but
|
# ChordTemplate.display_name defaults to "" on the dataclass, but
|
||||||
# the spec defaults displayName to name. Fall back here so
|
# the spec defaults displayName to name. Fall back here so
|
||||||
@@ -302,6 +316,10 @@ def chord_template_to_wire(ct: ChordTemplate) -> dict:
|
|||||||
"fingers": list(ct.fingers),
|
"fingers": list(ct.fingers),
|
||||||
"frets": list(ct.frets),
|
"frets": list(ct.frets),
|
||||||
}
|
}
|
||||||
|
# Harmony voicing (§6.6) — default-omitted, only when non-empty.
|
||||||
|
if ct.voicing:
|
||||||
|
out["voicing"] = ct.voicing
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _wire_int_optional(v, default=-1):
|
def _wire_int_optional(v, default=-1):
|
||||||
@@ -479,6 +497,30 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_fn(raw) -> dict | None:
|
||||||
|
"""Validate an optional chord harmony function (§6.3.1).
|
||||||
|
|
||||||
|
Returns a clean ``{"rn", "q", "deg"}`` dict only when ``raw`` is an object
|
||||||
|
with a non-empty ``rn`` string, a non-empty ``q`` string, and an int ``deg``
|
||||||
|
in 0..11. Any malformed / missing-key / out-of-range input -> ``None`` so a
|
||||||
|
partial fn (which would fail the schema's required-keys rule) never rides the
|
||||||
|
wire. Display/teaching only — MUST NEVER feed a grader. Mirrors the
|
||||||
|
drop-to-default tolerance of `_sanitize_bend_curve`."""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
rn = raw.get("rn")
|
||||||
|
q = raw.get("q")
|
||||||
|
deg = raw.get("deg")
|
||||||
|
if not isinstance(rn, str) or not rn.strip():
|
||||||
|
return None
|
||||||
|
if not isinstance(q, str) or not q.strip():
|
||||||
|
return None
|
||||||
|
# bool is an int subclass — reject it so `deg=True` can't pass as 1.
|
||||||
|
if not isinstance(deg, int) or isinstance(deg, bool) or not (0 <= deg <= 11):
|
||||||
|
return None
|
||||||
|
return {"rn": rn.strip(), "q": q.strip(), "deg": deg}
|
||||||
|
|
||||||
|
|
||||||
def chord_from_wire(d: dict) -> Chord:
|
def chord_from_wire(d: dict) -> Chord:
|
||||||
t = float(d.get("t", 0.0))
|
t = float(d.get("t", 0.0))
|
||||||
return Chord(
|
return Chord(
|
||||||
@@ -486,6 +528,7 @@ def chord_from_wire(d: dict) -> Chord:
|
|||||||
chord_id=int(d.get("id", 0)),
|
chord_id=int(d.get("id", 0)),
|
||||||
high_density=bool(d.get("hd", False)),
|
high_density=bool(d.get("hd", False)),
|
||||||
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
|
notes=[note_from_wire(cn, time=t) for cn in d.get("notes", [])],
|
||||||
|
fn=_validate_fn(d.get("fn")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -838,7 +881,9 @@ def arrangement_from_wire(d: dict) -> Arrangement:
|
|||||||
display_name=ct.get("displayName", ct.get("name", "")),
|
display_name=ct.get("displayName", ct.get("name", "")),
|
||||||
arpeggio=bool(ct.get("arp", False)),
|
arpeggio=bool(ct.get("arp", False)),
|
||||||
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")
|
||||||
|
if isinstance(ct.get("voicing"), str) else ""))
|
||||||
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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from song import (
|
|||||||
arrangement_string_count,
|
arrangement_string_count,
|
||||||
arrangement_to_wire,
|
arrangement_to_wire,
|
||||||
chord_from_wire,
|
chord_from_wire,
|
||||||
|
chord_template_to_wire,
|
||||||
chord_to_wire,
|
chord_to_wire,
|
||||||
sanitize_tempos,
|
sanitize_tempos,
|
||||||
compute_smart_names,
|
compute_smart_names,
|
||||||
@@ -395,6 +396,95 @@ def test_chord_notes_inherit_chord_time_on_deserialization():
|
|||||||
assert all(n.time == 3.0 for n in result.notes)
|
assert all(n.time == 3.0 for n in result.notes)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Harmony annotations: chord fn (§6.3.1) + template voicing (§6.6) ──────────
|
||||||
|
|
||||||
|
def test_chord_fn_round_trip():
|
||||||
|
"""A well-formed fn {rn, q, deg} survives the wire under its literal key."""
|
||||||
|
c = Chord(
|
||||||
|
time=2.0, chord_id=0,
|
||||||
|
notes=[Note(time=2.0, string=0, fret=2)],
|
||||||
|
fn={"rn": "ii7", "q": "m7", "deg": 2},
|
||||||
|
)
|
||||||
|
wire = chord_to_wire(c)
|
||||||
|
assert wire["fn"] == {"rn": "ii7", "q": "m7", "deg": 2}
|
||||||
|
assert chord_from_wire(wire) == c
|
||||||
|
|
||||||
|
|
||||||
|
def test_chord_fn_omitted_when_none():
|
||||||
|
"""fn defaults to None and produces no `fn` key on the wire."""
|
||||||
|
wire = chord_to_wire(Chord(time=0.0, chord_id=0,
|
||||||
|
notes=[Note(time=0.0, string=0, fret=0)]))
|
||||||
|
assert "fn" not in wire
|
||||||
|
assert chord_from_wire(wire).fn is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
None, # absent / null
|
||||||
|
"ii7", # not an object
|
||||||
|
{}, # empty
|
||||||
|
{"rn": "ii7", "q": "m7"}, # missing deg
|
||||||
|
{"rn": "ii7", "deg": 2}, # missing q
|
||||||
|
{"q": "m7", "deg": 2}, # missing rn
|
||||||
|
{"rn": "", "q": "m7", "deg": 2}, # blank rn
|
||||||
|
{"rn": "ii7", "q": " ", "deg": 2}, # blank q
|
||||||
|
{"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range (high)
|
||||||
|
{"rn": "ii7", "q": "m7", "deg": -1}, # deg out of range (low)
|
||||||
|
{"rn": "ii7", "q": "m7", "deg": "2"}, # deg not an int
|
||||||
|
{"rn": "ii7", "q": "m7", "deg": True}, # deg is a bool, not a real int
|
||||||
|
{"rn": 7, "q": "m7", "deg": 2}, # rn not a str
|
||||||
|
])
|
||||||
|
def test_chord_fn_malformed_drops_to_none(bad):
|
||||||
|
"""Any malformed / partial / out-of-range fn decodes to None (never partial)."""
|
||||||
|
c = chord_from_wire({"t": 1.0, "id": 0, "notes": [], "fn": bad})
|
||||||
|
assert c.fn is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_fn", [
|
||||||
|
{"rn": "ii7"}, # missing q + deg
|
||||||
|
{"rn": "ii7", "q": "m7", "deg": 15}, # deg out of range
|
||||||
|
{"rn": "", "q": "m7", "deg": 2}, # blank rn
|
||||||
|
])
|
||||||
|
def test_chord_to_wire_drops_invalid_fn_on_emit(bad_fn):
|
||||||
|
"""A directly-constructed Chord with a partial/out-of-range fn never rides the wire."""
|
||||||
|
wire = chord_to_wire(Chord(time=1.0, chord_id=0, notes=[], fn=bad_fn))
|
||||||
|
assert "fn" not in wire
|
||||||
|
|
||||||
|
|
||||||
|
def test_chord_fn_strips_whitespace_on_decode():
|
||||||
|
c = chord_from_wire({"t": 1.0, "id": 0, "notes": [],
|
||||||
|
"fn": {"rn": " V7 ", "q": " 7 ", "deg": 7}})
|
||||||
|
assert c.fn == {"rn": "V7", "q": "7", "deg": 7}
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_voicing_round_trip():
|
||||||
|
"""A non-empty voicing 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], voicing="open")
|
||||||
|
assert chord_template_to_wire(ct)["voicing"] == "open"
|
||||||
|
arr = Arrangement(name="Rhythm", chord_templates=[ct])
|
||||||
|
assert arrangement_from_wire(arrangement_to_wire(arr)).chord_templates[0] == ct
|
||||||
|
|
||||||
|
|
||||||
|
def test_template_voicing_omitted_when_default():
|
||||||
|
"""An empty voicing (the default) produces no `voicing` key."""
|
||||||
|
ct = ChordTemplate(name="Am", fingers=[-1] * 6, frets=[-1] * 6)
|
||||||
|
assert "voicing" 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].voicing == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [None, 7, ["open"], {"v": "open"}])
|
||||||
|
def test_template_voicing_tolerates_malformed(bad):
|
||||||
|
"""A non-string voicing on the wire falls back to the empty default."""
|
||||||
|
arr = arrangement_from_wire({
|
||||||
|
"name": "Rhythm",
|
||||||
|
"templates": [{"name": "Am", "fingers": [-1] * 6, "frets": [-1] * 6,
|
||||||
|
"voicing": bad}],
|
||||||
|
})
|
||||||
|
assert arr.chord_templates[0].voicing == ""
|
||||||
|
|
||||||
|
|
||||||
# ── 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