diff --git a/lib/gp2rs_gpx.py b/lib/gp2rs_gpx.py
index cc41020..5256f3a 100644
--- a/lib/gp2rs_gpx.py
+++ b/lib/gp2rs_gpx.py
@@ -722,6 +722,20 @@ def _note_has_vibrato(note_el: ET.Element, prop_map: dict) -> bool:
return 'Vibrato' in prop_map or note_el.find('Vibrato') is not None
+def _beat_has_tremolo(beat_el: ET.Element) -> bool:
+ """True if a GP7/GP8 beat carries tremolo picking.
+
+ GPIF encodes tremolo picking as a DIRECT beat-level
+ ``1/8`` child of ```` (the value is the rate). The
+ RS note model has a single boolean tremolo flag with no rate, so the rate is
+ intentionally ignored — any tremolo-picked beat maps to note tremolo across
+ it. Matched as a direct child (not ``.//``) so it is never confused with the
+ whammy-bar ``VibratoWTremBar`` Property, a separate beat-level effect
+ handled elsewhere.
+ """
+ return beat_el.find('Tremolo') is not None
+
+
# ---------------------------------------------------------------------------
# list_tracks — mirrors gp2rs.list_tracks interface
# ---------------------------------------------------------------------------
@@ -1696,6 +1710,17 @@ def convert_file(
for _bn in beat_rs_notes:
_bn.vibrato = True
+ # Tremolo picking: GP7/GP8 encodes the rate as a
+ # beat-level 1/8 child. The note
+ # model has a single tremolo flag (no rate), so map
+ # any tremolo-picked beat to note tremolo across it.
+ # Independent of vibrato above — a note can carry
+ # both. (Beat-level , not the whammy
+ # VibratoWTremBar Property, which is handled above.)
+ if _beat_has_tremolo(beat_el):
+ for _bn in beat_rs_notes:
+ _bn.tremolo = True
+
if len(beat_rs_notes) == 1:
rs_notes.append(beat_rs_notes[0])
elif len(beat_rs_notes) > 1:
diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py
index 4a8ae15..9c0a4ea 100644
--- a/tests/test_gp2rs_gpx.py
+++ b/tests/test_gp2rs_gpx.py
@@ -21,6 +21,7 @@ from gp2rs_gpx import (
_safe_filename_stem,
_note_is_tie,
_note_has_vibrato,
+ _beat_has_tremolo,
_note_midi,
_gpx_percussion_midis,
_gpx_tuning,
@@ -558,6 +559,55 @@ def test_convert_file_gp8_ascending_tuning_not_mirrored(tmp_path, monkeypatch):
assert (5, 0) in placed # high-e open note on RS string 5
+# Beat 0 carries a beat-level ; beat 1 does not. End-to-end proof that
+# the picked beat's note serializes tremolo="1" and the other stays "0".
+_GPIF_GUITAR_TREMOLO = """
+
+ TA
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0 1
+
+
+ 1/80
+ 1
+
+
+
+ 0
+ 0
+
+ 5
+ 0
+
+ Quarter
+
+"""
+
+
+def test_convert_file_gp8_tremolo_beat_flags_note(tmp_path, monkeypatch):
+ monkeypatch.setattr(gp2rs_gpx, "_load_gpif", lambda _p: ET.fromstring(_GPIF_GUITAR_TREMOLO))
+ out_files = convert_file(
+ "dummy.gp", str(tmp_path),
+ track_indices=[0], arrangement_names={0: "Lead"},
+ )
+ root = ET.parse(out_files[0]).getroot()
+ tremolo_by_string = {int(n.get("string")): n.get("tremolo")
+ for n in root.iter() if n.tag == "note"}
+ # Beat 0 note (RS string 0) was tremolo-picked; beat 1 note (string 5) wasn't.
+ assert tremolo_by_string[0] == "1"
+ assert tremolo_by_string[5] == "0"
+
+
def test_vocal_pitch_sidecar_sorts_multi_voice_by_time():
# Two voices in one bar. Voice 0 (traversed first) emits its lyric note at
# t=0.5 (a no-lyric quarter precedes it); voice 1 (traversed second) emits
@@ -732,6 +782,40 @@ def test_note_vibrato_ignores_whammy_trembar_property():
assert _note_has_vibrato(n, tp) is False
+# ── _beat_has_tremolo (GP6/7/8 tremolo-picking import) ──────────────────────
+
+def test_beat_tremolo_direct_element():
+ # GPIF encodes tremolo picking as a direct rate child of
+ # — the regression this fixes (GPX never read it). Rate-agnostic.
+ for rate in ("1/8", "1/16", "1/32"):
+ b = ET.fromstring(f''
+ f'{rate}')
+ assert _beat_has_tremolo(b) is True
+
+
+def test_beat_tremolo_absent_is_false():
+ b = ET.fromstring('1')
+ assert _beat_has_tremolo(b) is False
+
+
+def test_beat_tremolo_is_direct_child_only():
+ # Matched as a direct child (not `.//`), so a buried deeper must
+ # NOT trigger — guards against a false positive from unrelated nested markup.
+ b = ET.fromstring(''
+ '1/8'
+ '')
+ assert _beat_has_tremolo(b) is False
+
+
+def test_beat_tremolo_independent_of_whammy_trembar():
+ # VibratoWTremBar is the separate whammy-bar effect; a beat carrying only
+ # that (no ) is not tremolo picking.
+ b = ET.fromstring(''
+ 'Slight'
+ '')
+ assert _beat_has_tremolo(b) is False
+
+
# ── _gpif_left_fingering (GP7/GP8 per-note fret-hand finger -> fg) ───────────
# GPIF stores a single note's fret-hand finger as a direct
# child of (NOT a ), with classical p-i-m-a-c letter codes —