feedBack/tests/test_gp2rs.py
ChrisBeWithYou f1bae9774c
fix(gp2rs): write beat times at 6-decimal precision so imported tempo matches Guitar Pro (#819)
* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* 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) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-10 13:12:10 +02:00

1391 lines
55 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for lib/gp2rs.py tempo/tick math helpers + playback-schedule walker.
The math helpers are fixture-free: hand-constructed `TempoEvent` lists and
integer tick / string inputs. The playback-schedule tests use
`SimpleNamespace` mocks shaped like `guitarpro.MeasureHeader` / `Song` —
the schedule walker only reads a small set of attributes, so we don't need
real .gp files on disk.
See issue #46 (tempo math) and the GP repeat-expansion PR for the schedule
walker.
"""
import xml.etree.ElementTree as ET
from types import SimpleNamespace
from unittest import mock
import guitarpro
import pytest
from gp2rs import (
GP_TICKS_PER_QUARTER,
TempoEvent,
_bend_intent_from_values,
_build_playback_schedule,
_compute_tuning,
_extract_year,
_gp_bend_shape,
_gp_string_to_rs,
_is_bass_track,
_standard_tuning_for,
_tempo_at_tick,
_tick_to_seconds,
convert_drum_track,
convert_piano_track,
convert_track,
)
def _fake_track(string_midis, instrument=24):
"""Lightweight Track stand-in for the bass-detection / tuning helpers.
`string_midis` is GP-order (high → low). The real Track is a heavy
dataclass; the helpers only read `.strings[].number/.value` and
`.channel.instrument`, so SimpleNamespace is enough.
"""
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate(string_midis)]
channel = SimpleNamespace(instrument=instrument)
return SimpleNamespace(strings=strings, channel=channel)
def _minimal_converter_song(numerator=4, denominator=4, tempo_changes=None):
"""Fake song shape sufficient for converter-level XML tests."""
strings = [
SimpleNamespace(number=1, value=64),
SimpleNamespace(number=2, value=59),
SimpleNamespace(number=3, value=55),
SimpleNamespace(number=4, value=50),
SimpleNamespace(number=5, value=45),
SimpleNamespace(number=6, value=40),
]
beats = [SimpleNamespace(start=0, notes=[], effect=None)]
for tick, tempo in tempo_changes or []:
beats.append(SimpleNamespace(
start=tick,
notes=[],
effect=SimpleNamespace(
mixTableChange=SimpleNamespace(
tempo=SimpleNamespace(value=tempo),
),
),
))
measure = SimpleNamespace(
voices=[SimpleNamespace(beats=beats)],
)
header = SimpleNamespace(
start=0,
number=1,
timeSignature=SimpleNamespace(
numerator=numerator,
denominator=SimpleNamespace(value=denominator),
),
isRepeatOpen=False,
repeatClose=-1,
repeatAlternative=0,
direction=None,
fromDirection=None,
marker=None,
)
track = SimpleNamespace(
name="Guitar",
strings=strings,
channel=SimpleNamespace(instrument=24),
measures=[measure],
isPercussionTrack=False,
)
return SimpleNamespace(
title="Test Song",
artist="Test Artist",
album="",
copyright="",
subtitle="",
tempo=120,
measureHeaders=[header],
tracks=[track],
)
def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
song = _minimal_converter_song(
numerator=numerator,
denominator=denominator,
tempo_changes=tempo_changes,
)
root = ET.fromstring(converter(song, 0))
ebeats = root.find("ebeats")
assert ebeats is not None, "Converter output missing <ebeats> node"
return ebeats
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
# 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),
]
@pytest.mark.parametrize("converter", [
convert_track,
convert_piano_track,
convert_drum_track,
])
def test_converter_ebeats_use_time_signature_denominator_for_6_8(converter):
_assert_ebeats(converter, 6, 8, [
"0.000",
"0.250",
"0.500",
"0.750",
"1.000",
"1.250",
])
@pytest.mark.parametrize("converter", [
convert_track,
convert_piano_track,
convert_drum_track,
])
def test_converter_ebeats_preserve_quarter_note_spacing_for_4_4(converter):
_assert_ebeats(converter, 4, 4, [
"0.000",
"0.500",
"1.000",
"1.500",
])
@pytest.mark.parametrize("converter", [
convert_track,
convert_piano_track,
convert_drum_track,
])
@pytest.mark.parametrize(("numerator", "denominator", "expected_times"), [
pytest.param(12, 8, [
"0.000",
"0.250",
"0.500",
"0.750",
"1.000",
"1.250",
"1.500",
"1.750",
"2.000",
"2.250",
"2.500",
"2.750",
], id="12_8"),
pytest.param(3, 8, [
"0.000",
"0.250",
"0.500",
], id="3_8"),
pytest.param(2, 2, [
"0.000",
"1.000",
], id="2_2"),
pytest.param(5, 16, [
"0.000",
"0.125",
"0.250",
"0.375",
"0.500",
], id="5_16"),
])
def test_converter_ebeats_scale_by_denominator_for_other_meters(
converter,
numerator,
denominator,
expected_times,
):
_assert_ebeats(converter, numerator, denominator, expected_times)
@pytest.mark.parametrize("converter", [
convert_track,
convert_piano_track,
convert_drum_track,
])
def test_converter_ebeats_apply_internal_tempo_changes_in_6_8(converter):
_assert_ebeats(
converter,
6,
8,
[
"0.000",
"0.250",
"0.500",
"1.000",
"1.500",
"2.000",
],
tempo_changes=[
(GP_TICKS_PER_QUARTER, 60),
],
)
# ── _tick_to_seconds ─────────────────────────────────────────────────────────
def test_tick_to_seconds_at_zero():
# Tick 0 is always time 0 regardless of tempo.
tempo_map = [TempoEvent(tick=0, tempo=120.0)]
assert _tick_to_seconds(0, tempo_map) == 0.0
def test_tick_to_seconds_constant_tempo():
# At 120 BPM with 960 ticks/quarter, one quarter = 0.5s, so 1920 ticks = 1.0s.
tempo_map = [TempoEvent(tick=0, tempo=120.0)]
assert _tick_to_seconds(GP_TICKS_PER_QUARTER, tempo_map) == pytest.approx(0.5)
assert _tick_to_seconds(2 * GP_TICKS_PER_QUARTER, tempo_map) == pytest.approx(1.0)
assert _tick_to_seconds(4 * GP_TICKS_PER_QUARTER, tempo_map) == pytest.approx(2.0)
def test_tick_to_seconds_tempo_change_accumulates():
# 4 quarter notes at 120 BPM = 2.0s, then 4 at 60 BPM = 4.0s. Total 6.0s.
tempo_map = [
TempoEvent(tick=0, tempo=120.0),
TempoEvent(tick=4 * GP_TICKS_PER_QUARTER, tempo=60.0),
]
# At the tempo-change boundary, time is 2.0 (4 beats at 120).
assert _tick_to_seconds(4 * GP_TICKS_PER_QUARTER, tempo_map) == pytest.approx(2.0)
# 4 more beats at 60 BPM = 4.0s. Total 6.0.
assert _tick_to_seconds(8 * GP_TICKS_PER_QUARTER, tempo_map) == pytest.approx(6.0)
def test_tick_to_seconds_extrapolates_past_last_event():
# Ticks past the last tempo event use that last event's tempo.
tempo_map = [
TempoEvent(tick=0, tempo=120.0),
TempoEvent(tick=1000, tempo=240.0),
]
# First 1000 ticks at 120 BPM = 1000/960 * 0.5 = 0.5208...s
# Next 1000 ticks at 240 BPM = 1000/960 * 0.25 = 0.2604...s
expected = (1000 / GP_TICKS_PER_QUARTER) * (60.0 / 120.0) + \
(1000 / GP_TICKS_PER_QUARTER) * (60.0 / 240.0)
assert _tick_to_seconds(2000, tempo_map) == pytest.approx(expected)
# ── _tempo_at_tick ───────────────────────────────────────────────────────────
def test_tempo_at_tick_before_first_event_returns_first_tempo():
tempo_map = [TempoEvent(tick=100, tempo=120.0)]
# Tick 0 is before the "first" event (which is at 100). Function starts
# result at tempo_map[0].tempo and only updates when event.tick <= tick.
assert _tempo_at_tick(0, tempo_map) == 120.0
def test_tempo_at_tick_at_exact_event():
tempo_map = [
TempoEvent(tick=0, tempo=120.0),
TempoEvent(tick=500, tempo=200.0),
]
assert _tempo_at_tick(500, tempo_map) == 200.0
def test_tempo_at_tick_between_events():
tempo_map = [
TempoEvent(tick=0, tempo=120.0),
TempoEvent(tick=1000, tempo=200.0),
]
assert _tempo_at_tick(500, tempo_map) == 120.0
def test_tempo_at_tick_past_last_event():
tempo_map = [
TempoEvent(tick=0, tempo=120.0),
TempoEvent(tick=100, tempo=60.0),
TempoEvent(tick=500, tempo=180.0),
]
assert _tempo_at_tick(999999, tempo_map) == 180.0
def test_tempo_at_tick_single_event_map():
tempo_map = [TempoEvent(tick=0, tempo=90.0)]
assert _tempo_at_tick(0, tempo_map) == 90.0
assert _tempo_at_tick(100000, tempo_map) == 90.0
# ── _gp_string_to_rs ─────────────────────────────────────────────────────────
# GP string numbering: 1 = highest pitch, N = lowest
# RS string numbering: 0 = lowest pitch (low E on a guitar)
# Transform: rs_index = num_strings - gp_string
@pytest.mark.parametrize("gp_string,num_strings,rs_index", [
# 6-string guitar: GP 1 (high e) -> RS 5, GP 6 (low E) -> RS 0
(1, 6, 5),
(2, 6, 4),
(3, 6, 3),
(4, 6, 2),
(5, 6, 1),
(6, 6, 0),
# 4-string bass: GP 1 (G) -> RS 3, GP 4 (E) -> RS 0
(1, 4, 3),
(2, 4, 2),
(3, 4, 1),
(4, 4, 0),
# 7-string guitar: GP 1 (high e) -> RS 6, GP 7 (low B) -> RS 0
(1, 7, 6),
(7, 7, 0),
])
def test_gp_string_to_rs(gp_string, num_strings, rs_index):
assert _gp_string_to_rs(gp_string, num_strings) == rs_index
# ── _extract_year ────────────────────────────────────────────────────────────
@pytest.mark.parametrize("copyright_text, expected", [
("1998 Goat Head Music, WB Music Corp, USA", "1998"),
("Copyright 2024 Some Label", "2024"),
("Released in 1972 by ABC Records", "1972"),
("No year present anywhere", ""),
("", ""),
(None, ""),
# 4-digit numbers outside the [1800-2099] window aren't years.
("Catalog 4521", ""),
])
def test_extract_year(copyright_text, expected):
song = SimpleNamespace(copyright=copyright_text, subtitle=None)
assert _extract_year(song) == expected
def test_extract_year_falls_back_to_subtitle():
song = SimpleNamespace(copyright=None, subtitle="From the 2010 album")
assert _extract_year(song) == "2010"
# ── _is_bass_track ───────────────────────────────────────────────────────────
@pytest.mark.parametrize("instrument, expected", [
# GM Bass family is 32-39 inclusive. Lock the boundaries so an
# off-by-one in the program check (e.g. `32 < instrument < 39`
# vs `32 <= instrument <= 39`) doesn't silently regress.
(32, True), # Acoustic Bass — lower edge of bass family
(33, True), # Electric Bass (finger)
(39, True), # Synth Bass 2 — upper edge of bass family
(31, False), # Guitar Harmonics — just below bass family
(40, False), # Violin — just above bass family
])
def test_is_bass_track_gm_program_boundaries(instrument, expected):
# Top string is high (MIDI 64 = E4) so non-bass programs can't
# accidentally pass through the pitch fallback.
track = _fake_track([64, 59, 55, 50], instrument=instrument)
assert _is_bass_track(track) is expected
def test_is_bass_track_pitch_fallback_for_4_string_bass():
# Standard 4-string bass G2 D2 A1 E1 with the program mis-set to
# piano (0) — common GP file authoring artefact.
track = _fake_track([43, 38, 33, 28], instrument=0)
assert _is_bass_track(track) is True
def test_is_bass_track_detects_5_string_bass():
# 5-string bass with B0 added below E1, program mis-set to acoustic guitar.
track = _fake_track([43, 38, 33, 28, 23], instrument=24)
assert _is_bass_track(track) is True
def test_is_bass_track_rejects_standard_guitar():
# E4 B3 G3 D3 A2 E2 — top string > MIDI 48, no bass program.
track = _fake_track([64, 59, 55, 50, 45, 40], instrument=24)
assert _is_bass_track(track) is False
def test_is_bass_track_rejects_7_string_detuned_guitar():
# 7-string drop A: top still high (D4=62), low extends to A1 (33).
track = _fake_track([62, 57, 53, 48, 43, 38, 33], instrument=29)
assert _is_bass_track(track) is False
def test_is_bass_track_handles_empty_strings():
track = _fake_track([], instrument=24)
assert _is_bass_track(track) is False
# ── _standard_tuning_for ────────────────────────────────────────────────────
@pytest.mark.parametrize("num, is_bass, expected", [
(6, False, [64, 59, 55, 50, 45, 40]),
(7, False, [64, 59, 55, 50, 45, 40, 35]),
(8, False, [64, 59, 55, 50, 45, 40, 35, 30]),
(4, True, [43, 38, 33, 28]),
(5, True, [43, 38, 33, 28, 23]),
(6, True, [48, 43, 38, 33, 28, 23]),
])
def test_standard_tuning_for(num, is_bass, expected):
assert _standard_tuning_for(num, is_bass) == expected
def test_standard_tuning_for_pads_beyond_8_string_guitar():
# Pathological 9-string falls back to descending fourths.
out = _standard_tuning_for(9, is_bass=False)
assert len(out) == 9
assert out[:8] == [64, 59, 55, 50, 45, 40, 35, 30]
# Next entry extends down by a fourth (5 semitones).
assert out[8] == 30 - 5
# ── _compute_tuning ─────────────────────────────────────────────────────────
def test_compute_tuning_standard_6_string_guitar_returns_zeros():
track = _fake_track([64, 59, 55, 50, 45, 40], instrument=24)
assert _compute_tuning(track) == [0, 0, 0, 0, 0, 0]
def test_compute_tuning_eb_standard_guitar_returns_minus_one_per_string():
track = _fake_track([63, 58, 54, 49, 44, 39], instrument=24)
assert _compute_tuning(track) == [-1, -1, -1, -1, -1, -1]
def test_compute_tuning_7_string_preserves_length():
track = _fake_track([64, 59, 55, 50, 45, 40, 35], instrument=24)
assert _compute_tuning(track) == [0, 0, 0, 0, 0, 0, 0]
def test_compute_tuning_5_string_low_b_bass_returns_zeros():
# Low-B 5-string standard: G2 D2 A1 E1 B0 (MIDI 43 38 33 28 23).
track = _fake_track([43, 38, 33, 28, 23], instrument=33)
assert _compute_tuning(track) == [0, 0, 0, 0, 0]
def test_compute_tuning_5_string_high_c_bass_returns_zeros():
# High-C 5-string standard: C3 G2 D2 A1 E1 (MIDI 48 43 38 33 28).
# Previously this miscomputed as +5 on every string because the
# function always picked the low-B reference.
track = _fake_track([48, 43, 38, 33, 28], instrument=33)
assert _compute_tuning(track) == [0, 0, 0, 0, 0]
def test_standard_tuning_for_5_string_bass_picks_high_c_when_top_is_c():
# Explicit hint: top string at MIDI 48 → high-C variant.
assert _standard_tuning_for(5, is_bass=True, top_midi=48) == [48, 43, 38, 33, 28]
def test_standard_tuning_for_5_string_bass_defaults_to_low_b():
# Without a hint, fall back to the more common low-B layout.
assert _standard_tuning_for(5, is_bass=True) == [43, 38, 33, 28, 23]
def test_compute_tuning_6_string_bass_routes_to_bass_table():
track = _fake_track([48, 43, 38, 33, 28, 23], instrument=33)
assert _compute_tuning(track) == [0, 0, 0, 0, 0, 0]
def test_compute_tuning_drop_d_guitar():
# Drop D: low E2 (40) → D2 (38), other strings unchanged. RS tuning
# is stored low→high, so index 0 is the lowest string.
track = _fake_track([64, 59, 55, 50, 45, 38], instrument=24)
assert _compute_tuning(track) == [-2, 0, 0, 0, 0, 0]
# ── _build_playback_schedule ─────────────────────────────────────────────────
# Mocks `guitarpro.MeasureHeader` and `guitarpro.Song` with `SimpleNamespace`.
# The schedule walker reads only:
# - song.measureHeaders[i].start, .timeSignature.numerator/.denominator.value,
# .isRepeatOpen, .repeatClose, .repeatAlternative, .direction, .fromDirection
# That's all the fixture surface we need to construct.
def _make_song(headers):
return SimpleNamespace(measureHeaders=headers)
def _make_header(
start_quarters: float,
numerator: int = 4,
denominator: int = 4,
*,
is_repeat_open: bool = False,
repeat_close: int = -1,
repeat_alt: int = 0,
direction_name: str | None = None,
from_direction_name: str | None = None,
):
"""Build a mock MeasureHeader. ``start_quarters`` is in quarter-notes
from the song start; converted to ticks internally."""
return SimpleNamespace(
start=round(start_quarters * GP_TICKS_PER_QUARTER),
number=0, # unused by schedule walker; converters set it from mh.number
timeSignature=SimpleNamespace(
numerator=numerator,
denominator=SimpleNamespace(value=denominator),
),
isRepeatOpen=is_repeat_open,
repeatClose=repeat_close,
repeatAlternative=repeat_alt,
direction=SimpleNamespace(name=direction_name) if direction_name else None,
fromDirection=SimpleNamespace(name=from_direction_name) if from_direction_name else None,
marker=None,
)
def _ids(schedule):
"""Compact `(mh_index, pass_index)` summary for assertions."""
return [(e.mh_index, e.pass_index) for e in schedule]
# Standard tempo map: 120 BPM constant → one 4/4 measure = 2.0 s.
_TM_120 = [TempoEvent(tick=0, tempo=120.0)]
def test_schedule_no_repeats_no_directions():
# 4 plain measures → 4 entries, pass=0 each, output times monotonic at 2 s/measure.
headers = [_make_header(i * 4) for i in range(4)]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [(0, 0), (1, 0), (2, 0), (3, 0)]
assert [round(e.output_start_secs, 3) for e in schedule] == [0.0, 2.0, 4.0, 6.0]
def test_schedule_simple_repeat():
# ||: A | B :||x2 → 4 entries: A0 B0 A1 B1
headers = [
_make_header(0, is_repeat_open=True), # A
_make_header(4, repeat_close=1), # B (x2: 1 additional rep)
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [(0, 0), (1, 0), (0, 1), (1, 1)]
assert [round(e.output_start_secs, 3) for e in schedule] == [0.0, 2.0, 4.0, 6.0]
def test_schedule_with_volta():
# ||: A | B :|1.| C |2.| D || — C plays pass 0 only, D plays pass 1 only.
# Volta C: repeatAlternative bit 0 set; close-of-pass-0 is at C itself
# (repeatClose=1 because the bracket repeats once total, so 2 passes).
headers = [
_make_header(0, is_repeat_open=True), # A
_make_header(4), # B
_make_header(8, repeat_alt=0b01), # C (1st ending)
_make_header(12, repeat_alt=0b10, repeat_close=1), # D (2nd ending, closes)
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
# Pass 0: A B C (skip D). Pass 1: A B D (skip C).
assert _ids(schedule) == [
(0, 0), (1, 0), (2, 0),
(0, 1), (1, 1), (3, 1),
]
def test_schedule_sequential_groups():
# ||: A :||x2 | B | ||: C :||x3 → 2xA, B, 3xC
headers = [
_make_header(0, is_repeat_open=True, repeat_close=1), # A (x2)
_make_header(4), # B
_make_header(8, is_repeat_open=True, repeat_close=2), # C (x3)
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [
(0, 0), (0, 1), # 2xA
(1, 0), # B
(2, 0), (2, 1), (2, 2), # 3xC
]
def test_schedule_da_capo_al_fine():
# A | B(Fine) | C | D(D.C. al Fine) → A B C D A B (stop at Fine on pass 2)
headers = [
_make_header(0), # A
_make_header(4, direction_name="Fine"), # B
_make_header(8), # C
_make_header(12, from_direction_name="Da Capo al Fine"), # D
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [(0, 0), (1, 0), (2, 0), (3, 0), (0, 0), (1, 0)]
def test_schedule_dal_segno_al_coda():
# A | B(Segno) | C(To Coda) | D | E(D.S. al Coda) | F(Coda) | G
# → A B C D E B C F G (jump to Segno, replay until Da Coda redirect, jump to Coda)
headers = [
_make_header(0), # A
_make_header(4, direction_name="Segno"), # B
_make_header(8, from_direction_name="Da Coda"), # C (To Coda)
_make_header(12), # D
_make_header(16, from_direction_name="Da Segno al Coda"), # E
_make_header(20, direction_name="Coda"), # F
_make_header(24), # G
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
# First pass: A B C D E → at E, jump back to Segno (B). Now jumped_back=True,
# stop_at="coda". Replay from B: B is fine (no Da Coda). C has Da Coda →
# redirect to F. Then G plays. Final: A B C D E B F G.
assert _ids(schedule) == [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (1, 0), (5, 0), (6, 0)]
def test_schedule_da_capo_inside_repeat_block_fires_immediately():
# ||: A | B(D.C. al Fine) :||x2 | C(Fine) | D
# A D.C. authored *inside* a repeat block must still fire the first time
# we reach the measure carrying it — without the repeat completing the
# remaining passes. This is the regression the inline repeat sub-loop
# used to miss: it would silently complete the bracket and the D.C.
# never triggered.
headers = [
_make_header(0, is_repeat_open=True), # A
_make_header(4, repeat_close=1, from_direction_name="Da Capo al Fine"), # B
_make_header(8, direction_name="Fine"), # C
_make_header(12), # D
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
# First pass through the bracket plays A B once; the D.C. fires at the
# end of B before the second pass; the jumped-back walk plays A B C and
# stops at Fine.
assert _ids(schedule) == [(0, 0), (1, 0), (0, 0), (1, 0), (2, 0)]
def test_schedule_da_capo_suppresses_inner_repeats():
# ||: A :||x2 | B(D.C.) → first pass plays the bracket (A A B), then D.C.
# jumps back to measure 0 and replays inner repeat *once* (A B).
headers = [
_make_header(0, is_repeat_open=True, repeat_close=1), # A (x2)
_make_header(4, from_direction_name="Da Capo"), # B
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
# Pass 1: A A B (repeat honored). After D.C.: jumped_back=True → A B.
assert _ids(schedule) == [(0, 0), (0, 1), (1, 0), (0, 0), (1, 0)]
def test_schedule_expand_disabled():
# Same shape as simple_repeat but expand_repeats=False → 2 entries.
headers = [
_make_header(0, is_repeat_open=True),
_make_header(4, repeat_close=1),
]
schedule = _build_playback_schedule(
_make_song(headers), _TM_120, expand_repeats=False,
)
assert _ids(schedule) == [(0, 0), (1, 0)]
def _warning_messages(mock_log) -> list[str]:
"""Format every `log.warning(fmt, *args)` call into its rendered message.
We patch the module-level logger rather than using pytest's caplog because
feedBack's conftest installs a structlog processor chain that intercepts
logging records before caplog can see them — fine in production, but it
leaves caplog silent in CI even though the warning is emitted.
"""
out = []
for call in mock_log.warning.call_args_list:
fmt, *args = call.args
try:
out.append(fmt % tuple(args))
except TypeError:
out.append(str(fmt))
return out
def test_schedule_orphan_open_warns():
# Open without matching close → log warning, walk linearly.
headers = [
_make_header(0, is_repeat_open=True),
_make_header(4),
_make_header(8),
]
with mock.patch("gp2rs.log") as mock_log:
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [(0, 0), (1, 0), (2, 0)]
assert any("no matching close" in m for m in _warning_messages(mock_log))
def test_schedule_unresolved_dal_segno_warns():
# Da Segno with no Segno target → warn, advance linearly past the jump.
headers = [
_make_header(0),
_make_header(4, from_direction_name="Da Segno"),
_make_header(8),
]
with mock.patch("gp2rs.log") as mock_log:
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert _ids(schedule) == [(0, 0), (1, 0), (2, 0)]
assert any("no matching target" in m for m in _warning_messages(mock_log))
def test_schedule_note_time_shifts_under_repeat():
# ||: A :||x2 with 4/4 at 120 BPM → measure A is 2 s long. First-pass A
# starts at 0 s; second-pass A starts at 2 s.
headers = [_make_header(0, is_repeat_open=True, repeat_close=1)]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
assert len(schedule) == 2
assert schedule[0].output_start_secs == pytest.approx(0.0)
assert schedule[1].output_start_secs == pytest.approx(2.0)
# mh_authored_start_secs is the same for both (same source measure).
assert schedule[0].mh_authored_start_secs == schedule[1].mh_authored_start_secs
def test_schedule_song_length_reflects_expansion():
# ||: A | B :||x2 → expanded length is 4 measures x 2 s = 8 s, not 4 s.
headers = [
_make_header(0, is_repeat_open=True),
_make_header(4, repeat_close=1),
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
last = schedule[-1]
last_mh = headers[last.mh_index]
# Output end = last entry start + last measure duration.
measure_secs = (last_mh.timeSignature.numerator
* (4.0 / last_mh.timeSignature.denominator.value)
* GP_TICKS_PER_QUARTER) \
/ GP_TICKS_PER_QUARTER * (60.0 / 120.0)
expanded_end = last.output_start_secs + measure_secs
assert expanded_end == pytest.approx(8.0)
def test_schedule_empty_song():
# No headers → empty schedule. Should not crash.
schedule = _build_playback_schedule(_make_song([]), _TM_120)
assert schedule == []
def test_schedule_irregular_measure_lengths():
# A 3-quarter pickup followed by two 4-quarter measures, then ||: D :||x2.
# The pickup is intentionally shorter than its 4/4 time signature would
# suggest — that's how GP encodes an anacrusis. The schedule must use the
# tick delta to the next measure as the duration, not the time signature.
headers = [
_make_header(0, numerator=4), # A — 3 quarters long (starts at 0, next at 3)
_make_header(3, numerator=4), # B
_make_header(7, numerator=4), # C
_make_header(11, numerator=4, is_repeat_open=True, repeat_close=1), # D x2
]
schedule = _build_playback_schedule(_make_song(headers), _TM_120)
# 120 BPM: 1 quarter = 0.5 s. Output starts:
# A: 0.0, B: 1.5 (3 q), C: 3.5 (4 q), D pass 0: 5.5 (4 q), D pass 1: 7.5
out = [round(e.output_start_secs, 3) for e in schedule]
assert out == [0.0, 1.5, 3.5, 5.5, 7.5]
# ── convert_track: tied-note (NoteType.tie) handling ─────────────────────────
# Tied notes in GP are displayed with brackets, e.g. (0). They should extend
# the sustain of the previous note on the same string, not emit a second note.
def _ct_note_effect():
return SimpleNamespace(
bend=None,
hammer=False,
slides=[],
harmonic=None,
palmMute=False,
accentuatedNote=False,
heavyAccentuatedNote=False,
ghostNote=False,
vibrato=False,
tremoloPicking=False,
)
def _ct_beat(tick, dur_value, notes):
return SimpleNamespace(
start=tick,
duration=SimpleNamespace(
value=dur_value,
isDotted=False,
tuplet=SimpleNamespace(enters=1, times=1),
),
notes=notes,
effect=SimpleNamespace(mixTableChange=None, chord=None),
)
def _ct_note(note_type, gp_string, fret):
return SimpleNamespace(
type=note_type,
string=gp_string,
value=fret,
effect=_ct_note_effect(),
)
def _ct_song(beats, string_values=None):
"""One-measure mock song for convert_track, standard 6-string guitar at 120 BPM.
`string_values` overrides the tuning/string count (e.g. a 7-string track)."""
voice = SimpleNamespace(beats=beats)
measure = SimpleNamespace(voices=[voice])
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate(string_values or [64, 59, 55, 50, 45, 40])]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=24),
measures=[measure],
name="Guitar",
)
mh = SimpleNamespace(
start=0,
number=1,
timeSignature=SimpleNamespace(
numerator=4,
denominator=SimpleNamespace(value=4),
),
isRepeatOpen=False,
repeatClose=-1,
repeatAlternative=0,
direction=None,
fromDirection=None,
marker=None,
)
return SimpleNamespace(
title="Test",
artist="Test",
album="Test",
copyright=None,
subtitle=None,
tempo=120,
tracks=[track],
measureHeaders=[mh],
)
def test_tied_note_extends_sustain_not_duplicate():
"""NoteType.tie should extend the previous note's sustain, not emit a new note."""
# quarter note at 120 BPM = 0.5 s; two tied quarters → 1.0 s total sustain
note1 = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5)
note2 = _ct_note(guitarpro.NoteType.tie, gp_string=1, fret=5)
beat1 = _ct_beat(tick=0, dur_value=4, notes=[note1])
beat2 = _ct_beat(tick=GP_TICKS_PER_QUARTER, dur_value=4, notes=[note2])
xml_str = convert_track(_ct_song([beat1, beat2]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
notes = root.findall(".//notes/note")
assert len(notes) == 1, f"tie must not emit a second note; got {len(notes)}"
sustain = float(notes[0].get("sustain"))
assert sustain == pytest.approx(1.0, abs=0.01)
assert notes[0].get("fret") == "5"
def test_tied_note_without_predecessor_is_silently_dropped():
"""A tie with no previous note on that string is silently skipped."""
note = _ct_note(guitarpro.NoteType.tie, gp_string=1, fret=0)
beat = _ct_beat(tick=0, dur_value=4, notes=[note])
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
notes = root.findall(".//notes/note")
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):
"""Multi-voice variant of _ct_song. `voices_beats` is a list of beat-lists,
one per voice, all on the same single measure."""
voices = [SimpleNamespace(beats=beats) for beats in voices_beats]
measure = SimpleNamespace(voices=voices)
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate([64, 59, 55, 50, 45, 40])]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=24),
measures=[measure],
name="Guitar",
)
mh = SimpleNamespace(
start=0, number=1,
timeSignature=SimpleNamespace(numerator=4, denominator=SimpleNamespace(value=4)),
isRepeatOpen=False, repeatClose=-1, repeatAlternative=0,
direction=None, fromDirection=None, marker=None,
)
return SimpleNamespace(
title="Test", artist="Test", album="Test",
copyright=None, subtitle=None, tempo=120,
tracks=[track],
measureHeaders=[mh],
)
def test_tie_does_not_attach_to_overwritten_earlier_voice_note():
"""Voices are processed sequentially. Without an overwrite guard
(`rn.time >= existing.time` before updating last_note_per_string),
voice 1's beat-0 note would replace voice 0's beat-2 entry in the dict,
and voice 1's beat-3 tie would then incorrectly extend voice 1's beat-0
sustain across voice 0's beat-2 territory.
All beat.start values stay within a 4/4 single measure (0 3×quarter).
"""
# Voice 0: beat 2 fret 7 (t=1.0, sustain 0.5) — populates dict[string=1] first
v0_beat2 = _ct_beat(tick=GP_TICKS_PER_QUARTER * 2, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)])
# Voice 1: beat 0 fret 5 (t=0) and tie at beat 3 (t=1.5)
v1_beat0 = _ct_beat(tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5)])
v1_tie = _ct_beat(tick=GP_TICKS_PER_QUARTER * 3, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.tie, gp_string=1, fret=0)])
xml_str = convert_track(_ct_multivoice_song([[v0_beat2], [v1_beat0, v1_tie]]),
track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
notes = root.findall(".//notes/note")
sustains = {n.get("fret"): float(n.get("sustain")) for n in notes}
# Voice 1's beat-0 (fret 5) sustain must stay at its own duration (~0.5 s).
# Without the overwrite guard it would be inflated to ~2.0 s by the tie.
assert sustains.get("5") == pytest.approx(0.5, abs=0.01), \
"overwrite guard must keep voice 0's beat-2 as the tracked predecessor; " \
"voice 1's beat-0 sustain must not balloon to cover the tie's target time"
def test_two_normal_notes_on_same_string_are_both_emitted():
"""Non-tied consecutive notes on the same string each produce a note event."""
note1 = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5)
note2 = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=7)
beat1 = _ct_beat(tick=0, dur_value=4, notes=[note1])
beat2 = _ct_beat(tick=GP_TICKS_PER_QUARTER, dur_value=4, notes=[note2])
xml_str = convert_track(_ct_song([beat1, beat2]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
notes = root.findall(".//notes/note")
assert len(notes) == 2
assert notes[0].get("fret") == "5"
assert notes[1].get("fret") == "7"
# ── convert_piano_track: tied-note pitch-bucket collision ─────────────────────
# MIDI notes 48 (C3) and 50 (D3) both map to rs_string=2. A chord containing
# both, followed by ties for both, must extend each note individually — not
# share a single rs_string=2 bucket that only tracks whichever was stored last.
def _piano_song(beats):
"""One-measure mock for convert_piano_track with two GP strings at MIDI 48 and 50."""
voice = SimpleNamespace(beats=beats)
measure = SimpleNamespace(voices=[voice])
strings = [
SimpleNamespace(number=1, value=48), # C3 — encodes to rs_string=2, rs_fret=0
SimpleNamespace(number=2, value=50), # D3 — encodes to rs_string=2, rs_fret=2
]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=0),
measures=[measure],
name="Piano",
)
mh = SimpleNamespace(
start=0, number=1,
timeSignature=SimpleNamespace(numerator=4, denominator=SimpleNamespace(value=4)),
isRepeatOpen=False, repeatClose=-1, repeatAlternative=0,
direction=None, fromDirection=None, marker=None,
)
return SimpleNamespace(
title="Test", artist="Test", album="Test",
copyright=None, subtitle=None, tempo=120,
tracks=[track],
measureHeaders=[mh],
)
def test_piano_tied_chord_both_notes_extended():
"""Two simultaneous piano notes in the same rs_string bucket must each get
their own sustain extension — not share a single string-keyed slot."""
# Beat 1: chord of MIDI 48 (gp_str=1) and MIDI 50 (gp_str=2), both 0.5 s
n_c3 = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=0)
n_d3 = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=0)
beat1 = _ct_beat(tick=0, dur_value=4, notes=[n_c3, n_d3])
# Beat 2: ties for both — should extend each note to ~1.0 s
t_c3 = _ct_note(guitarpro.NoteType.tie, gp_string=1, fret=0)
t_d3 = _ct_note(guitarpro.NoteType.tie, gp_string=2, fret=0)
beat2 = _ct_beat(tick=GP_TICKS_PER_QUARTER, dur_value=4, notes=[t_c3, t_d3])
xml_str = convert_piano_track(_piano_song([beat1, beat2]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
chords = root.findall(".//chords/chord")
assert len(chords) == 1, "tie beat must not emit a second chord"
chord_notes = chords[0].findall("chordNote")
assert len(chord_notes) == 2
for cn in chord_notes:
sustain = float(cn.get("sustain"))
assert sustain == pytest.approx(1.0, abs=0.01), (
f"fret={cn.get('fret')} sustain={sustain:.3f}, expected ~1.0 s"
)
# ── convert_track: tie must not cross a backward repeat boundary ──────────────
# When the playback schedule loops backwards (repeat loopbacks, D.S., D.C.),
# the tie-tracking state must be cleared so a tie note at the start of a
# repeated section cannot accidentally extend the last note from the previous
# pass. Forward skips (volta alternatives, al-Coda redirects) are NOT cleared
# because consecutive forward schedule entries are adjacent in the output audio.
def _ct_song_repeat(beats_m0, beats_m1):
"""Two-measure mock song where measure 0 is the repeat-open and measure 1
is the repeat-close (×1 extra repeat → plays twice total).
120 BPM, 4/4, standard 6-string guitar.
"""
voice0 = SimpleNamespace(beats=beats_m0)
voice1 = SimpleNamespace(beats=beats_m1)
measure0 = SimpleNamespace(voices=[voice0])
measure1 = SimpleNamespace(voices=[voice1])
strings = [SimpleNamespace(number=i + 1, value=v)
for i, v in enumerate([64, 59, 55, 50, 45, 40])]
track = SimpleNamespace(
strings=strings,
channel=SimpleNamespace(instrument=24),
measures=[measure0, measure1],
name="Guitar",
)
# measure 0: repeat open, tick 0
mh0 = SimpleNamespace(
start=0,
number=1,
timeSignature=SimpleNamespace(
numerator=4, denominator=SimpleNamespace(value=4)
),
isRepeatOpen=True,
repeatClose=-1, # close is on mh1
repeatAlternative=0,
direction=None,
fromDirection=None,
marker=None,
)
# measure 1: repeat close, plays the bracket one extra time (total ×2)
mh1 = SimpleNamespace(
start=4 * GP_TICKS_PER_QUARTER,
number=2,
timeSignature=SimpleNamespace(
numerator=4, denominator=SimpleNamespace(value=4)
),
isRepeatOpen=False,
repeatClose=1, # repeat the bracket once more → 2 total passes
repeatAlternative=0,
direction=None,
fromDirection=None,
marker=None,
)
return SimpleNamespace(
title="Test",
artist="Test",
album="Test",
copyright=None,
subtitle=None,
tempo=120,
tracks=[track],
measureHeaders=[mh0, mh1],
)
def test_tie_not_extended_across_repeat_boundary():
"""A tie note at the start of a repeated section must be silently dropped on
the second pass — not extend the last note from the end of the first pass.
Schedule after repeat expansion:
mh=0 pass0 → mh=1 pass0 → mh=0 pass1 → mh=1 pass1
Measure 0 beat0 is a tie on string 1. No previous note exists on pass0
(correctly dropped). Measure 1 beat0 is a normal note on string 1.
Without the repeat-boundary clear, on pass1 the tie in mh=0 would
incorrectly extend measure 1's note from pass0. With the clear it is
dropped (still no valid predecessor within this pass).
"""
# measure 0: tie on string 1 (no predecessor on first pass → should drop)
tie_beat = _ct_beat(tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.tie, gp_string=1, fret=5)])
# measure 1: normal note on string 1
normal_beat = _ct_beat(tick=4 * GP_TICKS_PER_QUARTER, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5)])
xml_str = convert_track(_ct_song_repeat([tie_beat], [normal_beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
notes = root.findall(".//notes/note")
# Two passes through measure 1 → two normal notes; the ties are both dropped.
assert len(notes) == 2, (
f"repeat boundary must not allow tie to extend across passes; got {len(notes)} notes"
)
# Each note should have its authored sustain (~0.5 s for a quarter at 120 BPM),
# not an inflated value caused by an erroneous cross-boundary tie extension.
for n in notes:
sustain = float(n.get("sustain"))
# quarter note at 120 BPM = 0.5 s, which is > 0.2 threshold → sustain=0.5
assert sustain == pytest.approx(0.5, abs=0.01), (
f"sustain should be ~0.5 s (one quarter note), got {sustain:.3f}"
)
# ── convert_track: GP5 chord-diagram fingering extraction (E3) ───────────────
# pyguitarpro exposes the chord-diagram voicing on beat.effect.chord:
# .strings is per-string frets indexed 0 = highest string, .fingerings is the
# parallel Fingering enum list (open=-1, thumb=0, index=1, middle=2, ring=3,
# pinky=4 — already the RS finger integers). A chord beat carrying this data
# must import with per-string fingers; a chord beat without it stays all -1.
def _ct_chord(name, strings, fingerings):
return SimpleNamespace(
name=name, strings=list(strings),
fingerings=list(fingerings), length=len(strings),
)
def test_chord_diagram_fingers_extracted():
# Two-note voicing on high e (fret 3) + B (fret 2). chord.strings is
# indexed 0 = highest string, so strings[0] = high e, strings[1] = B.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3) # high e
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2) # B
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Gtest",
strings=[3, 2, -1, -1, -1, -1],
fingerings=[
guitarpro.Fingering.middle, # high e -> 2
guitarpro.Fingering.index, # B -> 1
],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "Gtest"
# _gp_string_to_rs(1, 6) = 5 (high e), _gp_string_to_rs(2, 6) = 4 (B).
assert ct.get("fret5") == "3" and ct.get("finger5") == "2"
assert ct.get("fret4") == "2" and ct.get("finger4") == "1"
assert [ct.get(f"finger{i}") for i in range(0, 4)] == ["-1"] * 4
def test_single_note_left_hand_finger_imports_as_fg():
"""A GP single note's leftHandFinger imports as the `fg` teaching mark and
survives convert_track XML → _parse_note → note_to_wire (§6.2.2)."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
note.effect.leftHandFinger = guitarpro.Fingering.middle # -> 2
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("fretFinger") == "2"
assert note_to_wire(_parse_note(xn))["fg"] == 2
def test_single_note_open_finger_omits_fg():
"""Open/unset leftHandFinger leaves fg unset — no fabricated finger."""
from song import _parse_note, note_to_wire
note = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5)
note.effect.leftHandFinger = guitarpro.Fingering.open # -1 -> unset
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("fretFinger") is None
assert "fg" not in note_to_wire(_parse_note(xn))
def test_chord_without_diagram_has_blank_fingers():
# A plain two-note chord (effect.chord is None) is unchanged: blank name,
# all-(-1) fingers — no regression for diagram-less charts.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b]) # chord=None
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_backfills_template_first_strummed_unannotated():
# The annotated chord must enrich its voicing even when an earlier,
# unannotated beat of the SAME fret pattern created the template first.
plain = _ct_beat(
tick=0, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
) # chord=None, creates the blank template
annotated = _ct_beat(
tick=GP_TICKS_PER_QUARTER, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
annotated.effect.chord = _ct_chord(
"Gtest", strings=[3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.middle, guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([plain, annotated]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1, "same voicing must dedup to one template"
assert cts[0].get("chordName") == "Gtest"
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_mismatch_not_applied():
# The attached diagram describes a DIFFERENT voicing (frets 5/5) than the
# notes actually played (3/2). It must NOT enrich the played template —
# otherwise a mislabeled chord would name/finger the wrong voicing (and the
# back-fill would spread it). Name + fingers stay blank.
note_e = _ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3)
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)
beat = _ct_beat(tick=0, dur_value=4, notes=[note_e, note_b])
beat.effect.chord = _ct_chord(
"Wrong", strings=[5, 5, -1, -1, -1, -1], # != played 3/2
fingerings=[guitarpro.Fingering.annular, guitarpro.Fingering.annular],
)
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
assert [ct.get(f"finger{i}") for i in range(6)] == ["-1"] * 6
def test_chord_diagram_name_then_fingers_decoupled():
# First annotated beat carries a NAME but no fingers (all open); a later beat
# of the same voicing carries the fingers. Both must land — a name-only first
# annotation must not block the later fingers (name/fingers back-fill
# independently).
def _beat(tick, name, fingerings):
b = _ct_beat(
tick=tick, dur_value=4,
notes=[_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=3),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=2)],
)
b.effect.chord = _ct_chord(name, strings=[3, 2, -1, -1, -1, -1],
fingerings=fingerings)
return b
first = _beat(0, "Gtest",
[guitarpro.Fingering.open, guitarpro.Fingering.open])
second = _beat(GP_TICKS_PER_QUARTER, "",
[guitarpro.Fingering.middle, guitarpro.Fingering.index])
xml_str = convert_track(_ct_song([first, second]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
cts = root.findall(".//chordTemplates/chordTemplate")
assert len(cts) == 1
assert cts[0].get("chordName") == "Gtest" # from the first (name-only) beat
# fingers from the second beat — not blocked by the first beat's name
assert cts[0].get("finger5") == "2" and cts[0].get("finger4") == "1"
def test_chord_diagram_barre_higher_position_matches():
# A voicing high on the neck: diagram strings hold ABSOLUTE frets (firstFret
# is display-only), so they match the played absolute frets and the template
# enriches. Guards against an absolute-vs-relative matching regression.
notes = [_ct_note(guitarpro.NoteType.normal, gp_string=1, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=2, fret=5),
_ct_note(guitarpro.NoteType.normal, gp_string=3, fret=6)]
beat = _ct_beat(tick=0, dur_value=4, notes=notes)
ch = _ct_chord("A", strings=[5, 5, 6, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.index,
guitarpro.Fingering.middle])
ch.firstFret = 5 # display base — must not affect matching
beat.effect.chord = ch
xml_str = convert_track(_ct_song([beat]), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == "A"
assert ct.get("fret5") == "5" and ct.get("finger5") == "1"
assert ct.get("fret4") == "5" and ct.get("finger4") == "1"
assert ct.get("fret3") == "6" and ct.get("finger3") == "2"
def test_chord_diagram_extended_string_outside_played_width_not_applied():
# 7-string track. Played voicing is on strings 2 & 3 only (width 6 — the
# high e / rs6 is unused), but the diagram ALSO frets string 1 (the extended
# rs6). The extra diagram note must make this a MISMATCH, not be silently
# trimmed to a false match — so the played template stays un-enriched.
seven = [64, 59, 55, 50, 45, 40, 35] # low-B 7-string
note_b = _ct_note(guitarpro.NoteType.normal, gp_string=2, fret=3) # rs5
note_g = _ct_note(guitarpro.NoteType.normal, gp_string=3, fret=2) # rs4
beat = _ct_beat(tick=0, dur_value=4, notes=[note_b, note_g])
# diagram index0 = gp_string1 (rs6) frets 5 (NOT played); index1/2 match.
beat.effect.chord = _ct_chord(
"Bogus", strings=[5, 3, 2, -1, -1, -1, -1],
fingerings=[guitarpro.Fingering.index, guitarpro.Fingering.middle,
guitarpro.Fingering.index],
)
xml_str = convert_track(_ct_song([beat], string_values=seven), track_index=0)
root = ET.fromstring(xml_str) # noqa: S314
ct = root.find(".//chordTemplates/chordTemplate")
assert ct is not None
assert ct.get("chordName") == ""
# played template is width 6 (rs6/high-e unused) -> finger0..finger5
assert all(ct.get(f"finger{i}") == "-1" for i in range(6))