File size: 10,776 Bytes
b2e4883
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
Test suite for gp5_to_keyswitched_mid.py.

These tests catch the regressions that have burned us during the week:
- Wrong tempo (was off by 60,000,000/x)
- Wrong time signature (was 4/16 instead of 4/4)
- Missing keyswitches (SlideIn for grace notes, SlideUp/Down direction, etc.)
- Wrong channel assignments
- Empty intro bars (was 35-bar silent intro)
- Tied continuations cutting the note off mid-tail
- Wrong keyswitch note numbers (e.g., sustain colliding with slide-up)

To add a new scenario: re-run tests/record_expected.py after editing the
GP file, then commit the updated baseline.yaml. The test suite will
pick up the new expected counts automatically.
"""
import collections
import os
import subprocess
import sys

import mido
import pytest


# ---------------------------------------------------------------------------
# Re-run the script for each track before the tests run, so we have
# fresh MIDI files in the output directory. (The script regenerates
# everything on each run.)
# ---------------------------------------------------------------------------
def _regenerate(song_name, track_name, include_bass, midi_path=None):
    """Run the script for one track and confirm it produced output.

    `midi_path` is the expected output path; if the same track name
    appears more than once, the file has a " (N)" suffix and we need
    to disambiguate with --only-index.
    """
    parent = os.path.dirname(os.path.dirname(__file__))  # the repo root
    gp5 = os.path.join(parent, "gp5_songs", song_name + ".gp5")
    cmd = ["python", os.path.join(parent, "gp5_to_keyswitched_mid.py"),
           gp5, "-o", track_name]
    if include_bass:
        cmd.insert(4, "-b")
    # If the expected output uses the " (N)" suffix, multiple tracks
    # have the same name; find which occurrence this one is and pass
    # --only-index.
    if midi_path:
        import re as _re
        m = _re.search(r" \((\d+)\)_keyswitched\.mid$", midi_path)
        if m:
            cmd += ["--only-index", m.group(1)]
    result = subprocess.run(
        cmd, capture_output=True, text=True, cwd=parent,
    )
    if result.returncode != 0:
        pytest.fail(
            f"Script failed for {song_name}/{track_name}:\n"
            f"  rc={result.returncode}\n"
            f"  stderr={result.stderr}"
        )


_BASELINE_CACHE = {}


def pytest_generate_tests(metafunc):
    """Parametrize tests over (song, track) pairs from the baseline."""
    if "expected_track" in metafunc.fixturenames:
        baseline = _BASELINE_CACHE.get("data")
        if baseline is None:
            from pathlib import Path
            import yaml
            expected_path = (
                Path(__file__).resolve().parent / "expected" / "baseline.yaml"
            )
            if not expected_path.exists():
                return
            with open(expected_path) as f:
                baseline = yaml.safe_load(f)
            _BASELINE_CACHE["data"] = baseline
        pairs = []
        for song in baseline["songs"]:
            for track in song["tracks"]:
                pairs.append((song["source_gp5"], track["name"], track))
        metafunc.parametrize(
            "expected_track", pairs,
            ids=[f"{s[:20]}/{t['name']}" for s, _, t in pairs],
        )


# ---------------------------------------------------------------------------
# Helper: read a MIDI file and extract facts
# ---------------------------------------------------------------------------
def _keyswitch_counts(midi_path):
    """Return {note_name: count} for keyswitch notes in track 1."""
    note_names = {
        17: "SusDown", 18: "SusUp", 20: "PalmDown", 23: "B0(SlideDown)",
        24: "C1(SlideUp)", 26: "D1(Hammer)", 27: "D#1(SlideIn)",
        91: "Bend", 9: "Harm",
    }
    counts = collections.Counter()
    mid = mido.MidiFile(midi_path)
    if len(mid.tracks) < 2:
        return {}
    for msg in mid.tracks[1]:
        if (hasattr(msg, "channel") and msg.type == "note_on"
                and msg.velocity > 0):
            counts[note_names.get(msg.note, str(msg.note))] += 1
    return dict(counts)


def _first_event_tick(midi_path):
    mid = mido.MidiFile(midi_path)
    if len(mid.tracks) < 2:
        return None
    abs_t = 0
    for msg in mid.tracks[1]:
        abs_t += msg.time
        if (hasattr(msg, "channel") and msg.type == "note_on"
                and msg.velocity > 0):
            return abs_t
    return None


def _last_event_tick(midi_path):
    mid = mido.MidiFile(midi_path)
    if len(mid.tracks) < 2:
        return None
    abs_t = 0
    last = 0
    for msg in mid.tracks[1]:
        abs_t += msg.time
        if hasattr(msg, "channel"):
            last = abs_t
    return last


def _conductor(midi_path):
    mid = mido.MidiFile(midi_path)
    facts = {}
    if not mid.tracks:
        return facts
    for msg in mid.tracks[0]:
        if msg.type == "set_tempo":
            facts["tempo_bpm"] = round(60_000_000 / msg.tempo)
        elif msg.type == "time_signature":
            facts["numerator"] = msg.numerator
            facts["denominator"] = msg.denominator
    return facts


@pytest.fixture
def track_setup(expected_track):
    """Regenerate the MIDI for this track and return the expected dict."""
    gp5 = expected_track[0]
    track = expected_track[2]
    base = os.path.basename(gp5)
    song_name = os.path.splitext(base)[0]

    include_bass = track.get("include_bass", False)
    repo = os.path.dirname(os.path.dirname(__file__))
    expected_midi_relpath = track.get("midi_path", f"{song_name}/{track['name']}_keyswitched.mid")
    expected_midi_abs = os.path.join(repo, expected_midi_relpath)
    _regenerate(song_name, track["name"], include_bass,
                midi_path=expected_midi_relpath)
    return {
        "track": track,
        "song_name": song_name,
        "midi_path": expected_midi_abs,
        "include_bass": include_bass,
    }


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_conductor_tempo(track_setup):
    """The conductor track has the correct tempo."""
    expected = track_setup["track"]["conductor"]["tempo_bpm"]
    actual = _conductor(track_setup["midi_path"])["tempo_bpm"]
    assert actual == expected, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"tempo {actual} BPM, expected {expected} BPM"
    )


def test_conductor_time_signature(track_setup):
    """The conductor track has the correct time signature."""
    expected = track_setup["track"]["conductor"]
    actual = _conductor(track_setup["midi_path"])
    assert actual["numerator"] == expected["numerator"], (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"numerator {actual['numerator']}, expected {expected['numerator']}"
    )
    assert actual["denominator"] == expected["denominator"], (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"denominator {actual['denominator']}, expected {expected['denominator']}"
    )


def test_first_event_at_tick_zero(track_setup):
    """The first note_on lands at tick 0 (no empty intro bars)."""
    actual = _first_event_tick(track_setup["midi_path"])
    assert actual == 0, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"first note_on at tick {actual}, expected tick 0"
    )


def test_channel_preserved(track_setup):
    """The output channel matches the GP file's channel for this track."""
    # The channel is preserved in the expected metadata.
    # We just verify that the file has at least one note_on on the
    # expected channel. (PyGuitarPro channel 1-16 = MIDI 0-15.)
    expected_ch = track_setup["track"]["channel"]
    mid = mido.MidiFile(track_setup["midi_path"])
    if len(mid.tracks) < 2:
        pytest.skip("Track 1 missing")
    channels_seen = set()
    for msg in mid.tracks[1]:
        if hasattr(msg, "channel"):
            channels_seen.add(msg.channel)
    assert expected_ch in channels_seen, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"expected channel {expected_ch} not used; saw {channels_seen}"
    )


