feedBack/tests/test_lyrics_transcribe.py
Sin 4148b0e72e Purge external-format terminology from code, tests and docs
Reword comments/docstrings/strings and rename identifiers that referenced
the external game and its file formats:

- format-id "psarc" -> "archive"; local vars psarc_path -> song_path,
  psarc_base -> tone_base
- lyrics provenance value "sng" -> "notechart" (legacy "sng" still accepted)
- highway_3d fret-ghost scope value "rocksmith" -> "chords" (invalid/legacy
  values fall back to the default, preserving behaviour)
- neutralise references in prose, test names/data, .gitattributes and docs

No functional change beyond the renamed identifiers; all Python compiles.
2026-06-16 19:36:53 +01:00

313 lines
12 KiB
Python

"""Tests for lib/lyrics_transcribe.py — WhisperX lyric transcription helpers.
The actual WhisperX inference is too heavy and non-deterministic to test
in CI (multi-GB model download, GPU optional, hallucinations on the
edges). These tests cover the parts that are deterministic and don't
need the model:
* `_whisperx_to_sloppak` — pure dict→dict mapping (score filter,
line-break heuristic, duration clamp, rounding).
* `vocals_has_signal` — RMS gate over a synthesized WAV.
* `whisperx_available` — graceful False when the package isn't installed.
The end-to-end positive case (archive without lyrics → sloppak with
auto-transcribed lyrics) is intentionally a manual verification step;
see the plan's verification section.
"""
from __future__ import annotations
import importlib
import pytest
from lyrics_transcribe import (
_whisperx_to_sloppak,
vocals_has_signal,
whisperx_available,
)
# ── Mapper: scores, line breaks, durations, rounding ────────────────────────
def test_mapper_passes_words_above_score_threshold():
aligned = {
"segments": [
{"words": [
{"word": "hello", "start": 1.0, "end": 1.3, "score": 0.9},
{"word": "world", "start": 1.4, "end": 1.7, "score": 0.8},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.35)
assert got == [
{"t": 1.0, "d": 0.3, "w": "hello"},
{"t": 1.4, "d": 0.3, "w": "world"},
]
def test_mapper_drops_words_below_score_threshold():
aligned = {
"segments": [
{"words": [
{"word": "good", "start": 1.0, "end": 1.2, "score": 0.9},
{"word": "bad", "start": 1.3, "end": 1.5, "score": 0.10},
{"word": "ugly", "start": 1.6, "end": 1.8, "score": 0.05},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.35)
assert [w["w"] for w in got] == ["good"]
def test_mapper_drops_words_with_missing_score():
# WhisperX occasionally emits words it failed to localize without a
# score field — those must drop, not pass-through as untrusted text.
aligned = {
"segments": [
{"words": [
{"word": "scored", "start": 1.0, "end": 1.2, "score": 0.9},
{"word": "unscored", "start": 1.3, "end": 1.5},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert [w["w"] for w in got] == ["scored"]
def test_mapper_inserts_line_break_on_segment_gap_above_threshold():
# Two segments separated by > 1.5s — the mapper appends `+` to the
# last word of the previous line. Matches static/highway.js which
# parses `+` as a suffix marker (raw.endsWith('+')) and strips it
# before rendering. A bare {"w": "+"} token would be rendered as an
# empty syllable / blank slot in the overlay.
aligned = {
"segments": [
{"words": [
{"word": "first", "start": 1.0, "end": 1.2, "score": 0.9},
]},
{"words": [
{"word": "second", "start": 5.0, "end": 5.3, "score": 0.9},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert got == [
{"t": 1.0, "d": 0.2, "w": "first+"},
{"t": 5.0, "d": 0.3, "w": "second"},
]
def test_mapper_does_not_insert_line_break_for_close_segments():
# Gap of 0.5s — well under the threshold, no break syllable.
aligned = {
"segments": [
{"words": [{"word": "a", "start": 1.0, "end": 1.2, "score": 0.9}]},
{"words": [{"word": "b", "start": 1.7, "end": 1.9, "score": 0.9}]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert [w["w"] for w in got] == ["a", "b"]
def test_mapper_no_line_break_for_singer_breath_gap():
# Gap of 2.0s — typical between-phrase singer breath on slow vocals.
# Pinned behavior: must NOT trigger a `+` break (would fragment a
# verse into one-line-per-phrase). The old 1.5s threshold would
# have broken here; the current 3.0s default leaves it intact.
aligned = {
"segments": [
{"words": [{"word": "phrase1", "start": 1.0, "end": 1.5, "score": 0.9}]},
{"words": [{"word": "phrase2", "start": 3.5, "end": 4.0, "score": 0.9}]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert [w["w"] for w in got] == ["phrase1", "phrase2"]
def test_mapper_clamps_zero_duration_to_floor():
# WhisperX sometimes emits start == end for ultra-short syllables.
# The lyrics overlay's fade timing needs a non-zero `d`, so the
# mapper clamps to a small floor.
aligned = {
"segments": [
{"words": [
{"word": "x", "start": 1.0, "end": 1.0, "score": 0.9},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert got[0]["d"] == 0.05
def test_mapper_rounds_to_three_decimals():
# Pin the rounding precision so an accidental change doesn't silently
# shift every downstream lyric timestamp.
aligned = {
"segments": [
{"words": [
{"word": "hi", "start": 1.234567, "end": 1.876543, "score": 0.9},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert got[0]["t"] == 1.235
assert got[0]["d"] == 0.642
def test_mapper_skips_empty_word_text():
# Whitespace-only "word" entries are dropped — the highway overlay
# would render them as blank syllables otherwise.
aligned = {
"segments": [
{"words": [
{"word": " ", "start": 1.0, "end": 1.2, "score": 0.9},
{"word": "real", "start": 1.3, "end": 1.5, "score": 0.9},
]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.0)
assert [w["w"] for w in got] == ["real"]
def test_mapper_gap_anchors_on_segment_end_not_last_surviving_word():
# Trailing word of segment 1 gets filtered (low score). prev_end must
# still advance to the segment's real end, otherwise the gap to
# segment 2 measures from "first" (1.2s) to "second" (3.0s) = 1.8s
# and falsely triggers a `+` line break. The segment's actual end
# is 5.0s, so gap is 3.0 - 5.0 = -2s, no break.
aligned = {
"segments": [
{
"end": 5.0,
"words": [
{"word": "first", "start": 1.0, "end": 1.2, "score": 0.9},
{"word": "dropped", "start": 4.5, "end": 5.0, "score": 0.05},
],
},
{
"words": [
{"word": "second", "start": 3.0, "end": 3.3, "score": 0.9},
],
},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.35)
# Only "first" + "second" survive the filter, no `+` suffix.
assert got == [
{"t": 1.0, "d": 0.2, "w": "first"},
{"t": 3.0, "d": 0.3, "w": "second"},
]
def test_mapper_advances_prev_end_past_fully_filtered_segment():
# Entire middle segment gets filtered out, but prev_end must still
# advance past it via segment.end. Otherwise the gap from "first"
# (end 1.2s) to "third" (start 8.0s) registers as 6.8s and triggers
# a `+` break against "first" — wrong, the actual gap from the
# filtered segment's true end (7.5s) to "third" (8.0s) is 0.5s.
aligned = {
"segments": [
{"end": 1.5, "words": [{"word": "first", "start": 1.0, "end": 1.2, "score": 0.9}]},
{"end": 7.5, "words": [{"word": "dropped", "start": 5.0, "end": 7.5, "score": 0.05}]},
{"words": [{"word": "third", "start": 8.0, "end": 8.4, "score": 0.9}]},
],
}
got = _whisperx_to_sloppak(aligned, min_score=0.35)
# No `+` suffix on "first" — short gap to the third segment.
assert got == [
{"t": 1.0, "d": 0.2, "w": "first"},
{"t": 8.0, "d": 0.4, "w": "third"},
]
def test_mapper_handles_empty_input():
assert _whisperx_to_sloppak({}, min_score=0.0) == []
assert _whisperx_to_sloppak({"segments": []}, min_score=0.0) == []
assert _whisperx_to_sloppak({"segments": [{"words": []}]}, min_score=0.0) == []
# ── Silence gate (uses soundfile + numpy) ───────────────────────────────────
def _import_soundfile_or_skip():
"""Import soundfile, skip the test if it OR its native libsndfile is
missing. `pytest.importorskip("soundfile")` alone only catches
ImportError, but `import soundfile` performs a ctypes load of
libsndfile at import time — on a host without the native lib it
raises OSError, which would error the test suite (especially now
that soundfile is in CI's requirements-test.txt and the wheel is
expected to be present) instead of cleanly skipping."""
try:
import soundfile as sf
return sf
except ImportError as e:
pytest.skip(f"soundfile not installed: {e}")
except OSError as e:
pytest.skip(f"soundfile native library unavailable: {e}")
def _make_wav(path, samples, sr: int = 22050):
sf = _import_soundfile_or_skip()
sf.write(str(path), samples, sr)
def test_vocals_has_signal_returns_false_for_silent_wav(tmp_path):
np = pytest.importorskip("numpy")
_import_soundfile_or_skip()
silent = np.zeros(22050, dtype="float32")
p = tmp_path / "silent.wav"
_make_wav(p, silent)
assert vocals_has_signal(p, threshold=0.005) is False
def test_vocals_has_signal_returns_true_for_loud_wav(tmp_path):
np = pytest.importorskip("numpy")
_import_soundfile_or_skip()
# 440Hz sine at full scale — clearly above any reasonable threshold.
t = np.linspace(0, 1.0, 22050, endpoint=False, dtype="float32")
sine = (0.5 * np.sin(2 * np.pi * 440 * t)).astype("float32")
p = tmp_path / "tone.wav"
_make_wav(p, sine)
assert vocals_has_signal(p, threshold=0.005) is True
def test_vocals_has_signal_open_fails_returns_true(tmp_path):
# Gate is best-effort: when reading the file fails we let downstream
# surface the real error rather than mis-classify the input as silent.
_import_soundfile_or_skip()
p = tmp_path / "does-not-exist.wav"
assert vocals_has_signal(p, threshold=0.005) is True
# ── Availability probe ─────────────────────────────────────────────────────
def test_whisperx_available_returns_bool():
# Doesn't matter whether the test machine has whisperx installed —
# the probe must return a plain bool, never raise.
result = whisperx_available()
assert isinstance(result, bool)
def test_whisperx_available_returns_false_when_import_fails(monkeypatch):
# Force the import to fail so we exercise the False branch even on
# machines that happen to have whisperx installed in the test venv.
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "whisperx":
raise ImportError("simulated")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
# Force a fresh module import so the deferred `import whisperx` inside
# whisperx_available() takes the patched path.
import lyrics_transcribe
importlib.reload(lyrics_transcribe)
assert lyrics_transcribe.whisperx_available() is False
# Reload again unpatched so we don't leave the patched module in
# sys.modules for downstream tests.
monkeypatch.undo()
importlib.reload(lyrics_transcribe)