From f1bae9774cc35aff01a3f482f3657fc92f18fbc4 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Fri, 10 Jul 2026 06:12:10 -0500 Subject: [PATCH] fix(gp2rs): write beat times at 6-decimal precision so imported tempo matches Guitar Pro (#819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gp2rs): write beat times at 6-decimal precision The editor/timeline derives per-bar BPM from beat spans (bpm = beats*60/span), which amplifies rounding: at millisecond (3-decimal) precision a constant-tempo GP import (e.g. 140 BPM) shows a spurious per-bar "tempo drift" of ~0.05-0.7 BPM because most bar lengths don't land on a ms boundary (worse for fast/odd meters). gp2rs computes these beat times exactly from the GP tempo map, so the only precision loss is the ebeat/startBeat format string. Writing them at 6 decimals (microseconds) makes the derived tempo match GP's authored value. Verified on GP5 imports (Highway to Hell 116, Equivalence 140, Living After Midnight 138): the derived per-bar BPM collapses from two drifting values to the single authored constant. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv Signed-off-by: ChrisBeWithYou * test(gp2rs): compare ebeat times by value, not string The 6-decimal beat-time write makes _assert_ebeats' exact-string compare fail ("0.500" vs "0.500000"). These tests only assert spacing, so parse both sides to float — precision-agnostic, no need to rewrite every parametrized list. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Signed-off-by: ChrisBeWithYou Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Byron Gamatos --- lib/gp2rs.py | 13 ++++++++++--- tests/test_gp2rs.py | 5 ++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/gp2rs.py b/lib/gp2rs.py index dcbbd59..525d542 100644 --- a/lib/gp2rs.py +++ b/lib/gp2rs.py @@ -1114,7 +1114,7 @@ def _build_xml( ET.SubElement(root, "arrangement").text = arrangement ET.SubElement(root, "offset").text = f"{audio_offset:.3f}" ET.SubElement(root, "songLength").text = f"{song_length:.3f}" - ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000" + ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000" ET.SubElement(root, "averageTempo").text = str(tempo) ET.SubElement(root, "artistName").text = artist ET.SubElement(root, "albumName").text = album @@ -1139,10 +1139,17 @@ def _build_xml( tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0)) ET.SubElement(root, "capo").text = "0" - # Ebeats + # Ebeats — write beat times at MICROSECOND (6-decimal) precision, not + # millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat + # spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a + # constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift + # (worse for fast/odd meters) because most bar lengths don't land on a ms + # boundary. gp2rs computes these times exactly from the GP tempo map, so the + # only loss is this format string — 6 decimals makes the derived tempo match + # GP's authored value. (Everything else stays at :.3f; only beats drive tempo.) ebeats = ET.SubElement(root, "ebeats", count=str(len(beats))) for b in beats: - ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure)) + ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure)) # Sections sections_el = ET.SubElement(root, "sections", count=str(len(sections))) diff --git a/tests/test_gp2rs.py b/tests/test_gp2rs.py index 0c0331c..bbaf611 100644 --- a/tests/test_gp2rs.py +++ b/tests/test_gp2rs.py @@ -121,7 +121,10 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None): def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None): ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes) - assert [ebeat.get("time") for ebeat in ebeats] == expected_times + # Compare by value, not string: beat times are written at 6-decimal + # (microsecond) precision so the derived per-bar tempo matches the authored + # GP value, but these tests only care about the spacing, not the format. + assert [float(ebeat.get("time")) for ebeat in ebeats] == [float(t) for t in expected_times] assert [ebeat.get("measure") for ebeat in ebeats] == [ "1", *["-1"] * (len(expected_times) - 1),