feat(core): per-note bend shape (bt + bnv) — wire + GP import (#531)

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 <note> and <chordNote> 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) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-20 23:17:39 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b8382139ca
commit e33df9a720
6 changed files with 417 additions and 39 deletions
+95 -25
View File
@@ -1,5 +1,6 @@
"""Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML.""" """Convert Guitar Pro files (.gp5/.gp4/.gp3) to arrangement XML."""
import json
import logging import logging
import re import re
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -56,6 +57,8 @@ class RsNote:
fret: int fret: int
sustain: float = 0.0 sustain: float = 0.0
bend: float = 0.0 bend: float = 0.0
bend_intent: int = 0
bend_values: list | None = None
slide_to: int = -1 slide_to: int = -1
slide_unpitch_to: int = -1 slide_unpitch_to: int = -1
hammer_on: bool = False hammer_on: bool = False
@@ -191,6 +194,67 @@ def _duration_to_seconds(duration: guitarpro.Duration, tempo: float) -> float:
return beats * (60.0 / tempo) 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 <note>/<chordNote>, 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: def _tempo_at_tick(tick: int, tempo_map: list[TempoEvent]) -> float:
"""Get the tempo at a given tick.""" """Get the tempo at a given tick."""
result = tempo_map[0].tempo result = tempo_map[0].tempo
@@ -735,12 +799,13 @@ def convert_track(
# Techniques # Techniques
eff = note.effect eff = note.effect
if eff.bend and eff.bend.points: if eff.bend and eff.bend.points:
# pyguitarpro bend point values are in quarter-tones # `bn` is the peak; `bnv`/`bt` describe the shape over
# (maxValue 12 = 3 whole tones = 6 semitones), so # time (§6.2.1). semitones = value / 2 (maxValue 12 = 6
# semitones = value / 2. The old /100.0 made every bend # semitones); the old /100.0 made every bend round to 0.
# round to 0 (a whole-tone bend is value 4 -> 0.04). peak, intent, curve = _gp_bend_shape(eff.bend, dur)
max_bend = max(p.value for p in eff.bend.points) rn.bend = peak
rn.bend = round(max_bend / 2.0, 1) rn.bend_intent = intent
rn.bend_values = curve
if eff.hammer: if eff.hammer:
# HO vs PO from pitch direction off the prior note on the # 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", "tap": "1" if n.tap else "0",
"ignore": "0", "ignore": "0",
} }
attrs.update(_bend_shape_xml_attrs(n))
ET.SubElement(notes_el, "note", **attrs) ET.SubElement(notes_el, "note", **attrs)
# Chords # Chords
@@ -1108,25 +1174,29 @@ def _build_xml(
chordId=str(ch.template_idx), chordId=str(ch.template_idx),
highDensity="0", strum="down") highDensity="0", strum="down")
for cn in ch.notes: for cn in ch.notes:
ET.SubElement(chord_el, "chordNote", cn_attrs = {
time=f"{cn.time:.3f}", "time": f"{cn.time:.3f}",
string=str(cn.string), "string": str(cn.string),
fret=str(cn.fret), "fret": str(cn.fret),
sustain=f"{cn.sustain:.3f}", "sustain": f"{cn.sustain:.3f}",
bend=f"{cn.bend:.1f}" if cn.bend else "0", "bend": f"{cn.bend:.1f}" if cn.bend else "0",
hammerOn="1" if cn.hammer_on else "0", "hammerOn": "1" if cn.hammer_on else "0",
pullOff="1" if cn.pull_off else "0", "pullOff": "1" if cn.pull_off else "0",
slideTo=str(cn.slide_to), "slideTo": str(cn.slide_to),
slideUnpitchTo=str(cn.slide_unpitch_to), "slideUnpitchTo": str(cn.slide_unpitch_to),
harmonic="1" if cn.harmonic else "0", "harmonic": "1" if cn.harmonic else "0",
harmonicPinch="1" if cn.harmonic_pinch else "0", "harmonicPinch": "1" if cn.harmonic_pinch else "0",
palmMute="1" if cn.palm_mute else "0", "palmMute": "1" if cn.palm_mute else "0",
mute="1" if cn.mute else "0", "mute": "1" if cn.mute else "0",
vibrato="1" if cn.vibrato else "0", "vibrato": "1" if cn.vibrato else "0",
tremolo="1" if cn.tremolo else "0", "tremolo": "1" if cn.tremolo else "0",
accent="1" if cn.accent else "0", "accent": "1" if cn.accent else "0",
linkNext="1" if cn.link_next else "0", "linkNext": "1" if cn.link_next else "0",
tap="1" if cn.tap else "0", ignore="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
anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors))) anchors_el = ET.SubElement(level, "anchors", count=str(len(anchors)))
+62 -14
View File
@@ -1147,6 +1147,59 @@ def _gpx_bend_scale(root: ET.Element) -> float:
return 50.0 if peak <= 400 else 2500.0 return 50.0 if peak <= 400 else 2500.0
def _gpx_bend_float(tp: dict, name: str):
"""Read a GPIF bend `<Property><Float>` 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): def _resolve_pending_slides(rs_notes, rs_chords, pending_slides):
"""Resolve GP slide flags collected during the beat loop into RS slide """Resolve GP slide flags collected during the beat loop into RS slide
fields, now that every note on each string is known. fields, now that every note on each string is known.
@@ -1564,21 +1617,16 @@ def convert_file(
rn.pull_off = True rn.pull_off = True
else: else:
rn.hammer_on = True rn.hammer_on = True
# Bend: peak amount (GPIF bend value → semitones, # Bend: `bn` is the peak; `bnv`/`bt` capture
# scale auto-detected per file in _bend_divisor). # the shape over time (§6.2.1). value/divisor
# = semitones (scale auto-detected per file).
if 'Bended' in _tp: if 'Bended' in _tp:
_bv = 0.0 _peak, _intent, _curve = _gpx_bend_shape(
for _bk in ('BendDestinationValue', _tp, _bend_divisor, rn.sustain)
'BendMiddleValue', 'BendOriginValue'): if _peak > 0:
_be = _tp.get(_bk) rn.bend = _peak
if _be is not None: rn.bend_intent = _intent
try: rn.bend_values = _curve
_bv = max(_bv, float(
_be.findtext('Float') or 0))
except (ValueError, TypeError):
pass
if _bv > 0:
rn.bend = round(_bv / _bend_divisor, 1)
# Slide flags: 1/2 = pitched slide to the next # Slide flags: 1/2 = pitched slide to the next
# note; 4 = slide out down, 8 = out up. Resolved # note; 4 = slide out down, 8 = out up. Resolved
# post-loop (needs the next note on the string). # post-loop (needs the next note on the string).
+60
View File
@@ -20,6 +20,13 @@ class Note:
slide_to: int = -1 slide_to: int = -1
slide_unpitch_to: int = -1 slide_unpitch_to: int = -1
bend: float = 0.0 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 hammer_on: bool = False
pull_off: bool = False pull_off: bool = False
harmonic: bool = False harmonic: bool = False
@@ -221,6 +228,16 @@ def note_to_wire(n: Note) -> dict:
out["pkd"] = n.pick_direction out["pkd"] = n.pick_direction
if n.ignore: if n.ignore:
out["ig"] = True 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 return out
@@ -283,6 +300,33 @@ def _wire_int_optional(v, default=-1):
return default 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: def note_from_wire(d: dict, time: float | None = None) -> Note:
return Note( return Note(
time=float(d.get("t", time if time is not None else 0.0)), 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_to=int(d.get("sl", -1)),
slide_unpitch_to=int(d.get("slu", -1)), slide_unpitch_to=int(d.get("slu", -1)),
bend=float(d.get("bn", 0.0)), 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)), hammer_on=bool(d.get("ho", False)),
pull_off=bool(d.get("po", False)), pull_off=bool(d.get("po", False)),
harmonic=bool(d.get("hm", False)), harmonic=bool(d.get("hm", False)),
@@ -768,6 +814,18 @@ def _chord_high_density(elem: ET.Element) -> bool:
return False 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: def _parse_note(n) -> Note:
return Note( return Note(
time=_float(n, "time"), time=_float(n, "time"),
@@ -777,6 +835,8 @@ def _parse_note(n) -> Note:
slide_to=_int(n, "slideTo", -1), slide_to=_int(n, "slideTo", -1),
slide_unpitch_to=_int(n, "slideUnpitchTo", -1), slide_unpitch_to=_int(n, "slideUnpitchTo", -1),
bend=_float(n, "bend"), bend=_float(n, "bend"),
bend_intent=_int(n, "bendIntent", 0),
bend_values=_parse_bend_values(n),
hammer_on=_bool(n, "hammerOn"), hammer_on=_bool(n, "hammerOn"),
pull_off=_bool(n, "pullOff"), pull_off=_bool(n, "pullOff"),
harmonic=_bool(n, "harmonic"), harmonic=_bool(n, "harmonic"),
+76
View File
@@ -20,9 +20,11 @@ import pytest
from gp2rs import ( from gp2rs import (
GP_TICKS_PER_QUARTER, GP_TICKS_PER_QUARTER,
TempoEvent, TempoEvent,
_bend_intent_from_values,
_build_playback_schedule, _build_playback_schedule,
_compute_tuning, _compute_tuning,
_extract_year, _extract_year,
_gp_bend_shape,
_gp_string_to_rs, _gp_string_to_rs,
_is_bass_track, _is_bass_track,
_standard_tuning_for, _standard_tuning_for,
@@ -864,6 +866,80 @@ def test_tied_note_without_predecessor_is_silently_dropped():
assert len(notes) == 0 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): def _ct_multivoice_song(voices_beats):
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists, """Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
one per voice, all on the same single measure.""" one per voice, all on the same single measure."""
+45
View File
@@ -31,6 +31,7 @@ from gp2rs_gpx import (
_collect_tone_events, _collect_tone_events,
_inject_tones, _inject_tones,
_resolve_pending_slides, _resolve_pending_slides,
_gpx_bend_shape,
) )
from gp2rs import RsNote from gp2rs import RsNote
@@ -55,6 +56,50 @@ def test_safe_filename_stem(name, expected):
assert ".." not in out assert ".." not in out
# ── _gpx_bend_shape (bn / bt / bnv, §6.2.1) ─────────────────────────────────
def _bend_props(**vals):
"""Build a GPIF property map {name: <Property> 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'<Property name="{name}"><Float>{num}</Float></Property>')
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 ───────────────────────────── # ── _decompress_bcfz / _parse_bcfs input guards ─────────────────────────────
def test_decompress_bcfz_rejects_bad_magic(): def test_decompress_bcfz_rejects_bad_magic():
+79
View File
@@ -169,6 +169,85 @@ def test_note_bend_nonzero_rounded_to_one_decimal():
assert note_to_wire(n)["bn"] == 1.8 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 ───────────────────────────────────────────────────────── # ── Chord round-trip ─────────────────────────────────────────────────────────
def test_chord_with_multiple_notes_round_trip(): def test_chord_with_multiple_notes_round_trip():