Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions lib/song.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -479,13 +497,38 @@ 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(
time=t,
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")),
)


Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions tests/test_song.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
arrangement_string_count,
arrangement_to_wire,
chord_from_wire,
chord_template_to_wire,
chord_to_wire,
sanitize_tempos,
compute_smart_names,
Expand Down Expand Up @@ -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():
Expand Down
Loading