"""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 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))