mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 12:52:29 +00:00
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.
313 lines
12 KiB
Python
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)
|