mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
* 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>
1391 lines
55 KiB
Python
1391 lines
55 KiB
Python
"""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))
|