mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 20:57:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
b8382139ca
commit
e33df9a720
+95
-25
@@ -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
@@ -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
@@ -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"),
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user