From ea227919841c1f08646a4d467c405b0cb301fae8 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 21 Jun 2026 11:02:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20carry=20chord=20harmony=20fn=20+?= =?UTF-8?q?=20template=20voicing=20on=20the=20wire=20(=C2=A76.3.1,=20?= =?UTF-8?q?=C2=A76.6)=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/song.py | 51 ++++++++++++++++++++++++-- tests/test_song.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/lib/song.py b/lib/song.py index 284c194..a1ba100 100644 --- a/lib/song.py +++ b/lib/song.py @@ -65,6 +65,9 @@ class ChordTemplate: frets: list[int] display_name: str = "" 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 @@ -73,6 +76,10 @@ class Chord: chord_id: int notes: list[Note] = field(default_factory=list) 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 @@ -268,12 +275,19 @@ def chord_note_to_wire(cn: Note) -> dict: def chord_to_wire(c: Chord) -> dict: - return { + out = { "t": round(c.time, 3), "id": c.chord_id, "hd": c.high_density, "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: @@ -290,7 +304,7 @@ def hand_shape_to_wire(h: HandShape) -> dict: def chord_template_to_wire(ct: ChordTemplate) -> dict: - return { + out = { "name": ct.name, # ChordTemplate.display_name defaults to "" on the dataclass, but # 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), "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): @@ -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: t = float(d.get("t", 0.0)) return Chord( @@ -486,6 +528,7 @@ def chord_from_wire(d: dict) -> Chord: chord_id=int(d.get("id", 0)), high_density=bool(d.get("hd", False)), 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", "")), arpeggio=bool(ct.get("arp", False)), 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", []) ], # `phrases` is optional — absent on single-level sources / older diff --git a/tests/test_song.py b/tests/test_song.py index 655dfe6..7f2da95 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -17,6 +17,7 @@ from song import ( arrangement_string_count, arrangement_to_wire, chord_from_wire, + chord_template_to_wire, chord_to_wire, sanitize_tempos, 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) +# ── 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 ─────────────────────────────────────────────────── def test_arrangement_empty_round_trip():