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 + + Lead Guitar + 40 45 50 55 59 64 + + + 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 —