def test_keyswitch_counts_exact(track_setup):
    """The keyswitch counts match the expected baseline exactly."""
    expected = track_setup["track"]["keyswitch_counts"]
    actual = _keyswitch_counts(track_setup["midi_path"])
    # Diff in both directions so we can see what's missing and what's extra.
    missing = {k: v for k, v in expected.items() if actual.get(k, 0) < v}
    extra = {k: v for k, v in actual.items() if expected.get(k, 0) < v}
    if missing or extra:
        msg = (f"{track_setup['song_name']}/{track_setup['track']['name']}: "
               f"keyswitch counts mismatch")
        if missing:
            msg += f"\n  Missing (expected but not emitted): {missing}"
        if extra:
            msg += f"\n  Extra (emitted but not expected): {extra}"
        pytest.fail(msg)


def test_total_keyswitch_count(track_setup):
    """The total number of keyswitches matches the expected total."""
    expected = sum(track_setup["track"]["keyswitch_counts"].values())
    actual = sum(_keyswitch_counts(track_setup["midi_path"]).values())
    assert actual == expected, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"total keyswitches {actual}, expected {expected}"
    )


def test_no_empty_track(track_setup):
    """The exported MIDI is not an empty placeholder (54 bytes)."""
    size = os.path.getsize(track_setup["midi_path"])
    assert size > 100, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"MIDI is only {size} bytes (likely empty/placeholder)"
    )


def test_track_count(track_setup):
    """The exported MIDI has exactly 2 tracks (conductor + audio)."""
    mid = mido.MidiFile(track_setup["midi_path"])
    assert len(mid.tracks) == 2, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"expected 2 tracks, got {len(mid.tracks)}"
    )


def test_midi_type_1(track_setup):
    """The exported MIDI is a Type 1 file (multi-track)."""
    mid = mido.MidiFile(track_setup["midi_path"])
    assert mid.type == 1, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"expected Type 1, got Type {mid.type}"
    )


def test_ticks_per_beat(track_setup):
    """The MIDI uses 960 ticks per beat (matches PyGuitarPro resolution)."""
    mid = mido.MidiFile(track_setup["midi_path"])
    assert mid.ticks_per_beat == 960, (
        f"{track_setup['song_name']}/{track_setup['track']['name']}: "
        f"expected 960 ticks/beat, got {mid.ticks_per_beat}"
    )