From bb249b9baf888ff51574eec04939b023cf03bda7 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Sat, 20 Jun 2026 22:39:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20per-note=20bend=20shape=20(bt=20+?= =?UTF-8?q?=20bnv)=20=E2=80=94=20wire=20+=20GP=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements feedpak spec §6.2.1 (feedpak 1.4.0) per-note bend shape on the core side: - `bn` stays the bend's peak magnitude in semitones (unchanged). - `bt` — bend intent (0 up, 1 release, 2 pre-bend, 3 pre-bend-release, 4 round-trip), default 0, default-omitted on the wire. - `bnv` — time-stamped bend curve [{t: seconds-from-onset, v: semitones}], authoritative when present; default-omitted. Older readers ignore both. Wire (lib/song.py): Note.bend_intent/bend_values; note_to_wire emits bt/bnv only when set; note_from_wire reads them via _sanitize_bend_curve (drops malformed entries, empty -> None never []). _parse_note reads them from the GP-import XML (bendIntent attr + bendValues JSON) so GP curves survive import -> XML -> wire -> highway. GP5 (lib/gp2rs.py): _gp_bend_shape maps pyguitarpro BendPoints to a bnv curve — semitones = value/2.0 (consistent with the existing scalar bn), t = position/12 * duration — and _bend_intent_from_values derives bt from the shape. Emitted for and via the shared _build_xml. GP8 (lib/gp2rs_gpx.py): _gpx_bend_shape builds a 3-point curve from the GPIF origin/middle/destination value+offset Properties (value/divisor semitones, offset/100 * sustain seconds), reusing the shared _build_xml. GPIF offset Property names should be confirmed against a real GP8 export. Tests cover wire round-trip + default-omit + sanitization, GP5 unit/time mapping + intent classification end-to-end through the XML, and the GP8 curve builder. Part of got-feedback/feedback#334 Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/gp2rs.py | 120 +++++++++++++++++++++++++++++++--------- lib/gp2rs_gpx.py | 76 ++++++++++++++++++++----- lib/song.py | 60 ++++++++++++++++++++ tests/test_gp2rs.py | 76 +++++++++++++++++++++++++ tests/test_gp2rs_gpx.py | 45 +++++++++++++++ tests/test_song.py | 79 ++++++++++++++++++++++++++ 6 files changed, 417 insertions(+), 39 deletions(-) diff --git a/lib/gp2rs.py b/lib/gp2rs.py index 486d8337..9b233e9a 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -1,5 +1,6 @@ """Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML.""" +import json import logging import re import xml.etree.ElementTree as ET @@ -56,6 +57,8 @@ class RsNote: fret: int sustain: float = 0.0 bend: float = 0.0 + bend_intent: int = 0 + bend_values: list | None = None slide_to: int = -1 slide_unpitch_to: int = -1 hammer_on: bool = False @@ -191,6 +194,67 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float: return beats * (60.0 / tempo) +# pyguitarpro models bend-point x-positions on 0..BendEffect.maxPosition (12) +# across the note's duration; y-values are half-quarter-tone units where 12 = 6 +# semitones, so semitones = value / 2.0 (matches the scalar `bend` derivation). +_GP_BEND_MAX_POSITION = 12 + + +def _bend_intent_from_values(values: list[float]) -> int: + """Classify a bend gesture (§6.2.1) from its time-ordered semitone values: + 0 up, 1 release, 2 pre-bend, 3 pre-bend-and-release, 4 round-trip.""" + if not values: + return 0 + eps = 0.05 + first, last, peak = values[0], values[-1], max(values) + if first > eps: + if last <= eps: + return 3 # pre-bent, then released to pitch + if last < first - eps: + return 1 # held bend let down + return 2 # pre-bend held + if peak > eps and last <= eps: + return 4 # bend up and back down + return 0 # plain bend up + + +def _gp_bend_shape(bend, duration_secs: float): + """From a pyguitarpro ``BendEffect``, return ``(peak, intent, curve)``. + + ``peak`` is the bend's peak in semitones (the scalar ``bn``); ``intent`` is + the §6.2.1 ``bt`` code; ``curve`` is the time-stamped ``bnv`` list + (``[{t: seconds-from-onset, v: semitones}]``) or ``None`` when there's no + usable shape (no points, or a zero-length note collapsing every point to + ``t=0``).""" + pts = sorted(bend.points or [], key=lambda p: p.position) + if not pts: + return 0.0, 0, None + values = [round(p.value / 2.0, 1) for p in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if duration_secs > 0 and len(pts) >= 2: + curve = [ + {"t": round(duration_secs * (p.position / _GP_BEND_MAX_POSITION), 3), + "v": v} + for p, v in zip(pts, values) + ] + return peak, intent, curve + + +def _bend_shape_xml_attrs(n: "RsNote") -> dict: + """Optional bend-shape XML attributes for a /, default- + omitted: `bendIntent` only when non-zero, `bendValues` (a JSON-encoded + [{t,v}] curve) only when present. `_parse_note` (lib/song.py) reads these + back so a GP-imported bend curve survives import → wire → highway.""" + attrs: dict = {} + if n.bend_intent: + attrs["bendIntent"] = str(int(n.bend_intent)) + if n.bend_values: + attrs["bendValues"] = json.dumps(n.bend_values, separators=(",", ":")) + return attrs + + def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float: """Get the tempo at a given tick.""" result = tempo_map[0].tempo @@ -735,12 +799,13 @@ def convert_track( # Techniques eff = note.effect if eff.bend and eff.bend.points: - # pyguitarpro bend point values are in quarter-tones - # (maxValue 12 = 3 whole tones = 6 semitones), so - # semitones = value / 2. The old /100.0 made every bend - # round to 0 (a whole-tone bend is value 4 -> 0.04). - max_bend = max(p.value for p in eff.bend.points) - rn.bend = round(max_bend / 2.0, 1) + # `bn` is the peak; `bnv`/`bt` describe the shape over + # time (§6.2.1). semitones = value / 2 (maxValue 12 = 6 + # semitones); the old /100.0 made every bend round to 0. + peak, intent, curve = _gp_bend_shape(eff.bend, dur) + rn.bend = peak + rn.bend_intent = intent + rn.bend_values = curve if eff.hammer: # HO vs PO from pitch direction off the prior note on the @@ -1098,6 +1163,7 @@ def _build_xml( "tap": "1" if n.tap else "0", "ignore": "0", } + attrs.update(_bend_shape_xml_attrs(n)) ET.SubElement(notes_el, "note", **attrs) # Chords @@ -1108,25 +1174,29 @@ def _build_xml( chordId=str(ch.template_idx), highDensity="0", strum="down") for cn in ch.notes: - ET.SubElement(chord_el, "chordNote", - time=f"{cn.time:.3f}", - string=str(cn.string), - fret=str(cn.fret), - sustain=f"{cn.sustain:.3f}", - bend=f"{cn.bend:.1f}" if cn.bend else "0", - hammerOn="1" if cn.hammer_on else "0", - pullOff="1" if cn.pull_off else "0", - slideTo=str(cn.slide_to), - slideUnpitchTo=str(cn.slide_unpitch_to), - harmonic="1" if cn.harmonic else "0", - harmonicPinch="1" if cn.harmonic_pinch else "0", - palmMute="1" if cn.palm_mute else "0", - mute="1" if cn.mute else "0", - vibrato="1" if cn.vibrato else "0", - tremolo="1" if cn.tremolo else "0", - accent="1" if cn.accent else "0", - linkNext="1" if cn.link_next else "0", - tap="1" if cn.tap else "0", ignore="0") + cn_attrs = { + "time": f"{cn.time:.3f}", + "string": str(cn.string), + "fret": str(cn.fret), + "sustain": f"{cn.sustain:.3f}", + "bend": f"{cn.bend:.1f}" if cn.bend else "0", + "hammerOn": "1" if cn.hammer_on else "0", + "pullOff": "1" if cn.pull_off else "0", + "slideTo": str(cn.slide_to), + "slideUnpitchTo": str(cn.slide_unpitch_to), + "harmonic": "1" if cn.harmonic else "0", + "harmonicPinch": "1" if cn.harmonic_pinch else "0", + "palmMute": "1" if cn.palm_mute else "0", + "mute": "1" if cn.mute else "0", + "vibrato": "1" if cn.vibrato else "0", + "tremolo": "1" if cn.tremolo else "0", + "accent": "1" if cn.accent else "0", + "linkNext": "1" if cn.link_next else "0", + "tap": "1" if cn.tap else "0", + "ignore": "0", + } + cn_attrs.update(_bend_shape_xml_attrs(cn)) + ET.SubElement(chord_el, "chordNote", **cn_attrs) # Anchors anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors))) diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py index 9588b357..ecd6a93b 100644 --- a/lib/gp2rs_gpx.py +++ b/lib/gp2rs_gpx.py @@ -1147,6 +1147,59 @@ def _gpx_bend_scale(root: ET.Element) -> float: return 50.0 if peak <= 400 else 2500.0 +def _gpx_bend_float(tp: dict, name: str): + """Read a GPIF bend `` value from the property map, or None.""" + el = tp.get(name) + if el is None: + return None + try: + return float(el.findtext('Float') or 0) + except (ValueError, TypeError): + return None + + +def _gpx_bend_shape(tp: dict, divisor: float, sustain: float): + """Build ``(peak, intent, curve)`` from a GPIF note's bend Properties (§6.2.1). + + GPIF describes a bend as origin / middle / destination value+offset pairs; + `value / divisor` is semitones (divisor auto-detected per file) and the + `*Offset` Properties are 0..100 (percent of the note's duration). Produces a + bnv curve of up to three points (mapping each offset to seconds-from-onset), + or ``None`` when there's no usable shape (no points, flat-zero, or a + zero-length note). When an offset Property is absent the stage falls back to + an evenly-spaced default (origin 0%, middle 50%, destination 100%). + + NOTE: offset Property names should be confirmed against a real GP8 export; + the value path matches the existing scalar-bend extraction either way.""" + from gp2rs import _bend_intent_from_values # lazy: gp2rs<->gpx circular + stages = ( + ('BendOriginValue', 'BendOriginOffset', 0.0), + ('BendMiddleValue', 'BendMiddleOffset1', 50.0), + ('BendDestinationValue', 'BendDestinationOffset', 100.0), + ) + pts = [] + for vkey, okey, default_off in stages: + v = _gpx_bend_float(tp, vkey) + if v is None: + continue + off = _gpx_bend_float(tp, okey) + if off is None: + off = default_off + off = max(0.0, min(100.0, off)) + pts.append((off, round(v / divisor, 1))) + if not pts: + return 0.0, 0, None + pts.sort(key=lambda p: p[0]) + values = [v for _, v in pts] + peak = round(max(values), 1) + intent = _bend_intent_from_values(values) + curve = None + if peak > 0 and sustain > 0 and len(pts) >= 2: + curve = [{"t": round(sustain * (off / 100.0), 3), "v": v} + for off, v in pts] + return peak, intent, curve + + def _resolve_pending_slides(rs_notes, rs_chords, pending_slides): """Resolve GP slide flags collected during the beat loop into RS slide fields, now that every note on each string is known. @@ -1564,21 +1617,16 @@ def convert_file( rn.pull_off = True else: rn.hammer_on = True - # Bend: peak amount (GPIF bend value → semitones, - # scale auto-detected per file in _bend_divisor). + # Bend: `bn` is the peak; `bnv`/`bt` capture + # the shape over time (§6.2.1). value/divisor + # = semitones (scale auto-detected per file). if 'Bended' in _tp: - _bv = 0.0 - for _bk in ('BendDestinationValue', - 'BendMiddleValue', 'BendOriginValue'): - _be = _tp.get(_bk) - if _be is not None: - try: - _bv = max(_bv, float( - _be.findtext('Float') or 0)) - except (ValueError, TypeError): - pass - if _bv > 0: - rn.bend = round(_bv / _bend_divisor, 1) + _peak, _intent, _curve = _gpx_bend_shape( + _tp, _bend_divisor, rn.sustain) + if _peak > 0: + rn.bend = _peak + rn.bend_intent = _intent + rn.bend_values = _curve # Slide flags: 1/2 = pitched slide to the next # note; 4 = slide out down, 8 = out up. Resolved # post-loop (needs the next note on the string). diff --git a/lib/song.py b/lib/song.py index b58f5b6f..779c743d 100644 --- a/lib/song.py +++ b/lib/song.py @@ -20,6 +20,13 @@ class Note: slide_to: int = -1 slide_unpitch_to: int = -1 bend: float = 0.0 + # Bend shape (§6.2.1, feedpak 1.4.0). `bend` stays the peak magnitude; + # `bend_intent` is the gesture (0 up, 1 release, 2 pre-bend, + # 3 pre-bend-release, 4 round-trip) and `bend_values` is the optional + # time-stamped curve [{t: seconds-from-onset, v: semitones}], authoritative + # when present. Both default-omitted on the wire; older readers ignore them. + bend_intent: int = 0 + bend_values: list | None = None hammer_on: bool = False pull_off: bool = False harmonic: bool = False @@ -221,6 +228,16 @@ def note_to_wire(n: Note) -> dict: out["pkd"] = n.pick_direction if n.ignore: out["ig"] = True + # Bend shape (§6.2.1) — default-omitted: `bt` only when non-zero, `bnv` + # only when a curve is present. Mirrors the spec's "omit fields equal to + # their default" so a plain bend stays a single `bn` scalar on the wire. + if n.bend_intent: + out["bt"] = int(n.bend_intent) + if n.bend_values: + out["bnv"] = [ + {"t": round(p["t"], 3), "v": round(p["v"], 1)} + for p in n.bend_values + ] return out @@ -283,6 +300,33 @@ def _wire_int_optional(v, default=-1): return default +def _sanitize_bend_curve(raw): + """Clean a time-stamped bend curve (``[{t, v}]``, §6.2.1): keep entries with + a finite, non-bool numeric ``t`` and ``v``, coerced to float and sorted by + ``t``. Non-list / absent / all-invalid input -> ``None`` so an empty curve + round-trips as *omitted*, never ``[]``. ``t`` is seconds from the note + onset; ``v`` is semitones (same scale as the scalar ``bn`` peak).""" + if not isinstance(raw, list): + return None + out: list[dict] = [] + for p in raw: + if not isinstance(p, dict): + continue + t = p.get("t") + v = p.get("v") + if (not isinstance(t, (int, float)) or isinstance(t, bool) + or not math.isfinite(t)): + continue + if (not isinstance(v, (int, float)) or isinstance(v, bool) + or not math.isfinite(v)): + continue + out.append({"t": float(t), "v": float(v)}) + if not out: + return None + out.sort(key=lambda e: e["t"]) + return out + + def note_from_wire(d: dict, time: float | None = None) -> Note: return Note( time=float(d.get("t", time if time is not None else 0.0)), @@ -292,6 +336,8 @@ def note_from_wire(d: dict, time: float | None = None) -> Note: slide_to=int(d.get("sl", -1)), slide_unpitch_to=int(d.get("slu", -1)), bend=float(d.get("bn", 0.0)), + bend_intent=_wire_int_optional(d.get("bt"), 0), + bend_values=_sanitize_bend_curve(d.get("bnv")), hammer_on=bool(d.get("ho", False)), pull_off=bool(d.get("po", False)), harmonic=bool(d.get("hm", False)), @@ -768,6 +814,18 @@ def _chord_high_density(elem: ET.Element) -> bool: return False +def _parse_bend_values(n): + """Read a `bendValues` JSON attribute (GP import emits it; §6.2.1) and + sanitize it into a [{t,v}] curve, or None when absent/malformed.""" + raw = n.get("bendValues") + if not raw: + return None + try: + return _sanitize_bend_curve(json.loads(raw)) + except (ValueError, TypeError): + return None + + def _parse_note(n) -> Note: return Note( time=_float(n, "time"), @@ -777,6 +835,8 @@ def _parse_note(n) -> Note: slide_to=_int(n, "slideTo", -1), slide_unpitch_to=_int(n, "slideUnpitchTo", -1), bend=_float(n, "bend"), + bend_intent=_int(n, "bendIntent", 0), + bend_values=_parse_bend_values(n), hammer_on=_bool(n, "hammerOn"), pull_off=_bool(n, "pullOff"), harmonic=_bool(n, "harmonic"), diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 54ef1970..9d867057 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -20,9 +20,11 @@ from gp2rs import ( GP_TICKS_PER_QUARTER, TempoEvent, + _bend_intent_from_values, _build_playback_schedule, _compute_tuning, _extract_year, + _gp_bend_shape, _gp_string_to_rs, _is_bass_track, _standard_tuning_for, @@ -864,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped(): assert len(notes) == 0 +# ── convert_track: bend shape (bn / bt / bnv, §6.2.1) ──────────────────────── + +def _ct_bend(points): + """A pyguitarpro-shaped BendEffect: points are (position 0..12, value) + pairs where value is half-quarter-tone units (12 = 6 semitones).""" + return SimpleNamespace( + points=[SimpleNamespace(position=p, value=v) for p, v in points], + ) + + +def test_bend_intent_classifier(): + assert _bend_intent_from_values([0.0, 1.0, 2.0]) == 0 # up + assert _bend_intent_from_values([2.0, 1.0, 0.0]) == 3 # pre-bend+release + assert _bend_intent_from_values([2.0, 2.0]) == 2 # pre-bend held + assert _bend_intent_from_values([2.0, 1.0]) == 1 # release (let down) + assert _bend_intent_from_values([0.0, 2.0, 0.0]) == 4 # round-trip + assert _bend_intent_from_values([]) == 0 + + +def test_gp_bend_shape_units_and_time(): + """value/2 = semitones; position/12 * duration = seconds-from-onset.""" + # 0.5 s note, up-bend 0 → value 4 (2 semitones) at the end. + peak, intent, curve = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.5) + assert peak == 2.0 + assert intent == 0 + assert curve == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}] + # Zero-length note collapses every point to t=0 → no usable curve. + _, _, curve0 = _gp_bend_shape(_ct_bend([(0, 0), (12, 4)]), 0.0) + assert curve0 is None + # A single point carries only the peak, no curve. + _, _, curve1 = _gp_bend_shape(_ct_bend([(6, 4)]), 0.5) + assert curve1 is None + + +def test_bent_note_imports_with_curve_through_wire(): + """A GP up-bend imports with bn (peak) + bt + bnv, and survives + convert_track XML → _parse_note → note_to_wire.""" + from song import _parse_note, note_to_wire + note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7) + # quarter @ 120 BPM = 0.5 s; round-trip bend 0 → 2 → 0 semitones. + note.effect.bend = _ct_bend([(0, 0), (6, 4), (12, 0)]) + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("bend") == "2.0" + assert xn.get("bendIntent") == "4" # round-trip + import json + assert json.loads(xn.get("bendValues")) == [ + {"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}] + + wire = note_to_wire(_parse_note(xn)) + assert wire["bn"] == 2.0 + assert wire["bt"] == 4 + assert wire["bnv"] == [ + {"t": 0.0, "v": 0.0}, {"t": 0.25, "v": 2.0}, {"t": 0.5, "v": 0.0}] + + +def test_non_bent_note_has_no_curve(): + note = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5) # bend=None + beat = _ct_beat(tick=0, dur_value=4, notes=[note]) + root = ET.fromstring(convert_track(_ct_song([beat]), track_index=0)) # noqa: S314 + xn = root.findall(".//notes/note")[0] + assert xn.get("bend") == "0" + assert xn.get("bendIntent") is None + assert xn.get("bendValues") is None + + from song import _parse_note + n = _parse_note(xn) + assert n.bend == 0.0 + assert n.bend_intent == 0 + assert n.bend_values is None + + def _ct_multivoice_song(voices_beats): """Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists, one per voice, all on the same single measure.""" diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py index a0038471..d177cb9b 100644 --- a/tests/test_gp2rs_gpx.py +++ b/tests/test_gp2rs_gpx.py @@ -31,6 +31,7 @@ _collect_tone_events, _inject_tones, _resolve_pending_slides, + _gpx_bend_shape, ) from gp2rs import RsNote @@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected): assert ".." not in out +# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ───────────────────────────────── + +def _bend_props(**vals): + """Build a GPIF property map {name: element} for the given + bend Float values, e.g. _bend_props(BendOriginValue=0, BendMiddleValue=100).""" + tp = {} + for name, num in vals.items(): + tp[name] = ET.fromstring( + f'{num}') + return tp + + +def test_gpx_bend_shape_round_trip_curve(): + """origin/middle/destination value+offset → 3-point bnv; value/divisor=semis.""" + tp = _bend_props( + BendOriginValue=0, BendOriginOffset=0, + BendMiddleValue=100, BendMiddleOffset1=50, # 100/50 = 2 semitones + BendDestinationValue=0, BendDestinationOffset=100, + ) + peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0) + assert peak == 2.0 + assert intent == 4 # round-trip (up then back down) + assert curve == [ + {"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}, {"t": 1.0, "v": 0.0}] + + +def test_gpx_bend_shape_falls_back_to_even_spacing_without_offsets(): + tp = _bend_props(BendOriginValue=0, BendDestinationValue=100) # no offsets + peak, intent, curve = _gpx_bend_shape(tp, divisor=50.0, sustain=1.0) + assert peak == 2.0 + assert intent == 0 # plain up + # origin defaults to 0%, destination to 100%. + assert curve == [{"t": 0.0, "v": 0.0}, {"t": 1.0, "v": 2.0}] + + +def test_gpx_bend_shape_no_props_and_zero_length(): + assert _gpx_bend_shape({}, divisor=50.0, sustain=1.0) == (0.0, 0, None) + # Peak + intent still derived for a zero-length note, but no curve. + peak, intent, curve = _gpx_bend_shape( + _bend_props(BendOriginValue=0, BendDestinationValue=100), + divisor=50.0, sustain=0.0) + assert peak == 2.0 and intent == 0 and curve is None + + # ── _decompress_bcfz / _parse_bcfs input guards ───────────────────────────── def test_decompress_bcfz_rejects_bad_magic(): diff --git a/tests/test_song.py b/tests/test_song.py index 73217fca..01163093 100644 --- a/tests/test_song.py +++ b/tests/test_song.py @@ -169,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal(): assert note_to_wire(n)["bn"] == 1.8 +# ── Bend shape (bt / bnv, §6.2.1) ──────────────────────────────────────────── + +def test_note_bend_shape_round_trip(): + """A note with bend intent + a time-stamped curve survives the wire.""" + n = Note( + time=0.5, string=0, fret=7, sustain=1.0, + bend=2.0, + bend_intent=4, # round-trip + bend_values=[ + {"t": 0.0, "v": 0.0}, + {"t": 0.25, "v": 2.0}, + {"t": 0.5, "v": 0.0}, + ], + ) + wire = note_to_wire(n) + assert wire["bt"] == 4 + assert wire["bnv"] == [ + {"t": 0.0, "v": 0.0}, + {"t": 0.25, "v": 2.0}, + {"t": 0.5, "v": 0.0}, + ] + assert note_from_wire(wire) == n + + +def test_note_bend_shape_omitted_when_default(): + """`bt`/`bnv` are default-omitted; absence decodes to 0 / None (not 0-present + / not []).""" + wire = note_to_wire(Note(time=0.0, string=0, fret=0, bend=1.0)) + assert "bt" not in wire + assert "bnv" not in wire + decoded = note_from_wire(wire) + assert decoded.bend_intent == 0 + assert decoded.bend_values is None + + +def test_note_bend_values_rounded_on_wire(): + """`bnv` rounds `t` to 3 and `v` to 1, matching the scalar `bn` precision.""" + n = Note( + time=0.0, string=0, fret=0, bend=1.0, bend_intent=1, + bend_values=[{"t": 0.123456, "v": 1.749}], + ) + assert note_to_wire(n)["bnv"] == [{"t": 0.123, "v": 1.7}] + + +def test_note_bend_values_sanitized_from_wire(): + """Malformed `bnv` entries are dropped; bad/empty -> None; result sorted by t.""" + # NaN / non-dict / non-numeric entries dropped, remaining sorted by t. + n = note_from_wire({ + "t": 0.0, "s": 0, "f": 0, "bn": 2.0, + "bnv": [ + {"t": 0.5, "v": 2.0}, + {"t": 0.0, "v": 0.0}, + {"t": "x", "v": 1.0}, # non-numeric t -> dropped + {"t": 0.25, "v": float("nan")}, # non-finite v -> dropped + "garbage", # non-dict -> dropped + ], + }) + assert n.bend_values == [{"t": 0.0, "v": 0.0}, {"t": 0.5, "v": 2.0}] + # Empty / non-list / all-invalid collapse to None (never []). + for bad in (None, [], "nope", [{"t": "a", "v": "b"}], [42]): + assert note_from_wire( + {"t": 0.0, "s": 0, "f": 0, "bnv": bad}).bend_values is None + + +def test_chord_note_carries_bend_shape(): + """Chord member notes inherit bt/bnv through chord_note_to_wire/chord_from_wire.""" + c = Chord( + time=2.0, chord_id=0, + notes=[Note( + time=2.0, string=1, fret=5, bend=1.0, bend_intent=2, + bend_values=[{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}], + )], + ) + decoded = chord_from_wire(chord_to_wire(c)) + cn = decoded.notes[0] + assert cn.bend_intent == 2 + assert cn.bend_values == [{"t": 0.0, "v": 1.0}, {"t": 0.3, "v": 0.0}] + + # ── Chord round-trip ───────────────────────────────────────────────────────── def test_chord_with_multiple_notes_round_trip():